incur 0.1.4 → 0.1.6

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/e2e.test.ts CHANGED
@@ -2,6 +2,14 @@ import { Cli, Errors, Skill, Typegen, z } from 'incur'
2
2
 
3
3
  let __mockSkillsHash: string | undefined
4
4
 
5
+ const originalIsTTY = process.stdout.isTTY
6
+ beforeAll(() => {
7
+ ;(process.stdout as any).isTTY = false
8
+ })
9
+ afterAll(() => {
10
+ ;(process.stdout as any).isTTY = originalIsTTY
11
+ })
12
+
5
13
  vi.mock('./SyncSkills.js', async (importOriginal) => {
6
14
  const actual = await importOriginal<typeof import('./SyncSkills.js')>()
7
15
  return { ...actual, readHash: () => __mockSkillsHash }
@@ -56,6 +64,18 @@ describe('routing', () => {
56
64
  test('unknown top-level command', async () => {
57
65
  const { output, exitCode } = await serve(createApp(), ['nonexistent'])
58
66
  expect(exitCode).toBe(1)
67
+ expect(output).toMatchInlineSnapshot(`
68
+ "code: COMMAND_NOT_FOUND
69
+ message: 'nonexistent' is not a command. See 'app --help' for a list of available commands.
70
+ "
71
+ `)
72
+ })
73
+
74
+ test('unknown top-level command shows human error in TTY', async () => {
75
+ ;(process.stdout as any).isTTY = true
76
+ const { output, exitCode } = await serve(createApp(), ['nonexistent'])
77
+ ;(process.stdout as any).isTTY = false
78
+ expect(exitCode).toBe(1)
59
79
  expect(output).toMatchInlineSnapshot(`
60
80
  "Error: 'nonexistent' is not a command. See 'app --help' for a list of available commands.
61
81
  "
@@ -66,7 +86,8 @@ describe('routing', () => {
66
86
  const { output, exitCode } = await serve(createApp(), ['auth', 'whoami'])
67
87
  expect(exitCode).toBe(1)
68
88
  expect(output).toMatchInlineSnapshot(`
69
- "Error: 'whoami' is not a command. See 'app auth --help' for a list of available commands.
89
+ "code: COMMAND_NOT_FOUND
90
+ message: 'whoami' is not a command. See 'app auth --help' for a list of available commands.
70
91
  "
71
92
  `)
72
93
  })
@@ -75,7 +96,8 @@ describe('routing', () => {
75
96
  const { output, exitCode } = await serve(createApp(), ['project', 'deploy', 'nope'])
76
97
  expect(exitCode).toBe(1)
77
98
  expect(output).toMatchInlineSnapshot(`
78
- "Error: 'nope' is not a command. See 'app project deploy --help' for a list of available commands.
99
+ "code: COMMAND_NOT_FOUND
100
+ message: 'nope' is not a command. See 'app project deploy --help' for a list of available commands.
79
101
  "
80
102
  `)
81
103
  })
@@ -163,12 +185,32 @@ describe('args and options', () => {
163
185
  test('missing required arg fails validation', async () => {
164
186
  const { output, exitCode } = await serve(createApp(), ['project', 'get'])
165
187
  expect(exitCode).toBe(1)
188
+ expect(output).toContain('VALIDATION_ERROR')
189
+ })
190
+
191
+ test('missing required arg shows human error in TTY', async () => {
192
+ ;(process.stdout as any).isTTY = true
193
+ const { output, exitCode } = await serve(createApp(), ['project', 'get'])
194
+ ;(process.stdout as any).isTTY = false
195
+ expect(exitCode).toBe(1)
166
196
  expect(output).toContain('Error: missing required argument <id>')
167
197
  })
168
198
 
169
199
  test('unknown flag returns error', async () => {
170
200
  const { output, exitCode } = await serve(createApp(), ['ping', '--unknown-flag'])
171
201
  expect(exitCode).toBe(1)
202
+ expect(output).toMatchInlineSnapshot(`
203
+ "code: UNKNOWN
204
+ message: "Unknown flag: --unknown-flag"
205
+ "
206
+ `)
207
+ })
208
+
209
+ test('unknown flag shows human error in TTY', async () => {
210
+ ;(process.stdout as any).isTTY = true
211
+ const { output, exitCode } = await serve(createApp(), ['ping', '--unknown-flag'])
212
+ ;(process.stdout as any).isTTY = false
213
+ expect(exitCode).toBe(1)
172
214
  expect(output).toMatchInlineSnapshot(`
173
215
  "Error: Unknown flag: --unknown-flag
174
216
  "
@@ -348,8 +390,20 @@ describe('undefined output', () => {
348
390
  })
349
391
 
350
392
  describe('error handling', () => {
351
- test('thrown Error shows human-readable message', async () => {
393
+ test('thrown Error shows structured error', async () => {
394
+ const { output, exitCode } = await serve(createApp(), ['explode'])
395
+ expect(exitCode).toBe(1)
396
+ expect(output).toMatchInlineSnapshot(`
397
+ "code: UNKNOWN
398
+ message: kaboom
399
+ "
400
+ `)
401
+ })
402
+
403
+ test('thrown Error shows human error in TTY', async () => {
404
+ ;(process.stdout as any).isTTY = true
352
405
  const { output, exitCode } = await serve(createApp(), ['explode'])
406
+ ;(process.stdout as any).isTTY = false
353
407
  expect(exitCode).toBe(1)
354
408
  expect(output).toMatchInlineSnapshot(`
355
409
  "Error: kaboom
@@ -1192,8 +1246,8 @@ describe('root command with subcommands', () => {
1192
1246
  const cli = Cli.create('tool', {
1193
1247
  description: 'A tool with a default action',
1194
1248
  args: z.object({ query: z.string().optional().describe('Search query') }),
1195
- run({ args }) {
1196
- return { default: true, query: args.query ?? null }
1249
+ run(c) {
1250
+ return { default: true, query: c.args.query ?? null }
1197
1251
  },
1198
1252
  })
1199
1253
  cli.command('info', {
@@ -1556,9 +1610,9 @@ function createApp() {
1556
1610
  scopes: z.array(z.string()).default([]).describe('OAuth scopes'),
1557
1611
  }),
1558
1612
  alias: { hostname: 'h', web: 'w' },
1559
- run({ env, options, ok }) {
1560
- return ok(
1561
- { hostname: env.AUTH_HOST, scopes: options.scopes },
1613
+ run(c) {
1614
+ return c.ok(
1615
+ { hostname: c.env.AUTH_HOST, scopes: c.options.scopes },
1562
1616
  {
1563
1617
  cta: {
1564
1618
  description: 'Verify your session:',
@@ -1570,15 +1624,15 @@ function createApp() {
1570
1624
  })
1571
1625
  .command('logout', {
1572
1626
  description: 'Log out of the service',
1573
- run({ ok }) {
1574
- return ok({ loggedOut: true })
1627
+ run(c) {
1628
+ return c.ok({ loggedOut: true })
1575
1629
  },
1576
1630
  })
1577
1631
  .command('status', {
1578
1632
  description: 'Show authentication status',
1579
1633
  output: z.object({ loggedIn: z.boolean(), hostname: z.string(), user: z.string() }),
1580
- run({ error }) {
1581
- return error({
1634
+ run(c) {
1635
+ return c.error({
1582
1636
  code: 'NOT_AUTHENTICATED',
1583
1637
  message: 'Not logged in',
1584
1638
  retryable: false,
@@ -1607,12 +1661,12 @@ function createApp() {
1607
1661
  ),
1608
1662
  total: z.number(),
1609
1663
  }),
1610
- run({ options, ok }) {
1664
+ run(c) {
1611
1665
  const items = [
1612
1666
  { id: 'p1', name: 'Alpha', archived: false },
1613
1667
  { id: 'p2', name: 'Beta', archived: true },
1614
- ].filter((p) => options.archived || !p.archived)
1615
- return ok(
1668
+ ].filter((p) => c.options.archived || !p.archived)
1669
+ return c.ok(
1616
1670
  { items, total: items.length },
1617
1671
  {
1618
1672
  cta: {
@@ -1634,9 +1688,9 @@ function createApp() {
1634
1688
  description: z.string(),
1635
1689
  members: z.array(z.object({ userId: z.string(), role: z.string() })),
1636
1690
  }),
1637
- run({ args, ok }) {
1638
- return ok({
1639
- id: args.id,
1691
+ run(c) {
1692
+ return c.ok({
1693
+ id: c.args.id,
1640
1694
  name: 'Alpha',
1641
1695
  description: 'Main project',
1642
1696
  members: [{ userId: 'u1', role: 'admin' }],
@@ -1653,13 +1707,13 @@ function createApp() {
1653
1707
  alias: { description: 'd' },
1654
1708
 
1655
1709
  output: z.object({ id: z.string(), url: z.string() }),
1656
- run({ args, ok }) {
1657
- return ok(
1710
+ run(c) {
1711
+ return c.ok(
1658
1712
  { id: 'p-new', url: 'https://example.com/projects/p-new' },
1659
1713
  {
1660
1714
  cta: {
1661
1715
  commands: [
1662
- { command: 'project get p-new', description: `View "${args.name}"` },
1716
+ { command: 'project get p-new', description: `View "${c.args.name}"` },
1663
1717
  'project list',
1664
1718
  ],
1665
1719
  },
@@ -1675,14 +1729,14 @@ function createApp() {
1675
1729
  }),
1676
1730
  alias: { force: 'f' },
1677
1731
 
1678
- run({ args, options }) {
1679
- if (!options.force)
1732
+ run(c) {
1733
+ if (!c.options.force)
1680
1734
  throw new Errors.IncurError({
1681
1735
  code: 'CONFIRMATION_REQUIRED',
1682
- message: `Use --force to delete project ${args.id}`,
1736
+ message: `Use --force to delete project ${c.args.id}`,
1683
1737
  retryable: true,
1684
1738
  })
1685
- return { deleted: true, id: args.id }
1739
+ return { deleted: true, id: c.args.id }
1686
1740
  },
1687
1741
  })
1688
1742
 
@@ -1705,11 +1759,11 @@ function createApp() {
1705
1759
  options: { branch: 'release', dryRun: true },
1706
1760
  },
1707
1761
  ],
1708
- run({ args, options, ok }) {
1709
- return ok({
1762
+ run(c) {
1763
+ return c.ok({
1710
1764
  deployId: 'd-123',
1711
- url: `https://${args.env}.example.com`,
1712
- status: options.dryRun ? 'dry-run' : 'pending',
1765
+ url: `https://${c.args.env}.example.com`,
1766
+ status: c.options.dryRun ? 'dry-run' : 'pending',
1713
1767
  })
1714
1768
  },
1715
1769
  })
@@ -1718,16 +1772,16 @@ function createApp() {
1718
1772
  args: z.object({ deployId: z.string().describe('Deployment ID') }),
1719
1773
 
1720
1774
  output: z.object({ deployId: z.string(), status: z.string(), progress: z.number() }),
1721
- run({ args }) {
1722
- return { deployId: args.deployId, status: 'running', progress: 75 }
1775
+ run(c) {
1776
+ return { deployId: c.args.deployId, status: 'running', progress: 75 }
1723
1777
  },
1724
1778
  })
1725
1779
  .command('rollback', {
1726
1780
  description: 'Rollback a deployment',
1727
1781
  args: z.object({ deployId: z.string().describe('Deployment ID') }),
1728
1782
 
1729
- run({ args }) {
1730
- return { rolledBack: true, deployId: args.deployId }
1783
+ run(c) {
1784
+ return { rolledBack: true, deployId: c.args.deployId }
1731
1785
  },
1732
1786
  })
1733
1787
 
@@ -1736,8 +1790,8 @@ function createApp() {
1736
1790
  const config = Cli.create('config', {
1737
1791
  description: 'Show current configuration',
1738
1792
  args: z.object({ key: z.string().optional().describe('Config key to show') }),
1739
- run({ args }) {
1740
- if (args.key) return { key: args.key, value: 'some-value' }
1793
+ run(c) {
1794
+ if (c.args.key) return { key: c.args.key, value: 'some-value' }
1741
1795
  return { apiUrl: 'https://api.example.com', timeout: 30, debug: false }
1742
1796
  },
1743
1797
  })
@@ -1765,10 +1819,10 @@ function createApp() {
1765
1819
  prefix: z.string().default('').describe('Prefix string'),
1766
1820
  }),
1767
1821
  alias: { upper: 'u', prefix: 'p' },
1768
- run({ args, options }) {
1769
- const count = args.repeat ?? 1
1770
- let msg = options.prefix ? `${options.prefix} ${args.message}` : args.message
1771
- if (options.upper) msg = msg.toUpperCase()
1822
+ run(c) {
1823
+ const count = c.args.repeat ?? 1
1824
+ let msg = c.options.prefix ? `${c.options.prefix} ${c.args.message}` : c.args.message
1825
+ if (c.options.upper) msg = msg.toUpperCase()
1772
1826
  return { result: Array(count).fill(msg) }
1773
1827
  },
1774
1828
  })
@@ -79,7 +79,7 @@ export function install(
79
79
  const canonicalDir = path.join(canonicalBase, skill.name)
80
80
 
81
81
  // Copy to canonical location
82
- fs.rmSync(canonicalDir, { recursive: true, force: true })
82
+ rmForce(canonicalDir)
83
83
  fs.mkdirSync(canonicalDir, { recursive: true })
84
84
  if (skill.root)
85
85
  fs.copyFileSync(path.join(skill.dir, 'SKILL.md'), path.join(canonicalDir, 'SKILL.md'))
@@ -96,7 +96,7 @@ export function install(
96
96
  if (agentDir === canonicalDir) continue
97
97
 
98
98
  try {
99
- fs.rmSync(agentDir, { recursive: true, force: true })
99
+ rmForce(agentDir)
100
100
  fs.mkdirSync(path.dirname(agentDir), { recursive: true })
101
101
  // Resolve through any existing symlinks in parent directories
102
102
  const realLinkDir = resolveParent(path.dirname(agentDir))
@@ -142,6 +142,27 @@ export declare namespace install {
142
142
  }
143
143
  }
144
144
 
145
+ /**
146
+ * Removes a skill by name from the canonical location and all detected agent directories.
147
+ */
148
+ export function remove(
149
+ skillName: string,
150
+ options: { global?: boolean | undefined; cwd?: string | undefined } = {},
151
+ ) {
152
+ const isGlobal = options.global !== false
153
+ const cwd = options.cwd || process.cwd()
154
+ const base = isGlobal ? home : cwd
155
+ const canonicalDir = path.join(base, '.agents', 'skills', skillName)
156
+ rmForce(canonicalDir)
157
+
158
+ for (const agent of detect()) {
159
+ if (agent.universal) continue
160
+ const agentSkillsDir = isGlobal ? agent.globalSkillsDir : path.join(cwd, agent.projectSkillsDir)
161
+ const agentDir = path.join(agentSkillsDir, skillName)
162
+ rmForce(agentDir)
163
+ }
164
+ }
165
+
145
166
  /** Recursively discovers skill directories (those containing a `SKILL.md`). */
146
167
  function discoverSkills(rootDir: string): { name: string; dir: string; root?: boolean }[] {
147
168
  const results: { name: string; dir: string; root?: boolean }[] = []
@@ -190,6 +211,15 @@ function sanitizeName(name: string): string {
190
211
  .slice(0, 255)
191
212
  }
192
213
 
214
+ /** Removes a file, directory, or symlink (including broken symlinks). */
215
+ function rmForce(target: string) {
216
+ try {
217
+ const stat = fs.lstatSync(target)
218
+ if (stat.isSymbolicLink()) fs.unlinkSync(target)
219
+ else fs.rmSync(target, { recursive: true, force: true })
220
+ } catch {}
221
+ }
222
+
193
223
  /** Resolves parent directories through symlinks. */
194
224
  function resolveParent(dir: string): string {
195
225
  try {