incur 0.0.0 → 0.0.2

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