incur 0.3.5 → 0.3.7

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 (51) hide show
  1. package/README.md +61 -0
  2. package/dist/Cli.d.ts +15 -0
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +300 -25
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Filter.js +0 -18
  7. package/dist/Filter.js.map +1 -1
  8. package/dist/Help.d.ts +4 -0
  9. package/dist/Help.d.ts.map +1 -1
  10. package/dist/Help.js +17 -14
  11. package/dist/Help.js.map +1 -1
  12. package/dist/Parser.d.ts +2 -0
  13. package/dist/Parser.d.ts.map +1 -1
  14. package/dist/Parser.js +69 -37
  15. package/dist/Parser.js.map +1 -1
  16. package/dist/bin.d.ts +1 -0
  17. package/dist/bin.d.ts.map +1 -1
  18. package/dist/bin.js +17 -2
  19. package/dist/bin.js.map +1 -1
  20. package/dist/internal/command.d.ts +2 -0
  21. package/dist/internal/command.d.ts.map +1 -1
  22. package/dist/internal/command.js +1 -0
  23. package/dist/internal/command.js.map +1 -1
  24. package/dist/internal/configSchema.d.ts +8 -0
  25. package/dist/internal/configSchema.d.ts.map +1 -0
  26. package/dist/internal/configSchema.js +57 -0
  27. package/dist/internal/configSchema.js.map +1 -0
  28. package/dist/internal/helpers.d.ts +9 -0
  29. package/dist/internal/helpers.d.ts.map +1 -0
  30. package/dist/internal/helpers.js +39 -0
  31. package/dist/internal/helpers.js.map +1 -0
  32. package/examples/npm/.npmrc.json +21 -0
  33. package/examples/npm/config.schema.json +137 -0
  34. package/package.json +1 -1
  35. package/src/Cli.test-d.ts +39 -0
  36. package/src/Cli.test.ts +714 -25
  37. package/src/Cli.ts +353 -27
  38. package/src/Filter.ts +0 -17
  39. package/src/Help.test.ts +66 -0
  40. package/src/Help.ts +20 -13
  41. package/src/Openapi.test.ts +6 -1
  42. package/src/Parser.test-d.ts +22 -0
  43. package/src/Parser.test.ts +89 -0
  44. package/src/Parser.ts +86 -35
  45. package/src/bin.ts +21 -2
  46. package/src/e2e.test.ts +22 -19
  47. package/src/internal/command.ts +3 -0
  48. package/src/internal/configSchema.test.ts +193 -0
  49. package/src/internal/configSchema.ts +66 -0
  50. package/src/internal/helpers.test.ts +54 -0
  51. package/src/internal/helpers.ts +41 -0
package/src/e2e.test.ts CHANGED
@@ -70,9 +70,9 @@ describe('routing', () => {
70
70
  "code: COMMAND_NOT_FOUND
71
71
  message: 'nonexistent' is not a command for 'app'.
72
72
  cta:
73
- description: "See available commands:"
74
- commands[1]{command}:
75
- app --help
73
+ description: "Next steps:"
74
+ commands[1]{command,description}:
75
+ app --help,see all available commands
76
76
  "
77
77
  `)
78
78
  })
@@ -85,8 +85,8 @@ describe('routing', () => {
85
85
  expect(output).toMatchInlineSnapshot(`
86
86
  "Error: 'nonexistent' is not a command for 'app'.
87
87
 
88
- See available commands:
89
- app --help
88
+ Next steps:
89
+ app --help # see all available commands
90
90
  "
91
91
  `)
92
92
  })
@@ -98,9 +98,9 @@ describe('routing', () => {
98
98
  "code: COMMAND_NOT_FOUND
99
99
  message: 'whoami' is not a command for 'app auth'.
100
100
  cta:
101
- description: "See available commands:"
102
- commands[1]{command}:
103
- app auth --help
101
+ description: "Next steps:"
102
+ commands[1]{command,description}:
103
+ app auth --help,see all available commands
104
104
  "
105
105
  `)
106
106
  })
