@jackwener/opencli 1.6.5 → 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.
@@ -2,9 +2,9 @@
2
2
  * Yollomi AI background generator — POST /api/ai/ai-background-generator
3
3
  */
4
4
  import * as path from 'node:path';
5
- import chalk from 'chalk';
6
5
  import { cli, Strategy } from '@jackwener/opencli/registry';
7
6
  import { CliError } from '@jackwener/opencli/errors';
7
+ import { log } from '@jackwener/opencli/logger';
8
8
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
9
9
  cli({
10
10
  site: 'yollomi',
@@ -22,7 +22,7 @@ cli({
22
22
  func: async (page, kwargs) => {
23
23
  const imageUrl = kwargs.image;
24
24
  const prompt = kwargs.prompt;
25
- process.stderr.write(chalk.dim('Generating background...\n'));
25
+ log.status('Generating background...');
26
26
  const data = await yollomiPost(page, '/api/ai/ai-background-generator', {
27
27
  images: [imageUrl],
28
28
  prompt: prompt || undefined,
@@ -3,9 +3,9 @@
3
3
  * Matches frontend workspace-generator.tsx for qwen-image-edit model.
4
4
  */
5
5
  import * as path from 'node:path';
6
- import chalk from 'chalk';
7
6
  import { cli, Strategy } from '@jackwener/opencli/registry';
8
7
  import { CliError } from '@jackwener/opencli/errors';
8
+ import { log } from '@jackwener/opencli/logger';
9
9
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
10
  cli({
11
11
  site: 'yollomi',
@@ -33,7 +33,7 @@ cli({
33
33
  body = { image: imageInput, prompt, go_fast: true, output_format: 'png' };
34
34
  }
35
35
  const apiPath = modelId === 'qwen-image-edit-plus' ? '/api/ai/qwen-image-edit-plus' : '/api/ai/qwen-image-edit';
36
- process.stderr.write(chalk.dim(`Editing with ${modelId}...\n`));
36
+ log.status(`Editing with ${modelId}...`);
37
37
  const data = await yollomiPost(page, apiPath, body);
38
38
  const images = data.images || (data.image ? [data.image] : []);
39
39
  if (!images.length)
@@ -46,7 +46,7 @@ cli({
46
46
  const filename = `yollomi_edit_${Date.now()}.png`;
47
47
  const { path: fp, size } = await downloadOutput(url, kwargs.output, filename);
48
48
  if (credits !== undefined)
49
- process.stderr.write(chalk.dim(`Credits remaining: ${credits}\n`));
49
+ log.status(`Credits remaining: ${credits}`);
50
50
  return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url }];
51
51
  }
52
52
  catch {
@@ -3,9 +3,9 @@
3
3
  * Uses swap_image / input_image field names matching the frontend.
4
4
  */
5
5
  import * as path from 'node:path';
6
- import chalk from 'chalk';
7
6
  import { cli, Strategy } from '@jackwener/opencli/registry';
8
7
  import { CliError } from '@jackwener/opencli/errors';
8
+ import { log } from '@jackwener/opencli/logger';
9
9
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
10
  cli({
11
11
  site: 'yollomi',
@@ -21,7 +21,7 @@ cli({
21
21
  ],
22
22
  columns: ['status', 'file', 'size', 'url'],
23
23
  func: async (page, kwargs) => {
24
- process.stderr.write(chalk.dim('Swapping faces...\n'));
24
+ log.status('Swapping faces...');
25
25
  const data = await yollomiPost(page, '/api/ai/face-swap', {
26
26
  swap_image: kwargs.source,
27
27
  input_image: kwargs.target,
@@ -7,9 +7,9 @@
7
7
  * POST /api/ai/flux-2-pro { prompt, aspectRatio, imageUrl?, ... }
8
8
  */
9
9
  import * as path from 'node:path';
10
- import chalk from 'chalk';
11
10
  import { cli, Strategy } from '@jackwener/opencli/registry';
12
11
  import { CliError } from '@jackwener/opencli/errors';
12
+ import { log } from '@jackwener/opencli/logger';
13
13
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes, MODEL_ROUTES } from './utils.js';
14
14
  function getDimensions(ratio) {
15
15
  const map = {
@@ -63,7 +63,7 @@ cli({
63
63
  if (kwargs.image)
64
64
  body.imageUrl = kwargs.image;
65
65
  }
66
- process.stderr.write(chalk.dim(`Generating with ${modelId}...\n`));
66
+ log.status(`Generating with ${modelId}...`);
67
67
  const data = await yollomiPost(page, apiPath, body);
68
68
  const images = data.images || (data.image ? [data.image] : []);
69
69
  if (!images.length)
@@ -94,7 +94,7 @@ cli({
94
94
  }
95
95
  }
96
96
  if (data.remainingCredits !== undefined)
97
- process.stderr.write(chalk.dim(`Credits remaining: ${data.remainingCredits}\n`));
97
+ log.status(`Credits remaining: ${data.remainingCredits}`);
98
98
  return results;
99
99
  },
100
100
  });
@@ -2,9 +2,9 @@
2
2
  * Yollomi object remover — POST /api/ai/object-remover
3
3
  */
4
4
  import * as path from 'node:path';
5
- import chalk from 'chalk';
6
5
  import { cli, Strategy } from '@jackwener/opencli/registry';
7
6
  import { CliError } from '@jackwener/opencli/errors';
7
+ import { log } from '@jackwener/opencli/logger';
8
8
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
9
9
  cli({
10
10
  site: 'yollomi',
@@ -20,7 +20,7 @@ cli({
20
20
  ],
21
21
  columns: ['status', 'file', 'size', 'url'],
22
22
  func: async (page, kwargs) => {
23
- process.stderr.write(chalk.dim('Removing object...\n'));
23
+ log.status('Removing object...');
24
24
  const data = await yollomiPost(page, '/api/ai/object-remover', {
25
25
  image: kwargs.image,
26
26
  mask: kwargs.mask,
@@ -2,9 +2,9 @@
2
2
  * Yollomi background removal — POST /api/ai/remove-bg (free, 0 credits)
3
3
  */
4
4
  import * as path from 'node:path';
5
- import chalk from 'chalk';
6
5
  import { cli, Strategy } from '@jackwener/opencli/registry';
7
6
  import { CliError } from '@jackwener/opencli/errors';
7
+ import { log } from '@jackwener/opencli/logger';
8
8
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
9
9
  cli({
10
10
  site: 'yollomi',
@@ -19,7 +19,7 @@ cli({
19
19
  ],
20
20
  columns: ['status', 'file', 'size', 'url'],
21
21
  func: async (page, kwargs) => {
22
- process.stderr.write(chalk.dim('Removing background...\n'));
22
+ log.status('Removing background...');
23
23
  const data = await yollomiPost(page, '/api/ai/remove-bg', { imageUrl: kwargs.image });
24
24
  const url = data.image || (data.images?.[0]);
25
25
  if (!url)
@@ -2,9 +2,9 @@
2
2
  * Yollomi photo restoration — POST /api/ai/photo-restoration
3
3
  */
4
4
  import * as path from 'node:path';
5
- import chalk from 'chalk';
6
5
  import { cli, Strategy } from '@jackwener/opencli/registry';
7
6
  import { CliError } from '@jackwener/opencli/errors';
7
+ import { log } from '@jackwener/opencli/logger';
8
8
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
9
9
  cli({
10
10
  site: 'yollomi',
@@ -19,7 +19,7 @@ cli({
19
19
  ],
20
20
  columns: ['status', 'file', 'size', 'url'],
21
21
  func: async (page, kwargs) => {
22
- process.stderr.write(chalk.dim('Restoring photo...\n'));
22
+ log.status('Restoring photo...');
23
23
  const data = await yollomiPost(page, '/api/ai/photo-restoration', { imageUrl: kwargs.image });
24
24
  const url = data.image || (data.images?.[0]);
25
25
  if (!url)
@@ -2,9 +2,9 @@
2
2
  * Yollomi virtual try-on — POST /api/ai/virtual-try-on
3
3
  */
4
4
  import * as path from 'node:path';
5
- import chalk from 'chalk';
6
5
  import { cli, Strategy } from '@jackwener/opencli/registry';
7
6
  import { CliError } from '@jackwener/opencli/errors';
7
+ import { log } from '@jackwener/opencli/logger';
8
8
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
9
9
  cli({
10
10
  site: 'yollomi',
@@ -21,7 +21,7 @@ cli({
21
21
  ],
22
22
  columns: ['status', 'file', 'size', 'url'],
23
23
  func: async (page, kwargs) => {
24
- process.stderr.write(chalk.dim('Processing virtual try-on...\n'));
24
+ log.status('Processing virtual try-on...');
25
25
  const data = await yollomiPost(page, '/api/ai/virtual-try-on', {
26
26
  person_image: kwargs.person,
27
27
  cloth_image: kwargs.cloth,
@@ -6,9 +6,9 @@
6
6
  */
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
- import chalk from 'chalk';
10
9
  import { cli, Strategy } from '@jackwener/opencli/registry';
11
10
  import { CliError } from '@jackwener/opencli/errors';
11
+ import { log } from '@jackwener/opencli/logger';
12
12
  import { YOLLOMI_DOMAIN, ensureOnYollomi, fmtBytes } from './utils.js';
13
13
  const MIME_MAP = {
14
14
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
@@ -42,7 +42,7 @@ cli({
42
42
  throw new CliError('FILE_TOO_LARGE', `File too large: ${fmtBytes(data.length)}`, `Max ${mime.startsWith('video/') ? '20MB' : '10MB'} (upload larger videos from a URL)`);
43
43
  const b64 = data.toString('base64');
44
44
  const fileName = path.basename(filePath);
45
- process.stderr.write(chalk.dim(`Uploading ${fileName} (${fmtBytes(data.length)})...\n`));
45
+ log.status(`Uploading ${fileName} (${fmtBytes(data.length)})...`);
46
46
  await ensureOnYollomi(page);
47
47
  const result = await page.evaluate(`
48
48
  (async () => {
@@ -65,7 +65,7 @@ cli({
65
65
  throw new CliError('UPLOAD_ERROR', result?.data?.error || 'Upload failed', 'Make sure you are logged in to yollomi.com');
66
66
  }
67
67
  const url = result.data.url;
68
- process.stderr.write(chalk.green(`Uploaded! Use this URL as input for other commands.\n`));
68
+ log.success('Uploaded! Use this URL as input for other commands.');
69
69
  return [{ status: 'uploaded', file: fileName, size: fmtBytes(data.length), url }];
70
70
  },
71
71
  });
@@ -2,9 +2,9 @@
2
2
  * Yollomi image upscaling — POST /api/ai/image-upscaler
3
3
  */
4
4
  import * as path from 'node:path';
5
- import chalk from 'chalk';
6
5
  import { cli, Strategy } from '@jackwener/opencli/registry';
7
6
  import { CliError } from '@jackwener/opencli/errors';
7
+ import { log } from '@jackwener/opencli/logger';
8
8
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
9
9
  cli({
10
10
  site: 'yollomi',
@@ -21,7 +21,7 @@ cli({
21
21
  columns: ['status', 'file', 'size', 'scale', 'url'],
22
22
  func: async (page, kwargs) => {
23
23
  const scale = parseInt(kwargs.scale, 10);
24
- process.stderr.write(chalk.dim(`Upscaling ${scale}x...\n`));
24
+ log.status(`Upscaling ${scale}x...`);
25
25
  const data = await yollomiPost(page, '/api/ai/image-upscaler', {
26
26
  imageUrl: kwargs.image,
27
27
  scale,
@@ -43,7 +43,7 @@ cli({
43
43
  const filename = `yollomi_upscale_${scale}x_${Date.now()}${ext}`;
44
44
  const { path: fp, size } = await downloadOutput(url, kwargs.output, filename);
45
45
  if (data.remainingCredits !== undefined)
46
- process.stderr.write(chalk.dim(`Credits remaining: ${data.remainingCredits}\n`));
46
+ log.status(`Credits remaining: ${data.remainingCredits}`);
47
47
  return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), scale: `${scale}x`, url }];
48
48
  }
49
49
  catch {
@@ -3,9 +3,9 @@
3
3
  * Matches the frontend video-generator.tsx request format exactly.
4
4
  */
5
5
  import * as path from 'node:path';
6
- import chalk from 'chalk';
7
6
  import { cli, Strategy } from '@jackwener/opencli/registry';
8
7
  import { CliError } from '@jackwener/opencli/errors';
8
+ import { log } from '@jackwener/opencli/logger';
9
9
  import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
10
10
  cli({
11
11
  site: 'yollomi',
@@ -31,7 +31,7 @@ cli({
31
31
  if (kwargs.image)
32
32
  inputs.image = kwargs.image;
33
33
  const body = { modelId, prompt, inputs };
34
- process.stderr.write(chalk.dim(`Generating video with ${modelId} (may take a while)...\n`));
34
+ log.status(`Generating video with ${modelId} (may take a while)...`);
35
35
  const data = await yollomiPost(page, '/api/ai/video', body);
36
36
  const videoUrl = data.video || '';
37
37
  if (!videoUrl)
@@ -46,7 +46,7 @@ cli({
46
46
  const filename = `yollomi_${modelId}_${Date.now()}.mp4`;
47
47
  const { path: fp, size } = await downloadOutput(videoUrl, outputDir, filename);
48
48
  if (credits !== undefined)
49
- process.stderr.write(chalk.dim(`Credits remaining: ${credits}\n`));
49
+ log.status(`Credits remaining: ${credits}`);
50
50
  return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url: videoUrl }];
51
51
  }
52
52
  catch {
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import TurndownService from 'turndown';
2
+ import { htmlToMarkdown } from '@jackwener/opencli/utils';
3
3
  import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
4
4
  import { YUANBAO_DOMAIN, IS_VISIBLE_JS, authRequired, isOnYuanbao, ensureYuanbaoPage, hasLoginGate } from './shared.js';
5
5
  const YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS = 2;
@@ -20,57 +20,40 @@ function normalizeBooleanFlag(value, fallback) {
20
20
  const normalized = String(value).trim().toLowerCase();
21
21
  return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
22
22
  }
23
- function createYuanbaoTurndown() {
24
- const td = new TurndownService({
25
- headingStyle: 'atx',
26
- codeBlockStyle: 'fenced',
27
- bulletListMarker: '-',
28
- });
29
- td.addRule('linebreak', {
30
- filter: 'br',
31
- replacement: () => '\n',
32
- });
33
- td.addRule('table', {
34
- filter: 'table',
35
- replacement: (content) => `\n\n${content}\n\n`,
36
- });
37
- td.addRule('tableSection', {
38
- filter: ['thead', 'tbody', 'tfoot'],
39
- replacement: (content) => content,
40
- });
41
- td.addRule('tableRow', {
42
- filter: 'tr',
43
- replacement: (content, node) => {
44
- const element = node;
45
- const cells = Array.from(element.children);
46
- const isHeaderRow = element.parentElement?.tagName === 'THEAD'
47
- || (cells.length > 0 && cells.every((cell) => cell.tagName === 'TH'));
48
- const row = `${content}\n`;
49
- if (!isHeaderRow)
50
- return row;
51
- const separator = `| ${cells.map(() => '---').join(' | ')} |\n`;
52
- return `${row}${separator}`;
53
- },
54
- });
55
- td.addRule('tableCell', {
56
- filter: ['th', 'td'],
57
- replacement: (content, node) => {
58
- const element = node;
59
- const index = element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : 0;
60
- const prefix = index === 0 ? '| ' : ' ';
61
- return `${prefix}${content.trim()} |`;
62
- },
63
- });
64
- return td;
65
- }
66
- const yuanbaoTurndown = createYuanbaoTurndown();
67
23
  export function convertYuanbaoHtmlToMarkdown(value) {
68
- const markdown = yuanbaoTurndown.turndown(value || '');
69
- return markdown
70
- .replace(/\u00a0/g, ' ')
71
- .replace(/\n{4,}/g, '\n\n\n')
72
- .replace(/[ \t]+$/gm, '')
73
- .trim();
24
+ return htmlToMarkdown(value, (td) => {
25
+ td.addRule('table', {
26
+ filter: 'table',
27
+ replacement: (content) => `\n\n${content}\n\n`,
28
+ });
29
+ td.addRule('tableSection', {
30
+ filter: ['thead', 'tbody', 'tfoot'],
31
+ replacement: (content) => content,
32
+ });
33
+ td.addRule('tableRow', {
34
+ filter: 'tr',
35
+ replacement: (content, node) => {
36
+ const element = node;
37
+ const cells = Array.from(element.children);
38
+ const isHeaderRow = element.parentElement?.tagName === 'THEAD'
39
+ || (cells.length > 0 && cells.every((cell) => cell.tagName === 'TH'));
40
+ const row = `${content}\n`;
41
+ if (!isHeaderRow)
42
+ return row;
43
+ const separator = `| ${cells.map(() => '---').join(' | ')} |\n`;
44
+ return `${row}${separator}`;
45
+ },
46
+ });
47
+ td.addRule('tableCell', {
48
+ filter: ['th', 'td'],
49
+ replacement: (content, node) => {
50
+ const element = node;
51
+ const index = element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : 0;
52
+ const prefix = index === 0 ? '| ' : ' ';
53
+ return `${prefix}${content.trim()} |`;
54
+ },
55
+ });
56
+ });
74
57
  }
75
58
  export function sanitizeYuanbaoResponseText(value, promptText) {
76
59
  let sanitized = value
@@ -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
+ });
@@ -78,12 +78,10 @@ export async function ensureUserCliCompatShims(baseDir = USER_OPENCLI_DIR) {
78
78
  catch { /* doesn't exist */ }
79
79
  if (needsUpdate) {
80
80
  await fs.promises.mkdir(symlinkDir, { recursive: true });
81
- // Use rm instead of unlink — handles both symlinks and stale directories
82
81
  try {
83
82
  await fs.promises.rm(symlinkPath, { recursive: true, force: true });
84
83
  }
85
84
  catch { /* doesn't exist */ }
86
- // Use 'junction' on Windows — doesn't require admin/Developer Mode privileges
87
85
  const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
88
86
  await fs.promises.symlink(opencliRoot, symlinkPath, symlinkType);
89
87
  }
@@ -82,6 +82,7 @@ cli({
82
82
  await fs.promises.writeFile(commandPath, `
83
83
  import { cli, Strategy } from '@jackwener/opencli/registry';
84
84
  import { CommandExecutionError } from '@jackwener/opencli/errors';
85
+ import { htmlToMarkdown } from '@jackwener/opencli/utils';
85
86
 
86
87
  cli({
87
88
  site: 'legacy-site',
@@ -89,13 +90,13 @@ cli({
89
90
  description: 'hello command',
90
91
  strategy: Strategy.PUBLIC,
91
92
  browser: false,
92
- func: async () => [{ ok: true, errorName: new CommandExecutionError('boom').name }],
93
+ func: async () => [{ ok: true, errorName: new CommandExecutionError('boom').name, markdown: htmlToMarkdown('<p>hello</p>') }],
93
94
  });
94
95
  `);
95
96
  await discoverClis(userClisDir);
96
97
  const cmd = getRegistry().get('legacy-site/hello');
97
98
  expect(cmd).toBeDefined();
98
- await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true, errorName: 'CommandExecutionError' }]);
99
+ await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true, errorName: 'CommandExecutionError', markdown: 'hello' }]);
99
100
  }
100
101
  finally {
101
102
  await fs.promises.rm(tempOpencliRoot, { recursive: true, force: true });
@@ -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);
@@ -7,6 +7,10 @@
7
7
  export declare const log: {
8
8
  /** Informational message (always shown) */
9
9
  info(msg: string): void;
10
+ /** Lightweight status line for adapter progress updates */
11
+ status(msg: string): void;
12
+ /** Positive completion/status line without the heavier info prefix */
13
+ success(msg: string): void;
10
14
  /** Warning (always shown) */
11
15
  warn(msg: string): void;
12
16
  /** Error (always shown) */
@@ -16,6 +16,14 @@ export const log = {
16
16
  info(msg) {
17
17
  process.stderr.write(`${chalk.blue('ℹ')} ${msg}\n`);
18
18
  },
19
+ /** Lightweight status line for adapter progress updates */
20
+ status(msg) {
21
+ process.stderr.write(`${chalk.dim(msg)}\n`);
22
+ },
23
+ /** Positive completion/status line without the heavier info prefix */
24
+ success(msg) {
25
+ process.stderr.write(`${chalk.green(msg)}\n`);
26
+ },
19
27
  /** Warning (always shown) */
20
28
  warn(msg) {
21
29
  process.stderr.write(`${chalk.yellow('⚠')} ${msg}\n`);
@@ -8,24 +8,41 @@
8
8
  import { describe, it, expect } from 'vitest';
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
+ import { builtinModules } from 'node:module';
11
12
  import { fileURLToPath } from 'node:url';
13
+ import ts from 'typescript';
12
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
15
  const ROOT = path.resolve(__dirname, '..');
14
16
  const CLIS_DIR = path.join(ROOT, 'clis');
15
17
  /** Recursively collect all .ts files in a directory. */
16
- function collectTsFiles(dir) {
18
+ function collectTsFiles(dir, opts) {
17
19
  const results = [];
18
20
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
19
21
  const full = path.join(dir, entry.name);
20
22
  if (entry.isDirectory()) {
21
- results.push(...collectTsFiles(full));
23
+ results.push(...collectTsFiles(full, opts));
22
24
  }
23
25
  else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
26
+ if (opts?.excludeTests && (entry.name.endsWith('.test.ts') || entry.name === 'test-utils.ts'))
27
+ continue;
24
28
  results.push(full);
25
29
  }
26
30
  }
27
31
  return results;
28
32
  }
33
+ const ALLOWED_BARE_IMPORTS = new Set([
34
+ '@jackwener/opencli',
35
+ ...builtinModules.flatMap((name) => name.startsWith('node:')
36
+ ? [name, name.slice(5)]
37
+ : [name, `node:${name}`]),
38
+ ]);
39
+ function isAllowedImport(specifier) {
40
+ return specifier.startsWith('./')
41
+ || specifier.startsWith('../')
42
+ || specifier.startsWith('/')
43
+ || specifier.startsWith('@jackwener/opencli/')
44
+ || ALLOWED_BARE_IMPORTS.has(specifier);
45
+ }
29
46
  /** Forbidden relative import patterns that should have been replaced.
30
47
  * Uses (?:\.\./)+ to catch any depth of ../ traversal.
31
48
  * Covers: import/export from, vi.mock(), vi.importActual(). */
@@ -37,6 +54,7 @@ const FORBIDDEN_PATTERNS = [
37
54
  ];
38
55
  describe('adapter imports use package exports', () => {
39
56
  const adapterFiles = collectTsFiles(CLIS_DIR);
57
+ const runtimeAdapterFiles = collectTsFiles(CLIS_DIR, { excludeTests: true });
40
58
  it('found adapter files to check', () => {
41
59
  expect(adapterFiles.length).toBeGreaterThan(100);
42
60
  });
@@ -54,6 +72,25 @@ describe('adapter imports use package exports', () => {
54
72
  }
55
73
  expect(violations).toEqual([]);
56
74
  });
75
+ it('non-test adapters only import node builtins, relative modules, or opencli public APIs', () => {
76
+ const violations = [];
77
+ for (const file of runtimeAdapterFiles) {
78
+ const source = fs.readFileSync(file, 'utf-8');
79
+ const module = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
80
+ for (const stmt of module.statements) {
81
+ if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt))
82
+ continue;
83
+ const specifier = stmt.moduleSpecifier?.getText(module).slice(1, -1);
84
+ if (specifier && !isAllowedImport(specifier)) {
85
+ violations.push({
86
+ file: path.relative(ROOT, file),
87
+ specifier,
88
+ });
89
+ }
90
+ }
91
+ }
92
+ expect(violations).toEqual([]);
93
+ });
57
94
  });
58
95
  describe('package.json exports resolve to real files', () => {
59
96
  const pkgJson = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Shared utility functions used across the codebase.
3
3
  */
4
+ import TurndownService from 'turndown';
4
5
  /** Type guard: checks if a value is a non-null, non-array object. */
5
6
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
6
7
  /** Simple async concurrency limiter. */
@@ -9,3 +10,5 @@ export declare function mapConcurrent<T, R>(items: T[], limit: number, fn: (item
9
10
  export declare function sleep(ms: number): Promise<void>;
10
11
  /** Save a base64-encoded string to a file, creating parent directories as needed. */
11
12
  export declare function saveBase64ToFile(base64: string, filePath: string): Promise<void>;
13
+ export declare function createMarkdownConverter(configure?: (td: TurndownService) => void): TurndownService;
14
+ export declare function htmlToMarkdown(value: string, configure?: (td: TurndownService) => void): string;
package/dist/src/utils.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import * as fs from 'node:fs';
5
5
  import * as path from 'node:path';
6
+ import TurndownService from 'turndown';
6
7
  /** Type guard: checks if a value is a non-null, non-array object. */
7
8
  export function isRecord(value) {
8
9
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -31,3 +32,24 @@ export async function saveBase64ToFile(base64, filePath) {
31
32
  await fs.promises.mkdir(dir, { recursive: true });
32
33
  await fs.promises.writeFile(filePath, Buffer.from(base64, 'base64'));
33
34
  }
35
+ export function createMarkdownConverter(configure) {
36
+ const td = new TurndownService({
37
+ headingStyle: 'atx',
38
+ codeBlockStyle: 'fenced',
39
+ bulletListMarker: '-',
40
+ });
41
+ td.addRule('linebreak', {
42
+ filter: 'br',
43
+ replacement: () => '\n',
44
+ });
45
+ if (configure)
46
+ configure(td);
47
+ return td;
48
+ }
49
+ export function htmlToMarkdown(value, configure) {
50
+ return createMarkdownConverter(configure).turndown(value || '')
51
+ .replace(/\u00a0/g, ' ')
52
+ .replace(/\n{4,}/g, '\n\n\n')
53
+ .replace(/[ \t]+$/gm, '')
54
+ .trim();
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.6.5",
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': {