incur 0.4.3 → 0.4.5
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/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +31 -5
- package/dist/Cli.js.map +1 -1
- package/dist/Errors.d.ts +4 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js.map +1 -1
- package/dist/Formatter.d.ts.map +1 -1
- package/dist/Formatter.js +11 -1
- package/dist/Formatter.js.map +1 -1
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +16 -0
- package/dist/Parser.js.map +1 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +4 -5
- package/dist/Skill.js.map +1 -1
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +22 -17
- package/dist/SyncSkills.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test.ts +129 -1
- package/src/Cli.ts +30 -4
- package/src/Errors.test.ts +4 -0
- package/src/Errors.ts +4 -0
- package/src/Formatter.test.ts +10 -0
- package/src/Formatter.ts +11 -1
- package/src/Parser.test.ts +34 -0
- package/src/Parser.ts +16 -0
- package/src/Skill.test.ts +8 -0
- package/src/Skill.ts +7 -5
- package/src/SyncSkills.test.ts +31 -0
- package/src/SyncSkills.ts +22 -18
package/src/Errors.test.ts
CHANGED
|
@@ -66,6 +66,8 @@ describe('ValidationError', () => {
|
|
|
66
66
|
message: 'Invalid arguments',
|
|
67
67
|
fieldErrors: [
|
|
68
68
|
{
|
|
69
|
+
code: 'invalid_value',
|
|
70
|
+
missing: false,
|
|
69
71
|
path: 'state',
|
|
70
72
|
expected: 'open | closed',
|
|
71
73
|
received: 'invalid',
|
|
@@ -76,6 +78,8 @@ describe('ValidationError', () => {
|
|
|
76
78
|
expect(error.name).toBe('Incur.ValidationError')
|
|
77
79
|
expect(error.fieldErrors).toEqual([
|
|
78
80
|
{
|
|
81
|
+
code: 'invalid_value',
|
|
82
|
+
missing: false,
|
|
79
83
|
path: 'state',
|
|
80
84
|
expected: 'open | closed',
|
|
81
85
|
received: 'invalid',
|
package/src/Errors.ts
CHANGED
|
@@ -73,6 +73,10 @@ export declare namespace IncurError {
|
|
|
73
73
|
|
|
74
74
|
/** A field-level validation error detail. */
|
|
75
75
|
export type FieldError = {
|
|
76
|
+
/** The Zod issue code. */
|
|
77
|
+
code?: string | undefined
|
|
78
|
+
/** Whether the input was missing entirely. */
|
|
79
|
+
missing?: boolean | undefined
|
|
76
80
|
/** The field path that failed validation. */
|
|
77
81
|
path: string
|
|
78
82
|
/** The expected value or type. */
|
package/src/Formatter.test.ts
CHANGED
|
@@ -81,6 +81,16 @@ describe('format', () => {
|
|
|
81
81
|
expect(Formatter.format('hello world', 'md')).toBe('hello world')
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
test('formats JSON object strings as JSON', () => {
|
|
85
|
+
const result = Formatter.format('{"url":"https://example.com/","title":""}', 'json')
|
|
86
|
+
expect(result).toMatchInlineSnapshot(`
|
|
87
|
+
"{
|
|
88
|
+
"url": "https://example.com/",
|
|
89
|
+
"title": ""
|
|
90
|
+
}"
|
|
91
|
+
`)
|
|
92
|
+
})
|
|
93
|
+
|
|
84
94
|
test('formats number value', () => {
|
|
85
95
|
expect(Formatter.format(42)).toBe('42')
|
|
86
96
|
expect(Formatter.format(42, 'json')).toBe('42')
|
package/src/Formatter.ts
CHANGED
|
@@ -7,7 +7,17 @@ export type Format = 'toon' | 'json' | 'yaml' | 'md' | 'jsonl'
|
|
|
7
7
|
/** Serializes a value to the specified format. Defaults to TOON. */
|
|
8
8
|
export function format(value: unknown, fmt: Format = 'toon'): string {
|
|
9
9
|
if (value == null) return ''
|
|
10
|
-
if (fmt === 'json')
|
|
10
|
+
if (fmt === 'json') {
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
const trimmed = value.trim()
|
|
13
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.stringify(JSON.parse(value), null, 2)
|
|
16
|
+
} catch {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return JSON.stringify(value, null, 2)
|
|
20
|
+
}
|
|
11
21
|
if (fmt === 'yaml') return yamlStringify(value)
|
|
12
22
|
if (fmt === 'md') return formatMarkdown(value)
|
|
13
23
|
if (fmt === 'jsonl') {
|
package/src/Parser.test.ts
CHANGED
|
@@ -107,6 +107,40 @@ describe('parse', () => {
|
|
|
107
107
|
).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' }))
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
+
test('captures missing metadata for missing positional args', () => {
|
|
111
|
+
try {
|
|
112
|
+
Parser.parse([], {
|
|
113
|
+
args: z.object({ name: z.string() }),
|
|
114
|
+
})
|
|
115
|
+
expect.unreachable()
|
|
116
|
+
} catch (error: any) {
|
|
117
|
+
expect(error.fieldErrors).toEqual([
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
code: 'invalid_type',
|
|
120
|
+
missing: true,
|
|
121
|
+
path: 'name',
|
|
122
|
+
}),
|
|
123
|
+
])
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('captures metadata for invalid option values', () => {
|
|
128
|
+
try {
|
|
129
|
+
Parser.parse(['--state', 'invalid'], {
|
|
130
|
+
options: z.object({ state: z.enum(['open', 'closed']) }),
|
|
131
|
+
})
|
|
132
|
+
expect.unreachable()
|
|
133
|
+
} catch (error: any) {
|
|
134
|
+
expect(error.fieldErrors).toEqual([
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
code: 'invalid_value',
|
|
137
|
+
missing: false,
|
|
138
|
+
path: 'state',
|
|
139
|
+
}),
|
|
140
|
+
])
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
110
144
|
test('stacks boolean short aliases (-vD)', () => {
|
|
111
145
|
const result = Parser.parse(['-vD'], {
|
|
112
146
|
options: z.object({
|
package/src/Parser.ts
CHANGED
|
@@ -263,6 +263,8 @@ function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
|
|
|
263
263
|
} catch (err: any) {
|
|
264
264
|
const issues: any[] = err?.issues ?? err?.error?.issues ?? []
|
|
265
265
|
const fieldErrors: FieldError[] = issues.map((issue: any) => ({
|
|
266
|
+
code: issue.code,
|
|
267
|
+
missing: !hasPath(data, issue.path ?? []),
|
|
266
268
|
path: (issue.path ?? []).join('.'),
|
|
267
269
|
expected: issue.expected ?? '',
|
|
268
270
|
received: issue.received ?? '',
|
|
@@ -276,6 +278,20 @@ function zodParse(schema: z.ZodObject<any>, data: Record<string, unknown>) {
|
|
|
276
278
|
}
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
/** Checks whether the raw input contains the full issue path. */
|
|
282
|
+
function hasPath(data: Record<string, unknown>, path: PropertyKey[]): boolean {
|
|
283
|
+
if (path.length === 0) return true
|
|
284
|
+
|
|
285
|
+
let current: unknown = data
|
|
286
|
+
for (const part of path) {
|
|
287
|
+
if (!isRecord(current) && !Array.isArray(current)) return false
|
|
288
|
+
if (!(part in current)) return false
|
|
289
|
+
current = (current as any)[part]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return true
|
|
293
|
+
}
|
|
294
|
+
|
|
279
295
|
/** Parses environment variables against a Zod schema. Falls back to `process.env` → `Deno.env` when no source is provided. */
|
|
280
296
|
export function parseEnv<const env extends z.ZodObject<any>>(
|
|
281
297
|
schema: env,
|
package/src/Skill.test.ts
CHANGED
|
@@ -373,6 +373,14 @@ describe('split', () => {
|
|
|
373
373
|
expect(files[0]!.content).toContain('requires_bin: gh')
|
|
374
374
|
})
|
|
375
375
|
|
|
376
|
+
test('YAML-quotes description containing colon-space', () => {
|
|
377
|
+
const groups = new Map([['search', 'Search items. Use key: value for precision']])
|
|
378
|
+
const files = Skill.split('app', [{ name: 'search list', description: 'List results' }], 1, groups)
|
|
379
|
+
expect(files[0]!.content).toContain(
|
|
380
|
+
'description: "Search items. Use key: value for precision. Run `app search --help` for usage details."',
|
|
381
|
+
)
|
|
382
|
+
})
|
|
383
|
+
|
|
376
384
|
test('no per-command frontmatter in split files', () => {
|
|
377
385
|
const files = Skill.split('gh', commands, 1, groups)
|
|
378
386
|
const afterFrontmatter = files[0]!.content.slice(
|
package/src/Skill.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import type { z } from 'zod'
|
|
3
|
+
import { stringify as yamlStringify } from 'yaml'
|
|
3
4
|
|
|
4
5
|
import * as Schema from './Schema.js'
|
|
5
6
|
|
|
@@ -136,13 +137,14 @@ function renderGroup(
|
|
|
136
137
|
? `${desc.replace(/\.$/, '')}. Run \`${title} --help\` for usage details.`
|
|
137
138
|
: `Run \`${title} --help\` for usage details.`
|
|
138
139
|
|
|
139
|
-
const fm =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
const fm = yamlStringify(
|
|
141
|
+
{ name: slugify(title), description, requires_bin: cli, command: title },
|
|
142
|
+
{ lineWidth: 0 },
|
|
143
|
+
).trimEnd()
|
|
144
|
+
const fmBlock = `---\n${fm}\n---`
|
|
143
145
|
|
|
144
146
|
const body = cmds.map((cmd) => renderCommandBody(cli, cmd)).join('\n\n---\n\n')
|
|
145
|
-
return `${
|
|
147
|
+
return `${fmBlock}\n\n${body}`
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
/** @internal Renders a command's heading and sections without frontmatter. */
|
package/src/SyncSkills.test.ts
CHANGED
|
@@ -161,6 +161,37 @@ test('installed SKILL.md contains frontmatter', async () => {
|
|
|
161
161
|
rmSync(tmp, { recursive: true, force: true })
|
|
162
162
|
})
|
|
163
163
|
|
|
164
|
+
test('sync returns unquoted descriptions from YAML frontmatter', async () => {
|
|
165
|
+
const tmp = join(tmpdir(), `clac-quoted-description-test-${Date.now()}`)
|
|
166
|
+
mkdirSync(tmp, { recursive: true })
|
|
167
|
+
|
|
168
|
+
const search = Cli.create('search', { description: 'Search items. Use key: value for precision' })
|
|
169
|
+
search.command('list', { description: 'List results', run: () => ({}) })
|
|
170
|
+
|
|
171
|
+
const cli = Cli.create('app')
|
|
172
|
+
cli.command('search', search)
|
|
173
|
+
|
|
174
|
+
const commands = Cli.toCommands.get(cli)!
|
|
175
|
+
const installDir = join(tmp, 'install')
|
|
176
|
+
mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true })
|
|
177
|
+
|
|
178
|
+
const result = await SyncSkills.sync('app', commands, {
|
|
179
|
+
global: false,
|
|
180
|
+
cwd: installDir,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(result.skills).toMatchInlineSnapshot(`
|
|
184
|
+
[
|
|
185
|
+
{
|
|
186
|
+
"description": "Search items. Use key: value for precision. Run \`app search --help\` for usage details.",
|
|
187
|
+
"name": "app-search",
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
`)
|
|
191
|
+
|
|
192
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
193
|
+
})
|
|
194
|
+
|
|
164
195
|
test('list returns skills from command map', async () => {
|
|
165
196
|
const cli = Cli.create('test', { description: 'A test CLI' })
|
|
166
197
|
cli.command('ping', { description: 'Health check', run: () => ({}) })
|
package/src/SyncSkills.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fsSync from 'node:fs'
|
|
|
2
2
|
import fs from 'node:fs/promises'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { parse as yamlParse } from 'yaml'
|
|
5
6
|
|
|
6
7
|
import { formatExamples } from './Cli.js'
|
|
7
8
|
import * as Agents from './internal/agents.js'
|
|
@@ -30,9 +31,8 @@ export async function sync(
|
|
|
30
31
|
: path.join(tmpDir, 'SKILL.md')
|
|
31
32
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
32
33
|
await fs.writeFile(filePath, `${file.content}\n`)
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
skills.push({ name: nameMatch?.[1] ?? (file.dir || name), description: descMatch?.[1] })
|
|
34
|
+
const meta = parseFrontmatter(file.content)
|
|
35
|
+
skills.push({ name: meta.name ?? (file.dir || name), description: meta.description })
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Include additional SKILL.md files matched by glob patterns
|
|
@@ -42,16 +42,14 @@ export async function sync(
|
|
|
42
42
|
for await (const match of fs.glob(globPattern, { cwd })) {
|
|
43
43
|
try {
|
|
44
44
|
const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
|
|
45
|
-
const
|
|
45
|
+
const meta = parseFrontmatter(content)
|
|
46
46
|
const skillName =
|
|
47
|
-
pattern === '_root' ? (
|
|
47
|
+
pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match))
|
|
48
48
|
const dest = path.join(tmpDir, skillName, 'SKILL.md')
|
|
49
49
|
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
50
50
|
await fs.writeFile(dest, content)
|
|
51
|
-
if (!skills.some((s) => s.name === skillName))
|
|
52
|
-
|
|
53
|
-
skills.push({ name: skillName, description: descMatch?.[1], external: true })
|
|
54
|
-
}
|
|
51
|
+
if (!skills.some((s) => s.name === skillName))
|
|
52
|
+
skills.push({ name: skillName, description: meta.description, external: true })
|
|
55
53
|
} catch {}
|
|
56
54
|
}
|
|
57
55
|
}
|
|
@@ -148,12 +146,11 @@ export async function list(
|
|
|
148
146
|
const installed = readInstalledSkills(name, { cwd })
|
|
149
147
|
|
|
150
148
|
for (const file of files) {
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
const skillName = nameMatch?.[1] ?? (file.dir || name)
|
|
149
|
+
const meta = parseFrontmatter(file.content)
|
|
150
|
+
const skillName = meta.name ?? (file.dir || name)
|
|
154
151
|
skills.push({
|
|
155
152
|
name: skillName,
|
|
156
|
-
description:
|
|
153
|
+
description: meta.description,
|
|
157
154
|
installed: installed.has(skillName),
|
|
158
155
|
})
|
|
159
156
|
}
|
|
@@ -165,14 +162,12 @@ export async function list(
|
|
|
165
162
|
for await (const match of fs.glob(globPattern, { cwd })) {
|
|
166
163
|
try {
|
|
167
164
|
const content = await fs.readFile(path.resolve(cwd, match), 'utf8')
|
|
168
|
-
const
|
|
169
|
-
const skillName =
|
|
170
|
-
pattern === '_root' ? (nameMatch?.[1] ?? name) : path.basename(path.dirname(match))
|
|
165
|
+
const meta = parseFrontmatter(content)
|
|
166
|
+
const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match))
|
|
171
167
|
if (!skills.some((s) => s.name === skillName)) {
|
|
172
|
-
const descMatch = content.match(/^description:\s*(.+)$/m)
|
|
173
168
|
skills.push({
|
|
174
169
|
name: skillName,
|
|
175
|
-
description:
|
|
170
|
+
description: meta.description,
|
|
176
171
|
installed: installed.has(skillName),
|
|
177
172
|
})
|
|
178
173
|
}
|
|
@@ -284,6 +279,15 @@ function collectEntries(
|
|
|
284
279
|
return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
|
|
285
280
|
}
|
|
286
281
|
|
|
282
|
+
function parseFrontmatter(content: string): { description?: string | undefined; name?: string | undefined } {
|
|
283
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
284
|
+
if (!match) return {}
|
|
285
|
+
|
|
286
|
+
const meta = yamlParse(match[1]!)
|
|
287
|
+
if (!meta || typeof meta !== 'object') return {}
|
|
288
|
+
return meta as { description?: string | undefined; name?: string | undefined }
|
|
289
|
+
}
|
|
290
|
+
|
|
287
291
|
/** 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()`. */
|
|
288
292
|
function resolvePackageRoot(): string {
|
|
289
293
|
const bin = process.argv[1]
|