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,96 @@
|
|
|
1
|
+
import { Errors } from 'incur'
|
|
2
|
+
|
|
3
|
+
describe('BaseError', () => {
|
|
4
|
+
test('extends Error and sets name', () => {
|
|
5
|
+
const error = new Errors.BaseError('something went wrong')
|
|
6
|
+
expect(error).toBeInstanceOf(Error)
|
|
7
|
+
expect(error.name).toBe('Incur.BaseError')
|
|
8
|
+
expect(error.shortMessage).toBe('something went wrong')
|
|
9
|
+
expect(error.message).toBe('something went wrong')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('extracts details from cause', () => {
|
|
13
|
+
const cause = new Error('connection refused')
|
|
14
|
+
const error = new Errors.BaseError('request failed', { cause })
|
|
15
|
+
expect(error.details).toBe('connection refused')
|
|
16
|
+
expect(error.message).toMatchInlineSnapshot(`
|
|
17
|
+
"request failed
|
|
18
|
+
|
|
19
|
+
Details: connection refused"
|
|
20
|
+
`)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('walk() returns deepest cause', () => {
|
|
24
|
+
const inner = new Error('root cause')
|
|
25
|
+
const middle = new Errors.BaseError('mid', { cause: inner })
|
|
26
|
+
const outer = new Errors.BaseError('top', { cause: middle })
|
|
27
|
+
expect(outer.walk()).toBe(inner)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('walk(fn) returns first matching cause', () => {
|
|
31
|
+
const inner = new Errors.IncurError({ code: 'FOO', message: 'foo' })
|
|
32
|
+
const outer = new Errors.BaseError('top', { cause: inner })
|
|
33
|
+
expect(outer.walk((e) => e instanceof Errors.IncurError)).toBe(inner)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('walk() without cause returns self', () => {
|
|
37
|
+
const error = new Errors.BaseError('standalone')
|
|
38
|
+
expect(error.walk()).toBe(error)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('IncurError', () => {
|
|
43
|
+
test('has code, hint, retryable', () => {
|
|
44
|
+
const error = new Errors.IncurError({
|
|
45
|
+
code: 'NOT_AUTHENTICATED',
|
|
46
|
+
message: 'Token not found',
|
|
47
|
+
hint: 'Set GH_TOKEN env var',
|
|
48
|
+
retryable: false,
|
|
49
|
+
})
|
|
50
|
+
expect(error.name).toBe('Incur.IncurError')
|
|
51
|
+
expect(error.code).toBe('NOT_AUTHENTICATED')
|
|
52
|
+
expect(error.hint).toBe('Set GH_TOKEN env var')
|
|
53
|
+
expect(error.retryable).toBe(false)
|
|
54
|
+
expect(error).toBeInstanceOf(Errors.BaseError)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('defaults retryable to false', () => {
|
|
58
|
+
const error = new Errors.IncurError({ code: 'FAIL', message: 'fail' })
|
|
59
|
+
expect(error.retryable).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('ValidationError', () => {
|
|
64
|
+
test('has fieldErrors', () => {
|
|
65
|
+
const error = new Errors.ValidationError({
|
|
66
|
+
message: 'Invalid arguments',
|
|
67
|
+
fieldErrors: [
|
|
68
|
+
{
|
|
69
|
+
path: 'state',
|
|
70
|
+
expected: 'open | closed',
|
|
71
|
+
received: 'invalid',
|
|
72
|
+
message: 'Invalid enum value',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
})
|
|
76
|
+
expect(error.name).toBe('Incur.ValidationError')
|
|
77
|
+
expect(error.fieldErrors).toEqual([
|
|
78
|
+
{
|
|
79
|
+
path: 'state',
|
|
80
|
+
expected: 'open | closed',
|
|
81
|
+
received: 'invalid',
|
|
82
|
+
message: 'Invalid enum value',
|
|
83
|
+
},
|
|
84
|
+
])
|
|
85
|
+
expect(error).toBeInstanceOf(Errors.BaseError)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('ParseError', () => {
|
|
90
|
+
test('sets name', () => {
|
|
91
|
+
const error = new Errors.ParseError({ message: 'Unknown flag: --foo' })
|
|
92
|
+
expect(error.name).toBe('Incur.ParseError')
|
|
93
|
+
expect(error.shortMessage).toBe('Unknown flag: --foo')
|
|
94
|
+
expect(error).toBeInstanceOf(Errors.BaseError)
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/Errors.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Base error with shortMessage, details from cause chain, and walk(). */
|
|
2
|
+
export class BaseError extends Error {
|
|
3
|
+
override name = 'Incur.BaseError'
|
|
4
|
+
/** The short, human-readable error message (without details). */
|
|
5
|
+
shortMessage: string
|
|
6
|
+
/** Details extracted from the cause's message, if any. */
|
|
7
|
+
details: string | undefined
|
|
8
|
+
|
|
9
|
+
constructor(shortMessage: string, options: BaseError.Options = {}) {
|
|
10
|
+
const details = options.cause instanceof Error ? options.cause.message : undefined
|
|
11
|
+
const message = details ? `${shortMessage}\n\nDetails: ${details}` : shortMessage
|
|
12
|
+
super(message, options.cause ? { cause: options.cause } : undefined)
|
|
13
|
+
this.shortMessage = shortMessage
|
|
14
|
+
this.details = details
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Traverses the cause chain.
|
|
19
|
+
* Without a callback, returns the deepest cause.
|
|
20
|
+
* With a callback, returns the first cause where `fn` returns `true`.
|
|
21
|
+
*/
|
|
22
|
+
walk(fn?: ((error: unknown) => boolean) | undefined): unknown {
|
|
23
|
+
return walk(this, fn)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export declare namespace BaseError {
|
|
28
|
+
/** Options for constructing a BaseError. */
|
|
29
|
+
type Options = {
|
|
30
|
+
/** The underlying cause of this error. */
|
|
31
|
+
cause?: Error | undefined
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** CLI error with code, hint, and retryable flag. */
|
|
36
|
+
export class IncurError extends BaseError {
|
|
37
|
+
override name = 'Incur.IncurError'
|
|
38
|
+
/** Machine-readable error code (e.g. `'NOT_AUTHENTICATED'`). */
|
|
39
|
+
code: string
|
|
40
|
+
/** Actionable hint for the user. */
|
|
41
|
+
hint: string | undefined
|
|
42
|
+
/** Whether the operation can be retried. */
|
|
43
|
+
retryable: boolean
|
|
44
|
+
|
|
45
|
+
constructor(options: IncurError.Options) {
|
|
46
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined)
|
|
47
|
+
this.code = options.code
|
|
48
|
+
this.hint = options.hint
|
|
49
|
+
this.retryable = options.retryable ?? false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export declare namespace IncurError {
|
|
54
|
+
/** Options for constructing a IncurError. */
|
|
55
|
+
type Options = {
|
|
56
|
+
/** Machine-readable error code. */
|
|
57
|
+
code: string
|
|
58
|
+
/** Human-readable error message. */
|
|
59
|
+
message: string
|
|
60
|
+
/** Actionable hint for the user. */
|
|
61
|
+
hint?: string | undefined
|
|
62
|
+
/** Whether the operation can be retried. Defaults to `false`. */
|
|
63
|
+
retryable?: boolean | undefined
|
|
64
|
+
/** The underlying cause. */
|
|
65
|
+
cause?: Error | undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A field-level validation error detail. */
|
|
70
|
+
export type FieldError = {
|
|
71
|
+
/** The field path that failed validation. */
|
|
72
|
+
path: string
|
|
73
|
+
/** The expected value or type. */
|
|
74
|
+
expected: string
|
|
75
|
+
/** The value that was received. */
|
|
76
|
+
received: string
|
|
77
|
+
/** Human-readable validation message. */
|
|
78
|
+
message: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Validation error with per-field error details. */
|
|
82
|
+
export class ValidationError extends BaseError {
|
|
83
|
+
override name = 'Incur.ValidationError'
|
|
84
|
+
/** Per-field validation errors. */
|
|
85
|
+
fieldErrors: FieldError[]
|
|
86
|
+
|
|
87
|
+
constructor(options: ValidationError.Options) {
|
|
88
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined)
|
|
89
|
+
this.fieldErrors = options.fieldErrors ?? []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export declare namespace ValidationError {
|
|
94
|
+
/** Options for constructing a ValidationError. */
|
|
95
|
+
type Options = {
|
|
96
|
+
/** Human-readable error message. */
|
|
97
|
+
message: string
|
|
98
|
+
/** Per-field validation errors. */
|
|
99
|
+
fieldErrors?: FieldError[] | undefined
|
|
100
|
+
/** The underlying cause. */
|
|
101
|
+
cause?: Error | undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Error thrown when argument parsing fails (unknown flags, missing values). */
|
|
106
|
+
export class ParseError extends BaseError {
|
|
107
|
+
override name = 'Incur.ParseError'
|
|
108
|
+
|
|
109
|
+
constructor(options: ParseError.Options) {
|
|
110
|
+
super(options.message, options.cause ? { cause: options.cause } : undefined)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export declare namespace ParseError {
|
|
115
|
+
/** Options for constructing a ParseError. */
|
|
116
|
+
type Options = {
|
|
117
|
+
/** Human-readable error message. */
|
|
118
|
+
message: string
|
|
119
|
+
/** The underlying cause. */
|
|
120
|
+
cause?: Error | undefined
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Walks the cause chain, returning the deepest cause or the first matching cause. */
|
|
125
|
+
function walk(error: unknown, fn?: ((error: unknown) => boolean) | undefined): unknown {
|
|
126
|
+
if (fn) {
|
|
127
|
+
// Find first matching cause (not self)
|
|
128
|
+
let current = (error as any)?.cause
|
|
129
|
+
while (current) {
|
|
130
|
+
if (fn(current)) return current
|
|
131
|
+
current = (current as any)?.cause
|
|
132
|
+
}
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
// Return deepest cause
|
|
136
|
+
let current = error
|
|
137
|
+
while ((current as any)?.cause) current = (current as any).cause
|
|
138
|
+
return current
|
|
139
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { decode } from '@toon-format/toon'
|
|
2
|
+
import { Formatter } from 'incur'
|
|
3
|
+
|
|
4
|
+
describe('format', () => {
|
|
5
|
+
test('formats success envelope as TOON', () => {
|
|
6
|
+
const result = Formatter.format({
|
|
7
|
+
ok: true,
|
|
8
|
+
data: { message: 'hello world' },
|
|
9
|
+
meta: { command: 'greet', duration: '0ms' },
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
expect(result).toMatchInlineSnapshot(`
|
|
13
|
+
"ok: true
|
|
14
|
+
data:
|
|
15
|
+
message: hello world
|
|
16
|
+
meta:
|
|
17
|
+
command: greet
|
|
18
|
+
duration: 0ms"
|
|
19
|
+
`)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('formats error envelope as TOON', () => {
|
|
23
|
+
const result = Formatter.format({
|
|
24
|
+
ok: false,
|
|
25
|
+
error: { code: 'UNKNOWN', message: 'boom' },
|
|
26
|
+
meta: { command: 'fail', duration: '0ms' },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(result).toMatchInlineSnapshot(`
|
|
30
|
+
"ok: false
|
|
31
|
+
error:
|
|
32
|
+
code: UNKNOWN
|
|
33
|
+
message: boom
|
|
34
|
+
meta:
|
|
35
|
+
command: fail
|
|
36
|
+
duration: 0ms"
|
|
37
|
+
`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('round-trips through TOON decode', () => {
|
|
41
|
+
const envelope = {
|
|
42
|
+
ok: true,
|
|
43
|
+
data: { items: [1, 2, 3] },
|
|
44
|
+
meta: { command: 'list', duration: '5ms' },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = decode(Formatter.format(envelope))
|
|
48
|
+
expect(result).toMatchObject(envelope)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('formats as TOON (explicit)', () => {
|
|
52
|
+
const result = Formatter.format({ message: 'hello' }, 'toon')
|
|
53
|
+
expect(result).toMatchInlineSnapshot(`"message: hello"`)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('formats as JSON', () => {
|
|
57
|
+
const result = Formatter.format({ message: 'hello' }, 'json')
|
|
58
|
+
expect(result).toMatchInlineSnapshot(`
|
|
59
|
+
"{
|
|
60
|
+
"message": "hello"
|
|
61
|
+
}"
|
|
62
|
+
`)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('formats as YAML', () => {
|
|
66
|
+
const result = Formatter.format({ message: 'hello' }, 'yaml')
|
|
67
|
+
expect(result).toMatchInlineSnapshot(`
|
|
68
|
+
"message: hello
|
|
69
|
+
"
|
|
70
|
+
`)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('defaults to TOON when no format specified', () => {
|
|
74
|
+
const result = Formatter.format({ message: 'hello' })
|
|
75
|
+
expect(result).toMatchInlineSnapshot(`"message: hello"`)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('formats string value as-is', () => {
|
|
79
|
+
expect(Formatter.format('hello world')).toBe('hello world')
|
|
80
|
+
expect(Formatter.format('hello world', 'json')).toBe('"hello world"')
|
|
81
|
+
expect(Formatter.format('hello world', 'md')).toBe('hello world')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('formats number value', () => {
|
|
85
|
+
expect(Formatter.format(42)).toBe('42')
|
|
86
|
+
expect(Formatter.format(42, 'json')).toBe('42')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('format md', () => {
|
|
91
|
+
test('formats flat object as key-value table', () => {
|
|
92
|
+
const result = Formatter.format({ message: 'hello', status: 'ok' }, 'md')
|
|
93
|
+
expect(result).toMatchInlineSnapshot(`
|
|
94
|
+
"| Key | Value |
|
|
95
|
+
|---------|-------|
|
|
96
|
+
| message | hello |
|
|
97
|
+
| status | ok |"
|
|
98
|
+
`)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('formats array of objects as columnar table', () => {
|
|
102
|
+
const result = Formatter.format(
|
|
103
|
+
{
|
|
104
|
+
items: [
|
|
105
|
+
{ name: 'a', state: 'open' },
|
|
106
|
+
{ name: 'b', state: 'closed' },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
'md',
|
|
110
|
+
)
|
|
111
|
+
expect(result).toMatchInlineSnapshot(`
|
|
112
|
+
"## items
|
|
113
|
+
|
|
114
|
+
| name | state |
|
|
115
|
+
|------|--------|
|
|
116
|
+
| a | open |
|
|
117
|
+
| b | closed |"
|
|
118
|
+
`)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('formats mixed top-level with headings', () => {
|
|
122
|
+
const result = Formatter.format({ items: [{ name: 'a' }], total: 2 }, 'md')
|
|
123
|
+
expect(result).toMatchInlineSnapshot(`
|
|
124
|
+
"## items
|
|
125
|
+
|
|
126
|
+
| name |
|
|
127
|
+
|------|
|
|
128
|
+
| a |
|
|
129
|
+
|
|
130
|
+
## total
|
|
131
|
+
|
|
132
|
+
2"
|
|
133
|
+
`)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('formats nested objects with dot-delimited path heading', () => {
|
|
137
|
+
const result = Formatter.format({ config: { db: { host: 'localhost', port: 5432 } } }, 'md')
|
|
138
|
+
expect(result).toMatchInlineSnapshot(`
|
|
139
|
+
"## config.db
|
|
140
|
+
|
|
141
|
+
| Key | Value |
|
|
142
|
+
|------|-----------|
|
|
143
|
+
| host | localhost |
|
|
144
|
+
| port | 5432 |"
|
|
145
|
+
`)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('formats deeply nested with multiple branches', () => {
|
|
149
|
+
const result = Formatter.format(
|
|
150
|
+
{
|
|
151
|
+
server: {
|
|
152
|
+
http: { host: '0.0.0.0', port: 3000 },
|
|
153
|
+
tls: { enabled: true, cert: '/etc/ssl/cert.pem' },
|
|
154
|
+
},
|
|
155
|
+
database: {
|
|
156
|
+
primary: { host: 'db1.internal', port: 5432, pool: 10 },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
'md',
|
|
160
|
+
)
|
|
161
|
+
expect(result).toMatchInlineSnapshot(`
|
|
162
|
+
"## server.http
|
|
163
|
+
|
|
164
|
+
| Key | Value |
|
|
165
|
+
|------|---------|
|
|
166
|
+
| host | 0.0.0.0 |
|
|
167
|
+
| port | 3000 |
|
|
168
|
+
|
|
169
|
+
## server.tls
|
|
170
|
+
|
|
171
|
+
| Key | Value |
|
|
172
|
+
|---------|-------------------|
|
|
173
|
+
| enabled | true |
|
|
174
|
+
| cert | /etc/ssl/cert.pem |
|
|
175
|
+
|
|
176
|
+
## database.primary
|
|
177
|
+
|
|
178
|
+
| Key | Value |
|
|
179
|
+
|------|--------------|
|
|
180
|
+
| host | db1.internal |
|
|
181
|
+
| port | 5432 |
|
|
182
|
+
| pool | 10 |"
|
|
183
|
+
`)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('formats mixed scalars, arrays, and nested at top level', () => {
|
|
187
|
+
const result = Formatter.format(
|
|
188
|
+
{
|
|
189
|
+
name: 'my-project',
|
|
190
|
+
version: '1.0.0',
|
|
191
|
+
dependencies: [
|
|
192
|
+
{ name: 'zod', version: '4.3.6' },
|
|
193
|
+
{ name: 'yaml', version: '2.8.2' },
|
|
194
|
+
],
|
|
195
|
+
config: { debug: false, logLevel: 'info' },
|
|
196
|
+
},
|
|
197
|
+
'md',
|
|
198
|
+
)
|
|
199
|
+
expect(result).toMatchInlineSnapshot(`
|
|
200
|
+
"## name
|
|
201
|
+
|
|
202
|
+
my-project
|
|
203
|
+
|
|
204
|
+
## version
|
|
205
|
+
|
|
206
|
+
1.0.0
|
|
207
|
+
|
|
208
|
+
## dependencies
|
|
209
|
+
|
|
210
|
+
| name | version |
|
|
211
|
+
|------|---------|
|
|
212
|
+
| zod | 4.3.6 |
|
|
213
|
+
| yaml | 2.8.2 |
|
|
214
|
+
|
|
215
|
+
## config
|
|
216
|
+
|
|
217
|
+
| Key | Value |
|
|
218
|
+
|----------|-------|
|
|
219
|
+
| debug | false |
|
|
220
|
+
| logLevel | info |"
|
|
221
|
+
`)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('formats single-key wrapper with array of objects', () => {
|
|
225
|
+
const result = Formatter.format(
|
|
226
|
+
{
|
|
227
|
+
users: [
|
|
228
|
+
{ id: 1, name: 'Alice', role: 'admin' },
|
|
229
|
+
{ id: 2, name: 'Bob', role: 'user' },
|
|
230
|
+
{ id: 3, name: 'Charlie', role: 'user' },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
'md',
|
|
234
|
+
)
|
|
235
|
+
expect(result).toMatchInlineSnapshot(`
|
|
236
|
+
"## users
|
|
237
|
+
|
|
238
|
+
| id | name | role |
|
|
239
|
+
|----|---------|-------|
|
|
240
|
+
| 1 | Alice | admin |
|
|
241
|
+
| 2 | Bob | user |
|
|
242
|
+
| 3 | Charlie | user |"
|
|
243
|
+
`)
|
|
244
|
+
})
|
|
245
|
+
})
|
package/src/Formatter.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { encode } from '@toon-format/toon'
|
|
2
|
+
import { stringify as yamlStringify } from 'yaml'
|
|
3
|
+
|
|
4
|
+
/** Supported output formats. */
|
|
5
|
+
export type Format = 'toon' | 'json' | 'yaml' | 'md' | 'jsonl'
|
|
6
|
+
|
|
7
|
+
/** Serializes a value to the specified format. Defaults to TOON. */
|
|
8
|
+
export function format(value: unknown, fmt: Format = 'toon'): string {
|
|
9
|
+
if (fmt === 'json') return JSON.stringify(value, null, 2)
|
|
10
|
+
if (fmt === 'yaml') return yamlStringify(value)
|
|
11
|
+
if (fmt === 'md') return formatMarkdown(value)
|
|
12
|
+
// toon
|
|
13
|
+
if (isScalar(value)) return String(value)
|
|
14
|
+
return encode(value as Record<string, unknown>)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether a value is a scalar (string, number, boolean, null, undefined). */
|
|
18
|
+
function isScalar(value: unknown): boolean {
|
|
19
|
+
return value === null || value === undefined || typeof value !== 'object'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether all values in an object are scalars. */
|
|
23
|
+
function isFlat(obj: Record<string, unknown>): boolean {
|
|
24
|
+
return Object.values(obj).every(isScalar)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a value is an array of plain objects. */
|
|
28
|
+
function isArrayOfObjects(value: unknown): value is Record<string, unknown>[] {
|
|
29
|
+
return (
|
|
30
|
+
Array.isArray(value) &&
|
|
31
|
+
value.length > 0 &&
|
|
32
|
+
value.every((v) => typeof v === 'object' && v !== null && !Array.isArray(v))
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Renders an aligned markdown table from headers and rows. */
|
|
37
|
+
function table(headers: string[], rows: string[][]): string {
|
|
38
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)))
|
|
39
|
+
const pad = (s: string, i: number) => s.padEnd(widths[i]!)
|
|
40
|
+
const headerRow = `| ${headers.map(pad).join(' | ')} |`
|
|
41
|
+
const sep = `|${widths.map((w) => '-'.repeat(w + 2)).join('|')}|`
|
|
42
|
+
const body = rows.map((r) => `| ${headers.map((_, i) => pad(r[i] ?? '', i)).join(' | ')} |`)
|
|
43
|
+
return `${headerRow}\n${sep}\n${body.join('\n')}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Renders a key-value table from a flat object. */
|
|
47
|
+
function kvTable(obj: Record<string, unknown>): string {
|
|
48
|
+
const entries = Object.entries(obj)
|
|
49
|
+
return table(
|
|
50
|
+
['Key', 'Value'],
|
|
51
|
+
entries.map(([k, v]) => [k, String(v)]),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Renders a columnar table from an array of objects. */
|
|
56
|
+
function columnarTable(items: Record<string, unknown>[]): string {
|
|
57
|
+
const keys = [...new Set(items.flatMap(Object.keys))]
|
|
58
|
+
return table(
|
|
59
|
+
keys,
|
|
60
|
+
items.map((item) => keys.map((k) => String(item[k] ?? ''))),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Formats a value as Markdown, recursing into nested objects. */
|
|
65
|
+
function formatMarkdown(value: unknown, path: string[] = []): string {
|
|
66
|
+
if (isScalar(value)) {
|
|
67
|
+
if (path.length === 0) return String(value)
|
|
68
|
+
return `## ${path.join('.')}\n\n${String(value)}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
if (isArrayOfObjects(value)) {
|
|
73
|
+
const table = columnarTable(value)
|
|
74
|
+
if (path.length === 0) return table
|
|
75
|
+
return `## ${path.join('.')}\n\n${table}`
|
|
76
|
+
}
|
|
77
|
+
return formatMarkdown(String(value), path)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const obj = value as Record<string, unknown>
|
|
81
|
+
const entries = Object.entries(obj)
|
|
82
|
+
|
|
83
|
+
// Single flat object at root — no headings needed
|
|
84
|
+
if (path.length === 0 && isFlat(obj)) return kvTable(obj)
|
|
85
|
+
|
|
86
|
+
// Check if we need headings (mixed types or nested at root)
|
|
87
|
+
const needsHeadings =
|
|
88
|
+
path.length > 0 || entries.length > 1 || entries.some(([, v]) => !isScalar(v))
|
|
89
|
+
|
|
90
|
+
if (needsHeadings) {
|
|
91
|
+
const sections = entries.map(([key, val]) => {
|
|
92
|
+
const childPath = [...path, key]
|
|
93
|
+
if (isScalar(val)) return `## ${childPath.join('.')}\n\n${String(val)}`
|
|
94
|
+
if (isArrayOfObjects(val)) return `## ${childPath.join('.')}\n\n${columnarTable(val)}`
|
|
95
|
+
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
96
|
+
const nested = val as Record<string, unknown>
|
|
97
|
+
if (isFlat(nested)) return `## ${childPath.join('.')}\n\n${kvTable(nested)}`
|
|
98
|
+
return formatMarkdown(nested, childPath)
|
|
99
|
+
}
|
|
100
|
+
return `## ${childPath.join('.')}\n\n${String(val)}`
|
|
101
|
+
})
|
|
102
|
+
return sections.join('\n\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return kvTable(obj)
|
|
106
|
+
}
|
package/src/Help.test.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Help, z } from 'incur'
|
|
2
|
+
|
|
3
|
+
describe('formatCommand', () => {
|
|
4
|
+
test('formats leaf command with args and options', () => {
|
|
5
|
+
const result = Help.formatCommand('gh pr list', {
|
|
6
|
+
description: 'List pull requests',
|
|
7
|
+
args: z.object({
|
|
8
|
+
repo: z.string().optional().describe('Repository in owner/repo format'),
|
|
9
|
+
}),
|
|
10
|
+
options: z.object({
|
|
11
|
+
state: z.string().default('open').describe('Filter by state'),
|
|
12
|
+
limit: z.number().default(30).describe('Max PRs to return'),
|
|
13
|
+
}),
|
|
14
|
+
})
|
|
15
|
+
expect(result).toMatchInlineSnapshot(`
|
|
16
|
+
"gh pr list — List pull requests
|
|
17
|
+
|
|
18
|
+
Usage: gh pr list [repo] [options]
|
|
19
|
+
|
|
20
|
+
Arguments:
|
|
21
|
+
repo Repository in owner/repo format
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--state <string> Filter by state (default: open)
|
|
25
|
+
--limit <number> Max PRs to return (default: 30)
|
|
26
|
+
|
|
27
|
+
Global Options:
|
|
28
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
29
|
+
--help Show help
|
|
30
|
+
--llms Print LLM-readable manifest
|
|
31
|
+
--verbose Show full output envelope"
|
|
32
|
+
`)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('omits sections when no schemas', () => {
|
|
36
|
+
const result = Help.formatCommand('tool ping', {
|
|
37
|
+
description: 'Health check',
|
|
38
|
+
})
|
|
39
|
+
expect(result).toMatchInlineSnapshot(`
|
|
40
|
+
"tool ping — Health check
|
|
41
|
+
|
|
42
|
+
Usage: tool ping
|
|
43
|
+
|
|
44
|
+
Global Options:
|
|
45
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
46
|
+
--help Show help
|
|
47
|
+
--llms Print LLM-readable manifest
|
|
48
|
+
--verbose Show full output envelope"
|
|
49
|
+
`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('formats optional args in brackets, required in angle brackets', () => {
|
|
53
|
+
const result = Help.formatCommand('tool greet', {
|
|
54
|
+
args: z.object({
|
|
55
|
+
name: z.string().describe('Name'),
|
|
56
|
+
title: z.string().optional().describe('Title'),
|
|
57
|
+
}),
|
|
58
|
+
})
|
|
59
|
+
expect(result).toMatchInlineSnapshot(`
|
|
60
|
+
"tool greet
|
|
61
|
+
|
|
62
|
+
Usage: tool greet <name> [title]
|
|
63
|
+
|
|
64
|
+
Arguments:
|
|
65
|
+
name Name
|
|
66
|
+
title Title
|
|
67
|
+
|
|
68
|
+
Global Options:
|
|
69
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
70
|
+
--help Show help
|
|
71
|
+
--llms Print LLM-readable manifest
|
|
72
|
+
--verbose Show full output envelope"
|
|
73
|
+
`)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('formatRoot', () => {
|
|
78
|
+
test('formats root with command list', () => {
|
|
79
|
+
const result = Help.formatRoot('gh', {
|
|
80
|
+
description: 'GitHub CLI',
|
|
81
|
+
commands: [
|
|
82
|
+
{ name: 'pr list', description: 'List pull requests' },
|
|
83
|
+
{ name: 'pr view', description: 'View a pull request' },
|
|
84
|
+
{ name: 'issue list', description: 'List issues' },
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
expect(result).toMatchInlineSnapshot(`
|
|
88
|
+
"gh — GitHub CLI
|
|
89
|
+
|
|
90
|
+
Usage: gh <command>
|
|
91
|
+
|
|
92
|
+
Commands:
|
|
93
|
+
pr list List pull requests
|
|
94
|
+
pr view View a pull request
|
|
95
|
+
issue list List issues
|
|
96
|
+
|
|
97
|
+
Global Options:
|
|
98
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
99
|
+
--help Show help
|
|
100
|
+
--llms Print LLM-readable manifest
|
|
101
|
+
--verbose Show full output envelope"
|
|
102
|
+
`)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('formats root with no description', () => {
|
|
106
|
+
const result = Help.formatRoot('tool', {
|
|
107
|
+
commands: [{ name: 'ping', description: 'Health check' }],
|
|
108
|
+
})
|
|
109
|
+
expect(result).toMatchInlineSnapshot(`
|
|
110
|
+
"tool
|
|
111
|
+
|
|
112
|
+
Usage: tool <command>
|
|
113
|
+
|
|
114
|
+
Commands:
|
|
115
|
+
ping Health check
|
|
116
|
+
|
|
117
|
+
Global Options:
|
|
118
|
+
--format <toon|json|yaml|md|jsonl> Output format
|
|
119
|
+
--help Show help
|
|
120
|
+
--llms Print LLM-readable manifest
|
|
121
|
+
--verbose Show full output envelope"
|
|
122
|
+
`)
|
|
123
|
+
})
|
|
124
|
+
})
|