incur 0.2.0 → 0.2.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/src/Errors.ts CHANGED
@@ -41,12 +41,15 @@ export class IncurError extends BaseError {
41
41
  hint: string | undefined
42
42
  /** Whether the operation can be retried. */
43
43
  retryable: boolean
44
+ /** Process exit code. When set, `serve()` uses this instead of `1`. */
45
+ exitCode: number | undefined
44
46
 
45
47
  constructor(options: IncurError.Options) {
46
48
  super(options.message, options.cause ? { cause: options.cause } : undefined)
47
49
  this.code = options.code
48
50
  this.hint = options.hint
49
51
  this.retryable = options.retryable ?? false
52
+ this.exitCode = options.exitCode
50
53
  }
51
54
  }
52
55
 
@@ -61,6 +64,8 @@ export declare namespace IncurError {
61
64
  hint?: string | undefined
62
65
  /** Whether the operation can be retried. Defaults to `false`. */
63
66
  retryable?: boolean | undefined
67
+ /** Process exit code. When set, `serve()` uses this instead of `1`. */
68
+ exitCode?: number | undefined
64
69
  /** The underlying cause. */
65
70
  cause?: Error | undefined
66
71
  }
@@ -0,0 +1,237 @@
1
+ import { Filter } from 'incur'
2
+
3
+ describe('parse', () => {
4
+ test('single key', () => {
5
+ expect(Filter.parse('foo')).toMatchInlineSnapshot(`
6
+ [
7
+ [
8
+ {
9
+ "key": "foo",
10
+ },
11
+ ],
12
+ ]
13
+ `)
14
+ })
15
+
16
+ test('dot notation', () => {
17
+ expect(Filter.parse('bar.baz')).toMatchInlineSnapshot(`
18
+ [
19
+ [
20
+ {
21
+ "key": "bar",
22
+ },
23
+ {
24
+ "key": "baz",
25
+ },
26
+ ],
27
+ ]
28
+ `)
29
+ })
30
+
31
+ test('slice notation', () => {
32
+ expect(Filter.parse('items[0,3]')).toMatchInlineSnapshot(`
33
+ [
34
+ [
35
+ {
36
+ "key": "items",
37
+ },
38
+ {
39
+ "end": 3,
40
+ "start": 0,
41
+ },
42
+ ],
43
+ ]
44
+ `)
45
+ })
46
+
47
+ test('mixed keys and slices', () => {
48
+ expect(Filter.parse('a.b.c[0,10]')).toMatchInlineSnapshot(`
49
+ [
50
+ [
51
+ {
52
+ "key": "a",
53
+ },
54
+ {
55
+ "key": "b",
56
+ },
57
+ {
58
+ "key": "c",
59
+ },
60
+ {
61
+ "end": 10,
62
+ "start": 0,
63
+ },
64
+ ],
65
+ ]
66
+ `)
67
+ })
68
+
69
+ test('multiple paths', () => {
70
+ expect(Filter.parse('name,age')).toMatchInlineSnapshot(`
71
+ [
72
+ [
73
+ {
74
+ "key": "name",
75
+ },
76
+ ],
77
+ [
78
+ {
79
+ "key": "age",
80
+ },
81
+ ],
82
+ ]
83
+ `)
84
+ })
85
+
86
+ test('slice followed by dot path', () => {
87
+ expect(Filter.parse('items[0,3].name')).toMatchInlineSnapshot(`
88
+ [
89
+ [
90
+ {
91
+ "key": "items",
92
+ },
93
+ {
94
+ "end": 3,
95
+ "start": 0,
96
+ },
97
+ {
98
+ "key": "name",
99
+ },
100
+ ],
101
+ ]
102
+ `)
103
+ })
104
+
105
+ test('comma inside slice is not a separator', () => {
106
+ expect(Filter.parse('foo,items[0,3],bar')).toMatchInlineSnapshot(`
107
+ [
108
+ [
109
+ {
110
+ "key": "foo",
111
+ },
112
+ ],
113
+ [
114
+ {
115
+ "key": "items",
116
+ },
117
+ {
118
+ "end": 3,
119
+ "start": 0,
120
+ },
121
+ ],
122
+ [
123
+ {
124
+ "key": "bar",
125
+ },
126
+ ],
127
+ ]
128
+ `)
129
+ })
130
+ })
131
+
132
+ describe('apply', () => {
133
+ test('selects single top-level key', () => {
134
+ const data = { name: 'alice', age: 30, email: 'alice@example.com' }
135
+ expect(Filter.apply(data, Filter.parse('name'))).toMatchInlineSnapshot(`"alice"`)
136
+ })
137
+
138
+ test('selects nested key with dot notation', () => {
139
+ const data = { user: { name: 'alice', email: 'alice@example.com' }, status: 'active' }
140
+ expect(Filter.apply(data, Filter.parse('user.name'))).toMatchInlineSnapshot(`
141
+ {
142
+ "user": {
143
+ "name": "alice",
144
+ },
145
+ }
146
+ `)
147
+ })
148
+
149
+ test('slices array', () => {
150
+ const data = { items: [1, 2, 3, 4, 5] }
151
+ expect(Filter.apply(data, Filter.parse('items[0,3]'))).toMatchInlineSnapshot(`
152
+ {
153
+ "items": [
154
+ 1,
155
+ 2,
156
+ 3,
157
+ ],
158
+ }
159
+ `)
160
+ })
161
+
162
+ test('selects nested field after slice', () => {
163
+ const data = {
164
+ users: [
165
+ { name: 'alice', age: 30 },
166
+ { name: 'bob', age: 25 },
167
+ { name: 'charlie', age: 35 },
168
+ ],
169
+ }
170
+ expect(Filter.apply(data, Filter.parse('users[0,2].name'))).toMatchInlineSnapshot(`
171
+ {
172
+ "users": [
173
+ {
174
+ "name": "alice",
175
+ },
176
+ {
177
+ "name": "bob",
178
+ },
179
+ ],
180
+ }
181
+ `)
182
+ })
183
+
184
+ test('multiple filter paths merged', () => {
185
+ const data = { name: 'alice', age: 30, email: 'alice@example.com' }
186
+ expect(Filter.apply(data, Filter.parse('name,age'))).toMatchInlineSnapshot(`
187
+ {
188
+ "age": 30,
189
+ "name": "alice",
190
+ }
191
+ `)
192
+ })
193
+
194
+ test('returns scalar directly for single key selection', () => {
195
+ const data = { message: 'hello world', status: 'ok' }
196
+ const result = Filter.apply(data, Filter.parse('message'))
197
+ expect(result).toBe('hello world')
198
+ })
199
+
200
+ test('returns object for single key with object value', () => {
201
+ const data = { user: { name: 'alice' }, status: 'ok' }
202
+ expect(Filter.apply(data, Filter.parse('user'))).toMatchInlineSnapshot(`
203
+ {
204
+ "user": {
205
+ "name": "alice",
206
+ },
207
+ }
208
+ `)
209
+ })
210
+
211
+ test('returns undefined for missing key', () => {
212
+ const data = { name: 'alice' }
213
+ expect(Filter.apply(data, Filter.parse('missing'))).toMatchInlineSnapshot(`undefined`)
214
+ })
215
+
216
+ test('applies filter to each element when data is array', () => {
217
+ const data = [
218
+ { name: 'alice', age: 30 },
219
+ { name: 'bob', age: 25 },
220
+ ]
221
+ expect(Filter.apply(data, Filter.parse('name'))).toMatchInlineSnapshot(`
222
+ [
223
+ "alice",
224
+ "bob",
225
+ ]
226
+ `)
227
+ })
228
+
229
+ test('empty paths returns data unchanged', () => {
230
+ const data = { foo: 'bar' }
231
+ expect(Filter.apply(data, [])).toMatchInlineSnapshot(`
232
+ {
233
+ "foo": "bar",
234
+ }
235
+ `)
236
+ })
237
+ })
package/src/Filter.ts ADDED
@@ -0,0 +1,139 @@
1
+ /** A single segment in a filter path: either a string key or an array slice. */
2
+ export type Segment = { key: string } | { start: number; end: number }
3
+
4
+ /** A filter path is an ordered list of segments to traverse. */
5
+ export type FilterPath = Segment[]
6
+
7
+ /** Parses a filter expression string into structured filter paths. */
8
+ export function parse(expression: string): FilterPath[] {
9
+ const paths: FilterPath[] = []
10
+ const tokens: string[] = []
11
+ let current = ''
12
+ let depth = 0
13
+
14
+ // Split on commas, but commas inside [...] are part of a slice
15
+ for (let i = 0; i < expression.length; i++) {
16
+ const ch = expression[i]!
17
+ if (ch === '[') depth++
18
+ else if (ch === ']') depth--
19
+
20
+ if (ch === ',' && depth === 0) {
21
+ tokens.push(current)
22
+ current = ''
23
+ } else current += ch
24
+ }
25
+ if (current) tokens.push(current)
26
+
27
+ for (const token of tokens) {
28
+ const path: FilterPath = []
29
+ let remaining = token
30
+
31
+ while (remaining.length > 0) {
32
+ const bracketIdx = remaining.indexOf('[')
33
+
34
+ if (bracketIdx === -1) {
35
+ // No more slices — split remaining by dots
36
+ for (const part of remaining.split('.')) if (part) path.push({ key: part })
37
+ break
38
+ }
39
+
40
+ // Parse dot-separated keys before the bracket
41
+ const before = remaining.slice(0, bracketIdx)
42
+ for (const part of before.split('.')) if (part) path.push({ key: part })
43
+
44
+ // Parse the slice [start,end]
45
+ const closeBracket = remaining.indexOf(']', bracketIdx)
46
+ const inner = remaining.slice(bracketIdx + 1, closeBracket)
47
+ const [startStr, endStr] = inner.split(',')
48
+ path.push({ start: Number(startStr), end: Number(endStr) })
49
+
50
+ remaining = remaining.slice(closeBracket + 1)
51
+ if (remaining.startsWith('.')) remaining = remaining.slice(1)
52
+ }
53
+
54
+ paths.push(path)
55
+ }
56
+
57
+ return paths
58
+ }
59
+
60
+ /** Applies parsed filter paths to a data value, returning a filtered copy. */
61
+ export function apply(data: unknown, paths: FilterPath[]): unknown {
62
+ if (paths.length === 0) return data
63
+
64
+ // Single key selecting a scalar → return the scalar directly
65
+ if (paths.length === 1 && paths[0]!.length === 1 && 'key' in paths[0]![0]!) {
66
+ const key = paths[0]![0]!.key
67
+ if (Array.isArray(data)) return data.map((item) => apply(item, paths))
68
+ if (typeof data === 'object' && data !== null) {
69
+ const val = (data as Record<string, unknown>)[key]
70
+ if (typeof val !== 'object' || val === null) return val
71
+ return { [key]: val }
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ if (Array.isArray(data)) return data.map((item) => apply(item, paths))
77
+
78
+ const result: Record<string, unknown> = {}
79
+ for (const path of paths) merge(result, data, path, 0)
80
+ return result
81
+ }
82
+
83
+ function resolve(data: unknown, segments: Segment[], index: number): unknown {
84
+ if (index >= segments.length) return data
85
+ const segment = segments[index]!
86
+
87
+ if ('key' in segment) {
88
+ if (typeof data !== 'object' || data === null) return undefined
89
+ const val = (data as Record<string, unknown>)[segment.key]
90
+ return resolve(val, segments, index + 1)
91
+ }
92
+
93
+ // slice segment
94
+ if (!Array.isArray(data)) return undefined
95
+ const sliced = data.slice(segment.start, segment.end)
96
+ if (index + 1 >= segments.length) return sliced
97
+ return sliced.map((item) => resolve(item, segments, index + 1))
98
+ }
99
+
100
+ function merge(target: Record<string, unknown>, data: unknown, segments: Segment[], index: number): void {
101
+ if (index >= segments.length || typeof data !== 'object' || data === null) return
102
+ const segment = segments[index]!
103
+
104
+ if ('key' in segment) {
105
+ const val = (data as Record<string, unknown>)[segment.key]
106
+ if (val === undefined) return
107
+
108
+ if (index + 1 >= segments.length) {
109
+ target[segment.key] = val
110
+ return
111
+ }
112
+
113
+ const next = segments[index + 1]!
114
+ if ('start' in next) {
115
+ // Next segment is a slice
116
+ if (!Array.isArray(val)) return
117
+ const sliced = val.slice(next.start, next.end)
118
+ if (index + 2 >= segments.length) {
119
+ target[segment.key] = sliced
120
+ return
121
+ }
122
+ target[segment.key] = sliced.map((item) => {
123
+ const sub: Record<string, unknown> = {}
124
+ merge(sub, item, segments, index + 2)
125
+ return sub
126
+ })
127
+ return
128
+ }
129
+
130
+ // Next segment is a key — recurse into nested object
131
+ if (typeof val !== 'object' || val === null) return
132
+ if (!target[segment.key] || typeof target[segment.key] !== 'object')
133
+ target[segment.key] = {}
134
+ merge(target[segment.key] as Record<string, unknown>, val, segments, index + 1)
135
+ return
136
+ }
137
+
138
+ // slice at root level — shouldn't happen in merge (merge starts from object keys)
139
+ }
package/src/Help.test.ts CHANGED
@@ -25,9 +25,14 @@ describe('formatCommand', () => {
25
25
  --limit <number> Max PRs to return (default: 30)
26
26
 
27
27
  Global Options:
28
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
28
29
  --format <toon|json|yaml|md|jsonl> Output format
29
30
  --help Show help
30
31
  --llms Print LLM-readable manifest
32
+ --schema Show JSON Schema for a command
33
+ --token-count Print token count of output (instead of output)
34
+ --token-limit <n> Limit output to n tokens
35
+ --token-offset <n> Skip first n tokens of output
31
36
  --verbose Show full output envelope"
32
37
  `)
33
38
  })
@@ -42,9 +47,14 @@ describe('formatCommand', () => {
42
47
  Usage: tool ping
43
48
 
44
49
  Global Options:
50
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
45
51
  --format <toon|json|yaml|md|jsonl> Output format
46
52
  --help Show help
47
53
  --llms Print LLM-readable manifest
54
+ --schema Show JSON Schema for a command
55
+ --token-count Print token count of output (instead of output)
56
+ --token-limit <n> Limit output to n tokens
57
+ --token-offset <n> Skip first n tokens of output
48
58
  --verbose Show full output envelope"
49
59
  `)
50
60
  })
@@ -66,9 +76,14 @@ describe('formatCommand', () => {
66
76
  title Title
67
77
 
68
78
  Global Options:
79
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
69
80
  --format <toon|json|yaml|md|jsonl> Output format
70
81
  --help Show help
71
82
  --llms Print LLM-readable manifest
83
+ --schema Show JSON Schema for a command
84
+ --token-count Print token count of output (instead of output)
85
+ --token-limit <n> Limit output to n tokens
86
+ --token-offset <n> Skip first n tokens of output
72
87
  --verbose Show full output envelope"
73
88
  `)
74
89
  })
@@ -107,9 +122,14 @@ describe('formatRoot', () => {
107
122
  issue list List issues
108
123
 
109
124
  Global Options:
125
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
110
126
  --format <toon|json|yaml|md|jsonl> Output format
111
127
  --help Show help
112
128
  --llms Print LLM-readable manifest
129
+ --schema Show JSON Schema for a command
130
+ --token-count Print token count of output (instead of output)
131
+ --token-limit <n> Limit output to n tokens
132
+ --token-offset <n> Skip first n tokens of output
113
133
  --verbose Show full output envelope"
114
134
  `)
115
135
  })
