incur 0.4.0 → 0.4.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/README.md +83 -22
- package/SKILL.md +6 -6
- package/dist/Cli.d.ts +46 -26
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +728 -441
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +4 -3
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js +17 -10
- package/dist/Completions.js.map +1 -1
- package/dist/Fetch.d.ts.map +1 -1
- package/dist/Fetch.js +10 -9
- package/dist/Fetch.js.map +1 -1
- package/dist/Filter.js +0 -18
- package/dist/Filter.js.map +1 -1
- package/dist/Formatter.d.ts.map +1 -1
- package/dist/Formatter.js +6 -1
- package/dist/Formatter.js.map +1 -1
- package/dist/Help.d.ts +7 -1
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +44 -27
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +37 -5
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +71 -72
- package/dist/Mcp.js.map +1 -1
- package/dist/Openapi.d.ts.map +1 -1
- package/dist/Openapi.js +22 -14
- package/dist/Openapi.js.map +1 -1
- package/dist/Parser.d.ts +4 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +70 -38
- package/dist/Parser.js.map +1 -1
- package/dist/Schema.d.ts +5 -1
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +13 -2
- package/dist/Schema.js.map +1 -1
- package/dist/Skill.d.ts +2 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +33 -19
- package/dist/Skill.js.map +1 -1
- package/dist/Skillgen.js +1 -1
- package/dist/Skillgen.js.map +1 -1
- package/dist/SyncSkills.d.ts +48 -0
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +108 -10
- package/dist/SyncSkills.js.map +1 -1
- package/dist/Typegen.js +4 -2
- package/dist/Typegen.js.map +1 -1
- package/dist/bin.d.ts +2 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +17 -2
- package/dist/bin.js.map +1 -1
- package/dist/internal/command.d.ts +170 -0
- package/dist/internal/command.d.ts.map +1 -0
- package/dist/internal/command.js +292 -0
- package/dist/internal/command.js.map +1 -0
- package/dist/internal/configSchema.d.ts +8 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +57 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/dereference.d.ts +12 -0
- package/dist/internal/dereference.d.ts.map +1 -0
- package/dist/internal/dereference.js +71 -0
- package/dist/internal/dereference.js.map +1 -0
- package/dist/internal/helpers.d.ts +9 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +54 -0
- package/dist/internal/helpers.js.map +1 -0
- package/dist/middleware.d.ts +6 -8
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +1 -1
- package/dist/middleware.js.map +1 -1
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +134 -0
- package/package.json +6 -29
- package/src/Cli.test-d.ts +44 -33
- package/src/Cli.test.ts +1231 -101
- package/src/Cli.ts +877 -569
- package/src/Completions.test.ts +136 -12
- package/src/Completions.ts +18 -13
- package/src/Fetch.test.ts +21 -0
- package/src/Fetch.ts +8 -10
- package/src/Filter.ts +0 -17
- package/src/Formatter.test.ts +15 -2
- package/src/Formatter.ts +5 -1
- package/src/Help.test.ts +184 -20
- package/src/Help.ts +52 -28
- package/src/Mcp.test.ts +159 -0
- package/src/Mcp.ts +108 -86
- package/src/Openapi.test.ts +17 -5
- package/src/Openapi.ts +21 -15
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +87 -36
- package/src/Schema.test.ts +29 -0
- package/src/Schema.ts +12 -2
- package/src/Skill.test.ts +87 -6
- package/src/Skill.ts +38 -21
- package/src/Skillgen.ts +1 -1
- package/src/SyncMcp.test.ts +6 -8
- package/src/SyncSkills.test.ts +146 -3
- package/src/SyncSkills.ts +191 -10
- package/src/Typegen.test.ts +15 -0
- package/src/Typegen.ts +4 -2
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +188 -98
- package/src/internal/command.ts +449 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- package/src/internal/dereference.test.ts +695 -0
- package/src/internal/dereference.ts +75 -0
- package/src/internal/helpers.test.ts +75 -0
- package/src/internal/helpers.ts +59 -0
- package/src/middleware.ts +5 -12
package/src/Parser.ts
CHANGED
|
@@ -2,29 +2,20 @@ import type { z } from 'zod'
|
|
|
2
2
|
|
|
3
3
|
import type { FieldError } from './Errors.js'
|
|
4
4
|
import { ParseError, ValidationError } from './Errors.js'
|
|
5
|
+
import { isRecord, toKebab } from './internal/helpers.js'
|
|
5
6
|
|
|
6
7
|
/** Parses raw argv tokens against Zod schemas for args and options. */
|
|
7
8
|
export function parse<
|
|
8
9
|
const args extends z.ZodObject<any> | undefined = undefined,
|
|
9
10
|
const options extends z.ZodObject<any> | undefined = undefined,
|
|
10
11
|
>(argv: string[], options: parse.Options<args, options> = {}): parse.ReturnType<args, options> {
|
|
11
|
-
const { args: argsSchema, options: optionsSchema, alias } = options
|
|
12
|
+
const { args: argsSchema, options: optionsSchema, alias, defaults } = options
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
const aliasToName = new Map<string, string>()
|
|
15
|
-
if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
|
|
16
|
-
|
|
17
|
-
// Known option names from schema, plus kebab-case → camelCase map
|
|
18
|
-
const knownOptions = new Set(optionsSchema ? Object.keys(optionsSchema.shape) : [])
|
|
19
|
-
const kebabToCamel = new Map<string, string>()
|
|
20
|
-
for (const name of knownOptions) {
|
|
21
|
-
const kebab = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
22
|
-
if (kebab !== name) kebabToCamel.set(kebab, name)
|
|
23
|
-
}
|
|
14
|
+
const optionNames = createOptionNames(optionsSchema, alias)
|
|
24
15
|
|
|
25
16
|
// First pass: split argv into positional tokens and raw option values
|
|
26
17
|
const positionals: string[] = []
|
|
27
|
-
const
|
|
18
|
+
const rawArgvOptions: Record<string, unknown> = {}
|
|
28
19
|
|
|
29
20
|
let i = 0
|
|
30
21
|
while (i < argv.length) {
|
|
@@ -32,36 +23,34 @@ export function parse<
|
|
|
32
23
|
|
|
33
24
|
if (token.startsWith('--no-') && token.length > 5) {
|
|
34
25
|
// --no-flag negation
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
rawOptions[name] = false
|
|
26
|
+
const name = normalizeOptionName(token.slice(5), optionNames)
|
|
27
|
+
if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
|
|
28
|
+
rawArgvOptions[name] = false
|
|
39
29
|
i++
|
|
40
30
|
} else if (token.startsWith('--')) {
|
|
41
31
|
const eqIdx = token.indexOf('=')
|
|
42
32
|
if (eqIdx !== -1) {
|
|
43
33
|
// --flag=value
|
|
44
34
|
const raw = token.slice(2, eqIdx)
|
|
45
|
-
const name =
|
|
46
|
-
if (!
|
|
47
|
-
setOption(
|
|
35
|
+
const name = normalizeOptionName(raw, optionNames)
|
|
36
|
+
if (!name) throw new ParseError({ message: `Unknown flag: --${raw}` })
|
|
37
|
+
setOption(rawArgvOptions, name, token.slice(eqIdx + 1), optionsSchema)
|
|
48
38
|
i++
|
|
49
39
|
} else {
|
|
50
40
|
// --flag [value]
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
if (!knownOptions.has(name)) throw new ParseError({ message: `Unknown flag: ${token}` })
|
|
41
|
+
const name = normalizeOptionName(token.slice(2), optionNames)
|
|
42
|
+
if (!name) throw new ParseError({ message: `Unknown flag: ${token}` })
|
|
54
43
|
if (isCountOption(name, optionsSchema)) {
|
|
55
|
-
|
|
44
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
56
45
|
i++
|
|
57
46
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
58
|
-
|
|
47
|
+
rawArgvOptions[name] = true
|
|
59
48
|
i++
|
|
60
49
|
} else {
|
|
61
50
|
const value = argv[i + 1]
|
|
62
51
|
if (value === undefined)
|
|
63
52
|
throw new ParseError({ message: `Missing value for flag: ${token}` })
|
|
64
|
-
setOption(
|
|
53
|
+
setOption(rawArgvOptions, name, value, optionsSchema)
|
|
65
54
|
i += 2
|
|
66
55
|
}
|
|
67
56
|
}
|
|
@@ -70,28 +59,28 @@ export function parse<
|
|
|
70
59
|
const chars = token.slice(1)
|
|
71
60
|
for (let j = 0; j < chars.length; j++) {
|
|
72
61
|
const short = chars[j]!
|
|
73
|
-
const name = aliasToName.get(short)
|
|
62
|
+
const name = optionNames.aliasToName.get(short)
|
|
74
63
|
if (!name) throw new ParseError({ message: `Unknown flag: -${short}` })
|
|
75
64
|
const isLast = j === chars.length - 1
|
|
76
65
|
if (!isLast) {
|
|
77
66
|
if (isCountOption(name, optionsSchema)) {
|
|
78
|
-
|
|
67
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
79
68
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
80
|
-
|
|
69
|
+
rawArgvOptions[name] = true
|
|
81
70
|
} else {
|
|
82
71
|
throw new ParseError({
|
|
83
72
|
message: `Non-boolean flag -${short} must be last in a stacked alias`,
|
|
84
73
|
})
|
|
85
74
|
}
|
|
86
75
|
} else if (isCountOption(name, optionsSchema)) {
|
|
87
|
-
|
|
76
|
+
rawArgvOptions[name] = ((rawArgvOptions[name] as number) ?? 0) + 1
|
|
88
77
|
} else if (isBooleanOption(name, optionsSchema)) {
|
|
89
|
-
|
|
78
|
+
rawArgvOptions[name] = true
|
|
90
79
|
} else {
|
|
91
80
|
const value = argv[i + 1]
|
|
92
81
|
if (value === undefined)
|
|
93
82
|
throw new ParseError({ message: `Missing value for flag: -${short}` })
|
|
94
|
-
setOption(
|
|
83
|
+
setOption(rawArgvOptions, name, value, optionsSchema)
|
|
95
84
|
i++
|
|
96
85
|
}
|
|
97
86
|
}
|
|
@@ -117,15 +106,19 @@ export function parse<
|
|
|
117
106
|
// Validate args through zod
|
|
118
107
|
const args = argsSchema ? zodParse(argsSchema, rawArgs) : {}
|
|
119
108
|
|
|
109
|
+
const rawDefaults = normalizeOptionDefaults(defaults, optionsSchema, optionNames)
|
|
110
|
+
|
|
120
111
|
// Coerce raw option values before zod validation
|
|
121
112
|
if (optionsSchema) {
|
|
122
|
-
for (const [name, value] of Object.entries(
|
|
123
|
-
|
|
113
|
+
for (const [name, value] of Object.entries(rawArgvOptions)) {
|
|
114
|
+
rawArgvOptions[name] = coerce(value, name, optionsSchema)
|
|
124
115
|
}
|
|
125
116
|
}
|
|
126
117
|
|
|
118
|
+
const mergedOptions = { ...rawDefaults, ...rawArgvOptions }
|
|
119
|
+
|
|
127
120
|
// Validate options through zod
|
|
128
|
-
const parsedOptions = optionsSchema ? zodParse(optionsSchema,
|
|
121
|
+
const parsedOptions = optionsSchema ? zodParse(optionsSchema, mergedOptions) : {}
|
|
129
122
|
|
|
130
123
|
return { args, options: parsedOptions } as parse.ReturnType<args, options>
|
|
131
124
|
}
|
|
@@ -138,6 +131,8 @@ export declare namespace parse {
|
|
|
138
131
|
> = {
|
|
139
132
|
/** Zod schema for positional arguments. Keys define order. */
|
|
140
133
|
args?: args
|
|
134
|
+
/** Config-backed option defaults merged before argv parsing. */
|
|
135
|
+
defaults?: options extends z.ZodObject<any> ? Partial<z.input<options>> | undefined : undefined
|
|
141
136
|
/** Zod schema for named options/flags. */
|
|
142
137
|
options?: options
|
|
143
138
|
/** Map of option names to single-char aliases. */
|
|
@@ -155,6 +150,62 @@ export declare namespace parse {
|
|
|
155
150
|
}
|
|
156
151
|
}
|
|
157
152
|
|
|
153
|
+
type OptionNames = {
|
|
154
|
+
aliasToName: Map<string, string>
|
|
155
|
+
kebabToCamel: Map<string, string>
|
|
156
|
+
knownOptions: Set<string>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Builds lookup tables for option names and short aliases. */
|
|
160
|
+
function createOptionNames(
|
|
161
|
+
schema: z.ZodObject<any> | undefined,
|
|
162
|
+
alias: Record<string, string> | undefined,
|
|
163
|
+
): OptionNames {
|
|
164
|
+
const aliasToName = new Map<string, string>()
|
|
165
|
+
if (alias) for (const [name, short] of Object.entries(alias)) aliasToName.set(short, name)
|
|
166
|
+
|
|
167
|
+
const knownOptions = new Set(schema ? Object.keys(schema.shape) : [])
|
|
168
|
+
const kebabToCamel = new Map<string, string>()
|
|
169
|
+
for (const name of knownOptions) {
|
|
170
|
+
const kebab = toKebab(name)
|
|
171
|
+
if (kebab !== name) kebabToCamel.set(kebab, name)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { aliasToName, kebabToCamel, knownOptions }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Normalizes a long option name, accepting kebab-case aliases for camelCase schema keys. */
|
|
178
|
+
function normalizeOptionName(raw: string, options: OptionNames): string | undefined {
|
|
179
|
+
const name = options.kebabToCamel.get(raw) ?? raw
|
|
180
|
+
return options.knownOptions.has(name) ? name : undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Normalizes config-backed defaults and validates config structure/key names. */
|
|
184
|
+
function normalizeOptionDefaults(
|
|
185
|
+
defaults: unknown,
|
|
186
|
+
schema: z.ZodObject<any> | undefined,
|
|
187
|
+
optionNames: OptionNames,
|
|
188
|
+
): Record<string, unknown> {
|
|
189
|
+
if (defaults === undefined) return {}
|
|
190
|
+
if (!isRecord(defaults))
|
|
191
|
+
throw new ParseError({
|
|
192
|
+
message: 'Invalid config section: expected an object of option defaults',
|
|
193
|
+
})
|
|
194
|
+
if (!schema) {
|
|
195
|
+
const [first] = Object.keys(defaults)
|
|
196
|
+
if (first) throw new ParseError({ message: `Unknown config option: ${first}` })
|
|
197
|
+
return {}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const normalized: Record<string, unknown> = {}
|
|
201
|
+
for (const [rawName, value] of Object.entries(defaults)) {
|
|
202
|
+
const name = normalizeOptionName(rawName, optionNames)
|
|
203
|
+
if (!name) throw new ParseError({ message: `Unknown config option: ${rawName}` })
|
|
204
|
+
normalized[name] = value
|
|
205
|
+
}
|
|
206
|
+
return normalized
|
|
207
|
+
}
|
|
208
|
+
|
|
158
209
|
/** Unwraps ZodDefault/ZodOptional to get the inner type. */
|
|
159
210
|
function unwrap(schema: z.ZodType): z.ZodType {
|
|
160
211
|
let s = schema as any
|
|
@@ -264,7 +315,7 @@ function coerce(value: unknown, name: string, schema: z.ZodObject<any>): unknown
|
|
|
264
315
|
}
|
|
265
316
|
|
|
266
317
|
/** Returns the best available env source for the current runtime. */
|
|
267
|
-
function defaultEnvSource(): Record<string, string | undefined> {
|
|
318
|
+
export function defaultEnvSource(): Record<string, string | undefined> {
|
|
268
319
|
if (typeof globalThis !== 'undefined') {
|
|
269
320
|
const g = globalThis as any
|
|
270
321
|
if (g.process?.env) return g.process.env
|
package/src/Schema.test.ts
CHANGED
|
@@ -97,6 +97,35 @@ describe('toJsonSchema', () => {
|
|
|
97
97
|
})
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
test('converts z.bigint() as string', () => {
|
|
101
|
+
expect(Schema.toJsonSchema(z.bigint())).toEqual({ type: 'string' })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('converts z.coerce.bigint() as string', () => {
|
|
105
|
+
expect(Schema.toJsonSchema(z.coerce.bigint())).toEqual({ type: 'string' })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('converts z.object() with bigint field', () => {
|
|
109
|
+
expect(
|
|
110
|
+
Schema.toJsonSchema(z.object({ amount: z.coerce.bigint().describe('Token amount') })),
|
|
111
|
+
).toEqual({
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
amount: { type: 'string', description: 'Token amount' },
|
|
115
|
+
},
|
|
116
|
+
required: ['amount'],
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('converts z.date() as string', () => {
|
|
122
|
+
expect(Schema.toJsonSchema(z.date())).toEqual({ type: 'string' })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('converts z.coerce.date() as string', () => {
|
|
126
|
+
expect(Schema.toJsonSchema(z.coerce.date())).toEqual({ type: 'string' })
|
|
127
|
+
})
|
|
128
|
+
|
|
100
129
|
test('full object with optional, default, and describe', () => {
|
|
101
130
|
const result = Schema.toJsonSchema(
|
|
102
131
|
z.object({
|
package/src/Schema.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Converts a Zod schema to a JSON Schema object. Strips the `$schema`
|
|
5
|
+
* meta-property. Represents bigints and dates as `{ type: "string" }`
|
|
6
|
+
* since JSON lacks native types for them.
|
|
7
|
+
*/
|
|
4
8
|
export function toJsonSchema(schema: z.ZodType): Record<string, unknown> {
|
|
5
|
-
const result = z.toJSONSchema(schema
|
|
9
|
+
const result = z.toJSONSchema(schema, {
|
|
10
|
+
unrepresentable: 'any',
|
|
11
|
+
override: (ctx) => {
|
|
12
|
+
const type = ctx.zodSchema._zod?.def?.type
|
|
13
|
+
if (type === 'bigint' || type === 'date') ctx.jsonSchema.type = 'string'
|
|
14
|
+
},
|
|
15
|
+
}) as Record<string, unknown>
|
|
6
16
|
delete result.$schema
|
|
7
17
|
return result
|
|
8
18
|
}
|
package/src/Skill.test.ts
CHANGED
|
@@ -216,6 +216,40 @@ describe('hash', () => {
|
|
|
216
216
|
})
|
|
217
217
|
})
|
|
218
218
|
|
|
219
|
+
describe('root command (no name)', () => {
|
|
220
|
+
test('generate renders root command without trailing space', () => {
|
|
221
|
+
const result = Skill.generate('my-cli', [
|
|
222
|
+
{
|
|
223
|
+
description: 'Fetch a URL',
|
|
224
|
+
args: z.object({ url: z.string().describe('URL to fetch') }),
|
|
225
|
+
},
|
|
226
|
+
{ name: 'auth', description: 'Auth commands' },
|
|
227
|
+
])
|
|
228
|
+
expect(result).toContain('# my-cli\n\nFetch a URL')
|
|
229
|
+
expect(result).not.toContain('# my-cli \n')
|
|
230
|
+
expect(result).toContain('| `url` | `string` | yes | URL to fetch |')
|
|
231
|
+
expect(result).toContain('# my-cli auth')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('index renders root command signature without trailing space', () => {
|
|
235
|
+
const result = Skill.index('my-cli', [
|
|
236
|
+
{ description: 'Fetch a URL', args: z.object({ url: z.string() }) },
|
|
237
|
+
{ name: 'auth', description: 'Auth commands' },
|
|
238
|
+
])
|
|
239
|
+
expect(result).toContain('| `my-cli <url>` | Fetch a URL |')
|
|
240
|
+
expect(result).toContain('| `my-cli auth` | Auth commands |')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('hash changes when root command is added', () => {
|
|
244
|
+
const a = Skill.hash([{ name: 'ping', description: 'Health check' }])
|
|
245
|
+
const b = Skill.hash([
|
|
246
|
+
{ description: 'Root command' },
|
|
247
|
+
{ name: 'ping', description: 'Health check' },
|
|
248
|
+
])
|
|
249
|
+
expect(a).not.toBe(b)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
219
253
|
describe('split', () => {
|
|
220
254
|
const commands: Skill.CommandInfo[] = [
|
|
221
255
|
{ name: 'auth login', description: 'Log in' },
|
|
@@ -252,7 +286,7 @@ describe('split', () => {
|
|
|
252
286
|
expect(files[0]!.content).toMatchInlineSnapshot(`
|
|
253
287
|
"---
|
|
254
288
|
name: gh-auth
|
|
255
|
-
description: Authenticate with GitHub.
|
|
289
|
+
description: Authenticate with GitHub. Run \`gh auth --help\` for usage details.
|
|
256
290
|
requires_bin: gh
|
|
257
291
|
command: gh auth
|
|
258
292
|
---
|
|
@@ -270,7 +304,7 @@ describe('split', () => {
|
|
|
270
304
|
expect(files[1]!.content).toMatchInlineSnapshot(`
|
|
271
305
|
"---
|
|
272
306
|
name: gh-pr
|
|
273
|
-
description: Manage pull requests.
|
|
307
|
+
description: Manage pull requests. Run \`gh pr --help\` for usage details.
|
|
274
308
|
requires_bin: gh
|
|
275
309
|
command: gh pr
|
|
276
310
|
---
|
|
@@ -287,11 +321,9 @@ describe('split', () => {
|
|
|
287
321
|
`)
|
|
288
322
|
})
|
|
289
323
|
|
|
290
|
-
test('depth 1 without group descriptions uses
|
|
324
|
+
test('depth 1 without group descriptions uses fallback hint', () => {
|
|
291
325
|
const files = Skill.split('gh', commands, 1)
|
|
292
|
-
expect(files[0]!.content).toContain(
|
|
293
|
-
'description: Log in, Check status. Run `gh auth --help` for usage details.',
|
|
294
|
-
)
|
|
326
|
+
expect(files[0]!.content).toContain('description: Run `gh auth --help` for usage details.')
|
|
295
327
|
})
|
|
296
328
|
|
|
297
329
|
test('depth 2 groups by first two segments', () => {
|
|
@@ -349,4 +381,53 @@ describe('split', () => {
|
|
|
349
381
|
expect(afterFrontmatter).not.toMatch(/^title:/m)
|
|
350
382
|
expect(afterFrontmatter).not.toMatch(/^command:/m)
|
|
351
383
|
})
|
|
384
|
+
|
|
385
|
+
test('root command uses command description in frontmatter', () => {
|
|
386
|
+
const cmds: Skill.CommandInfo[] = [
|
|
387
|
+
{ description: 'Fetch a URL' },
|
|
388
|
+
{ name: 'auth login', description: 'Log in' },
|
|
389
|
+
]
|
|
390
|
+
const files = Skill.split('my-cli', cmds, 1)
|
|
391
|
+
const rootFile = files.find((f) => f.dir === 'my-cli')!
|
|
392
|
+
expect(rootFile.content).toContain(
|
|
393
|
+
'description: Fetch a URL. Run `my-cli --help` for usage details.',
|
|
394
|
+
)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('single-command group uses command description in frontmatter', () => {
|
|
398
|
+
const files = Skill.split('test', [{ name: 'ping', description: 'Health check' }], 1)
|
|
399
|
+
expect(files[0]!.content).toContain(
|
|
400
|
+
'description: Health check. Run `test ping --help` for usage details.',
|
|
401
|
+
)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test('depth 1 creates separate file for root command', () => {
|
|
405
|
+
const cmds: Skill.CommandInfo[] = [
|
|
406
|
+
{
|
|
407
|
+
description: 'Fetch a URL',
|
|
408
|
+
args: z.object({ url: z.string().describe('URL to fetch') }),
|
|
409
|
+
},
|
|
410
|
+
{ name: 'auth login', description: 'Log in' },
|
|
411
|
+
{ name: 'auth status', description: 'Check status' },
|
|
412
|
+
]
|
|
413
|
+
const files = Skill.split('my-cli', cmds, 1)
|
|
414
|
+
expect(files.map((f) => f.dir)).toEqual(['auth', 'my-cli'])
|
|
415
|
+
const rootFile = files.find((f) => f.dir === 'my-cli')!
|
|
416
|
+
expect(rootFile.content).toContain('name: my-cli')
|
|
417
|
+
expect(rootFile.content).toContain('command: my-cli')
|
|
418
|
+
expect(rootFile.content).toContain('# my-cli')
|
|
419
|
+
expect(rootFile.content).toContain('| `url` | `string` | yes | URL to fetch |')
|
|
420
|
+
expect(rootFile.content).not.toContain('# my-cli ')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('depth 0 includes root command in single file', () => {
|
|
424
|
+
const cmds: Skill.CommandInfo[] = [
|
|
425
|
+
{ description: 'Fetch a URL' },
|
|
426
|
+
{ name: 'ping', description: 'Health check' },
|
|
427
|
+
]
|
|
428
|
+
const files = Skill.split('test', cmds, 0)
|
|
429
|
+
expect(files).toHaveLength(1)
|
|
430
|
+
expect(files[0]!.content).toContain('# test\n\nFetch a URL')
|
|
431
|
+
expect(files[0]!.content).toContain('# test ping')
|
|
432
|
+
})
|
|
352
433
|
})
|
package/src/Skill.ts
CHANGED
|
@@ -5,7 +5,8 @@ import * as Schema from './Schema.js'
|
|
|
5
5
|
|
|
6
6
|
/** Information about a single command, passed to `generate()`. */
|
|
7
7
|
export type CommandInfo = {
|
|
8
|
-
name
|
|
8
|
+
/** Command name (subcommand path). Omit for root commands. */
|
|
9
|
+
name?: string | undefined
|
|
9
10
|
description?: string | undefined
|
|
10
11
|
args?: z.ZodObject<any> | undefined
|
|
11
12
|
env?: z.ZodObject<any> | undefined
|
|
@@ -48,7 +49,7 @@ export function index(
|
|
|
48
49
|
|
|
49
50
|
/** @internal Builds a command signature with arg placeholders. */
|
|
50
51
|
function buildSignature(cli: string, cmd: CommandInfo): string {
|
|
51
|
-
const base = `${cli} ${cmd.name}`
|
|
52
|
+
const base = !cmd.name ? cli : `${cli} ${cmd.name}`
|
|
52
53
|
if (!cmd.args) return base
|
|
53
54
|
const shape = cmd.args.shape as Record<string, z.ZodType>
|
|
54
55
|
const json = Schema.toJsonSchema(cmd.args)
|
|
@@ -70,14 +71,16 @@ export function generate(
|
|
|
70
71
|
let lastGroup: string | undefined
|
|
71
72
|
|
|
72
73
|
for (const cmd of commands) {
|
|
73
|
-
const segment = cmd.name.split(' ')[0]!
|
|
74
|
+
const segment = !cmd.name ? '' : cmd.name.split(' ')[0]!
|
|
74
75
|
if (segment !== lastGroup) {
|
|
75
76
|
lastGroup = segment
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
if (segment) {
|
|
78
|
+
const desc = groups.get(segment)
|
|
79
|
+
const heading = desc ? `## ${name} ${segment}\n\n${desc}` : `## ${name} ${segment}`
|
|
80
|
+
sections.push(heading)
|
|
81
|
+
}
|
|
79
82
|
}
|
|
80
|
-
sections.push(renderCommandBody(name, cmd, 3))
|
|
83
|
+
sections.push(renderCommandBody(name, cmd, segment ? 3 : 2))
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
return sections.join('\n\n')
|
|
@@ -94,6 +97,13 @@ export function split(
|
|
|
94
97
|
|
|
95
98
|
const buckets = new Map<string, CommandInfo[]>()
|
|
96
99
|
for (const cmd of commands) {
|
|
100
|
+
if (!cmd.name) {
|
|
101
|
+
const key = slugify(name)
|
|
102
|
+
const bucket = buckets.get(key) ?? []
|
|
103
|
+
bucket.push(cmd)
|
|
104
|
+
buckets.set(key, bucket)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
97
107
|
const segments = cmd.name.split(' ')
|
|
98
108
|
const key = segments.slice(0, depth).join('-')
|
|
99
109
|
const bucket = buckets.get(key) ?? []
|
|
@@ -104,8 +114,10 @@ export function split(
|
|
|
104
114
|
return [...buckets.entries()]
|
|
105
115
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
106
116
|
.map(([dir, cmds]) => {
|
|
107
|
-
const
|
|
108
|
-
|
|
117
|
+
const first = cmds[0]!
|
|
118
|
+
const prefix = !first.name ? '' : first.name.split(' ').slice(0, depth).join(' ')
|
|
119
|
+
const title = prefix ? `${name} ${prefix}` : name
|
|
120
|
+
return { dir, content: renderGroup(name, title, cmds, groups, prefix || undefined) }
|
|
109
121
|
})
|
|
110
122
|
}
|
|
111
123
|
|
|
@@ -118,17 +130,13 @@ function renderGroup(
|
|
|
118
130
|
prefix?: string | undefined,
|
|
119
131
|
): string {
|
|
120
132
|
const groupDesc = prefix ? groups.get(prefix) : undefined
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
: `Run \`${title} --help\` for usage details.`
|
|
129
|
-
|
|
130
|
-
const slug = title.replace(/\s+/g, '-')
|
|
131
|
-
const fm = ['---', `name: ${slug}`]
|
|
133
|
+
const fallbackDesc = cmds.length === 1 && cmds[0]!.description ? cmds[0]!.description : undefined
|
|
134
|
+
const desc = groupDesc ?? fallbackDesc
|
|
135
|
+
const description = desc
|
|
136
|
+
? `${desc.replace(/\.$/, '')}. Run \`${title} --help\` for usage details.`
|
|
137
|
+
: `Run \`${title} --help\` for usage details.`
|
|
138
|
+
|
|
139
|
+
const fm = ['---', `name: ${slugify(title)}`]
|
|
132
140
|
fm.push(`description: ${description}`)
|
|
133
141
|
fm.push(`requires_bin: ${cli}`)
|
|
134
142
|
fm.push(`command: ${title}`, '---')
|
|
@@ -139,7 +147,7 @@ function renderGroup(
|
|
|
139
147
|
|
|
140
148
|
/** @internal Renders a command's heading and sections without frontmatter. */
|
|
141
149
|
function renderCommandBody(cli: string, cmd: CommandInfo, level = 1): string {
|
|
142
|
-
const fullName = `${cli} ${cmd.name}`
|
|
150
|
+
const fullName = !cmd.name ? cli : `${cli} ${cmd.name}`
|
|
143
151
|
const sections: string[] = []
|
|
144
152
|
const h = (n: number) => '#'.repeat(n)
|
|
145
153
|
|
|
@@ -283,6 +291,15 @@ function schemaToTable(schema: Record<string, unknown>, prefix = ''): string | u
|
|
|
283
291
|
return `| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n${rows.join('\n')}`
|
|
284
292
|
}
|
|
285
293
|
|
|
294
|
+
/** @internal Converts a string to a lowercase slug (e.g. `"my-cli"` → `"my-cli"`, `"My Tool"` → `"my-tool"`). */
|
|
295
|
+
function slugify(s: string): string {
|
|
296
|
+
return s
|
|
297
|
+
.toLowerCase()
|
|
298
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
299
|
+
.replace(/-{2,}/g, '-')
|
|
300
|
+
.replace(/^-|-$/g, '')
|
|
301
|
+
}
|
|
302
|
+
|
|
286
303
|
/** @internal Resolves a simple type name from a JSON Schema property. */
|
|
287
304
|
function resolveTypeName(prop: Record<string, unknown> | undefined): string {
|
|
288
305
|
if (!prop) return 'unknown'
|
package/src/Skillgen.ts
CHANGED
package/src/SyncMcp.test.ts
CHANGED
|
@@ -19,21 +19,19 @@ vi.mock('node:os', async (importOriginal) => {
|
|
|
19
19
|
}
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
let savedArgv1: string | undefined
|
|
23
22
|
let tmp: string
|
|
24
23
|
|
|
25
24
|
beforeEach(() => {
|
|
26
|
-
savedArgv1 = process.argv[1]
|
|
25
|
+
const savedArgv1 = process.argv[1]
|
|
27
26
|
tmp = join(tmpdir(), `clac-test-${Date.now()}`)
|
|
28
27
|
mkdirSync(join(tmp, 'node_modules', '.bin'), { recursive: true })
|
|
29
28
|
fakeHome = join(tmp, 'home')
|
|
30
29
|
mkdirSync(fakeHome, { recursive: true })
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
rmSync(tmp, { recursive: true, force: true })
|
|
30
|
+
return () => {
|
|
31
|
+
process.argv[1] = savedArgv1!
|
|
32
|
+
fakeHome = undefined
|
|
33
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
34
|
+
}
|
|
37
35
|
})
|
|
38
36
|
|
|
39
37
|
function setupPkg(deps: Record<string, string>) {
|