@inspecto-dev/cli 0.2.0-alpha.6 → 0.3.0-alpha.1

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/types.ts CHANGED
@@ -11,6 +11,14 @@ export type BuildTool = 'vite' | 'webpack' | 'rspack' | 'rsbuild' | 'esbuild' |
11
11
  /** Machine-readable status for onboarding commands */
12
12
  export type CommandStatus = 'ok' | 'warning' | 'blocked' | 'error'
13
13
 
14
+ /** Assistant-facing status for single-entry onboarding */
15
+ export type OnboardStatus =
16
+ | 'success'
17
+ | 'partial_success'
18
+ | 'needs_target_selection'
19
+ | 'needs_confirmation'
20
+ | 'error'
21
+
14
22
  /** Structured message emitted by onboarding commands */
15
23
  export interface CommandMessage {
16
24
  code: string
@@ -54,6 +62,87 @@ export interface OnboardingContext {
54
62
  providers: OnboardingProvider[]
55
63
  }
56
64
 
65
+ export interface OnboardingTargetCandidate {
66
+ packagePath: string
67
+ configPath: string
68
+ buildTool: BuildTool
69
+ frameworks: string[]
70
+ automaticInjection: boolean
71
+ }
72
+
73
+ export interface OnboardingTargetResolution {
74
+ status: 'resolved' | 'needs_selection'
75
+ selected?: OnboardingTargetCandidate
76
+ candidates: OnboardingTargetCandidate[]
77
+ reason: string
78
+ }
79
+
80
+ export interface OnboardingSummary {
81
+ headline: string
82
+ changes: string[]
83
+ risks: string[]
84
+ manualFollowUp: string[]
85
+ }
86
+
87
+ export interface OnboardingConfirmation {
88
+ required: boolean
89
+ reason?: string
90
+ question?: string
91
+ }
92
+
93
+ export interface OnboardingExecutionResult {
94
+ changedFiles: string[]
95
+ installedDependencies: string[]
96
+ selectedProviderDefault?: string
97
+ selectedIDE?: string
98
+ mutations: Mutation[]
99
+ }
100
+
101
+ export interface OnboardingDiagnostics {
102
+ warnings: string[]
103
+ errors: string[]
104
+ nextSteps: string[]
105
+ }
106
+
107
+ export interface OnboardingIdeExtensionStatus {
108
+ required: boolean
109
+ installed: boolean
110
+ manualRequired: boolean
111
+ installCommand?: string
112
+ marketplaceUrl?: string
113
+ openVsxUrl?: string
114
+ }
115
+
116
+ export interface OnboardingVerification {
117
+ available: boolean
118
+ devCommand?: string
119
+ message: string
120
+ }
121
+
122
+ export interface ResolvedOnboardingSession {
123
+ status: OnboardStatus
124
+ target: OnboardingTargetResolution
125
+ summary: OnboardingSummary
126
+ confirmation: OnboardingConfirmation
127
+ verification: OnboardingVerification
128
+ context: OnboardingContext
129
+ plan: PlanResult
130
+ projectRoot: string
131
+ selectedIDE?: { ide: string; supported: boolean } | null
132
+ providerDefault?: string
133
+ }
134
+
135
+ export interface OnboardCommandResult {
136
+ status: OnboardStatus
137
+ target: OnboardingTargetResolution
138
+ summary: OnboardingSummary
139
+ confirmation: OnboardingConfirmation
140
+ ideExtension?: OnboardingIdeExtensionStatus
141
+ verification?: OnboardingVerification
142
+ result?: OnboardingExecutionResult
143
+ diagnostics?: OnboardingDiagnostics
144
+ }
145
+
57
146
  /** Machine-readable detection output for skill-first onboarding */
58
147
  export interface DetectionResult {
59
148
  status: CommandStatus
@@ -121,7 +121,7 @@ describe('apply onboarding flow', () => {
121
121
  'pnpm add -D @inspecto-dev/plugin @inspecto-dev/core',
122
122
  '/repo',
123
123
  )
124
- expect(astInjectorUtils.injectPlugin).toHaveBeenCalledWith('/repo', supportedBuild, false)
124
+ expect(astInjectorUtils.injectPlugin).toHaveBeenCalledWith('/repo', supportedBuild, false, false)
125
125
  expect(fsUtils.writeJSON).toHaveBeenCalledWith('/repo/.inspecto/settings.local.json', {
126
126
  ide: 'vscode',
127
127
  'provider.default': 'codex.extension',
@@ -534,4 +534,50 @@ describe('apply onboarding flow', () => {
534
534
  )
535
535
  expect(fsUtils.writeJSON).toHaveBeenCalledWith('/repo/.inspecto/prompts.local.json', [])
536
536
  })
537
+
538
+ it('prints the same short 3-step success guide when apply finishes cleanly', async () => {
539
+ const context: OnboardingContext = {
540
+ root: '/repo',
541
+ packageManager: 'pnpm',
542
+ buildTools: {
543
+ supported: [supportedBuild],
544
+ unsupported: [],
545
+ },
546
+ frameworks: {
547
+ supported: ['react'],
548
+ unsupported: [],
549
+ },
550
+ ides: [{ ide: 'vscode', supported: true }],
551
+ providers: [{ id: 'codex', label: 'Codex CLI', supported: true, preferredMode: 'cli' }],
552
+ }
553
+
554
+ const planResult: PlanResult = {
555
+ status: 'ok',
556
+ warnings: [],
557
+ blockers: [],
558
+ strategy: 'supported',
559
+ actions: [],
560
+ defaults: {
561
+ provider: 'codex',
562
+ ide: 'vscode',
563
+ shared: false,
564
+ extension: true,
565
+ },
566
+ }
567
+
568
+ vi.mocked(onboardingContext.buildOnboardingContext).mockResolvedValue(context)
569
+ vi.mocked(planner.createPlanResult).mockReturnValue(planResult)
570
+
571
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
572
+
573
+ await apply()
574
+
575
+ const output = consoleSpy.mock.calls.flatMap(call => call.map(value => String(value)))
576
+ expect(output.some(line => line.includes('Ready! Inspecto is set up.'))).toBe(true)
577
+ expect(output.some(line => line.includes('1. Start or restart your dev server.'))).toBe(true)
578
+ expect(output.some(line => line.includes('2. Open your app in the browser.'))).toBe(true)
579
+ expect(output.some(line => line.includes('3. Hold Alt + Click any element to inspect.'))).toBe(
580
+ true,
581
+ )
582
+ })
537
583
  })
@@ -330,4 +330,35 @@ describe('init in monorepo roots', () => {
330
330
  output.some(line => line.includes('Ready! Hold Alt + Click any element to inspect.')),
331
331
  ).toBe(false)
332
332
  })
333
+
334
+ it('prints a short 3-step success guide after automatic setup succeeds', async () => {
335
+ vi.mocked(buildToolUtils.detectBuildTools).mockResolvedValue({
336
+ supported: [
337
+ {
338
+ tool: 'vite',
339
+ configPath: 'vite.config.ts',
340
+ label: 'Vite (vite.config.ts)',
341
+ },
342
+ ],
343
+ unsupported: [],
344
+ })
345
+
346
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
347
+
348
+ await init({
349
+ shared: false,
350
+ skipInstall: false,
351
+ dryRun: false,
352
+ noExtension: false,
353
+ force: false,
354
+ })
355
+
356
+ const output = consoleSpy.mock.calls.flatMap(call => call.map(value => String(value)))
357
+ expect(output.some(line => line.includes('Ready! Inspecto is set up.'))).toBe(true)
358
+ expect(output.some(line => line.includes('1. Start or restart your dev server.'))).toBe(true)
359
+ expect(output.some(line => line.includes('2. Open your app in the browser.'))).toBe(true)
360
+ expect(output.some(line => line.includes('3. Hold Alt + Click any element to inspect.'))).toBe(
361
+ true,
362
+ )
363
+ })
333
364
  })
