Compare commits

...
Author SHA1 Message Date
L1nSn0w abb2ea82d7 feat(cli): add amp, openclaw, qoder, windsurf, hermes to skills install registry
Extend the agent registry with five verified entries. Amp and OpenClaw
read the shared ~/.agents/skills directory that Codex already uses, so
the installer now groups agents by resolved target and writes a shared
path once (dry-run merges the agent names into a single line).

Paths verified against each tool's documentation:
- amp:      probe ~/.config/amp      -> shared ~/.agents/skills
- openclaw: probe ~/.openclaw        -> shared ~/.agents/skills
- qoder:    probe ~/.qoder           -> ~/.qoder/skills
- windsurf: probe ~/.codeium/windsurf -> ~/.codeium/windsurf/skills
- hermes:   probe ~/.hermes          -> ~/.hermes/skills
2026-07-13 11:57:51 +08:00
5 changed files with 153 additions and 13 deletions
+2 -2
View File
@@ -54,11 +54,11 @@ For agents (and scripting), start with `difyctl help agent` — the cross-comman
`difyctl skills install` installs a single, pure-delegation `SKILL.md` into your local agents so they auto-load it. The skill does not freeze the command set — it points the agent at `difyctl help -o json` for the live surface, so it never drifts from your binary. It is embedded in the binary (version-stamped) rather than checked in.
- `difyctl skills install` — dry-run: detect installed agents (Claude Code, Codex, opencode, Cursor, pi) and print where the skill would land. Writes nothing.
- `difyctl skills install` — dry-run: detect installed agents (Claude Code, Codex, opencode, Cursor, pi, Amp, OpenClaw, Qoder, Windsurf, Hermes) and print where the skill would land. Writes nothing.
- `difyctl skills install --yes` — write to every detected agent, printing each path. `--agent claude-code[,cursor]` restricts to a subset; `<dir>` forces one explicit directory (handy when your agent isn't detected).
- `difyctl skills install --stdout` — print the `SKILL.md` to stdout (for piping or self-install); writes nothing.
Detection is by config-directory existence (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`). If a copy ever looks stale, run `difyctl version` and re-run `difyctl skills install`.
Detection is by config-directory existence (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`, `~/.config/amp`, `~/.openclaw`, `~/.qoder`, `~/.codeium/windsurf`, `~/.hermes`). Codex, Amp and OpenClaw all read the shared `~/.agents/skills` directory, so one copy there serves all three. If a copy ever looks stale, run `difyctl version` and re-run `difyctl skills install`.
## Output formats
@@ -30,12 +30,22 @@ describe('detectAgents', () => {
await mkdir(join(home, '.config', 'opencode'), { recursive: true })
await mkdir(join(home, '.cursor'))
await mkdir(join(home, '.pi'))
await mkdir(join(home, '.config', 'amp'), { recursive: true })
await mkdir(join(home, '.openclaw'))
await mkdir(join(home, '.qoder'))
await mkdir(join(home, '.codeium', 'windsurf'), { recursive: true })
await mkdir(join(home, '.hermes'))
expect(detectAgents(home).map((a) => a.name)).toEqual([
'claude-code',
'codex',
'opencode',
'cursor',
'pi',
'amp',
'openclaw',
'qoder',
'windsurf',
'hermes',
])
})
@@ -73,4 +83,42 @@ describe('agent registry paths', () => {
expect(pi?.probeDir(home)).toBe(join(home, '.pi'))
expect(pi?.skillDir(home)).toBe(join(home, '.pi', 'agent', 'skills', 'difyctl'))
})
it('installs Amp into shared ~/.agents/skills, detected via ~/.config/amp', () => {
const amp = AGENTS.find((a) => a.name === 'amp')
expect(amp?.probeDir(home)).toBe(join(home, '.config', 'amp'))
expect(amp?.skillDir(home)).toBe(join(home, '.agents', 'skills', 'difyctl'))
})
it('installs OpenClaw into shared ~/.agents/skills, detected via ~/.openclaw', () => {
const openclaw = AGENTS.find((a) => a.name === 'openclaw')
expect(openclaw?.probeDir(home)).toBe(join(home, '.openclaw'))
expect(openclaw?.skillDir(home)).toBe(join(home, '.agents', 'skills', 'difyctl'))
})
it('installs Qoder under ~/.qoder/skills, detected via ~/.qoder', () => {
const qoder = AGENTS.find((a) => a.name === 'qoder')
expect(qoder?.probeDir(home)).toBe(join(home, '.qoder'))
expect(qoder?.skillDir(home)).toBe(join(home, '.qoder', 'skills', 'difyctl'))
})
it('installs Windsurf under ~/.codeium/windsurf/skills, detected via ~/.codeium/windsurf', () => {
const windsurf = AGENTS.find((a) => a.name === 'windsurf')
expect(windsurf?.probeDir(home)).toBe(join(home, '.codeium', 'windsurf'))
expect(windsurf?.skillDir(home)).toBe(join(home, '.codeium', 'windsurf', 'skills', 'difyctl'))
})
it('installs Hermes under ~/.hermes/skills, detected via ~/.hermes', () => {
const hermes = AGENTS.find((a) => a.name === 'hermes')
expect(hermes?.probeDir(home)).toBe(join(home, '.hermes'))
expect(hermes?.skillDir(home)).toBe(join(home, '.hermes', 'skills', 'difyctl'))
})
it('codex, amp and openclaw share one skillDir (the ~/.agents/skills convention)', () => {
const shared = ['codex', 'amp', 'openclaw'].map((name) =>
AGENTS.find((a) => a.name === name)?.skillDir(home),
)
expect(new Set(shared).size).toBe(1)
expect(shared[0]).toBe(join(home, '.agents', 'skills', 'difyctl'))
})
})
+31 -1
View File
@@ -7,7 +7,7 @@ import { join } from 'node:path'
//
// Detection is config-DIRECTORY existence only — no PATH probe, no subprocess.
// difyctl spawns nothing at runtime, and a tool's config dir (`~/.claude`,
// `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`) is the reliable "this
// `~/.codex`, `~/.openclaw`, `~/.hermes`, …) is the reliable "this
// agent is set up here" signal; agents themselves discover each other's skills
// by reading these dirs, not by locating executables. The narrow "installed but
// never launched, so no config dir yet" case is served by `skills install <dir>`.
@@ -17,6 +17,11 @@ import { join } from 'node:path'
// `~/.codex` but reads user skills from `~/.agents/skills`, and pi is configured
// under `~/.pi` but reads them from `~/.pi/agent/skills` (each tool's documented
// location). Adding an agent is one entry; paths are verified against its docs.
//
// `~/.agents/skills` is a shared user-level convention, not a Codex-only dir:
// Amp and OpenClaw document reading it too, so their entries point at the same
// skillDir. Entries may therefore share a skillDir — the installer dedupes
// writes by resolved path, so a shared target is written once.
export type AgentEntry = {
readonly name: string
readonly probeDir: (home: string) => string
@@ -49,6 +54,31 @@ export const AGENTS: readonly AgentEntry[] = [
probeDir: (home) => join(home, '.pi'),
skillDir: (home) => join(home, '.pi', 'agent', 'skills', 'difyctl'),
},
{
name: 'amp',
probeDir: (home) => join(home, '.config', 'amp'),
skillDir: (home) => join(home, '.agents', 'skills', 'difyctl'),
},
{
name: 'openclaw',
probeDir: (home) => join(home, '.openclaw'),
skillDir: (home) => join(home, '.agents', 'skills', 'difyctl'),
},
{
name: 'qoder',
probeDir: (home) => join(home, '.qoder'),
skillDir: (home) => join(home, '.qoder', 'skills', 'difyctl'),
},
{
name: 'windsurf',
probeDir: (home) => join(home, '.codeium', 'windsurf'),
skillDir: (home) => join(home, '.codeium', 'windsurf', 'skills', 'difyctl'),
},
{
name: 'hermes',
probeDir: (home) => join(home, '.hermes'),
skillDir: (home) => join(home, '.hermes', 'skills', 'difyctl'),
},
]
// Agents whose config dir exists under `home`. `home` is injectable so tests can
@@ -153,4 +153,51 @@ describe('runSkillsInstall', () => {
await readFile(join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'), 'utf8'),
).toBe(SKILL)
})
it('writes qoder, windsurf and hermes to their documented dirs', async () => {
await mkdir(join(home, '.qoder'))
await mkdir(join(home, '.codeium', 'windsurf'), { recursive: true })
await mkdir(join(home, '.hermes'))
const result = await runSkillsInstall(opts({ write: true }))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok') return
expect(result.wrote).toEqual([
join(home, '.qoder', 'skills', 'difyctl', 'SKILL.md'),
join(home, '.codeium', 'windsurf', 'skills', 'difyctl', 'SKILL.md'),
join(home, '.hermes', 'skills', 'difyctl', 'SKILL.md'),
])
})
it('writes the shared ~/.agents/skills path once for codex, amp and openclaw', async () => {
await mkdir(join(home, '.codex'))
await mkdir(join(home, '.config', 'amp'), { recursive: true })
await mkdir(join(home, '.openclaw'))
const shared = join(home, '.agents', 'skills', 'difyctl', 'SKILL.md')
const result = await runSkillsInstall(opts({ write: true }))
expect(result).toEqual({ kind: 'ok', text: `wrote ${shared}\n`, wrote: [shared] })
expect(await readFile(shared, 'utf8')).toBe(SKILL)
})
it('dry-run merges agents sharing a target into one line', async () => {
await mkdir(join(home, '.codex'))
await mkdir(join(home, '.config', 'amp'), { recursive: true })
await mkdir(join(home, '.openclaw'))
const shared = join(home, '.agents', 'skills', 'difyctl', 'SKILL.md')
const result = await runSkillsInstall(opts({}))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok') return
// The header counts agents; the target line merges the ones sharing a path.
expect(result.text).toContain('Detected 3 agents: codex, amp, openclaw')
expect(result.text).toContain(`would write to codex, amp, openclaw: ${shared}`)
expect(result.wrote).toEqual([])
})
it('--agent amp alone writes the shared path once', async () => {
await mkdir(join(home, '.config', 'amp'), { recursive: true })
const shared = join(home, '.agents', 'skills', 'difyctl', 'SKILL.md')
const result = await runSkillsInstall(opts({ write: true, agents: ['amp'] }))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok') return
expect(result.wrote).toEqual([shared])
})
})
+25 -10
View File
@@ -23,8 +23,11 @@ export type SkillsInstallResult =
| { readonly kind: 'ok'; readonly text: string; readonly wrote: readonly string[] }
| { readonly kind: 'usage'; readonly message: string }
// One write target. Several agents may resolve to the same path (the shared
// `~/.agents/skills` convention), so a target carries every agent name that
// maps to it and the path is written (and listed) once.
type InstallTarget = {
readonly name: string
readonly names: readonly string[]
readonly path: string
}
@@ -38,19 +41,28 @@ async function writeSkill(content: string, target: string): Promise<void> {
await rename(tmp, target)
}
// Group agents by their resolved SKILL.md path, preserving registry order.
// Agents sharing a skillDir collapse into one target with the names merged.
function groupByPath(agents: readonly AgentEntry[], home: string): InstallTarget[] {
const groups = new Map<string, string[]>()
for (const agent of agents) {
const path = join(agent.skillDir(home), 'SKILL.md')
const names = groups.get(path)
if (names) names.push(agent.name)
else groups.set(path, [agent.name])
}
return [...groups].map(([path, names]) => ({ names, path }))
}
function resolveTargets(
opts: SkillsInstallOptions,
home: string,
): InstallTarget[] | SkillsInstallResult {
// Explicit directory: skip detection entirely.
if (opts.dir !== undefined && opts.dir !== '')
return [{ name: opts.dir, path: join(resolve(opts.dir), 'SKILL.md') }]
return [{ names: [opts.dir], path: join(resolve(opts.dir), 'SKILL.md') }]
const detected = detectAgents(home)
const target = (a: AgentEntry): InstallTarget => ({
name: a.name,
path: join(a.skillDir(home), 'SKILL.md'),
})
// An explicit --agent must name agents that are actually detected. This is
// checked before the zero-detected guidance below: naming an agent that is
@@ -64,7 +76,10 @@ function resolveTargets(
message: `unknown or undetected agent(s): ${unknown.join(', ')} (detected: ${[...known].join(', ') || 'none'})`,
}
}
return detected.filter((a) => opts.agents.includes(a.name)).map(target)
return groupByPath(
detected.filter((a) => opts.agents.includes(a.name)),
home,
)
}
// No --agent and nothing detected: not an error — guide the user, write nothing.
@@ -80,7 +95,7 @@ function resolveTargets(
}
}
return detected.map(target)
return groupByPath(detected, home)
}
export async function runSkillsInstall(opts: SkillsInstallOptions): Promise<SkillsInstallResult> {
@@ -96,13 +111,13 @@ export async function runSkillsInstall(opts: SkillsInstallOptions): Promise<Skil
// Dry-run: list where the skill would land, write nothing.
if (!opts.write) {
const lines = targets.map((t) => `would write to ${t.name}: ${t.path}`).join('\n')
const lines = targets.map((t) => `would write to ${t.names.join(', ')}: ${t.path}`).join('\n')
// Explicit <dir>: no detection happened, so no agent summary / selectors.
if (opts.dir !== undefined && opts.dir !== '')
return { kind: 'ok', text: `${lines}\n\nRe-run with --yes to write.\n`, wrote: [] }
const names = targets.map((t) => t.name)
const names = targets.flatMap((t) => t.names)
const selected = opts.agents.length > 0
const header = `${selected ? 'Selected' : 'Detected'} ${names.length} agent${names.length === 1 ? '' : 's'}: ${names.join(', ')}`
// Only suggest --agent when the user hasn't already used it and there is more