@@ -112,9 +112,9 @@ describe('routing', () => {
112
112
  "code: COMMAND_NOT_FOUND
113
113
  message: 'nope' is not a command for 'app project deploy'.
114
114
  cta:
115
- description: "See available commands:"
116
- commands[1]{command}:
117
- app project deploy --help
115
+ description: "Next steps:"
116
+ commands[1]{command,description}:
117
+ app project deploy --help,see all available commands
118
118
  "
119
119
  `)
120
120
  })
@@ -650,9 +650,10 @@ describe('error handling', () => {
650
650
  "commands": [
651
651
  {
652
652
  "command": "app --help",
653
+ "description": "see all available commands",
653
654
  },
654
655
  ],
655
- "description": "See available commands:",
656
+ "description": "Next steps:",
656
657
  },
657
658
  "duration": "<stripped>",
658
659
  },
@@ -1974,13 +1975,15 @@ describe('skills staleness', () => {
1974
1975
 
1975
1976
  afterEach(() => {
1976
1977
  stderrSpy.mockRestore()
1978
+ __mockSkillsHash = undefined
1977
1979
  })
1978
1980
 
1979
- test('warns when running a command with stale skills', async () => {
1981
+ test('includes skills CTA when stale', async () => {
1980
1982
  __mockSkillsHash = '0000000000000000'
1981
1983
  const { output } = await serve(createApp(), ['ping'])
1982
1984
  expect(output).toContain('pong: true')
1983
- expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Skills are out of date.'))
1985
+ expect(output).toContain('Skills are out of date:')
1986
+ expect(output).toContain('skills add')
1984
1987
  })
1985
1988
 
1986
1989
  test('no warning when skills hash matches', async () => {
@@ -1991,20 +1994,20 @@ describe('skills staleness', () => {
1991
1994
 
1992
1995
  const { output } = await serve(cli, ['ping'])
1993
1996
  expect(output).toContain('pong: true')
1994
- expect(stderrSpy).not.toHaveBeenCalled()
1997
+ expect(output).not.toContain('Skills are out of date')
1995
1998
  })
1996
1999
 
1997
2000
  test('no warning on first use (no hash stored)', async () => {
1998
2001
  __mockSkillsHash = undefined
1999
2002
  const { output } = await serve(createApp(), ['ping'])
2000
2003
  expect(output).toContain('pong: true')
2001
- expect(stderrSpy).not.toHaveBeenCalled()
2004
+ expect(output).not.toContain('Skills are out of date')
2002
2005
  })
2003
2006
 
2004
2007
  test('no warning for --llms', async () => {
2005
2008
  __mockSkillsHash = '0000000000000000'
2006
- await serve(createApp(), ['--llms'])
2007
- expect(stderrSpy).not.toHaveBeenCalled()
2009
+ const { output } = await serve(createApp(), ['--llms'])
2010
+ expect(output).not.toContain('Skills are out of date')
2008
2011
  })
2009
2012
 
2010
2013
  test('no warning for --mcp', async () => {
@@ -2204,7 +2207,7 @@ describe('fetch gateway', () => {
2204
2207
  })
2205
2208
 
2206
2209
  test('query params from --key value', async () => {
2207
- const { output } = await serve(createApp(), ['api', 'users', '--limit', '5'])
2210
+ await serve(createApp(), ['api', 'users', '--limit', '5'])
2208
2211
  const { output: jsonOut } = await serve(createApp(), [
2209
2212
  'api',
2210
2213
  'users',
@@ -71,6 +71,7 @@ export async function execute(command: any, options: execute.Options): Promise<e
71
71
  const parsed = Parser.parse(argv, {
72
72
  alias: command.alias as Record<string, string> | undefined,
73
73
  args: command.args,
74
+ defaults: options.defaults,
74
75
  options: command.options,
75
76
  })
76
77
  args = parsed.args
@@ -269,6 +270,8 @@ export declare namespace execute {
269
270
  agent: boolean
270
271
  /** Raw positional tokens (already separated from flags). For HTTP/MCP, pass `[]`. */
271
272
  argv: string[]
273
+ /** Default option values from config file. */
274
+ defaults?: Record<string, unknown> | undefined
272
275
  /** CLI-level env schema. */
273
276
  env?: z.ZodObject<any> | undefined
274
277
  /** Source for environment variables. Defaults to `process.env`. */
@@ -0,0 +1,193 @@
1
+ import { Cli, z } from 'incur'
2
+
3
+ import * as ConfigSchema from './configSchema.js'
4
+
5
+ describe('fromCli', () => {
6
+ test('generates schema for root options and leaf commands', () => {
7
+ const cli = Cli.create('test', {
8
+ options: z.object({
9
+ verbose: z.boolean().default(false),
10
+ }),
11
+ })
12
+ cli.command('echo', {
13
+ options: z.object({
14
+ prefix: z.string().default(''),
15
+ upper: z.boolean().default(false),
16
+ }),
17
+ run: (c) => c.options,
18
+ })
19
+
20
+ const schema = ConfigSchema.fromCli(cli)
21
+ expect(schema).toMatchInlineSnapshot(`
22
+ {
23
+ "additionalProperties": false,
24
+ "properties": {
25
+ "$schema": {
26
+ "type": "string",
27
+ },
28
+ "commands": {
29
+ "additionalProperties": false,
30
+ "properties": {
31
+ "echo": {
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "options": {
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "prefix": {
38
+ "default": "",
39
+ "type": "string",
40
+ },
41
+ "upper": {
42
+ "default": false,
43
+ "type": "boolean",
44
+ },
45
+ },
46
+ "type": "object",
47
+ },
48
+ },
49
+ "type": "object",
50
+ },
51
+ },
52
+ "type": "object",
53
+ },
54
+ "options": {
55
+ "additionalProperties": false,
56
+ "properties": {
57
+ "verbose": {
58
+ "default": false,
59
+ "type": "boolean",
60
+ },
61
+ },
62
+ "type": "object",
63
+ },
64
+ },
65
+ "type": "object",
66
+ }
67
+ `)
68
+ })
69
+
70
+ test('generates schema for nested command groups', () => {
71
+ const project = Cli.create('project')
72
+ project.command('list', {
73
+ options: z.object({
74
+ limit: z.number().default(10),
75
+ label: z.array(z.string()).default([]),
76
+ }),
77
+ run: (c) => c.options,
78
+ })
79
+
80
+ const cli = Cli.create('test')
81
+ cli.command(project)
82
+
83
+ const schema = ConfigSchema.fromCli(cli)
84
+ expect(schema).toMatchInlineSnapshot(`
85
+ {
86
+ "additionalProperties": false,
87
+ "properties": {
88
+ "$schema": {
89
+ "type": "string",
90
+ },
91
+ "commands": {
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "project": {
95
+ "additionalProperties": false,
96
+ "properties": {
97
+ "commands": {
98
+ "additionalProperties": false,
99
+ "properties": {
100
+ "list": {
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "options": {
104
+ "additionalProperties": false,
105
+ "properties": {
106
+ "label": {
107
+ "default": [],
108
+ "items": {
109
+ "type": "string",
110
+ },
111
+ "type": "array",
112
+ },
113
+ "limit": {
114
+ "default": 10,
115
+ "type": "number",
116
+ },
117
+ },
118
+ "type": "object",
119
+ },
120
+ },
121
+ "type": "object",
122
+ },
123
+ },
124
+ "type": "object",
125
+ },
126
+ },
127
+ "type": "object",
128
+ },
129
+ },
130
+ "type": "object",
131
+ },
132
+ },
133
+ "type": "object",
134
+ }
135
+ `)
136
+ })
137
+
138
+ test('returns schema with only $schema for cli with no commands', () => {
139
+ const cli = Cli.create('test')
140
+ const schema = ConfigSchema.fromCli(cli)
141
+ expect(schema).toEqual({
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: { $schema: { type: 'string' } },
145
+ })
146
+ })
147
+
148
+ test('skips fetch gateway commands', () => {
149
+ const cli = Cli.create('test')
150
+ cli.command('echo', {
151
+ options: z.object({ prefix: z.string().default('') }),
152
+ run: (c) => c.options,
153
+ })
154
+ cli.command('api', {
155
+ description: 'API gateway',
156
+ fetch: () => new Response('ok'),
157
+ })
158
+
159
+ const schema = ConfigSchema.fromCli(cli)
160
+ const commandKeys = Object.keys((schema as any).properties.commands.properties)
161
+ expect(commandKeys).toEqual(['echo'])
162
+ })
163
+
164
+ test('includes commands without options as empty objects', () => {
165
+ const cli = Cli.create('test')
166
+ cli.command('ping', {
167
+ run: () => 'pong',
168
+ })
169
+
170
+ const schema = ConfigSchema.fromCli(cli)
171
+ expect(schema).toMatchInlineSnapshot(`
172
+ {
173
+ "additionalProperties": false,
174
+ "properties": {
175
+ "$schema": {
176
+ "type": "string",
177
+ },
178
+ "commands": {
179
+ "additionalProperties": false,
180
+ "properties": {
181
+ "ping": {
182
+ "additionalProperties": false,
183
+ "type": "object",
184
+ },
185
+ },
186
+ "type": "object",
187
+ },
188
+ },
189
+ "type": "object",
190
+ }
191
+ `)
192
+ })
193
+ })
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs/promises'
2
+ import type { z } from 'zod'
3
+
4
+ import * as Cli from '../Cli.js'
5
+ import * as Schema from '../Schema.js'
6
+ import { importCli } from './utils.js'
7
+
8
+ /** Returns `true` if the CLI has `config` enabled on `Cli.create()`. */
9
+ export function hasConfig(cli: Cli.Cli): boolean {
10
+ return Cli.toConfigEnabled.get(cli) === true
11
+ }
12
+
13
+ /** Imports a CLI from `input` (must `export default` a `Cli`), generates the JSON Schema, and writes it to `output`. */
14
+ export async function generate(input: string, output: string): Promise<void> {
15
+ const cli = await importCli(input)
16
+ await fs.writeFile(output, JSON.stringify(fromCli(cli), null, 2) + '\n')
17
+ }
18
+
19
+ /** Generates a JSON Schema describing the config file structure for a CLI. */
20
+ export function fromCli(cli: Cli.Cli): Record<string, unknown> {
21
+ const commands = Cli.toCommands.get(cli)
22
+ if (!commands) return { type: 'object' }
23
+
24
+ const rootOptions = Cli.toRootOptions.get(cli)
25
+ const node = buildNode(commands, rootOptions)
26
+ const properties = (node.properties ?? {}) as Record<string, unknown>
27
+ properties.$schema = { type: 'string' }
28
+ node.properties = properties
29
+ return node
30
+ }
31
+
32
+ /** Builds a JSON Schema node for a command level. */
33
+ function buildNode(
34
+ commands: Map<string, any>,
35
+ options?: z.ZodObject<any>,
36
+ ): Record<string, unknown> {
37
+ const properties: Record<string, unknown> = {}
38
+
39
+ // Add `options` property from the options schema
40
+ if (options) {
41
+ const optSchema = Schema.toJsonSchema(options)
42
+ const props = optSchema.properties as Record<string, unknown> | undefined
43
+ if (props && Object.keys(props).length > 0)
44
+ properties.options = { type: 'object', additionalProperties: false, properties: props }
45
+ }
46
+
47
+ // Add `commands` property with subcommand namespaces
48
+ const commandProps: Record<string, unknown> = {}
49
+ for (const [name, entry] of commands) {
50
+ if ('_group' in entry && entry._group) {
51
+ commandProps[name] = buildNode(entry.commands, undefined)
52
+ } else if (!('_fetch' in entry)) {
53
+ const cmd = entry as { options?: z.ZodObject<any> }
54
+ commandProps[name] = buildNode(new Map(), cmd.options)
55
+ }
56
+ }
57
+ if (Object.keys(commandProps).length > 0)
58
+ properties.commands = { type: 'object', additionalProperties: false, properties: commandProps }
59
+
60
+ const node: Record<string, unknown> = {
61
+ type: 'object',
62
+ additionalProperties: false,
63
+ }
64
+ if (Object.keys(properties).length > 0) node.properties = properties
65
+ return node
66
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { levenshtein, suggest } from './helpers.js'
3
+
4
+ describe('levenshtein', () => {
5
+ test('identical strings', () => {
6
+ expect(levenshtein('abc', 'abc')).toBe(0)
7
+ })
8
+
9
+ test('single insertion', () => {
10
+ expect(levenshtein('abc', 'abcd')).toBe(1)
11
+ })
12
+
13
+ test('single deletion', () => {
14
+ expect(levenshtein('abcd', 'abc')).toBe(1)
15
+ })
16
+
17
+ test('single substitution', () => {
18
+ expect(levenshtein('abc', 'axc')).toBe(1)
19
+ })
20
+
21
+ test('transposition counts as 2', () => {
22
+ expect(levenshtein('mpc', 'mcp')).toBe(2)
23
+ })
24
+
25
+ test('empty strings', () => {
26
+ expect(levenshtein('', '')).toBe(0)
27
+ expect(levenshtein('abc', '')).toBe(3)
28
+ expect(levenshtein('', 'abc')).toBe(3)
29
+ })
30
+ })
31
+
32
+ describe('suggest', () => {
33
+ const commands = ['deploy', 'status', 'list', 'create']
34
+
35
+ test('returns closest match within threshold', () => {
36
+ expect(suggest('deplyo', commands)).toBe('deploy')
37
+ })
38
+
39
+ test('returns match for transposition', () => {
40
+ expect(suggest('mpc', ['mcp', 'skills', 'completions'])).toBe('mcp')
41
+ })
42
+
43
+ test('returns undefined when no match is close enough', () => {
44
+ expect(suggest('xyz', commands)).toBeUndefined()
45
+ })
46
+
47
+ test('returns undefined for empty candidates', () => {
48
+ expect(suggest('deploy', [])).toBeUndefined()
49
+ })
50
+
51
+ test('picks the closest among multiple candidates', () => {
52
+ expect(suggest('craete', ['list', 'create', 'delete'])).toBe('create')
53
+ })
54
+ })
@@ -0,0 +1,41 @@
1
+ /** Checks whether a value is a plain object record. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
4
+ }
5
+
6
+ /** Converts a camelCase string to kebab-case. */
7
+ export function toKebab(value: string): string {
8
+ return value.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
9
+ }
10
+
11
+ /** Computes the Levenshtein edit distance between two strings. */
12
+ export function levenshtein(a: string, b: string): number {
13
+ const m = a.length
14
+ const n = b.length
15
+ const dp: number[] = Array.from({ length: n + 1 }, (_, i) => i)
16
+ for (let i = 1; i <= m; i++) {
17
+ let prev = dp[0]!
18
+ dp[0] = i
19
+ for (let j = 1; j <= n; j++) {
20
+ const tmp = dp[j]!
21
+ dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j]!, dp[j - 1]!)
22
+ prev = tmp
23
+ }
24
+ }
25
+ return dp[n]!
26
+ }
27
+
28
+ /** Suggests the closest command name from a set, returning it if within a reasonable edit distance. */
29
+ export function suggest(input: string, candidates: Iterable<string>): string | undefined {
30
+ const threshold = input.length <= 4 ? 2 : Math.floor(input.length / 2)
31
+ let best: string | undefined
32
+ let bestDist = threshold + 1
33
+ for (const c of candidates) {
34
+ const d = levenshtein(input, c)
35
+ if (d < bestDist) {
36
+ bestDist = d
37
+ best = c
38
+ }
39
+ }
40
+ return best
41
+ }