@jackwener/opencli 1.6.6 → 1.6.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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Structured diagnostic output for AI-driven adapter repair.
3
+ *
4
+ * When OPENCLI_DIAGNOSTIC=1, failed commands emit a JSON RepairContext to stderr
5
+ * containing the error, adapter source, and browser state (DOM snapshot, network
6
+ * requests, console errors). AI Agents consume this to diagnose and fix adapters.
7
+ */
8
+ import type { IPage } from './types.js';
9
+ import type { InternalCliCommand } from './registry.js';
10
+ export interface RepairContext {
11
+ error: {
12
+ code: string;
13
+ message: string;
14
+ hint?: string;
15
+ stack?: string;
16
+ };
17
+ adapter: {
18
+ site: string;
19
+ command: string;
20
+ sourcePath?: string;
21
+ source?: string;
22
+ };
23
+ page?: {
24
+ url: string;
25
+ snapshot: string;
26
+ networkRequests: unknown[];
27
+ consoleErrors: unknown[];
28
+ };
29
+ timestamp: string;
30
+ }
31
+ /** Whether diagnostic mode is enabled. */
32
+ export declare function isDiagnosticEnabled(): boolean;
33
+ /** Build a RepairContext from an error, command metadata, and optional page state. */
34
+ export declare function buildRepairContext(err: unknown, cmd: InternalCliCommand, pageState?: RepairContext['page']): RepairContext;
35
+ /** Collect full diagnostic context including page state. */
36
+ export declare function collectDiagnostic(err: unknown, cmd: InternalCliCommand, page: IPage | null): Promise<RepairContext>;
37
+ /** Emit diagnostic JSON to stderr. */
38
+ export declare function emitDiagnostic(ctx: RepairContext): void;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Structured diagnostic output for AI-driven adapter repair.
3
+ *
4
+ * When OPENCLI_DIAGNOSTIC=1, failed commands emit a JSON RepairContext to stderr
5
+ * containing the error, adapter source, and browser state (DOM snapshot, network
6
+ * requests, console errors). AI Agents consume this to diagnose and fix adapters.
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import { CliError, getErrorMessage } from './errors.js';
10
+ import { fullName } from './registry.js';
11
+ // ── Diagnostic collection ────────────────────────────────────────────────────
12
+ /** Whether diagnostic mode is enabled. */
13
+ export function isDiagnosticEnabled() {
14
+ return process.env.OPENCLI_DIAGNOSTIC === '1';
15
+ }
16
+ /** Safely collect page diagnostic state. Individual failures are swallowed. */
17
+ async function collectPageState(page) {
18
+ try {
19
+ const [url, snapshot, networkRequests, consoleErrors] = await Promise.all([
20
+ page.getCurrentUrl?.().catch(() => null) ?? Promise.resolve(null),
21
+ page.snapshot().catch(() => '(snapshot unavailable)'),
22
+ page.networkRequests().catch(() => []),
23
+ page.consoleMessages('error').catch(() => []),
24
+ ]);
25
+ return { url: url ?? 'unknown', snapshot, networkRequests, consoleErrors };
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ /** Read adapter source file content. */
32
+ function readAdapterSource(modulePath) {
33
+ if (!modulePath)
34
+ return undefined;
35
+ try {
36
+ return fs.readFileSync(modulePath, 'utf-8');
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** Build a RepairContext from an error, command metadata, and optional page state. */
43
+ export function buildRepairContext(err, cmd, pageState) {
44
+ const isCliError = err instanceof CliError;
45
+ return {
46
+ error: {
47
+ code: isCliError ? err.code : 'UNKNOWN',
48
+ message: getErrorMessage(err),
49
+ hint: isCliError ? err.hint : undefined,
50
+ stack: err instanceof Error ? err.stack : undefined,
51
+ },
52
+ adapter: {
53
+ site: cmd.site,
54
+ command: fullName(cmd),
55
+ sourcePath: cmd._modulePath,
56
+ source: readAdapterSource(cmd._modulePath),
57
+ },
58
+ page: pageState,
59
+ timestamp: new Date().toISOString(),
60
+ };
61
+ }
62
+ /** Collect full diagnostic context including page state. */
63
+ export async function collectDiagnostic(err, cmd, page) {
64
+ const pageState = page ? await collectPageState(page) : undefined;
65
+ return buildRepairContext(err, cmd, pageState);
66
+ }
67
+ /** Emit diagnostic JSON to stderr. */
68
+ export function emitDiagnostic(ctx) {
69
+ const marker = '___OPENCLI_DIAGNOSTIC___';
70
+ process.stderr.write(`\n${marker}\n${JSON.stringify(ctx)}\n${marker}\n`);
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { buildRepairContext, isDiagnosticEnabled, emitDiagnostic } from './diagnostic.js';
3
+ import { SelectorError, CommandExecutionError } from './errors.js';
4
+ function makeCmd(overrides = {}) {
5
+ return {
6
+ site: 'test-site',
7
+ name: 'test-cmd',
8
+ description: 'test',
9
+ args: [],
10
+ ...overrides,
11
+ };
12
+ }
13
+ describe('isDiagnosticEnabled', () => {
14
+ const origEnv = process.env.OPENCLI_DIAGNOSTIC;
15
+ afterEach(() => {
16
+ if (origEnv === undefined)
17
+ delete process.env.OPENCLI_DIAGNOSTIC;
18
+ else
19
+ process.env.OPENCLI_DIAGNOSTIC = origEnv;
20
+ });
21
+ it('returns false when env not set', () => {
22
+ delete process.env.OPENCLI_DIAGNOSTIC;
23
+ expect(isDiagnosticEnabled()).toBe(false);
24
+ });
25
+ it('returns true when env is "1"', () => {
26
+ process.env.OPENCLI_DIAGNOSTIC = '1';
27
+ expect(isDiagnosticEnabled()).toBe(true);
28
+ });
29
+ it('returns false for other values', () => {
30
+ process.env.OPENCLI_DIAGNOSTIC = 'true';
31
+ expect(isDiagnosticEnabled()).toBe(false);
32
+ });
33
+ });
34
+ describe('buildRepairContext', () => {
35
+ it('captures CliError fields', () => {
36
+ const err = new SelectorError('.missing-element', 'Element removed');
37
+ const ctx = buildRepairContext(err, makeCmd());
38
+ expect(ctx.error.code).toBe('SELECTOR');
39
+ expect(ctx.error.message).toContain('.missing-element');
40
+ expect(ctx.error.hint).toBe('Element removed');
41
+ expect(ctx.error.stack).toBeDefined();
42
+ expect(ctx.adapter.site).toBe('test-site');
43
+ expect(ctx.adapter.command).toBe('test-site/test-cmd');
44
+ expect(ctx.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
45
+ });
46
+ it('handles non-CliError errors', () => {
47
+ const err = new TypeError('Cannot read property "x" of undefined');
48
+ const ctx = buildRepairContext(err, makeCmd());
49
+ expect(ctx.error.code).toBe('UNKNOWN');
50
+ expect(ctx.error.message).toContain('Cannot read property');
51
+ expect(ctx.error.hint).toBeUndefined();
52
+ });
53
+ it('includes page state when provided', () => {
54
+ const pageState = {
55
+ url: 'https://example.com/page',
56
+ snapshot: '<div>...</div>',
57
+ networkRequests: [{ url: '/api/data', status: 200 }],
58
+ consoleErrors: ['Uncaught TypeError'],
59
+ };
60
+ const ctx = buildRepairContext(new CommandExecutionError('boom'), makeCmd(), pageState);
61
+ expect(ctx.page).toEqual(pageState);
62
+ });
63
+ it('omits page when not provided', () => {
64
+ const ctx = buildRepairContext(new Error('boom'), makeCmd());
65
+ expect(ctx.page).toBeUndefined();
66
+ });
67
+ });
68
+ describe('emitDiagnostic', () => {
69
+ it('writes delimited JSON to stderr', () => {
70
+ const writeSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
71
+ const ctx = buildRepairContext(new CommandExecutionError('test error'), makeCmd());
72
+ emitDiagnostic(ctx);
73
+ const output = writeSpy.mock.calls.map(c => c[0]).join('');
74
+ expect(output).toContain('___OPENCLI_DIAGNOSTIC___');
75
+ expect(output).toContain('"code":"COMMAND_EXEC"');
76
+ expect(output).toContain('"message":"test error"');
77
+ // Verify JSON is parseable between markers
78
+ const match = output.match(/___OPENCLI_DIAGNOSTIC___\n(.*)\n___OPENCLI_DIAGNOSTIC___/);
79
+ expect(match).toBeTruthy();
80
+ const parsed = JSON.parse(match[1]);
81
+ expect(parsed.error.code).toBe('COMMAND_EXEC');
82
+ writeSpy.mockRestore();
83
+ });
84
+ });
@@ -13,6 +13,7 @@ import { Strategy, getRegistry, fullName } from './registry.js';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import { executePipeline } from './pipeline/index.js';
15
15
  import { AdapterLoadError, ArgumentError, BrowserConnectError, CommandExecutionError, getErrorMessage } from './errors.js';
16
+ import { isDiagnosticEnabled, collectDiagnostic, emitDiagnostic } from './diagnostic.js';
16
17
  import { shouldUseBrowserSession } from './capabilityRouting.js';
17
18
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
18
19
  import { emitHook } from './hooks.js';
@@ -129,6 +130,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
129
130
  };
130
131
  await emitHook('onBeforeExecute', hookCtx);
131
132
  let result;
133
+ let diagnosticEmitted = false;
132
134
  try {
133
135
  if (shouldUseBrowserSession(cmd)) {
134
136
  const electron = isElectronApp(cmd.site);
@@ -176,10 +178,22 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
176
178
  log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
177
179
  }
178
180
  }
179
- return runWithTimeout(runCommand(cmd, page, kwargs, debug), {
180
- timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT,
181
- label: fullName(cmd),
182
- });
181
+ try {
182
+ return await runWithTimeout(runCommand(cmd, page, kwargs, debug), {
183
+ timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT,
184
+ label: fullName(cmd),
185
+ });
186
+ }
187
+ catch (err) {
188
+ // Collect diagnostic while page is still alive (before browserSession closes it).
189
+ if (isDiagnosticEnabled()) {
190
+ const internal = cmd;
191
+ const ctx = await collectDiagnostic(err, internal, page);
192
+ emitDiagnostic(ctx);
193
+ diagnosticEmitted = true;
194
+ }
195
+ throw err;
196
+ }
183
197
  }, { workspace: `site:${cmd.site}`, cdpEndpoint });
184
198
  }
185
199
  else {
@@ -198,6 +212,13 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
198
212
  }
199
213
  }
200
214
  catch (err) {
215
+ // Emit diagnostic if not already emitted (browser session emits with page state;
216
+ // this fallback covers non-browser commands and pre-session failures like BrowserConnectError).
217
+ if (isDiagnosticEnabled() && !diagnosticEmitted) {
218
+ const internal = cmd;
219
+ const ctx = await collectDiagnostic(err, internal, null);
220
+ emitDiagnostic(ctx);
221
+ }
201
222
  hookCtx.error = err;
202
223
  hookCtx.finishedAt = Date.now();
203
224
  await emitHook('onAfterExecute', hookCtx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * postinstall script — automatically install shell completion files.
4
+ * postinstall script — install shell completion files and print setup instructions.
5
5
  *
6
6
  * Detects the user's default shell and writes the completion script to the
7
- * standard system completion directory so that tab-completion works immediately
8
- * after `npm install -g`.
7
+ * standard completion directory. For zsh and bash, the script prints manual
8
+ * instructions instead of modifying rc files (~/.zshrc, ~/.bashrc) — this
9
+ * avoids breaking multi-line shell commands and other fragile rc structures.
10
+ * Fish completions work automatically without rc changes.
9
11
  *
10
12
  * Supported shells: bash, zsh, fish.
11
13
  *
@@ -13,7 +15,7 @@
13
15
  * the main source tree) so that it can run without a build step.
14
16
  */
15
17
 
16
- import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
18
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
17
19
  import { join } from 'node:path';
18
20
  import { homedir } from 'node:os';
19
21
 
@@ -69,54 +71,6 @@ function ensureDir(dir) {
69
71
  }
70
72
  }
71
73
 
72
- /**
73
- * Ensure fpath contains the custom completions directory in .zshrc.
74
- *
75
- * Key detail: the fpath line MUST appear BEFORE the first `compinit` call,
76
- * otherwise compinit won't scan our completions directory. This is critical
77
- * for oh-my-zsh users (source $ZSH/oh-my-zsh.sh calls compinit internally).
78
- */
79
- function ensureZshFpath(completionsDir, zshrcPath) {
80
- const fpathLine = `fpath=(${completionsDir} $fpath)`;
81
- const autoloadLine = `autoload -Uz compinit && compinit`;
82
- const marker = '# opencli completion';
83
-
84
- if (!existsSync(zshrcPath)) {
85
- writeFileSync(zshrcPath, `${marker}\n${fpathLine}\n${autoloadLine}\n`, 'utf8');
86
- return;
87
- }
88
-
89
- const content = readFileSync(zshrcPath, 'utf8');
90
-
91
- // Already configured — nothing to do
92
- if (content.includes(completionsDir)) {
93
- return;
94
- }
95
-
96
- // Find the first line that triggers compinit (direct call or oh-my-zsh source)
97
- const lines = content.split('\n');
98
- let insertIdx = -1;
99
- for (let i = 0; i < lines.length; i++) {
100
- const trimmed = lines[i].trim();
101
- // Skip comment-only lines
102
- if (trimmed.startsWith('#')) continue;
103
- if (/compinit/.test(trimmed) || /source\s+.*oh-my-zsh\.sh/.test(trimmed)) {
104
- insertIdx = i;
105
- break;
106
- }
107
- }
108
-
109
- if (insertIdx !== -1) {
110
- // Insert fpath BEFORE the compinit / oh-my-zsh source line
111
- lines.splice(insertIdx, 0, marker, fpathLine);
112
- writeFileSync(zshrcPath, lines.join('\n'), 'utf8');
113
- } else {
114
- // No compinit found — append fpath + compinit at the end
115
- let addition = `\n${marker}\n${fpathLine}\n${autoloadLine}\n`;
116
- appendFileSync(zshrcPath, addition, 'utf8');
117
- }
118
- }
119
-
120
74
  // ── Main ───────────────────────────────────────────────────────────────────
121
75
 
122
76
  function main() {
@@ -147,35 +101,28 @@ function main() {
147
101
  ensureDir(completionsDir);
148
102
  writeFileSync(completionFile, ZSH_COMPLETION, 'utf8');
149
103
 
150
- // Ensure fpath is set up in .zshrc
151
- const zshrcPath = join(home, '.zshrc');
152
- ensureZshFpath(completionsDir, zshrcPath);
153
-
154
104
  console.log(`✓ Zsh completion installed to ${completionFile}`);
155
- console.log(` Restart your shell or run: source ~/.zshrc`);
105
+ console.log('');
106
+ console.log(' \x1b[1mTo enable, add these lines to your ~/.zshrc:\x1b[0m');
107
+ console.log(` fpath=(${completionsDir} $fpath)`);
108
+ console.log(' autoload -Uz compinit && compinit');
109
+ console.log('');
110
+ console.log(' If you already have compinit (oh-my-zsh, zinit, etc.), just add the fpath line \x1b[1mbefore\x1b[0m it.');
111
+ console.log(' Then restart your shell or run: \x1b[36mexec zsh\x1b[0m');
156
112
  break;
157
113
  }
158
114
  case 'bash': {
159
- // Try system-level first, fall back to user-level
160
115
  const userCompDir = join(home, '.bash_completion.d');
161
116
  const completionFile = join(userCompDir, 'opencli');
162
117
  ensureDir(userCompDir);
163
118
  writeFileSync(completionFile, BASH_COMPLETION, 'utf8');
164
119
 
165
- // Ensure .bashrc sources the completion directory
166
- const bashrcPath = join(home, '.bashrc');
167
- if (existsSync(bashrcPath)) {
168
- const content = readFileSync(bashrcPath, 'utf8');
169
- if (!content.includes('.bash_completion.d/opencli')) {
170
- appendFileSync(bashrcPath,
171
- `\n# opencli completion\n[ -f "${completionFile}" ] && source "${completionFile}"\n`,
172
- 'utf8'
173
- );
174
- }
175
- }
176
-
177
120
  console.log(`✓ Bash completion installed to ${completionFile}`);
178
- console.log(` Restart your shell or run: source ~/.bashrc`);
121
+ console.log('');
122
+ console.log(' \x1b[1mTo enable, add this line to your ~/.bashrc:\x1b[0m');
123
+ console.log(` [ -f "${completionFile}" ] && source "${completionFile}"`);
124
+ console.log('');
125
+ console.log(' Then restart your shell or run: \x1b[36msource ~/.bashrc\x1b[0m');
179
126
  break;
180
127
  }
181
128
  case 'fish': {