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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/SKILL.md +664 -0
  4. package/dist/Cli.d.ts +255 -0
  5. package/dist/Cli.d.ts.map +1 -0
  6. package/dist/Cli.js +900 -0
  7. package/dist/Cli.js.map +1 -0
  8. package/dist/Errors.d.ts +92 -0
  9. package/dist/Errors.d.ts.map +1 -0
  10. package/dist/Errors.js +75 -0
  11. package/dist/Errors.js.map +1 -0
  12. package/dist/Formatter.d.ts +5 -0
  13. package/dist/Formatter.d.ts.map +1 -0
  14. package/dist/Formatter.js +91 -0
  15. package/dist/Formatter.js.map +1 -0
  16. package/dist/Help.d.ts +53 -0
  17. package/dist/Help.d.ts.map +1 -0
  18. package/dist/Help.js +231 -0
  19. package/dist/Help.js.map +1 -0
  20. package/dist/Mcp.d.ts +13 -0
  21. package/dist/Mcp.d.ts.map +1 -0
  22. package/dist/Mcp.js +140 -0
  23. package/dist/Mcp.js.map +1 -0
  24. package/dist/Parser.d.ts +24 -0
  25. package/dist/Parser.d.ts.map +1 -0
  26. package/dist/Parser.js +215 -0
  27. package/dist/Parser.js.map +1 -0
  28. package/dist/Register.d.ts +19 -0
  29. package/dist/Register.d.ts.map +1 -0
  30. package/dist/Register.js +2 -0
  31. package/dist/Register.js.map +1 -0
  32. package/dist/Schema.d.ts +4 -0
  33. package/dist/Schema.d.ts.map +1 -0
  34. package/dist/Schema.js +8 -0
  35. package/dist/Schema.js.map +1 -0
  36. package/dist/Skill.d.ts +29 -0
  37. package/dist/Skill.d.ts.map +1 -0
  38. package/dist/Skill.js +196 -0
  39. package/dist/Skill.js.map +1 -0
  40. package/dist/Skillgen.d.ts +3 -0
  41. package/dist/Skillgen.d.ts.map +1 -0
  42. package/dist/Skillgen.js +67 -0
  43. package/dist/Skillgen.js.map +1 -0
  44. package/dist/SyncMcp.d.ts +23 -0
  45. package/dist/SyncMcp.d.ts.map +1 -0
  46. package/dist/SyncMcp.js +100 -0
  47. package/dist/SyncMcp.js.map +1 -0
  48. package/dist/SyncSkills.d.ts +38 -0
  49. package/dist/SyncSkills.d.ts.map +1 -0
  50. package/dist/SyncSkills.js +163 -0
  51. package/dist/SyncSkills.js.map +1 -0
  52. package/dist/Typegen.d.ts +6 -0
  53. package/dist/Typegen.d.ts.map +1 -0
  54. package/dist/Typegen.js +92 -0
  55. package/dist/Typegen.js.map +1 -0
  56. package/dist/bin.d.ts +14 -0
  57. package/dist/bin.d.ts.map +1 -0
  58. package/dist/bin.js +30 -0
  59. package/dist/bin.js.map +1 -0
  60. package/dist/index.d.ts +15 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +14 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/internal/pm.d.ts +3 -0
  65. package/dist/internal/pm.d.ts.map +1 -0
  66. package/dist/internal/pm.js +11 -0
  67. package/dist/internal/pm.js.map +1 -0
  68. package/dist/internal/types.d.ts +11 -0
  69. package/dist/internal/types.d.ts.map +1 -0
  70. package/dist/internal/types.js +2 -0
  71. package/dist/internal/types.js.map +1 -0
  72. package/dist/internal/utils.d.ts +8 -0
  73. package/dist/internal/utils.d.ts.map +1 -0
  74. package/dist/internal/utils.js +51 -0
  75. package/dist/internal/utils.js.map +1 -0
  76. package/examples/npm/cli.ts +180 -0
  77. package/examples/npm/node_modules/.bin/incur.src +21 -0
  78. package/examples/npm/node_modules/.bin/tsx +21 -0
  79. package/examples/npm/package.json +14 -0
  80. package/examples/npm/tsconfig.json +9 -0
  81. package/examples/presto/cli.ts +246 -0
  82. package/examples/presto/node_modules/.bin/incur.src +21 -0
  83. package/examples/presto/node_modules/.bin/tsx +21 -0
  84. package/examples/presto/package.json +14 -0
  85. package/examples/presto/tsconfig.json +9 -0
  86. package/package.json +53 -2
  87. package/src/Cli.test-d.ts +135 -0
  88. package/src/Cli.test.ts +1373 -0
  89. package/src/Cli.ts +1470 -0
  90. package/src/Errors.test.ts +96 -0
  91. package/src/Errors.ts +139 -0
  92. package/src/Formatter.test.ts +245 -0
  93. package/src/Formatter.ts +106 -0
  94. package/src/Help.test.ts +124 -0
  95. package/src/Help.ts +302 -0
  96. package/src/Mcp.test.ts +254 -0
  97. package/src/Mcp.ts +195 -0
  98. package/src/Parser.test-d.ts +45 -0
  99. package/src/Parser.test.ts +118 -0
  100. package/src/Parser.ts +247 -0
  101. package/src/Register.ts +18 -0
  102. package/src/Schema.test.ts +125 -0
  103. package/src/Schema.ts +8 -0
  104. package/src/Skill.test.ts +293 -0
  105. package/src/Skill.ts +253 -0
  106. package/src/Skillgen.ts +66 -0
  107. package/src/SyncMcp.test.ts +75 -0
  108. package/src/SyncMcp.ts +132 -0
  109. package/src/SyncSkills.test.ts +92 -0
  110. package/src/SyncSkills.ts +205 -0
  111. package/src/Typegen.test.ts +150 -0
  112. package/src/Typegen.ts +107 -0
  113. package/src/bin.ts +33 -0
  114. package/src/e2e.test.ts +1710 -0
  115. package/src/index.ts +14 -0
  116. package/src/internal/pm.test.ts +38 -0
  117. package/src/internal/pm.ts +8 -0
  118. package/src/internal/types.ts +22 -0
  119. package/src/internal/utils.ts +50 -0
  120. package/src/tsconfig.json +8 -0
@@ -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
+ }