@@ -127,9 +147,14 @@ describe('formatRoot', () => {
127
147
  ping Health check
128
148
 
129
149
  Global Options:
150
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
130
151
  --format <toon|json|yaml|md|jsonl> Output format
131
152
  --help Show help
132
153
  --llms Print LLM-readable manifest
154
+ --schema Show JSON Schema for a command
155
+ --token-count Print token count of output (instead of output)
156
+ --token-limit <n> Limit output to n tokens
157
+ --token-offset <n> Skip first n tokens of output
133
158
  --verbose Show full output envelope"
134
159
  `)
135
160
  })
@@ -151,9 +176,14 @@ describe('formatRoot', () => {
151
176
  fetch Fetch a URL
152
177
 
153
178
  Global Options:
179
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
154
180
  --format <toon|json|yaml|md|jsonl> Output format
155
181
  --help Show help
156
182
  --llms Print LLM-readable manifest
183
+ --schema Show JSON Schema for a command
184
+ --token-count Print token count of output (instead of output)
185
+ --token-limit <n> Limit output to n tokens
186
+ --token-offset <n> Skip first n tokens of output
157
187
  --verbose Show full output envelope"
158
188
  `)
159
189
  })
@@ -175,9 +205,14 @@ describe('formatRoot', () => {
175
205
  url URL to fetch
176
206
 
177
207
  Global Options:
208
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
178
209
  --format <toon|json|yaml|md|jsonl> Output format
179
210
  --help Show help
180
211
  --llms Print LLM-readable manifest
212
+ --schema Show JSON Schema for a command
213
+ --token-count Print token count of output (instead of output)
214
+ --token-limit <n> Limit output to n tokens
215
+ --token-offset <n> Skip first n tokens of output
181
216
  --verbose Show full output envelope"
182
217
  `)
183
218
  })
package/src/Help.ts CHANGED
@@ -326,10 +326,15 @@ function globalOptionsLines(root = false): string[] {
326
326
  }
327
327
 
328
328
  const flags = [
329
+ { flag: '--filter-output <keys>', desc: 'Filter output by key paths (e.g. foo,bar.baz,a[0,3])' },
329
330
  { flag: '--format <toon|json|yaml|md|jsonl>', desc: 'Output format' },
330
331
  { flag: '--help', desc: 'Show help' },
331
332
  { flag: '--llms', desc: 'Print LLM-readable manifest' },
332
333
  ...(root ? [{ flag: '--mcp', desc: 'Start as MCP stdio server' }] : []),
334
+ { flag: '--schema', desc: 'Show JSON Schema for a command' },
335
+ { flag: '--token-count', desc: 'Print token count of output (instead of output)' },
336
+ { flag: '--token-limit <n>', desc: 'Limit output to n tokens' },
337
+ { flag: '--token-offset <n>', desc: 'Skip first n tokens of output' },
333
338
  { flag: '--verbose', desc: 'Show full output envelope' },
334
339
  ...(root ? [{ flag: '--version', desc: 'Show version' }] : []),
335
340
  ]
package/src/Mcp.ts CHANGED
@@ -52,7 +52,7 @@ export declare namespace serve {
52
52
  }
53
53
 
54
54
  /** @internal Executes a tool call and returns a CallToolResult. */
55
- async function callTool(
55
+ export async function callTool(
56
56
  tool: ToolEntry,
57
57
  params: Record<string, unknown>,
58
58
  extra?: {
@@ -135,7 +135,7 @@ function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unkn
135
135
  }
136
136
 
137
137
  /** @internal A resolved tool entry from the command tree. */
138
- type ToolEntry = {
138
+ export type ToolEntry = {
139
139
  name: string
140
140
  description?: string | undefined
141
141
  inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
@@ -143,7 +143,7 @@ type ToolEntry = {
143
143
  }
144
144
 
145
145
  /** @internal Recursively collects leaf commands as tool entries. */
146
- function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
146
+ export function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
147
147
  const result: ToolEntry[] = []
148
148
  for (const [name, entry] of commands) {
149
149
  const path = [...prefix, name]