@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.
- package/dist/clis/yollomi/background.js +2 -2
- package/dist/clis/yollomi/edit.js +3 -3
- package/dist/clis/yollomi/face-swap.js +2 -2
- package/dist/clis/yollomi/generate.js +3 -3
- package/dist/clis/yollomi/object-remover.js +2 -2
- package/dist/clis/yollomi/remove-bg.js +2 -2
- package/dist/clis/yollomi/restore.js +2 -2
- package/dist/clis/yollomi/try-on.js +2 -2
- package/dist/clis/yollomi/upload.js +3 -3
- package/dist/clis/yollomi/upscale.js +3 -3
- package/dist/clis/yollomi/video.js +3 -3
- package/dist/clis/yuanbao/ask.js +34 -51
- package/dist/src/diagnostic.d.ts +38 -0
- package/dist/src/diagnostic.js +71 -0
- package/dist/src/diagnostic.test.d.ts +1 -0
- package/dist/src/diagnostic.test.js +84 -0
- package/dist/src/discovery.js +0 -2
- package/dist/src/engine.test.js +3 -2
- package/dist/src/execution.js +25 -4
- package/dist/src/logger.d.ts +4 -0
- package/dist/src/logger.js +8 -0
- package/dist/src/package-exports.test.js +39 -2
- package/dist/src/utils.d.ts +3 -0
- package/dist/src/utils.js +22 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +18 -71
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/clis/yuanbao/ask.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.
|
|
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
|
+
});
|
package/dist/src/discovery.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/engine.test.js
CHANGED
|
@@ -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 });
|
package/dist/src/execution.js
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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/dist/src/logger.d.ts
CHANGED
|
@@ -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) */
|
package/dist/src/logger.js
CHANGED
|
@@ -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'));
|
package/dist/src/utils.d.ts
CHANGED
|
@@ -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
package/scripts/postinstall.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* postinstall script —
|
|
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
|
|
8
|
-
*
|
|
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
|
|
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(
|
|
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(
|
|
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': {
|