@@ -0,0 +1,76 @@
1
+ import { execFile } from 'node:child_process'
2
+ import fs from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { promisify } from 'node:util'
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+
10
+ describe('assistant integration bootstrap wrapper', () => {
11
+ const tempDirs: string[] = []
12
+
13
+ afterEach(async () => {
14
+ await Promise.all(tempDirs.map(dir => fs.rm(dir, { recursive: true, force: true })))
15
+ tempDirs.length = 0
16
+ })
17
+
18
+ it('falls back to raw asset download and still honors --force', async () => {
19
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'inspecto-wrapper-'))
20
+ tempDirs.push(tempRoot)
21
+
22
+ const fakeBin = path.join(tempRoot, 'bin')
23
+ await fs.mkdir(fakeBin, { recursive: true })
24
+ await fs.mkdir(path.join(tempRoot, '.github'), { recursive: true })
25
+ await fs.writeFile(
26
+ path.join(tempRoot, '.github/copilot-instructions.md'),
27
+ 'old content\n',
28
+ 'utf8',
29
+ )
30
+
31
+ await fs.writeFile(path.join(fakeBin, 'npx'), '#!/usr/bin/env bash\nexit 127\n', 'utf8')
32
+ await fs.chmod(path.join(fakeBin, 'npx'), 0o755)
33
+
34
+ await fs.writeFile(
35
+ path.join(fakeBin, 'curl'),
36
+ [
37
+ '#!/usr/bin/env bash',
38
+ 'set -euo pipefail',
39
+ 'out=""',
40
+ 'while [[ $# -gt 0 ]]; do',
41
+ ' case "$1" in',
42
+ ' -o)',
43
+ ' out="$2"',
44
+ ' shift 2',
45
+ ' ;;',
46
+ ' *)',
47
+ ' shift',
48
+ ' ;;',
49
+ ' esac',
50
+ 'done',
51
+ 'printf "downloaded from wrapper\n" > "$out"',
52
+ ].join('\n'),
53
+ 'utf8',
54
+ )
55
+ await fs.chmod(path.join(fakeBin, 'curl'), 0o755)
56
+
57
+ const scriptPath = path.resolve(
58
+ __dirname,
59
+ '../../../assistant-integrations/scripts/install.sh',
60
+ )
61
+
62
+ await execFileAsync('bash', [scriptPath, 'copilot', '--force'], {
63
+ cwd: tempRoot,
64
+ env: {
65
+ ...process.env,
66
+ PATH: `${fakeBin}:${process.env.PATH ?? ''}`,
67
+ },
68
+ })
69
+
70
+ const installed = await fs.readFile(
71
+ path.join(tempRoot, '.github/copilot-instructions.md'),
72
+ 'utf8',
73
+ )
74
+ expect(installed).toBe('downloaded from wrapper\n')
75
+ })
76
+ })
@@ -0,0 +1,294 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const writeFileMock = vi.fn()
4
+ const existsMock = vi.fn()
5
+ const chmodMock = vi.fn()
6
+ const logMock = {
7
+ header: vi.fn(),
8
+ info: vi.fn(),
9
+ success: vi.fn(),
10
+ warn: vi.fn(),
11
+ hint: vi.fn(),
12
+ ready: vi.fn(),
13
+ error: vi.fn(),
14
+ blank: vi.fn(),
15
+ }
16
+
17
+ vi.mock('../src/utils/fs.js', () => ({
18
+ writeFile: writeFileMock,
19
+ exists: existsMock,
20
+ }))
21
+
22
+ vi.mock('node:fs/promises', () => ({
23
+ default: {
24
+ chmod: chmodMock,
25
+ },
26
+ }))
27
+
28
+ vi.mock('node:os', () => ({
29
+ homedir: () => '/Users/tester',
30
+ }))
31
+
32
+ vi.mock('../src/utils/logger.js', () => ({
33
+ log: logMock,
34
+ }))
35
+
36
+ describe('integration install', () => {
37
+ beforeEach(() => {
38
+ vi.resetModules()
39
+ vi.clearAllMocks()
40
+ existsMock.mockResolvedValue(false)
41
+ vi.stubGlobal(
42
+ 'fetch',
43
+ vi.fn().mockResolvedValue({
44
+ ok: true,
45
+ text: async () => '# mock asset',
46
+ status: 200,
47
+ statusText: 'OK',
48
+ }),
49
+ )
50
+ })
51
+
52
+ it('installs the Claude Code skill into the user skill directory', async () => {
53
+ const { installIntegration } = await import('../src/commands/integration-install.js')
54
+
55
+ await installIntegration('claude-code', { scope: 'user' })
56
+
57
+ expect(writeFileMock).toHaveBeenCalledWith(
58
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/SKILL.md',
59
+ '# mock asset',
60
+ )
61
+ expect(writeFileMock).toHaveBeenCalledWith(
62
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/agents/openai.yaml',
63
+ '# mock asset',
64
+ )
65
+ expect(writeFileMock).toHaveBeenCalledWith(
66
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/scripts/run-inspecto.sh',
67
+ '# mock asset',
68
+ )
69
+ expect(chmodMock).toHaveBeenCalledWith(
70
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/scripts/run-inspecto.sh',
71
+ 0o755,
72
+ )
73
+ })
74
+
75
+ it('installs the Codex skill into the Codex skill directory', async () => {
76
+ const { installIntegration } = await import('../src/commands/integration-install.js')
77
+
78
+ await installIntegration('codex')
79
+
80
+ expect(writeFileMock).toHaveBeenCalledWith(
81
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/SKILL.md',
82
+ '# mock asset',
83
+ )
84
+ expect(writeFileMock).toHaveBeenCalledWith(
85
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/agents/openai.yaml',
86
+ '# mock asset',
87
+ )
88
+ expect(writeFileMock).toHaveBeenCalledWith(
89
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/scripts/run-inspecto.sh',
90
+ '# mock asset',
91
+ )
92
+ expect(chmodMock).toHaveBeenCalledWith(
93
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/scripts/run-inspecto.sh',
94
+ 0o755,
95
+ )
96
+ })
97
+
98
+ it('installs the Cursor AGENTS fallback when agents mode is selected', async () => {
99
+ const { installIntegration } = await import('../src/commands/integration-install.js')
100
+
101
+ await installIntegration('cursor', { mode: 'agents' })
102
+
103
+ expect(writeFileMock).toHaveBeenCalledTimes(1)
104
+ expect(writeFileMock).toHaveBeenCalledWith('AGENTS.md', '# mock asset')
105
+ })
106
+
107
+ it('refuses a multi-file Claude install before writing anything when one target already exists', async () => {
108
+ const { installIntegration } = await import('../src/commands/integration-install.js')
109
+
110
+ existsMock.mockImplementation(async filePath => {
111
+ return (
112
+ filePath ===
113
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/agents/openai.yaml'
114
+ )
115
+ })
116
+
117
+ await expect(installIntegration('claude-code', { scope: 'user' })).rejects.toThrow(
118
+ 'Refusing to overwrite existing file',
119
+ )
120
+
121
+ expect(writeFileMock).not.toHaveBeenCalled()
122
+ expect(chmodMock).not.toHaveBeenCalled()
123
+ })
124
+
125
+ it('downloads all Claude assets before writing any file', async () => {
126
+ const fetchMock = vi
127
+ .fn()
128
+ .mockResolvedValueOnce({
129
+ ok: true,
130
+ text: async () => '# skill',
131
+ status: 200,
132
+ statusText: 'OK',
133
+ })
134
+ .mockResolvedValueOnce({
135
+ ok: false,
136
+ text: async () => '',
137
+ status: 503,
138
+ statusText: 'Unavailable',
139
+ })
140
+
141
+ vi.stubGlobal('fetch', fetchMock)
142
+
143
+ const { installIntegration } = await import('../src/commands/integration-install.js')
144
+
145
+ await expect(installIntegration('claude-code', { scope: 'user' })).rejects.toThrow(
146
+ 'Failed to download',
147
+ )
148
+
149
+ expect(writeFileMock).not.toHaveBeenCalled()
150
+ expect(chmodMock).not.toHaveBeenCalled()
151
+ })
152
+
153
+ it('surfaces the asset URL when fetch itself fails', async () => {
154
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fetch failed')))
155
+
156
+ const { installIntegration } = await import('../src/commands/integration-install.js')
157
+
158
+ await expect(installIntegration('codex')).rejects.toThrow(
159
+ 'Failed to download https://raw.githubusercontent.com/inspecto-dev/inspecto/main/skills/inspecto-onboarding-codex/SKILL.md: fetch failed',
160
+ )
161
+
162
+ expect(writeFileMock).not.toHaveBeenCalled()
163
+ expect(chmodMock).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('lists supported integrations with their preferred install targets', async () => {
167
+ const { listIntegrationManifests } = await import('../src/commands/integration-install.js')
168
+
169
+ expect(listIntegrationManifests()).toEqual(
170
+ expect.arrayContaining([
171
+ expect.objectContaining({
172
+ assistant: 'codex',
173
+ type: 'native-skill',
174
+ installTarget: '~/.codex/skills/',
175
+ cliSupported: true,
176
+ }),
177
+ expect.objectContaining({
178
+ assistant: 'claude-code',
179
+ type: 'native-skill',
180
+ installTarget: '.claude/skills/ or ~/.claude/skills/',
181
+ }),
182
+ expect.objectContaining({
183
+ assistant: 'copilot',
184
+ type: 'instruction-template',
185
+ installTarget: '.github/copilot-instructions.md or AGENTS.md',
186
+ }),
187
+ ]),
188
+ )
189
+ })
190
+
191
+ it('describes the resolved install targets for a selected assistant variant', async () => {
192
+ const { describeIntegration } = await import('../src/commands/integration-install.js')
193
+
194
+ expect(describeIntegration('claude-code', { scope: 'user' })).toMatchObject({
195
+ assistant: 'claude-code',
196
+ targets: [
197
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/SKILL.md',
198
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/agents/openai.yaml',
199
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/scripts/run-inspecto.sh',
200
+ ],
201
+ preferredInstall: 'npx @inspecto-dev/cli integrations install claude-code --scope project',
202
+ })
203
+ })
204
+
205
+ it('describes codex using the same CLI-managed install target model', async () => {
206
+ const { describeIntegration } = await import('../src/commands/integration-install.js')
207
+
208
+ expect(describeIntegration('codex')).toMatchObject({
209
+ assistant: 'codex',
210
+ targets: [
211
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/SKILL.md',
212
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/agents/openai.yaml',
213
+ '/Users/tester/.codex/skills/inspecto-onboarding-codex/scripts/run-inspecto.sh',
214
+ ],
215
+ preferredInstall: 'npx @inspecto-dev/cli integrations install codex',
216
+ })
217
+ })
218
+
219
+ it('prints integration paths without using post-install hints', async () => {
220
+ const { printIntegrationPath } = await import('../src/commands/integration-install.js')
221
+
222
+ printIntegrationPath('claude-code', { scope: 'user' })
223
+
224
+ expect(logMock.info).toHaveBeenCalledWith(
225
+ '/Users/tester/.claude/skills/inspecto-onboarding-claude-code/SKILL.md',
226
+ )
227
+ expect(logMock.hint).toHaveBeenCalledWith(
228
+ 'Preferred install: npx @inspecto-dev/cli integrations install claude-code --scope project',
229
+ )
230
+ expect(logMock.hint).not.toHaveBeenCalledWith('Restart Claude Code to load the new skill.')
231
+ })
232
+
233
+ it('rejects unsupported extra args and options for list/path', async () => {
234
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
235
+
236
+ vi.doMock('../src/commands/init.js', () => ({ init: vi.fn() }))
237
+ vi.doMock('../src/commands/doctor.js', () => ({ doctor: vi.fn() }))
238
+ vi.doMock('../src/commands/teardown.js', () => ({ teardown: vi.fn() }))
239
+ vi.doMock('../src/commands/detect.js', () => ({ detect: vi.fn() }))
240
+ vi.doMock('../src/commands/plan.js', () => ({ plan: vi.fn() }))
241
+ vi.doMock('../src/commands/apply.js', () => ({ apply: vi.fn() }))
242
+
243
+ const { runCli } = await import('../src/bin.js')
244
+
245
+ await runCli(['node', 'inspecto', 'integrations', 'list', 'extra'])
246
+ await runCli(['node', 'inspecto', 'integrations', 'path', 'claude-code', '--force'])
247
+
248
+ expect(logMock.error).toHaveBeenCalledWith(
249
+ 'The `list` subcommand does not accept assistant names, --scope, --mode, or --force.',
250
+ )
251
+ expect(logMock.error).toHaveBeenCalledWith('The `path` subcommand does not support `--force`.')
252
+ expect(exitSpy).toHaveBeenCalledWith(1)
253
+
254
+ exitSpy.mockRestore()
255
+ })
256
+
257
+ it('wires the nested CLI command to the integration installer', async () => {
258
+ const installCommand = vi.fn().mockResolvedValue(undefined)
259
+ const listCommand = vi.fn().mockResolvedValue(undefined)
260
+ const pathCommand = vi.fn().mockResolvedValue(undefined)
261
+
262
+ vi.doMock('../src/commands/init.js', () => ({ init: vi.fn() }))
263
+ vi.doMock('../src/commands/doctor.js', () => ({ doctor: vi.fn() }))
264
+ vi.doMock('../src/commands/teardown.js', () => ({ teardown: vi.fn() }))
265
+ vi.doMock('../src/commands/detect.js', () => ({ detect: vi.fn() }))
266
+ vi.doMock('../src/commands/plan.js', () => ({ plan: vi.fn() }))
267
+ vi.doMock('../src/commands/apply.js', () => ({ apply: vi.fn() }))
268
+ vi.doMock('../src/commands/integration-install.js', () => ({
269
+ installIntegration: installCommand,
270
+ printIntegrationList: listCommand,
271
+ printIntegrationPath: pathCommand,
272
+ }))
273
+
274
+ const { runCli } = await import('../src/bin.js')
275
+
276
+ await runCli([
277
+ 'node',
278
+ 'inspecto',
279
+ 'integrations',
280
+ 'install',
281
+ 'copilot',
282
+ '--mode',
283
+ 'agents',
284
+ '--force',
285
+ ])
286
+
287
+ await runCli(['node', 'inspecto', 'integrations', 'list'])
288
+ await runCli(['node', 'inspecto', 'integrations', 'path', 'claude-code', '--scope', 'user'])
289
+
290
+ expect(installCommand).toHaveBeenCalledWith('copilot', { mode: 'agents', force: true })
291
+ expect(listCommand).toHaveBeenCalledWith()
292
+ expect(pathCommand).toHaveBeenCalledWith('claude-code', { scope: 'user' })
293
+ })
294
+ })