refactor(install): prefer native plugin install across targets (#609)
Co-authored-by: John Cavanaugh <cavanaug@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
47
src/utils/json-config.ts
Normal 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 {}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
25
src/utils/opencode-config.ts
Normal file
25
src/utils/opencode-config.ts
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user