Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb2ea82d7 |
+2
-2
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user