refactor(install): prefer native plugin install across targets (#609)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

Co-authored-by: John Cavanaugh <cavanaug@users.noreply.github.com>
This commit is contained in:
Trevin Chow
2026-04-20 18:47:07 -07:00
committed by GitHub
parent 9497a00d90
commit c2d60b47be
104 changed files with 7073 additions and 7068 deletions

View File

@@ -1,6 +1,7 @@
export type CodexInvocationTargets = {
promptTargets: Record<string, string>
skillTargets: Record<string, string>
agentTargets?: Record<string, string>
}
export type CodexTransformOptions = {
@@ -27,20 +28,34 @@ export function transformContentForCodex(
let result = body
const promptTargets = targets?.promptTargets ?? {}
const skillTargets = targets?.skillTargets ?? {}
const agentTargets = targets?.agentTargets ?? {}
const unknownSlashBehavior = options.unknownSlashBehavior ?? "prompt"
const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9:-]*)\(([^)]*)\)/gm
result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
const agentTarget = resolveAgentTarget(agentName, agentTargets)
const trimmedArgs = args.trim()
if (agentTarget) {
return trimmedArgs
? `${prefix}Spawn the custom agent \`${agentTarget}\` with task: ${trimmedArgs}`
: `${prefix}Spawn the custom agent \`${agentTarget}\``
}
// For namespaced calls like "compound-engineering:research:repo-research-analyst",
// use only the final segment as the skill name.
// use only the final segment as the skill name when no custom agent target exists.
const finalSegment = agentName.includes(":") ? agentName.split(":").pop()! : agentName
const skillName = normalizeCodexName(finalSegment)
const trimmedArgs = args.trim()
return trimmedArgs
? `${prefix}Use the $${skillName} skill to: ${trimmedArgs}`
: `${prefix}Use the $${skillName} skill`
})
const backtickedAgentPattern = /`([a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*){1,2})`/gi
result = result.replace(backtickedAgentPattern, (match, agentName: string) => {
const agentTarget = resolveAgentTarget(agentName, agentTargets)
return agentTarget ? `custom agent \`${agentTarget}\`` : match
})
const slashCommandPattern = /(?<![:\w>}\]\)])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
result = result.replace(slashCommandPattern, (match, commandName: string) => {
if (commandName.includes("/")) return match
@@ -65,6 +80,8 @@ export function transformContentForCodex(
const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
result = result.replace(agentRefPattern, (_match, agentName: string) => {
const agentTarget = resolveAgentTarget(agentName, agentTargets)
if (agentTarget) return `custom agent \`${agentTarget}\``
const skillName = normalizeCodexName(agentName)
return `$${skillName} skill`
})
@@ -72,6 +89,21 @@ export function transformContentForCodex(
return result
}
function resolveAgentTarget(value: string, agentTargets: Record<string, string>): string | null {
const parts = value.split(":").filter(Boolean)
const candidates = [
normalizeCodexName(value),
parts.length >= 2 ? normalizeCodexName(parts.slice(-2).join(":")) : "",
parts.length >= 1 ? normalizeCodexName(parts[parts.length - 1]) : "",
].filter(Boolean)
for (const candidate of candidates) {
const target = agentTargets[candidate]
if (target) return target
}
return null
}
export function normalizeCodexName(value: string): string {
const trimmed = value.trim()
if (!trimmed) return "item"

View File

@@ -1,6 +1,7 @@
import os from "os"
import path from "path"
import { pathExists } from "./files"
import { syncTargets } from "../sync/registry"
import { resolveOpenCodeGlobalRoot } from "./opencode-config"
export type DetectedTool = {
name: string
@@ -8,12 +9,78 @@ export type DetectedTool = {
reason: string
}
type DetectableTool = {
name: string
detectPaths: (home: string, cwd: string) => string[]
}
const detectableTools: DetectableTool[] = [
{
name: "opencode",
detectPaths: (home, cwd) => {
// Resolve the OpenCode global root through the shared helper so that
// detection agrees with install/cleanup on `OPENCODE_CONFIG_DIR`. When
// the env var is unset, the helper falls back to `os.homedir()`, which
// may differ from the `home` arg threaded through for testability; in
// that case prefer the explicit `home` param so existing callers that
// override it keep working.
const envDir = process.env.OPENCODE_CONFIG_DIR?.trim()
const globalRoot = envDir
? resolveOpenCodeGlobalRoot()
: path.join(home, ".config", "opencode")
return [globalRoot, path.join(cwd, ".opencode")]
},
},
{
name: "codex",
detectPaths: (home) => [path.join(home, ".codex")],
},
{
name: "pi",
detectPaths: (home) => [path.join(home, ".pi")],
},
{
name: "droid",
detectPaths: (home) => [path.join(home, ".factory")],
},
{
name: "copilot",
detectPaths: (home, cwd) => [
path.join(home, ".copilot"),
path.join(cwd, ".github", "skills"),
path.join(cwd, ".github", "agents"),
path.join(cwd, ".github", "copilot-instructions.md"),
],
},
{
name: "gemini",
detectPaths: (home, cwd) => [
path.join(cwd, ".gemini"),
path.join(home, ".gemini"),
],
},
{
name: "kiro",
detectPaths: (home, cwd) => [
path.join(home, ".kiro"),
path.join(cwd, ".kiro"),
],
},
{
name: "qwen",
detectPaths: (home, cwd) => [
path.join(home, ".qwen"),
path.join(cwd, ".qwen"),
],
},
]
export async function detectInstalledTools(
home: string = os.homedir(),
cwd: string = process.cwd(),
): Promise<DetectedTool[]> {
const results: DetectedTool[] = []
for (const target of syncTargets) {
for (const target of detectableTools) {
let detected = false
let reason = "not found"
for (const p of target.detectPaths(home, cwd)) {

View File

@@ -85,6 +85,43 @@ export function sanitizePathName(name: string): string {
return name.replace(/:/g, "-")
}
/**
* Validate that a manifest-supplied relative path is safe to join against a
* managed root before deleting or moving anything at that location.
*
* Install manifests (`install-manifest.json`) are read back from disk during
* reinstall/cleanup and fed into `fs.rm`/`fs.rename`. An attacker or a
* corrupted file could include entries like `../../config.toml` or
* `/etc/passwd` that would cause the cleanup to operate outside the intended
* managed tree. This helper rejects:
*
* - non-string values
* - empty strings
* - absolute paths (POSIX `/foo`, Windows `C:\foo`)
* - any `..` path segment (including `foo/../bar`)
* - paths that, when joined with `rootDir`, resolve outside `rootDir`
*
* The `rootDir` check is defense-in-depth against edge cases the first two
* checks miss (e.g. platform-specific separators or encoded traversal the
* split-based check didn't catch).
*/
export function isSafeManagedPath(rootDir: string, candidate: unknown): candidate is string {
if (typeof candidate !== "string" || candidate.length === 0) return false
if (path.isAbsolute(candidate)) return false
// Reject any traversal segment (`..`) split on either separator so the
// check is uniform across platforms.
const segments = candidate.split(/[\\/]/)
if (segments.some((segment) => segment === "..")) return false
// Final containment check: the fully-resolved candidate must stay inside
// the resolved root. This catches anything the above two checks missed.
const resolvedRoot = path.resolve(rootDir)
const resolvedCandidate = path.resolve(resolvedRoot, candidate)
if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(resolvedRoot + path.sep)) {
return false
}
return true
}
/**
* Resolve a colon-separated command name into a filesystem path.
* e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md"

47
src/utils/json-config.ts Normal file
View File

@@ -0,0 +1,47 @@
import path from "path"
import { pathExists, readJson, writeJsonSecure } from "./files"
type JsonObject = Record<string, unknown>
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export async function mergeJsonConfigAtKey(options: {
configPath: string
key: string
incoming: Record<string, unknown>
}): Promise<void> {
const { configPath, key, incoming } = options
const existing = await readJsonObjectSafe(configPath)
const existingEntries = isJsonObject(existing[key]) ? existing[key] : {}
const merged = {
...existing,
[key]: {
...existingEntries,
...incoming,
},
}
await writeJsonSecure(configPath, merged)
}
async function readJsonObjectSafe(configPath: string): Promise<JsonObject> {
if (!(await pathExists(configPath))) {
return {}
}
try {
const parsed = await readJson<unknown>(configPath)
if (isJsonObject(parsed)) {
return parsed
}
} catch {
// Fall through to warning and replacement.
}
console.warn(
`Warning: existing ${path.basename(configPath)} could not be parsed and will be replaced.`,
)
return {}
}

View File

@@ -20,9 +20,30 @@ import { parseFrontmatter } from "./frontmatter"
/** Old skill directory names that no longer exist after the v3 rename. */
const STALE_SKILL_DIRS = [
// ce: -> ce- (dirs were already hyphenated by sanitizePathName, so these
// only collide if the old name was exactly the same after sanitization —
// which it was for all 8 workflow skills. No orphans from this group.)
// ce: -> ce-. Some targets sanitized these to ce-*; others left raw colon
// directories on filesystems that permit them.
"ce:brainstorm",
"ce:compound",
"ce:compound-refresh",
"ce:ideate",
"ce:plan",
"ce:plan-beta",
"ce:review",
"ce:review-beta",
"ce:work",
"ce:work-beta",
// workflows:* -> ce-*.
"workflows:brainstorm",
"workflows:compound",
"workflows:plan",
"workflows:review",
"workflows:work",
"workflows-brainstorm",
"workflows-compound",
"workflows-plan",
"workflows-review",
"workflows-work",
// git-* -> ce-*
"git-commit",
@@ -62,6 +83,8 @@ const STALE_SKILL_DIRS = [
// ce-review -> ce-code-review, ce-document-review -> ce-doc-review
"ce-review",
"ce-document-review",
"ce-plan-beta",
"ce-review-beta",
]
/** Old agent names (used as generated skill dirs or flat .md files). */
@@ -225,6 +248,14 @@ const LEGACY_ONLY_SKILL_DESCRIPTIONS: Record<string, string> = {
"This skill should be used when orchestrating multi-agent swarms using Claude Code's TeammateTool and Task system. It applies when coordinating multiple agents, running parallel code reviews, creating pipeline workflows with dependencies, building self-organizing task queues, or any task benefiting from divide-and-conquer patterns.",
"reproduce-bug":
"Systematically reproduce and investigate a bug from a GitHub issue. Use when the user provides a GitHub issue number or URL for a bug they want reproduced or investigated.",
"ce:plan-beta":
"[BETA] Transform feature descriptions or requirements into structured implementation plans grounded in repo patterns and research. Use when the user says 'plan this', 'create a plan', 'write a tech plan', 'plan the implementation', 'how should we build', 'what's the approach for', 'break this down', or when a brainstorm/requirements document is ready for technical planning. Best when requirements are at least roughly defined; for exploratory or ambiguous requests, prefer ce:brainstorm first.",
"ce-plan-beta":
"[BETA] Transform feature descriptions or requirements into structured implementation plans grounded in repo patterns and research. Use when the user says 'plan this', 'create a plan', 'write a tech plan', 'plan the implementation', 'how should we build', 'what's the approach for', 'break this down', or when a brainstorm/requirements document is ready for technical planning. Best when requirements are at least roughly defined; for exploratory or ambiguous requests, prefer ce:brainstorm first.",
"ce:review-beta":
"[BETA] Structured code review using tiered persona agents, confidence-gated findings, and a merge/dedup pipeline. Use when reviewing code changes before creating a PR.",
"ce-review-beta":
"[BETA] Structured code review using tiered persona agents, confidence-gated findings, and a merge/dedup pipeline. Use when reviewing code changes before creating a PR.",
}
/**
@@ -248,6 +279,19 @@ type LegacyFingerprints = {
let legacyFingerprintsPromise: Promise<LegacyFingerprints> | null = null
function currentSkillNameForLegacy(legacyName: string): string {
if (legacyName === "ce:review" || legacyName === "workflows:review" || legacyName === "workflows-review") {
return "ce-code-review"
}
if (legacyName.startsWith("ce:")) {
return legacyName.replace(/^ce:/, "ce-")
}
if (legacyName.startsWith("workflows:")) {
return `ce-${legacyName.slice("workflows:".length)}`
}
if (legacyName.startsWith("workflows-")) {
return `ce-${legacyName.slice("workflows-".length)}`
}
switch (legacyName) {
case "git-commit":
return "ce-commit"

View File

@@ -48,8 +48,8 @@ export function addProviderPrefix(model: string): string {
}
/**
* Normalize a model for targets that use provider-prefixed IDs
* (OpenCode, OpenClaw). Resolves bare aliases and adds provider prefix.
* Normalize a model for targets that use provider-prefixed IDs.
* Resolves bare aliases and adds provider prefix.
*
* "sonnet" -> "anthropic/claude-sonnet-4-6"
* "claude-sonnet-4-20250514" -> "anthropic/claude-sonnet-4-20250514"
@@ -66,4 +66,3 @@ export function normalizeModelWithProvider(model: string): string {
}
return addProviderPrefix(resolved)
}

View File

@@ -0,0 +1,25 @@
import os from "os"
import path from "path"
import { expandHome } from "./resolve-home"
/**
* Resolve the OpenCode global-config root.
*
* Order of precedence:
* 1. `OPENCODE_CONFIG_DIR` environment variable (NixOS, Docker, non-default
* `XDG_CONFIG_HOME` setups).
* 2. `~/.config/opencode` (XDG default).
*
* See: https://opencode.ai/docs/config/
*
* Both `install` and `cleanup` must agree on this resolution so that an
* install at `OPENCODE_CONFIG_DIR=/custom/path` is later cleaned at the same
* location.
*/
export function resolveOpenCodeGlobalRoot(): string {
const envDir = process.env.OPENCODE_CONFIG_DIR?.trim()
if (envDir) {
return path.resolve(expandHome(envDir))
}
return path.join(os.homedir(), ".config", "opencode")
}

View File

@@ -1,50 +1,49 @@
import os from "os"
import path from "path"
import type { TargetScope } from "../targets"
import { resolveOpenCodeGlobalRoot } from "./opencode-config"
export function resolveTargetOutputRoot(options: {
targetName: string
outputRoot: string
codexHome: string
piHome: string
openclawHome?: string
qwenHome?: string
pluginName?: string
hasExplicitOutput: boolean
scope?: TargetScope
}): string {
const { targetName, outputRoot, codexHome, piHome, openclawHome, qwenHome, pluginName, hasExplicitOutput, scope } = options
const { targetName, outputRoot, codexHome, piHome, hasExplicitOutput } = options
if (targetName === "codex") return codexHome
if (targetName === "pi") return piHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".cursor")
}
if (targetName === "gemini") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".gemini")
}
if (targetName === "copilot") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".github")
}
if (targetName === "kiro") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".kiro")
}
if (targetName === "windsurf") {
if (hasExplicitOutput) return outputRoot
if (scope === "global") return path.join(os.homedir(), ".codeium", "windsurf")
return path.join(process.cwd(), ".windsurf")
}
if (targetName === "openclaw") {
const home = openclawHome ?? path.join(os.homedir(), ".openclaw", "extensions")
return path.join(home, pluginName ?? "plugin")
}
if (targetName === "qwen") {
const home = qwenHome ?? path.join(os.homedir(), ".qwen", "extensions")
return path.join(home, pluginName ?? "plugin")
if (targetName === "opencode") {
// Without an explicit --output, default to the OpenCode global-config root
// (OPENCODE_CONFIG_DIR or ~/.config/opencode). With an explicit --output,
// honor it as a workspace root and let the writer nest under .opencode/.
if (!hasExplicitOutput) return resolveOpenCodeGlobalRoot()
return outputRoot
}
return outputRoot
}
/**
* Returns "global" when the OpenCode writer should use the flat global-config
* layout (no `.opencode/` nesting). This is the case when the user did not
* pass `--output` and did not pass an explicit `--scope`. Returns the
* caller's requested scope otherwise so explicit `--scope workspace` still
* wins.
*/
export function resolveOpenCodeWriteScope(
hasExplicitOutput: boolean,
requestedScope: TargetScope | undefined,
): TargetScope | undefined {
if (requestedScope !== undefined) return requestedScope
if (!hasExplicitOutput) return "global"
return undefined
}