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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Schema, z } from 'incur'
|
|
2
|
+
|
|
3
|
+
describe('toJsonSchema', () => {
|
|
4
|
+
test('converts z.string()', () => {
|
|
5
|
+
expect(Schema.toJsonSchema(z.string())).toEqual({ type: 'string' })
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
test('converts z.number()', () => {
|
|
9
|
+
expect(Schema.toJsonSchema(z.number())).toEqual({ type: 'number' })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('converts z.boolean()', () => {
|
|
13
|
+
expect(Schema.toJsonSchema(z.boolean())).toEqual({ type: 'boolean' })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('converts z.enum()', () => {
|
|
17
|
+
expect(Schema.toJsonSchema(z.enum(['open', 'closed']))).toEqual({
|
|
18
|
+
type: 'string',
|
|
19
|
+
enum: ['open', 'closed'],
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('converts z.array()', () => {
|
|
24
|
+
expect(Schema.toJsonSchema(z.array(z.string()))).toEqual({
|
|
25
|
+
type: 'array',
|
|
26
|
+
items: { type: 'string' },
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('converts z.object() with required fields', () => {
|
|
31
|
+
expect(Schema.toJsonSchema(z.object({ name: z.string(), count: z.number() }))).toEqual({
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
name: { type: 'string' },
|
|
35
|
+
count: { type: 'number' },
|
|
36
|
+
},
|
|
37
|
+
required: ['name', 'count'],
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('.optional() removes from required', () => {
|
|
43
|
+
expect(
|
|
44
|
+
Schema.toJsonSchema(
|
|
45
|
+
z.object({
|
|
46
|
+
name: z.string(),
|
|
47
|
+
age: z.number().optional(),
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
).toEqual({
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
name: { type: 'string' },
|
|
54
|
+
age: { type: 'number' },
|
|
55
|
+
},
|
|
56
|
+
required: ['name'],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('.default() adds default to schema', () => {
|
|
62
|
+
const result = Schema.toJsonSchema(
|
|
63
|
+
z.object({
|
|
64
|
+
state: z.enum(['open', 'closed']).default('open'),
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
expect(result).toMatchObject({
|
|
68
|
+
properties: {
|
|
69
|
+
state: { type: 'string', enum: ['open', 'closed'], default: 'open' },
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('.describe() adds description', () => {
|
|
75
|
+
const result = Schema.toJsonSchema(
|
|
76
|
+
z.object({
|
|
77
|
+
name: z.string().describe('The user name'),
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
expect(result).toMatchObject({
|
|
81
|
+
properties: {
|
|
82
|
+
name: { type: 'string', description: 'The user name' },
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('full object with optional, default, and describe', () => {
|
|
88
|
+
const result = Schema.toJsonSchema(
|
|
89
|
+
z.object({
|
|
90
|
+
name: z.string().describe('User name'),
|
|
91
|
+
state: z.enum(['open', 'closed']).default('open').describe('Filter state'),
|
|
92
|
+
limit: z.number().optional().describe('Max items'),
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
expect(result).toMatchInlineSnapshot(`
|
|
96
|
+
{
|
|
97
|
+
"additionalProperties": false,
|
|
98
|
+
"properties": {
|
|
99
|
+
"limit": {
|
|
100
|
+
"description": "Max items",
|
|
101
|
+
"type": "number",
|
|
102
|
+
},
|
|
103
|
+
"name": {
|
|
104
|
+
"description": "User name",
|
|
105
|
+
"type": "string",
|
|
106
|
+
},
|
|
107
|
+
"state": {
|
|
108
|
+
"default": "open",
|
|
109
|
+
"description": "Filter state",
|
|
110
|
+
"enum": [
|
|
111
|
+
"open",
|
|
112
|
+
"closed",
|
|
113
|
+
],
|
|
114
|
+
"type": "string",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
"required": [
|
|
118
|
+
"name",
|
|
119
|
+
"state",
|
|
120
|
+
],
|
|
121
|
+
"type": "object",
|
|
122
|
+
}
|
|
123
|
+
`)
|
|
124
|
+
})
|
|
125
|
+
})
|
package/src/Schema.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** Converts a Zod schema to a JSON Schema object. Strips the `$schema` meta-property. */
|
|
4
|
+
export function toJsonSchema(schema: z.ZodType): Record<string, unknown> {
|
|
5
|
+
const result = z.toJSONSchema(schema) as Record<string, unknown>
|
|
6
|
+
delete result.$schema
|
|
7
|
+
return result
|
|
8
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Skill, z } from 'incur'
|
|
2
|
+
|
|
3
|
+
test('generates skill file with frontmatter and heading', () => {
|
|
4
|
+
const result = Skill.generate('test', [{ name: 'ping', description: 'Health check' }])
|
|
5
|
+
expect(result).toMatchInlineSnapshot(`
|
|
6
|
+
"# test ping
|
|
7
|
+
|
|
8
|
+
Health check"
|
|
9
|
+
`)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('includes arguments table', () => {
|
|
13
|
+
const result = Skill.generate('test', [
|
|
14
|
+
{
|
|
15
|
+
name: 'greet',
|
|
16
|
+
description: 'Greet someone',
|
|
17
|
+
args: z.object({ name: z.string().describe('Name to greet') }),
|
|
18
|
+
},
|
|
19
|
+
])
|
|
20
|
+
expect(result).toMatchInlineSnapshot(`
|
|
21
|
+
"# test greet
|
|
22
|
+
|
|
23
|
+
Greet someone
|
|
24
|
+
|
|
25
|
+
## Arguments
|
|
26
|
+
|
|
27
|
+
| Name | Type | Required | Description |
|
|
28
|
+
|------|------|----------|-------------|
|
|
29
|
+
| \`name\` | \`string\` | yes | Name to greet |"
|
|
30
|
+
`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('includes options table', () => {
|
|
34
|
+
const result = Skill.generate('test', [
|
|
35
|
+
{
|
|
36
|
+
name: 'list',
|
|
37
|
+
description: 'List items',
|
|
38
|
+
options: z.object({
|
|
39
|
+
limit: z.number().default(30).describe('Max items'),
|
|
40
|
+
verbose: z.boolean().default(false).describe('Show details'),
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
])
|
|
44
|
+
expect(result).toMatchInlineSnapshot(`
|
|
45
|
+
"# test list
|
|
46
|
+
|
|
47
|
+
List items
|
|
48
|
+
|
|
49
|
+
## Options
|
|
50
|
+
|
|
51
|
+
| Flag | Type | Default | Description |
|
|
52
|
+
|------|------|---------|-------------|
|
|
53
|
+
| \`--limit\` | \`number\` | \`30\` | Max items |
|
|
54
|
+
| \`--verbose\` | \`boolean\` | \`false\` | Show details |"
|
|
55
|
+
`)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('includes output schema', () => {
|
|
59
|
+
const result = Skill.generate('test', [
|
|
60
|
+
{
|
|
61
|
+
name: 'greet',
|
|
62
|
+
description: 'Greet someone',
|
|
63
|
+
output: z.object({ message: z.string().describe('Greeting message') }),
|
|
64
|
+
},
|
|
65
|
+
])
|
|
66
|
+
expect(result).toMatchInlineSnapshot(`
|
|
67
|
+
"# test greet
|
|
68
|
+
|
|
69
|
+
Greet someone
|
|
70
|
+
|
|
71
|
+
## Output
|
|
72
|
+
|
|
73
|
+
| Field | Type | Required | Description |
|
|
74
|
+
|-------|------|----------|-------------|
|
|
75
|
+
| \`message\` | \`string\` | yes | Greeting message |"
|
|
76
|
+
`)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('expands nested output schema', () => {
|
|
80
|
+
const result = Skill.generate('test', [
|
|
81
|
+
{
|
|
82
|
+
name: 'list',
|
|
83
|
+
description: 'List items',
|
|
84
|
+
output: z.object({
|
|
85
|
+
items: z.array(
|
|
86
|
+
z.object({
|
|
87
|
+
id: z.number(),
|
|
88
|
+
meta: z.object({ tag: z.string() }),
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
])
|
|
94
|
+
expect(result).toMatchInlineSnapshot(`
|
|
95
|
+
"# test list
|
|
96
|
+
|
|
97
|
+
List items
|
|
98
|
+
|
|
99
|
+
## Output
|
|
100
|
+
|
|
101
|
+
| Field | Type | Required | Description |
|
|
102
|
+
|-------|------|----------|-------------|
|
|
103
|
+
| \`items\` | \`array\` | yes | |
|
|
104
|
+
| \`items[].id\` | \`number\` | yes | |
|
|
105
|
+
| \`items[].meta\` | \`object\` | yes | |
|
|
106
|
+
| \`items[].meta.tag\` | \`string\` | yes | |"
|
|
107
|
+
`)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('omits sections when not applicable', () => {
|
|
111
|
+
const result = Skill.generate('test', [{ name: 'ping', description: 'Health check' }])
|
|
112
|
+
expect(result).not.toContain('## Arguments')
|
|
113
|
+
expect(result).not.toContain('## Options')
|
|
114
|
+
expect(result).not.toContain('## Output')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('concatenates multiple commands', () => {
|
|
118
|
+
const result = Skill.generate('test', [
|
|
119
|
+
{ name: 'ping', description: 'Health check' },
|
|
120
|
+
{ name: 'pong', description: 'Pong back' },
|
|
121
|
+
])
|
|
122
|
+
expect(result).toMatchInlineSnapshot(`
|
|
123
|
+
"# test ping
|
|
124
|
+
|
|
125
|
+
Health check
|
|
126
|
+
|
|
127
|
+
# test pong
|
|
128
|
+
|
|
129
|
+
Pong back"
|
|
130
|
+
`)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('hash', () => {
|
|
134
|
+
test('returns consistent hash for same commands', () => {
|
|
135
|
+
const commands: Skill.CommandInfo[] = [
|
|
136
|
+
{ name: 'ping', description: 'Health check' },
|
|
137
|
+
{ name: 'greet', description: 'Say hello' },
|
|
138
|
+
]
|
|
139
|
+
expect(Skill.hash(commands)).toBe(Skill.hash(commands))
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('changes when command is added', () => {
|
|
143
|
+
const a = Skill.hash([{ name: 'ping', description: 'Health check' }])
|
|
144
|
+
const b = Skill.hash([
|
|
145
|
+
{ name: 'ping', description: 'Health check' },
|
|
146
|
+
{ name: 'greet', description: 'Say hello' },
|
|
147
|
+
])
|
|
148
|
+
expect(a).not.toBe(b)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('changes when description changes', () => {
|
|
152
|
+
const a = Skill.hash([{ name: 'ping', description: 'Health check' }])
|
|
153
|
+
const b = Skill.hash([{ name: 'ping', description: 'Check health' }])
|
|
154
|
+
expect(a).not.toBe(b)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('changes when schema changes', () => {
|
|
158
|
+
const a = Skill.hash([{ name: 'greet', args: z.object({ name: z.string() }) }])
|
|
159
|
+
const b = Skill.hash([{ name: 'greet', args: z.object({ name: z.string(), age: z.number() }) }])
|
|
160
|
+
expect(a).not.toBe(b)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('returns 16-char hex string', () => {
|
|
164
|
+
const h = Skill.hash([{ name: 'ping' }])
|
|
165
|
+
expect(h).toMatch(/^[0-9a-f]{16}$/)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('split', () => {
|
|
170
|
+
const commands: Skill.CommandInfo[] = [
|
|
171
|
+
{ name: 'auth login', description: 'Log in' },
|
|
172
|
+
{ name: 'auth status', description: 'Check status' },
|
|
173
|
+
{ name: 'pr list', description: 'List PRs' },
|
|
174
|
+
{ name: 'pr create', description: 'Create PR' },
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
const groups = new Map([
|
|
178
|
+
['auth', 'Authenticate with GitHub'],
|
|
179
|
+
['pr', 'Manage pull requests'],
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
test('depth 0 returns single file', () => {
|
|
183
|
+
const files = Skill.split('gh', commands, 0)
|
|
184
|
+
expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
|
|
185
|
+
[
|
|
186
|
+
"",
|
|
187
|
+
]
|
|
188
|
+
`)
|
|
189
|
+
expect(files[0]!.content).toContain('name: gh')
|
|
190
|
+
expect(files[0]!.content).toContain('# gh auth login')
|
|
191
|
+
expect(files[0]!.content).toContain('# gh pr list')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('depth 1 groups by first segment with group frontmatter', () => {
|
|
195
|
+
const files = Skill.split('gh', commands, 1, groups)
|
|
196
|
+
expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
|
|
197
|
+
[
|
|
198
|
+
"auth",
|
|
199
|
+
"pr",
|
|
200
|
+
]
|
|
201
|
+
`)
|
|
202
|
+
expect(files[0]!.content).toMatchInlineSnapshot(`
|
|
203
|
+
"---
|
|
204
|
+
name: gh-auth
|
|
205
|
+
description: Authenticate with GitHub. Log in, Check status. Run \`gh auth --help\` for usage details.
|
|
206
|
+
command: gh auth
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
# gh auth login
|
|
210
|
+
|
|
211
|
+
Log in
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
# gh auth status
|
|
216
|
+
|
|
217
|
+
Check status"
|
|
218
|
+
`)
|
|
219
|
+
expect(files[1]!.content).toMatchInlineSnapshot(`
|
|
220
|
+
"---
|
|
221
|
+
name: gh-pr
|
|
222
|
+
description: Manage pull requests. List PRs, Create PR. Run \`gh pr --help\` for usage details.
|
|
223
|
+
command: gh pr
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
# gh pr list
|
|
227
|
+
|
|
228
|
+
List PRs
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
# gh pr create
|
|
233
|
+
|
|
234
|
+
Create PR"
|
|
235
|
+
`)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('depth 1 without group descriptions uses child descriptions', () => {
|
|
239
|
+
const files = Skill.split('gh', commands, 1)
|
|
240
|
+
expect(files[0]!.content).toContain('description: Log in, Check status. Run `gh auth --help` for usage details.')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('depth 2 groups by first two segments', () => {
|
|
244
|
+
const files = Skill.split('gh', commands, 2)
|
|
245
|
+
expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
|
|
246
|
+
[
|
|
247
|
+
"auth-login",
|
|
248
|
+
"auth-status",
|
|
249
|
+
"pr-create",
|
|
250
|
+
"pr-list",
|
|
251
|
+
]
|
|
252
|
+
`)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('shallow commands use available segments', () => {
|
|
256
|
+
const files = Skill.split('test', [{ name: 'ping', description: 'Ping' }], 2)
|
|
257
|
+
expect(files.map((f) => f.dir)).toMatchInlineSnapshot(`
|
|
258
|
+
[
|
|
259
|
+
"ping",
|
|
260
|
+
]
|
|
261
|
+
`)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('description includes --help hint for depth 0', () => {
|
|
265
|
+
const files = Skill.split('gh', commands, 0, groups)
|
|
266
|
+
expect(files[0]!.content).toContain('Run `gh --help` for usage details.')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('description includes --help hint for depth 1 with groups', () => {
|
|
270
|
+
const files = Skill.split('gh', commands, 1, groups)
|
|
271
|
+
expect(files[0]!.content).toContain('Run `gh auth --help` for usage details.')
|
|
272
|
+
expect(files[1]!.content).toContain('Run `gh pr --help` for usage details.')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('description includes --help hint for depth 2', () => {
|
|
276
|
+
const files = Skill.split('gh', commands, 2)
|
|
277
|
+
expect(files[0]!.content).toContain('Run `gh auth login --help` for usage details.')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('omits --help hint when no descriptions exist', () => {
|
|
281
|
+
const files = Skill.split('test', [{ name: 'ping' }], 1)
|
|
282
|
+
expect(files[0]!.content).not.toContain('--help')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('no per-command frontmatter in split files', () => {
|
|
286
|
+
const files = Skill.split('gh', commands, 1, groups)
|
|
287
|
+
const afterFrontmatter = files[0]!.content.slice(
|
|
288
|
+
files[0]!.content.indexOf('---', files[0]!.content.indexOf('---') + 3) + 3,
|
|
289
|
+
)
|
|
290
|
+
expect(afterFrontmatter).not.toMatch(/^title:/m)
|
|
291
|
+
expect(afterFrontmatter).not.toMatch(/^command:/m)
|
|
292
|
+
})
|
|
293
|
+
})
|
package/src/Skill.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import * as Schema from './Schema.js'
|
|
5
|
+
|
|
6
|
+
/** Information about a single command, passed to `generate()`. */
|
|
7
|
+
export type CommandInfo = {
|
|
8
|
+
name: string
|
|
9
|
+
description?: string | undefined
|
|
10
|
+
args?: z.ZodObject<any> | undefined
|
|
11
|
+
env?: z.ZodObject<any> | undefined
|
|
12
|
+
hint?: string | undefined
|
|
13
|
+
options?: z.ZodObject<any> | undefined
|
|
14
|
+
output?: z.ZodType | undefined
|
|
15
|
+
examples?: { command: string; description?: string }[] | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A skill file entry with its directory name and content. */
|
|
19
|
+
export type File = {
|
|
20
|
+
/** Directory name relative to output root (empty string for depth 0). */
|
|
21
|
+
dir: string
|
|
22
|
+
/** Markdown content. */
|
|
23
|
+
content: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Generates a Markdown skill file from a CLI name and collected command data. */
|
|
27
|
+
export function generate(
|
|
28
|
+
name: string,
|
|
29
|
+
commands: CommandInfo[],
|
|
30
|
+
groups: Map<string, string> = new Map(),
|
|
31
|
+
): string {
|
|
32
|
+
const hasGroups = groups.size > 0
|
|
33
|
+
if (!hasGroups) return commands.map((cmd) => renderCommandBody(name, cmd)).join('\n\n')
|
|
34
|
+
|
|
35
|
+
const sections: string[] = [`# ${name}`]
|
|
36
|
+
let lastGroup: string | undefined
|
|
37
|
+
|
|
38
|
+
for (const cmd of commands) {
|
|
39
|
+
const segment = cmd.name.split(' ')[0]!
|
|
40
|
+
if (segment !== lastGroup) {
|
|
41
|
+
lastGroup = segment
|
|
42
|
+
const desc = groups.get(segment)
|
|
43
|
+
const heading = desc ? `## ${name} ${segment}\n\n${desc}` : `## ${name} ${segment}`
|
|
44
|
+
sections.push(heading)
|
|
45
|
+
}
|
|
46
|
+
sections.push(renderCommandBody(name, cmd, 3))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return sections.join('\n\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Splits commands into skill files grouped by depth. */
|
|
53
|
+
export function split(
|
|
54
|
+
name: string,
|
|
55
|
+
commands: CommandInfo[],
|
|
56
|
+
depth: number,
|
|
57
|
+
groups: Map<string, string> = new Map(),
|
|
58
|
+
): File[] {
|
|
59
|
+
if (depth === 0) return [{ dir: '', content: renderGroup(name, name, commands, groups, name) }]
|
|
60
|
+
|
|
61
|
+
const buckets = new Map<string, CommandInfo[]>()
|
|
62
|
+
for (const cmd of commands) {
|
|
63
|
+
const segments = cmd.name.split(' ')
|
|
64
|
+
const key = segments.slice(0, depth).join('-')
|
|
65
|
+
const bucket = buckets.get(key) ?? []
|
|
66
|
+
bucket.push(cmd)
|
|
67
|
+
buckets.set(key, bucket)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [...buckets.entries()]
|
|
71
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
72
|
+
.map(([dir, cmds]) => {
|
|
73
|
+
const prefix = cmds[0]!.name.split(' ').slice(0, depth).join(' ')
|
|
74
|
+
return { dir, content: renderGroup(name, `${name} ${prefix}`, cmds, groups, prefix) }
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @internal Renders a group-level frontmatter + command bodies. */
|
|
79
|
+
function renderGroup(
|
|
80
|
+
cli: string,
|
|
81
|
+
title: string,
|
|
82
|
+
cmds: CommandInfo[],
|
|
83
|
+
groups: Map<string, string>,
|
|
84
|
+
prefix?: string | undefined,
|
|
85
|
+
): string {
|
|
86
|
+
const groupDesc = prefix ? groups.get(prefix) : undefined
|
|
87
|
+
const childDescs = cmds.map((c) => c.description).filter(Boolean) as string[]
|
|
88
|
+
const descParts: string[] = []
|
|
89
|
+
if (groupDesc) descParts.push(groupDesc.replace(/\.$/, ''))
|
|
90
|
+
if (childDescs.length > 0) descParts.push(childDescs.join(', '))
|
|
91
|
+
const description = descParts.join('. ') || undefined
|
|
92
|
+
|
|
93
|
+
const slug = title.replace(/\s+/g, '-')
|
|
94
|
+
const fm = ['---', `name: ${slug}`]
|
|
95
|
+
if (description) fm.push(`description: ${description}. Run \`${title} --help\` for usage details.`)
|
|
96
|
+
fm.push(`command: ${title}`, '---')
|
|
97
|
+
|
|
98
|
+
const body = cmds.map((cmd) => renderCommandBody(cli, cmd)).join('\n\n---\n\n')
|
|
99
|
+
return `${fm.join('\n')}\n\n${body}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @internal Renders a command's heading and sections without frontmatter. */
|
|
103
|
+
function renderCommandBody(cli: string, cmd: CommandInfo, level = 1): string {
|
|
104
|
+
const fullName = `${cli} ${cmd.name}`
|
|
105
|
+
const sections: string[] = []
|
|
106
|
+
const h = (n: number) => '#'.repeat(n)
|
|
107
|
+
|
|
108
|
+
let heading = `${h(level)} ${fullName}`
|
|
109
|
+
if (cmd.description) heading += `\n\n${cmd.description}`
|
|
110
|
+
sections.push(heading)
|
|
111
|
+
|
|
112
|
+
const sub = h(level + 1)
|
|
113
|
+
|
|
114
|
+
// Arguments table
|
|
115
|
+
if (cmd.args) {
|
|
116
|
+
const shape = cmd.args.shape as Record<string, z.ZodType>
|
|
117
|
+
const json = Schema.toJsonSchema(cmd.args)
|
|
118
|
+
const required = new Set((json.required as string[] | undefined) ?? [])
|
|
119
|
+
const properties = json.properties as Record<string, Record<string, unknown>> | undefined
|
|
120
|
+
const rows = Object.entries(shape).map(([key, field]) => {
|
|
121
|
+
const prop = properties?.[key]
|
|
122
|
+
const type = resolveTypeName(prop)
|
|
123
|
+
const req = required.has(key) ? 'yes' : 'no'
|
|
124
|
+
const desc = field.description ?? ''
|
|
125
|
+
return `| \`${key}\` | \`${type}\` | ${req} | ${desc} |`
|
|
126
|
+
})
|
|
127
|
+
sections.push(
|
|
128
|
+
`${sub} Arguments\n\n| Name | Type | Required | Description |\n|------|------|----------|-------------|\n${rows.join('\n')}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Environment Variables table
|
|
133
|
+
if (cmd.env) {
|
|
134
|
+
const shape = cmd.env.shape as Record<string, z.ZodType>
|
|
135
|
+
const json = Schema.toJsonSchema(cmd.env)
|
|
136
|
+
const required = new Set((json.required as string[] | undefined) ?? [])
|
|
137
|
+
const properties = json.properties as Record<string, Record<string, unknown>> | undefined
|
|
138
|
+
const rows = Object.entries(shape).map(([key, field]) => {
|
|
139
|
+
const prop = properties?.[key]
|
|
140
|
+
const type = resolveTypeName(prop)
|
|
141
|
+
const def = prop?.default !== undefined ? String(prop.default) : ''
|
|
142
|
+
const req = required.has(key) ? 'yes' : 'no'
|
|
143
|
+
const desc = field.description ?? ''
|
|
144
|
+
return `| \`${key}\` | \`${type}\` | ${req} | ${def ? `\`${def}\`` : ''} | ${desc} |`
|
|
145
|
+
})
|
|
146
|
+
sections.push(
|
|
147
|
+
`${sub} Environment Variables\n\n| Name | Type | Required | Default | Description |\n|------|------|----------|---------|-------------|\n${rows.join('\n')}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Options table
|
|
152
|
+
if (cmd.options) {
|
|
153
|
+
const shape = cmd.options.shape as Record<string, z.ZodType>
|
|
154
|
+
const json = Schema.toJsonSchema(cmd.options)
|
|
155
|
+
const properties = json.properties as Record<string, Record<string, unknown>> | undefined
|
|
156
|
+
const rows = Object.entries(shape).map(([key, field]) => {
|
|
157
|
+
const prop = properties?.[key]
|
|
158
|
+
const type = resolveTypeName(prop)
|
|
159
|
+
const def = prop?.default !== undefined ? String(prop.default) : ''
|
|
160
|
+
const desc = field.description ?? ''
|
|
161
|
+
return `| \`--${key}\` | \`${type}\` | ${def ? `\`${def}\`` : ''} | ${desc} |`
|
|
162
|
+
})
|
|
163
|
+
sections.push(
|
|
164
|
+
`${sub} Options\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n${rows.join('\n')}`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Output table
|
|
169
|
+
if (cmd.output) {
|
|
170
|
+
const outputSchema = Schema.toJsonSchema(cmd.output)
|
|
171
|
+
const table = schemaToTable(outputSchema)
|
|
172
|
+
if (table) sections.push(`${sub} Output\n\n${table}`)
|
|
173
|
+
else {
|
|
174
|
+
const type = resolveTypeName(outputSchema)
|
|
175
|
+
sections.push(`${sub} Output\n\nType: \`${type}\``)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Examples
|
|
180
|
+
if (cmd.examples && cmd.examples.length > 0) {
|
|
181
|
+
const lines = cmd.examples.map((ex) => {
|
|
182
|
+
const comment = ex.description ? `# ${ex.description}\n` : ''
|
|
183
|
+
return `${comment}${cli} ${ex.command}`
|
|
184
|
+
})
|
|
185
|
+
sections.push(`${sub} Examples\n\n\`\`\`sh\n${lines.join('\n\n')}\n\`\`\``)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Hint
|
|
189
|
+
if (cmd.hint) sections.push(`> ${cmd.hint}`)
|
|
190
|
+
|
|
191
|
+
return sections.join('\n\n')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Computes a deterministic hash of command structure for staleness detection. */
|
|
195
|
+
export function hash(commands: CommandInfo[]): string {
|
|
196
|
+
const data = commands.map((cmd) => ({
|
|
197
|
+
name: cmd.name,
|
|
198
|
+
description: cmd.description,
|
|
199
|
+
args: cmd.args ? Schema.toJsonSchema(cmd.args) : undefined,
|
|
200
|
+
env: cmd.env ? Schema.toJsonSchema(cmd.env) : undefined,
|
|
201
|
+
options: cmd.options ? Schema.toJsonSchema(cmd.options) : undefined,
|
|
202
|
+
output: cmd.output ? Schema.toJsonSchema(cmd.output) : undefined,
|
|
203
|
+
}))
|
|
204
|
+
return createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 16)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @internal Renders a JSON Schema object as a Markdown table. Returns `undefined` for non-object schemas. */
|
|
208
|
+
function schemaToTable(schema: Record<string, unknown>, prefix = ''): string | undefined {
|
|
209
|
+
if (schema.type !== 'object') return undefined
|
|
210
|
+
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
211
|
+
if (!properties || Object.keys(properties).length === 0) return undefined
|
|
212
|
+
const required = new Set((schema.required as string[] | undefined) ?? [])
|
|
213
|
+
|
|
214
|
+
const rows: string[] = []
|
|
215
|
+
for (const [key, prop] of Object.entries(properties)) {
|
|
216
|
+
const name = prefix ? `${prefix}.${key}` : key
|
|
217
|
+
const type = resolveTypeName(prop)
|
|
218
|
+
const req = required.has(key) ? 'yes' : 'no'
|
|
219
|
+
const desc = (prop.description as string) ?? ''
|
|
220
|
+
rows.push(`| \`${name}\` | \`${type}\` | ${req} | ${desc} |`)
|
|
221
|
+
|
|
222
|
+
// Expand nested objects inline
|
|
223
|
+
if (prop.type === 'object' && prop.properties) {
|
|
224
|
+
const nested = schemaToTable(prop, name)
|
|
225
|
+
if (nested) {
|
|
226
|
+
const lines = nested.split('\n')
|
|
227
|
+
rows.push(...lines.slice(2)) // skip header + separator
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Expand array item objects inline
|
|
232
|
+
if (prop.type === 'array' && prop.items) {
|
|
233
|
+
const items = prop.items as Record<string, unknown>
|
|
234
|
+
if (items.type === 'object' && items.properties) {
|
|
235
|
+
const nested = schemaToTable(items, `${name}[]`)
|
|
236
|
+
if (nested) {
|
|
237
|
+
const lines = nested.split('\n')
|
|
238
|
+
rows.push(...lines.slice(2))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return `| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n${rows.join('\n')}`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @internal Resolves a simple type name from a JSON Schema property. */
|
|
248
|
+
function resolveTypeName(prop: Record<string, unknown> | undefined): string {
|
|
249
|
+
if (!prop) return 'unknown'
|
|
250
|
+
const type = prop.type as string | undefined
|
|
251
|
+
if (type) return type === 'integer' ? 'number' : type
|
|
252
|
+
return 'unknown'
|
|
253
|
+
}
|