incur 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/SKILL.md +664 -0
- package/dist/Cli.d.ts +255 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +900 -0
- package/dist/Cli.js.map +1 -0
- package/dist/Errors.d.ts +92 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +75 -0
- package/dist/Errors.js.map +1 -0
- package/dist/Formatter.d.ts +5 -0
- package/dist/Formatter.d.ts.map +1 -0
- package/dist/Formatter.js +91 -0
- package/dist/Formatter.js.map +1 -0
- package/dist/Help.d.ts +53 -0
- package/dist/Help.d.ts.map +1 -0
- package/dist/Help.js +231 -0
- package/dist/Help.js.map +1 -0
- package/dist/Mcp.d.ts +13 -0
- package/dist/Mcp.d.ts.map +1 -0
- package/dist/Mcp.js +140 -0
- package/dist/Mcp.js.map +1 -0
- package/dist/Parser.d.ts +24 -0
- package/dist/Parser.d.ts.map +1 -0
- package/dist/Parser.js +215 -0
- package/dist/Parser.js.map +1 -0
- package/dist/Register.d.ts +19 -0
- package/dist/Register.d.ts.map +1 -0
- package/dist/Register.js +2 -0
- package/dist/Register.js.map +1 -0
- package/dist/Schema.d.ts +4 -0
- package/dist/Schema.d.ts.map +1 -0
- package/dist/Schema.js +8 -0
- package/dist/Schema.js.map +1 -0
- package/dist/Skill.d.ts +29 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +196 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Skillgen.d.ts +3 -0
- package/dist/Skillgen.d.ts.map +1 -0
- package/dist/Skillgen.js +67 -0
- package/dist/Skillgen.js.map +1 -0
- package/dist/SyncMcp.d.ts +23 -0
- package/dist/SyncMcp.d.ts.map +1 -0
- package/dist/SyncMcp.js +100 -0
- package/dist/SyncMcp.js.map +1 -0
- package/dist/SyncSkills.d.ts +38 -0
- package/dist/SyncSkills.d.ts.map +1 -0
- package/dist/SyncSkills.js +163 -0
- package/dist/SyncSkills.js.map +1 -0
- package/dist/Typegen.d.ts +6 -0
- package/dist/Typegen.d.ts.map +1 -0
- package/dist/Typegen.js +92 -0
- package/dist/Typegen.js.map +1 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +30 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/pm.d.ts +3 -0
- package/dist/internal/pm.d.ts.map +1 -0
- package/dist/internal/pm.js +11 -0
- package/dist/internal/pm.js.map +1 -0
- package/dist/internal/types.d.ts +11 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/internal/utils.d.ts +8 -0
- package/dist/internal/utils.d.ts.map +1 -0
- package/dist/internal/utils.js +51 -0
- package/dist/internal/utils.js.map +1 -0
- package/examples/npm/cli.ts +180 -0
- package/examples/npm/node_modules/.bin/incur.src +21 -0
- package/examples/npm/node_modules/.bin/tsx +21 -0
- package/examples/npm/package.json +14 -0
- package/examples/npm/tsconfig.json +9 -0
- package/examples/presto/cli.ts +246 -0
- package/examples/presto/node_modules/.bin/incur.src +21 -0
- package/examples/presto/node_modules/.bin/tsx +21 -0
- package/examples/presto/package.json +14 -0
- package/examples/presto/tsconfig.json +9 -0
- package/package.json +53 -2
- package/src/Cli.test-d.ts +135 -0
- package/src/Cli.test.ts +1373 -0
- package/src/Cli.ts +1470 -0
- package/src/Errors.test.ts +96 -0
- package/src/Errors.ts +139 -0
- package/src/Formatter.test.ts +245 -0
- package/src/Formatter.ts +106 -0
- package/src/Help.test.ts +124 -0
- package/src/Help.ts +302 -0
- package/src/Mcp.test.ts +254 -0
- package/src/Mcp.ts +195 -0
- package/src/Parser.test-d.ts +45 -0
- package/src/Parser.test.ts +118 -0
- package/src/Parser.ts +247 -0
- package/src/Register.ts +18 -0
- package/src/Schema.test.ts +125 -0
- package/src/Schema.ts +8 -0
- package/src/Skill.test.ts +293 -0
- package/src/Skill.ts +253 -0
- package/src/Skillgen.ts +66 -0
- package/src/SyncMcp.test.ts +75 -0
- package/src/SyncMcp.ts +132 -0
- package/src/SyncSkills.test.ts +92 -0
- package/src/SyncSkills.ts +205 -0
- package/src/Typegen.test.ts +150 -0
- package/src/Typegen.ts +107 -0
- package/src/bin.ts +33 -0
- package/src/e2e.test.ts +1710 -0
- package/src/index.ts +14 -0
- package/src/internal/pm.test.ts +38 -0
- package/src/internal/pm.ts +8 -0
- package/src/internal/types.ts +22 -0
- package/src/internal/utils.ts +50 -0
- package/src/tsconfig.json +8 -0
package/src/Skillgen.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import * as Cli from './Cli.js'
|
|
5
|
+
import { importCli } from './internal/utils.js'
|
|
6
|
+
import * as Skill from './Skill.js'
|
|
7
|
+
|
|
8
|
+
/** Imports a CLI from `input`, generates Markdown skill files, and writes them to `output`. */
|
|
9
|
+
export async function generate(input: string, output: string, depth = 1): Promise<string[]> {
|
|
10
|
+
const cli = await importCli(input)
|
|
11
|
+
const commands = Cli.toCommands.get(cli)
|
|
12
|
+
if (!commands) throw new Error('No commands registered on this CLI instance')
|
|
13
|
+
|
|
14
|
+
const groups = new Map<string, string>()
|
|
15
|
+
if (cli.description) groups.set(cli.name, cli.description)
|
|
16
|
+
const entries = collectEntries(commands, [], groups)
|
|
17
|
+
const files = Skill.split(cli.name, entries, depth, groups)
|
|
18
|
+
|
|
19
|
+
if (depth > 0) await fs.rm(output, { recursive: true, force: true })
|
|
20
|
+
|
|
21
|
+
const written: string[] = []
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
const filePath = file.dir
|
|
24
|
+
? path.join(output, file.dir, 'SKILL.md')
|
|
25
|
+
: path.join(output, 'SKILL.md')
|
|
26
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
27
|
+
await fs.writeFile(filePath, `${file.content}\n`)
|
|
28
|
+
written.push(filePath)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return written
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Recursively collects leaf commands as `Skill.CommandInfo` and group descriptions. */
|
|
35
|
+
function collectEntries(
|
|
36
|
+
commands: Map<string, any>,
|
|
37
|
+
prefix: string[],
|
|
38
|
+
groups: Map<string, string> = new Map(),
|
|
39
|
+
): Skill.CommandInfo[] {
|
|
40
|
+
const result: Skill.CommandInfo[] = []
|
|
41
|
+
for (const [name, entry] of commands) {
|
|
42
|
+
const path = [...prefix, name]
|
|
43
|
+
if ('_group' in entry && entry._group) {
|
|
44
|
+
if (entry.description) groups.set(path.join(' '), entry.description)
|
|
45
|
+
result.push(...collectEntries(entry.commands, path, groups))
|
|
46
|
+
} else {
|
|
47
|
+
const cmd: Skill.CommandInfo = { name: path.join(' ') }
|
|
48
|
+
if (entry.description) cmd.description = entry.description
|
|
49
|
+
if (entry.args) cmd.args = entry.args
|
|
50
|
+
if (entry.env) cmd.env = entry.env
|
|
51
|
+
if (entry.hint) cmd.hint = entry.hint
|
|
52
|
+
if (entry.options) cmd.options = entry.options
|
|
53
|
+
if (entry.output) cmd.output = entry.output
|
|
54
|
+
const examples = Cli.formatExamples(entry.examples)
|
|
55
|
+
if (examples) {
|
|
56
|
+
const cmdName = path.join(' ')
|
|
57
|
+
cmd.examples = examples.map((e) => ({
|
|
58
|
+
...e,
|
|
59
|
+
command: e.command ? `${cmdName} ${e.command}` : cmdName,
|
|
60
|
+
}))
|
|
61
|
+
}
|
|
62
|
+
result.push(cmd)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result.sort((a, b) => a.name.localeCompare(b.name))
|
|
66
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { detectPackageSpecifier } from './SyncMcp.js'
|
|
6
|
+
|
|
7
|
+
let savedArgv1: string | undefined
|
|
8
|
+
let tmp: string
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
savedArgv1 = process.argv[1]
|
|
12
|
+
tmp = join(tmpdir(), `clac-test-${Date.now()}`)
|
|
13
|
+
mkdirSync(join(tmp, 'node_modules', '.bin'), { recursive: true })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
process.argv[1] = savedArgv1!
|
|
18
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function setupPkg(deps: Record<string, string>) {
|
|
22
|
+
writeFileSync(join(tmp, 'package.json'), JSON.stringify({ dependencies: deps }))
|
|
23
|
+
process.argv[1] = join(tmp, 'node_modules', '.bin', 'my-cli')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('returns bare name when argv[1] is undefined', () => {
|
|
27
|
+
process.argv[1] = undefined as any
|
|
28
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('returns bare name when no node_modules in path', () => {
|
|
32
|
+
process.argv[1] = '/usr/local/bin/my-cli'
|
|
33
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('returns bare name when package.json is missing', () => {
|
|
37
|
+
process.argv[1] = join(tmp, 'node_modules', '.bin', 'my-cli')
|
|
38
|
+
// no package.json written
|
|
39
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('returns bare name when dep is not found', () => {
|
|
43
|
+
setupPkg({ other: '1.0.0' })
|
|
44
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('returns bare name when multiple deps exist', () => {
|
|
48
|
+
setupPkg({ 'my-cli': '1.0.0', other: '2.0.0' })
|
|
49
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('returns URL specifier for https dep', () => {
|
|
53
|
+
setupPkg({ 'my-cli': 'https://pkg.pr.new/my-cli@abc123' })
|
|
54
|
+
expect(detectPackageSpecifier('my-cli')).toBe('https://pkg.pr.new/my-cli@abc123')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('returns URL specifier for file: dep', () => {
|
|
58
|
+
setupPkg({ 'my-cli': 'file:../local-cli' })
|
|
59
|
+
expect(detectPackageSpecifier('my-cli')).toBe('file:../local-cli')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('returns name@version for pinned version', () => {
|
|
63
|
+
setupPkg({ 'my-cli': '1.2.3' })
|
|
64
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli@1.2.3')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('returns bare name for range specifier', () => {
|
|
68
|
+
setupPkg({ 'my-cli': '^1.0.0' })
|
|
69
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('returns bare name for tag specifier', () => {
|
|
73
|
+
setupPkg({ 'my-cli': 'latest' })
|
|
74
|
+
expect(detectPackageSpecifier('my-cli')).toBe('my-cli')
|
|
75
|
+
})
|
package/src/SyncMcp.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { detectRunner } from './internal/pm.js'
|
|
7
|
+
|
|
8
|
+
/** Registers the CLI as an MCP server via `npx add-mcp` and direct config writes for unsupported agents. */
|
|
9
|
+
export async function register(
|
|
10
|
+
name: string,
|
|
11
|
+
options: register.Options = {},
|
|
12
|
+
): Promise<register.Result> {
|
|
13
|
+
const runner = detectRunner()
|
|
14
|
+
const command = options.command ?? `${runner} ${detectPackageSpecifier(name)} --mcp`
|
|
15
|
+
const targetAgents = options.agents ?? []
|
|
16
|
+
const ampOnly = targetAgents.length === 1 && targetAgents[0] === 'amp'
|
|
17
|
+
|
|
18
|
+
const agents: string[] = []
|
|
19
|
+
|
|
20
|
+
// Run add-mcp for agents it supports (skip if only targeting Amp)
|
|
21
|
+
if (!ampOnly) {
|
|
22
|
+
const args = [command, '--name', name, '-y']
|
|
23
|
+
if (options.global !== false) args.push('-g')
|
|
24
|
+
for (const agent of targetAgents.filter((a) => a !== 'amp')) args.push('-a', agent)
|
|
25
|
+
|
|
26
|
+
const [cmd, ...prefix] = runner.split(' ')
|
|
27
|
+
const { stdout } = await exec(cmd!, [...prefix, 'add-mcp', ...args])
|
|
28
|
+
|
|
29
|
+
// Extract agent names from add-mcp output (lines like "│ ✓ Claude Code: ~/.claude.json │")
|
|
30
|
+
agents.push(
|
|
31
|
+
...stdout
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter((l) => l.includes('✓') || l.includes('✔'))
|
|
34
|
+
.map((l) =>
|
|
35
|
+
l
|
|
36
|
+
.replace(/[│┃|]/g, '')
|
|
37
|
+
.replace(/.*[✓✔]\s*/, '')
|
|
38
|
+
.replace(/:.*/, '')
|
|
39
|
+
.trim(),
|
|
40
|
+
)
|
|
41
|
+
.filter(Boolean),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Register with Amp directly (add-mcp doesn't support it)
|
|
46
|
+
if (targetAgents.length === 0 || targetAgents.includes('amp')) {
|
|
47
|
+
const registered = registerAmp(name, command)
|
|
48
|
+
if (registered) agents.push('Amp')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { command, agents }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal Registers an MCP server in Amp's settings.json. */
|
|
55
|
+
function registerAmp(name: string, command: string): boolean {
|
|
56
|
+
const configPath = join(homedir(), '.config', 'amp', 'settings.json')
|
|
57
|
+
|
|
58
|
+
let config: Record<string, any> = {}
|
|
59
|
+
if (existsSync(configPath)) {
|
|
60
|
+
try {
|
|
61
|
+
config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const [cmd, ...args] = command.split(' ')
|
|
68
|
+
if (!cmd) return false
|
|
69
|
+
|
|
70
|
+
const servers: Record<string, any> = config['amp.mcpServers'] ?? {}
|
|
71
|
+
servers[name] = { command: cmd, args }
|
|
72
|
+
config['amp.mcpServers'] = servers
|
|
73
|
+
|
|
74
|
+
const dir = dirname(configPath)
|
|
75
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
76
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
77
|
+
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export declare namespace register {
|
|
82
|
+
/** Options for registering an MCP server. */
|
|
83
|
+
type Options = {
|
|
84
|
+
/** Target specific agents (e.g. `'claude-code'`, `'cursor'`). */
|
|
85
|
+
agents?: string[] | undefined
|
|
86
|
+
/** Override the command agents will run. Defaults to `<runner> <name> --mcp`. */
|
|
87
|
+
command?: string | undefined
|
|
88
|
+
/** Install globally. Defaults to `true`. */
|
|
89
|
+
global?: boolean | undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Result of a register operation. */
|
|
93
|
+
type Result = {
|
|
94
|
+
/** Agents the server was registered with. */
|
|
95
|
+
agents: string[]
|
|
96
|
+
/** The command registered. */
|
|
97
|
+
command: string
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @internal Detects the package specifier used to run this CLI (handles dlx/npx URL and version installs). */
|
|
102
|
+
export function detectPackageSpecifier(name: string): string {
|
|
103
|
+
const bin = process.argv[1]
|
|
104
|
+
if (!bin) return name
|
|
105
|
+
|
|
106
|
+
const match = bin.match(/^(.+)[/\\]node_modules[/\\]/)
|
|
107
|
+
if (!match) return name
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const pkg = JSON.parse(readFileSync(join(match[1]!, 'package.json'), 'utf-8'))
|
|
111
|
+
const deps = pkg.dependencies ?? {}
|
|
112
|
+
const spec = deps[name]
|
|
113
|
+
if (!spec || Object.keys(deps).length !== 1) return name
|
|
114
|
+
|
|
115
|
+
if (/^https?:\/\//.test(spec) || spec.startsWith('file:')) return spec
|
|
116
|
+
if (/^\d/.test(spec)) return `${name}@${spec}`
|
|
117
|
+
} catch {}
|
|
118
|
+
|
|
119
|
+
return name
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Promisified execFile with stderr in error message. */
|
|
123
|
+
function exec(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
execFile(cmd, args, (error, stdout, stderr) => {
|
|
126
|
+
if (error) {
|
|
127
|
+
const msg = stderr?.trim() || stdout?.trim() || error.message
|
|
128
|
+
reject(new Error(msg))
|
|
129
|
+
} else resolve({ stdout, stderr })
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Cli, SyncSkills } from 'incur'
|
|
2
|
+
import { mkdirSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
let mockExecError: Error | null = null
|
|
7
|
+
|
|
8
|
+
vi.mock('node:child_process', () => ({
|
|
9
|
+
execFile: (_cmd: string, _args: string[], cb: Function) => {
|
|
10
|
+
if (mockExecError) cb(mockExecError, '', '')
|
|
11
|
+
else cb(null, '', '')
|
|
12
|
+
},
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
let savedXdg: string | undefined
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
mockExecError = null
|
|
19
|
+
savedXdg = process.env.XDG_DATA_HOME
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
if (savedXdg === undefined) delete process.env.XDG_DATA_HOME
|
|
24
|
+
else process.env.XDG_DATA_HOME = savedXdg
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('generates skill files to temp dir and calls runner', async () => {
|
|
28
|
+
const cli = Cli.create('test', { description: 'A test CLI' })
|
|
29
|
+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
|
|
30
|
+
cli.command('greet', { description: 'Say hello', run: () => ({ hi: true }) })
|
|
31
|
+
|
|
32
|
+
const commands = Cli.toCommands.get(cli)!
|
|
33
|
+
const result = await SyncSkills.sync('test', commands, {
|
|
34
|
+
description: 'A test CLI',
|
|
35
|
+
runner: 'npx',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(result.skills.length).toBeGreaterThan(0)
|
|
39
|
+
expect(result.skills.map((s) => s.name)).toContain('greet')
|
|
40
|
+
expect(result.skills.map((s) => s.name)).toContain('ping')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('uses custom depth', async () => {
|
|
44
|
+
const cli = Cli.create('test')
|
|
45
|
+
cli.command('ping', { description: 'Ping', run: () => ({}) })
|
|
46
|
+
cli.command('pong', { description: 'Pong', run: () => ({}) })
|
|
47
|
+
|
|
48
|
+
const commands = Cli.toCommands.get(cli)!
|
|
49
|
+
const result = await SyncSkills.sync('test', commands, { depth: 0, runner: 'npx' })
|
|
50
|
+
|
|
51
|
+
// depth 0 = single skill
|
|
52
|
+
expect(result.skills).toHaveLength(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('propagates runner errors', async () => {
|
|
56
|
+
mockExecError = new Error('skills not found')
|
|
57
|
+
|
|
58
|
+
const cli = Cli.create('test')
|
|
59
|
+
cli.command('ping', { run: () => ({}) })
|
|
60
|
+
|
|
61
|
+
const commands = Cli.toCommands.get(cli)!
|
|
62
|
+
await expect(SyncSkills.sync('test', commands, { runner: 'npx' })).rejects.toThrow(
|
|
63
|
+
'skills not found',
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('writes hash after successful sync', async () => {
|
|
68
|
+
const tmp = join(tmpdir(), `clac-hash-test-${Date.now()}`)
|
|
69
|
+
mkdirSync(tmp, { recursive: true })
|
|
70
|
+
process.env.XDG_DATA_HOME = tmp
|
|
71
|
+
|
|
72
|
+
const cli = Cli.create('hash-test')
|
|
73
|
+
cli.command('ping', { description: 'Health check', run: () => ({}) })
|
|
74
|
+
|
|
75
|
+
const commands = Cli.toCommands.get(cli)!
|
|
76
|
+
await SyncSkills.sync('hash-test', commands, { runner: 'npx' })
|
|
77
|
+
|
|
78
|
+
const stored = SyncSkills.readHash('hash-test')
|
|
79
|
+
expect(stored).toMatch(/^[0-9a-f]{16}$/)
|
|
80
|
+
|
|
81
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('readHash returns undefined when no hash exists', () => {
|
|
85
|
+
const tmp = join(tmpdir(), `clac-hash-test-${Date.now()}`)
|
|
86
|
+
mkdirSync(tmp, { recursive: true })
|
|
87
|
+
process.env.XDG_DATA_HOME = tmp
|
|
88
|
+
|
|
89
|
+
expect(SyncSkills.readHash('nonexistent')).toBeUndefined()
|
|
90
|
+
|
|
91
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
92
|
+
})
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import fsSync from 'node:fs'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { formatExamples } from './Cli.js'
|
|
8
|
+
import { detectRunner } from './internal/pm.js'
|
|
9
|
+
import * as Skill from './Skill.js'
|
|
10
|
+
|
|
11
|
+
/** Generates skill files from a command map and installs them via `skills add`. */
|
|
12
|
+
export async function sync(
|
|
13
|
+
name: string,
|
|
14
|
+
commands: Map<string, any>,
|
|
15
|
+
options: sync.Options = {},
|
|
16
|
+
): Promise<sync.Result> {
|
|
17
|
+
const cwd = options.cwd ?? resolvePackageRoot()
|
|
18
|
+
const { depth = 1, description, global = true } = options
|
|
19
|
+
|
|
20
|
+
const groups = new Map<string, string>()
|
|
21
|
+
if (description) groups.set(name, description)
|
|
22
|
+
const entries = collectEntries(commands, [], groups)
|
|
23
|
+
const files = Skill.split(name, entries, depth, groups)
|
|
24
|
+
|
|
25
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), `incur-skills-${name}-`))
|
|
26
|
+
try {
|
|
27
|
+
const skills: sync.Skill[] = []
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const filePath = file.dir
|
|
30
|
+
? path.join(tmpDir, file.dir, 'SKILL.md')
|
|
31
|
+
: path.join(tmpDir, 'SKILL.md')
|
|
32
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
33
|
+
await fs.writeFile(filePath, `${file.content}\n`)
|
|
34
|
+
const descMatch = file.content.match(/^description:\s*(.+)$/m)
|
|
35
|
+
skills.push({ name: file.dir || name, description: descMatch?.[1] })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Include additional SKILL.md files matched by glob patterns
|
|
39
|
+
if (options.include) {
|
|
40
|
+
for (const pattern of options.include) {
|
|
41
|
+
const globPattern = pattern === '_root' ? 'SKILL.md' : path.join(pattern, 'SKILL.md')
|
|
42
|
+
for await (const match of fs.glob(globPattern, { cwd })) {
|
|
43
|
+
try {
|
|
44
|
+
const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
|
|
45
|
+
const nameMatch = content.match(/^name:\s*(.+)$/m)
|
|
46
|
+
const skillName =
|
|
47
|
+
pattern === '_root' ? (nameMatch?.[1] ?? name) : path.basename(path.dirname(match))
|
|
48
|
+
const dest = path.join(tmpDir, skillName, 'SKILL.md')
|
|
49
|
+
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
50
|
+
await fs.writeFile(dest, content)
|
|
51
|
+
if (!skills.some((s) => s.name === skillName)) {
|
|
52
|
+
const descMatch = content.match(/^description:\s*(.+)$/m)
|
|
53
|
+
skills.push({ name: skillName, description: descMatch?.[1], external: true })
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const runner = options.runner ?? detectRunner()
|
|
61
|
+
const [cmd, ...prefix] = runner.split(' ')
|
|
62
|
+
const flags = ['--yes', ...(global ? ['--global'] : [])]
|
|
63
|
+
const { stdout } = await exec(cmd!, [...prefix, 'skills', 'add', tmpDir, ...flags])
|
|
64
|
+
|
|
65
|
+
// Extract installed paths from `skills add` output (lines like "✓ ~/path/to/skill")
|
|
66
|
+
const paths = stdout
|
|
67
|
+
.split('\n')
|
|
68
|
+
.filter((l) => l.includes('✓'))
|
|
69
|
+
.map((l) =>
|
|
70
|
+
l
|
|
71
|
+
.replace(/.*✓\s*/, '')
|
|
72
|
+
.replace(/[│┃|]/g, '')
|
|
73
|
+
.trim(),
|
|
74
|
+
)
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
|
|
77
|
+
// Write skills hash for staleness detection
|
|
78
|
+
const entries = collectEntries(commands, [])
|
|
79
|
+
writeHash(name, Skill.hash(entries))
|
|
80
|
+
|
|
81
|
+
return { skills, paths }
|
|
82
|
+
} finally {
|
|
83
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export declare namespace sync {
|
|
88
|
+
/** Options for syncing skills. */
|
|
89
|
+
type Options = {
|
|
90
|
+
/** Working directory for resolving `include` globs. Defaults to `process.cwd()`. */
|
|
91
|
+
cwd?: string | undefined
|
|
92
|
+
/** Grouping depth for skill files. Defaults to `1`. */
|
|
93
|
+
depth?: number | undefined
|
|
94
|
+
/** CLI description, used as the top-level group description. */
|
|
95
|
+
description?: string | undefined
|
|
96
|
+
/** Install globally (`~/.config/agents/skills/`) instead of project-local. Defaults to `true`. */
|
|
97
|
+
global?: boolean | undefined
|
|
98
|
+
/** Glob patterns for directories containing SKILL.md files to include (e.g. `"skills/*"`, `"my-skill"`). Skill name is the parent directory name. */
|
|
99
|
+
include?: string[] | undefined
|
|
100
|
+
/** Override the package manager runner (e.g. `npx`, `pnpx`, `bunx`). Auto-detected if omitted. */
|
|
101
|
+
runner?: string | undefined
|
|
102
|
+
}
|
|
103
|
+
/** Result of a sync operation. */
|
|
104
|
+
type Result = {
|
|
105
|
+
/** Installed paths reported by `skills add`. */
|
|
106
|
+
paths: string[]
|
|
107
|
+
/** Synced skills with metadata. */
|
|
108
|
+
skills: Skill[]
|
|
109
|
+
}
|
|
110
|
+
/** A synced skill entry. */
|
|
111
|
+
type Skill = {
|
|
112
|
+
/** Description extracted from the skill frontmatter. */
|
|
113
|
+
description?: string | undefined
|
|
114
|
+
/** Whether this skill was included from a local file (not generated from commands). */
|
|
115
|
+
external?: boolean | undefined
|
|
116
|
+
/** Skill directory name. */
|
|
117
|
+
name: string
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Recursively collects leaf commands as `Skill.CommandInfo`. */
|
|
122
|
+
function collectEntries(
|
|
123
|
+
commands: Map<string, any>,
|
|
124
|
+
prefix: string[],
|
|
125
|
+
groups: Map<string, string> = new Map(),
|
|
126
|
+
): Skill.CommandInfo[] {
|
|
127
|
+
const result: Skill.CommandInfo[] = []
|
|
128
|
+
for (const [name, entry] of commands) {
|
|
129
|
+
const entryPath = [...prefix, name]
|
|
130
|
+
if ('_group' in entry && entry._group) {
|
|
131
|
+
if (entry.description) groups.set(entryPath.join(' '), entry.description)
|
|
132
|
+
result.push(...collectEntries(entry.commands, entryPath, groups))
|
|
133
|
+
} else {
|
|
134
|
+
const cmd: Skill.CommandInfo = { name: entryPath.join(' ') }
|
|
135
|
+
if (entry.description) cmd.description = entry.description
|
|
136
|
+
if (entry.args) cmd.args = entry.args
|
|
137
|
+
if (entry.env) cmd.env = entry.env
|
|
138
|
+
if (entry.hint) cmd.hint = entry.hint
|
|
139
|
+
if (entry.options) cmd.options = entry.options
|
|
140
|
+
if (entry.output) cmd.output = entry.output
|
|
141
|
+
const examples = formatExamples(entry.examples)
|
|
142
|
+
if (examples) {
|
|
143
|
+
const cmdName = entryPath.join(' ')
|
|
144
|
+
cmd.examples = examples.map((e) => ({
|
|
145
|
+
...e,
|
|
146
|
+
command: e.command ? `${cmdName} ${e.command}` : cmdName,
|
|
147
|
+
}))
|
|
148
|
+
}
|
|
149
|
+
result.push(cmd)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result.sort((a, b) => a.name.localeCompare(b.name))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */
|
|
156
|
+
function resolvePackageRoot(): string {
|
|
157
|
+
const bin = process.argv[1]
|
|
158
|
+
if (!bin) return process.cwd()
|
|
159
|
+
let dir = path.dirname(fsSync.realpathSync(bin))
|
|
160
|
+
const root = path.parse(dir).root
|
|
161
|
+
while (dir !== root) {
|
|
162
|
+
try {
|
|
163
|
+
fsSync.accessSync(path.join(dir, 'package.json'))
|
|
164
|
+
return dir
|
|
165
|
+
} catch {}
|
|
166
|
+
dir = path.dirname(dir)
|
|
167
|
+
}
|
|
168
|
+
return process.cwd()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Returns the hash file path for a CLI. */
|
|
172
|
+
function hashPath(name: string): string {
|
|
173
|
+
const dir = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
|
|
174
|
+
return path.join(dir, 'incur', `${name}.json`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @internal Writes the skills hash for staleness detection. */
|
|
178
|
+
function writeHash(name: string, hash: string) {
|
|
179
|
+
const file = hashPath(name)
|
|
180
|
+
const dir = path.dirname(file)
|
|
181
|
+
if (!fsSync.existsSync(dir)) fsSync.mkdirSync(dir, { recursive: true })
|
|
182
|
+
fsSync.writeFileSync(file, JSON.stringify({ hash, at: new Date().toISOString() }) + '\n')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Reads the stored skills hash for a CLI. Returns `undefined` if no hash exists. */
|
|
186
|
+
export function readHash(name: string): string | undefined {
|
|
187
|
+
try {
|
|
188
|
+
const data = JSON.parse(fsSync.readFileSync(hashPath(name), 'utf-8'))
|
|
189
|
+
return data.hash
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Promisified execFile with stderr in error message. */
|
|
196
|
+
function exec(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
execFile(cmd, args, (error, stdout, stderr) => {
|
|
199
|
+
if (error) {
|
|
200
|
+
const msg = stderr?.trim() || stdout?.trim() || error.message
|
|
201
|
+
reject(new Error(msg))
|
|
202
|
+
} else resolve({ stdout, stderr })
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
}
|