@jackwener/opencli 1.5.9 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +18 -0
- package/SKILL.md +59 -0
- package/autoresearch/baseline-browse.txt +1 -0
- package/autoresearch/baseline-skill.txt +1 -0
- package/autoresearch/browse-tasks.json +688 -0
- package/autoresearch/eval-browse.ts +185 -0
- package/autoresearch/eval-skill.ts +248 -0
- package/autoresearch/run-browse.sh +9 -0
- package/autoresearch/run-skill.sh +9 -0
- package/bun.lock +615 -0
- package/dist/browser/daemon-client.d.ts +20 -1
- package/dist/browser/daemon-client.js +37 -30
- package/dist/browser/daemon-client.test.d.ts +1 -0
- package/dist/browser/daemon-client.test.js +77 -0
- package/dist/browser/discover.js +8 -19
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +48 -1
- package/dist/cli-manifest.json +2 -2
- package/dist/cli.js +392 -0
- package/dist/clis/twitter/article.js +28 -1
- package/dist/clis/twitter/search.js +67 -5
- package/dist/clis/twitter/search.test.js +83 -5
- package/dist/clis/xiaohongshu/note.js +11 -0
- package/dist/clis/xiaohongshu/note.test.js +49 -0
- package/dist/commanderAdapter.js +1 -1
- package/dist/commanderAdapter.test.js +43 -0
- package/dist/commands/daemon.js +7 -46
- package/dist/commands/daemon.test.js +44 -69
- package/dist/discovery.js +27 -0
- package/dist/types.d.ts +8 -0
- package/docs/guide/getting-started.md +21 -0
- package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
- package/docs/zh/guide/getting-started.md +21 -0
- package/extension/package-lock.json +2 -2
- package/extension/src/background.ts +51 -4
- package/extension/src/cdp.ts +77 -124
- package/extension/src/protocol.ts +5 -1
- package/package.json +1 -1
- package/skills/opencli-explorer/SKILL.md +6 -0
- package/skills/opencli-oneshot/SKILL.md +6 -0
- package/skills/opencli-operate/SKILL.md +213 -0
- package/skills/opencli-usage/SKILL.md +113 -32
- package/src/browser/daemon-client.test.ts +103 -0
- package/src/browser/daemon-client.ts +53 -30
- package/src/browser/discover.ts +8 -17
- package/src/browser/page.ts +48 -1
- package/src/cli.ts +392 -0
- package/src/clis/twitter/article.ts +31 -1
- package/src/clis/twitter/search.test.ts +88 -5
- package/src/clis/twitter/search.ts +68 -5
- package/src/clis/xiaohongshu/note.test.ts +51 -0
- package/src/clis/xiaohongshu/note.ts +18 -0
- package/src/commanderAdapter.test.ts +62 -0
- package/src/commanderAdapter.ts +1 -1
- package/src/commands/daemon.test.ts +49 -83
- package/src/commands/daemon.ts +7 -55
- package/src/discovery.ts +22 -0
- package/src/doctor.ts +1 -1
- package/src/types.ts +8 -0
- package/extension/dist/background.js +0 -681
|
@@ -137,6 +137,57 @@ describe('xiaohongshu note', () => {
|
|
|
137
137
|
await expect(command!.func!(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
it('throws a token hint when the note page renders as an empty shell', async () => {
|
|
141
|
+
const page = createPageMock({
|
|
142
|
+
loginWall: false,
|
|
143
|
+
notFound: false,
|
|
144
|
+
title: '',
|
|
145
|
+
desc: '',
|
|
146
|
+
author: '',
|
|
147
|
+
likes: '',
|
|
148
|
+
collects: '',
|
|
149
|
+
comments: '',
|
|
150
|
+
tags: [],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await command!.func!(page, { 'note-id': '69ca3927000000001a020fd5' });
|
|
155
|
+
throw new Error('expected xiaohongshu note to fail on an empty shell page');
|
|
156
|
+
} catch (error) {
|
|
157
|
+
expect(error).toMatchObject({
|
|
158
|
+
code: 'EMPTY_RESULT',
|
|
159
|
+
hint: expect.stringMatching(/xsec_token|full url|search_result/i),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('keeps the empty-shell hint generic when the user already passed a full URL', async () => {
|
|
165
|
+
const page = createPageMock({
|
|
166
|
+
loginWall: false,
|
|
167
|
+
notFound: false,
|
|
168
|
+
title: '',
|
|
169
|
+
desc: '',
|
|
170
|
+
author: '',
|
|
171
|
+
likes: '',
|
|
172
|
+
collects: '',
|
|
173
|
+
comments: '',
|
|
174
|
+
tags: [],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await command!.func!(page, {
|
|
179
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/69ca3927000000001a020fd5?xsec_token=abc',
|
|
180
|
+
});
|
|
181
|
+
throw new Error('expected xiaohongshu note to fail on an empty shell page');
|
|
182
|
+
} catch (error) {
|
|
183
|
+
expect(error).toMatchObject({
|
|
184
|
+
code: 'EMPTY_RESULT',
|
|
185
|
+
hint: expect.stringContaining('loaded without visible content'),
|
|
186
|
+
});
|
|
187
|
+
expect((error as { hint?: string }).hint).not.toContain('bare note ID');
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
140
191
|
it('normalizes placeholder text to 0 for zero-count metrics', async () => {
|
|
141
192
|
const page = createPageMock({
|
|
142
193
|
loginWall: false, notFound: false,
|
|
@@ -21,6 +21,7 @@ cli({
|
|
|
21
21
|
columns: ['field', 'value'],
|
|
22
22
|
func: async (page, kwargs) => {
|
|
23
23
|
const raw = String(kwargs['note-id']);
|
|
24
|
+
const isBareNoteId = !/^https?:\/\//.test(raw.trim());
|
|
24
25
|
const noteId = parseNoteId(raw);
|
|
25
26
|
const url = buildNoteUrl(raw);
|
|
26
27
|
|
|
@@ -68,6 +69,23 @@ cli({
|
|
|
68
69
|
// XHS renders placeholder text like "赞"/"收藏"/"评论" when count is 0;
|
|
69
70
|
// normalize to '0' unless the value looks numeric.
|
|
70
71
|
const numOrZero = (v: string) => /^\d+/.test(v) ? v : '0';
|
|
72
|
+
|
|
73
|
+
// XHS sometimes renders an empty shell page for bare /explore/<id> visits
|
|
74
|
+
// when the request lacks a valid xsec_token. Title + author are always
|
|
75
|
+
// present on a real note, so their absence is the simplest reliable signal.
|
|
76
|
+
const emptyShell = !d.title && !d.author;
|
|
77
|
+
if (emptyShell) {
|
|
78
|
+
if (isBareNoteId) {
|
|
79
|
+
throw new EmptyResultError(
|
|
80
|
+
'xiaohongshu/note',
|
|
81
|
+
'Pass the full search_result URL with xsec_token, for example from `opencli xiaohongshu search`, instead of a bare note ID.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
throw new EmptyResultError(
|
|
85
|
+
'xiaohongshu/note',
|
|
86
|
+
'The note page loaded without visible content. Retry with a fresh URL or run with --verbose; if it persists, the page structure may have changed.',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
71
89
|
const rows = [
|
|
72
90
|
{ field: 'title', value: d.title || '' },
|
|
73
91
|
{ field: 'author', value: d.author || '' },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import type { CliCommand } from './registry.js';
|
|
4
|
+
import { EmptyResultError, SelectorError } from './errors.js';
|
|
4
5
|
|
|
5
6
|
const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
|
|
6
7
|
mockExecuteCommand: vi.fn(),
|
|
@@ -200,3 +201,64 @@ describe('commanderAdapter default formats', () => {
|
|
|
200
201
|
);
|
|
201
202
|
});
|
|
202
203
|
});
|
|
204
|
+
|
|
205
|
+
describe('commanderAdapter empty result hints', () => {
|
|
206
|
+
const cmd: CliCommand = {
|
|
207
|
+
site: 'xiaohongshu',
|
|
208
|
+
name: 'note',
|
|
209
|
+
description: 'Read one note',
|
|
210
|
+
browser: false,
|
|
211
|
+
args: [
|
|
212
|
+
{ name: 'note-id', positional: true, required: true, help: 'Note ID' },
|
|
213
|
+
],
|
|
214
|
+
func: vi.fn(),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
mockExecuteCommand.mockReset();
|
|
219
|
+
mockRenderOutput.mockReset();
|
|
220
|
+
delete process.env.OPENCLI_VERBOSE;
|
|
221
|
+
process.exitCode = undefined;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('prints the adapter hint instead of the generic outdated-adapter message', async () => {
|
|
225
|
+
const program = new Command();
|
|
226
|
+
const siteCmd = program.command('xiaohongshu');
|
|
227
|
+
registerCommandToProgram(siteCmd, cmd);
|
|
228
|
+
|
|
229
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
230
|
+
mockExecuteCommand.mockRejectedValueOnce(
|
|
231
|
+
new EmptyResultError(
|
|
232
|
+
'xiaohongshu/note',
|
|
233
|
+
'Pass the full search_result URL with xsec_token instead of a bare note ID.',
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
|
|
238
|
+
|
|
239
|
+
const output = errorSpy.mock.calls.flat().join('\n');
|
|
240
|
+
expect(output).toContain('xsec_token');
|
|
241
|
+
expect(output).not.toContain('this adapter may be outdated');
|
|
242
|
+
|
|
243
|
+
errorSpy.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('prints selector-specific hints too', async () => {
|
|
247
|
+
const program = new Command();
|
|
248
|
+
const siteCmd = program.command('xiaohongshu');
|
|
249
|
+
registerCommandToProgram(siteCmd, cmd);
|
|
250
|
+
|
|
251
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
252
|
+
mockExecuteCommand.mockRejectedValueOnce(
|
|
253
|
+
new SelectorError('.note-title', 'The note title selector no longer matches the current page.'),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await program.parseAsync(['node', 'opencli', 'xiaohongshu', 'note', '69ca3927000000001a020fd5']);
|
|
257
|
+
|
|
258
|
+
const output = errorSpy.mock.calls.flat().join('\n');
|
|
259
|
+
expect(output).toContain('selector no longer matches');
|
|
260
|
+
expect(output).not.toContain('this adapter may be outdated');
|
|
261
|
+
|
|
262
|
+
errorSpy.mockRestore();
|
|
263
|
+
});
|
|
264
|
+
});
|
package/src/commanderAdapter.ts
CHANGED
|
@@ -225,7 +225,7 @@ async function renderError(err: unknown, cmdName: string, verbose: boolean): Pro
|
|
|
225
225
|
if (err instanceof SelectorError || err instanceof EmptyResultError) {
|
|
226
226
|
const icon = ERROR_ICONS[err.code] ?? '⚠️';
|
|
227
227
|
console.error(chalk.red(`${icon} ${err.message}`));
|
|
228
|
-
console.error(chalk.yellow('
|
|
228
|
+
console.error(chalk.yellow(`→ ${err.hint ?? 'The page structure may have changed — this adapter may be outdated.'}`));
|
|
229
229
|
console.error(chalk.dim(` Debug: ${cmdName} --verbose`));
|
|
230
230
|
console.error(chalk.dim(` Report: ${ISSUES_URL}`));
|
|
231
231
|
return;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
|
|
3
|
+
const {
|
|
4
|
+
fetchDaemonStatusMock,
|
|
5
|
+
requestDaemonShutdownMock,
|
|
6
|
+
} = vi.hoisted(() => ({
|
|
7
|
+
fetchDaemonStatusMock: vi.fn(),
|
|
8
|
+
requestDaemonShutdownMock: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
3
11
|
vi.mock('chalk', () => ({
|
|
4
12
|
default: {
|
|
5
13
|
green: (s: string) => s,
|
|
@@ -16,6 +24,11 @@ vi.mock('../browser/bridge.js', () => ({
|
|
|
16
24
|
},
|
|
17
25
|
}));
|
|
18
26
|
|
|
27
|
+
vi.mock('../browser/daemon-client.js', () => ({
|
|
28
|
+
fetchDaemonStatus: fetchDaemonStatusMock,
|
|
29
|
+
requestDaemonShutdown: requestDaemonShutdownMock,
|
|
30
|
+
}));
|
|
31
|
+
|
|
19
32
|
import { daemonStatus, daemonStop, daemonRestart } from './daemon.js';
|
|
20
33
|
|
|
21
34
|
describe('daemon commands', () => {
|
|
@@ -25,6 +38,8 @@ describe('daemon commands', () => {
|
|
|
25
38
|
beforeEach(() => {
|
|
26
39
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
27
40
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
41
|
+
fetchDaemonStatusMock.mockReset();
|
|
42
|
+
requestDaemonShutdownMock.mockReset();
|
|
28
43
|
});
|
|
29
44
|
|
|
30
45
|
afterEach(() => {
|
|
@@ -34,7 +49,7 @@ describe('daemon commands', () => {
|
|
|
34
49
|
|
|
35
50
|
describe('daemonStatus', () => {
|
|
36
51
|
it('shows "not running" when daemon is unreachable', async () => {
|
|
37
|
-
|
|
52
|
+
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
38
53
|
|
|
39
54
|
await daemonStatus();
|
|
40
55
|
|
|
@@ -42,7 +57,7 @@ describe('daemon commands', () => {
|
|
|
42
57
|
});
|
|
43
58
|
|
|
44
59
|
it('shows "not running" when daemon returns non-ok response', async () => {
|
|
45
|
-
|
|
60
|
+
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
46
61
|
|
|
47
62
|
await daemonStatus();
|
|
48
63
|
|
|
@@ -61,13 +76,7 @@ describe('daemon commands', () => {
|
|
|
61
76
|
port: 19825,
|
|
62
77
|
};
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
'fetch',
|
|
66
|
-
vi.fn().mockResolvedValue({
|
|
67
|
-
ok: true,
|
|
68
|
-
json: () => Promise.resolve(status),
|
|
69
|
-
}),
|
|
70
|
-
);
|
|
79
|
+
fetchDaemonStatusMock.mockResolvedValue(status);
|
|
71
80
|
|
|
72
81
|
await daemonStatus();
|
|
73
82
|
|
|
@@ -91,13 +100,7 @@ describe('daemon commands', () => {
|
|
|
91
100
|
port: 19825,
|
|
92
101
|
};
|
|
93
102
|
|
|
94
|
-
|
|
95
|
-
'fetch',
|
|
96
|
-
vi.fn().mockResolvedValue({
|
|
97
|
-
ok: true,
|
|
98
|
-
json: () => Promise.resolve(status),
|
|
99
|
-
}),
|
|
100
|
-
);
|
|
103
|
+
fetchDaemonStatusMock.mockResolvedValue(status);
|
|
101
104
|
|
|
102
105
|
await daemonStatus();
|
|
103
106
|
|
|
@@ -107,7 +110,7 @@ describe('daemon commands', () => {
|
|
|
107
110
|
|
|
108
111
|
describe('daemonStop', () => {
|
|
109
112
|
it('reports "not running" when daemon is unreachable', async () => {
|
|
110
|
-
|
|
113
|
+
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
111
114
|
|
|
112
115
|
await daemonStop();
|
|
113
116
|
|
|
@@ -115,59 +118,36 @@ describe('daemon commands', () => {
|
|
|
115
118
|
});
|
|
116
119
|
|
|
117
120
|
it('sends shutdown and reports success', async () => {
|
|
118
|
-
|
|
121
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
119
122
|
ok: true,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
port: 19825,
|
|
130
|
-
}),
|
|
131
|
-
};
|
|
132
|
-
const shutdownResponse = { ok: true };
|
|
133
|
-
|
|
134
|
-
const mockFetch = vi.fn()
|
|
135
|
-
.mockResolvedValueOnce(statusResponse)
|
|
136
|
-
.mockResolvedValueOnce(shutdownResponse);
|
|
137
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
123
|
+
pid: 12345,
|
|
124
|
+
uptime: 100,
|
|
125
|
+
extensionConnected: true,
|
|
126
|
+
pending: 0,
|
|
127
|
+
lastCliRequestTime: Date.now(),
|
|
128
|
+
memoryMB: 50,
|
|
129
|
+
port: 19825,
|
|
130
|
+
});
|
|
131
|
+
requestDaemonShutdownMock.mockResolvedValue(true);
|
|
138
132
|
|
|
139
133
|
await daemonStop();
|
|
140
134
|
|
|
141
|
-
|
|
142
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
143
|
-
const shutdownCall = mockFetch.mock.calls[1];
|
|
144
|
-
expect(shutdownCall[0]).toContain('/shutdown');
|
|
145
|
-
expect(shutdownCall[1]).toMatchObject({ method: 'POST' });
|
|
146
|
-
|
|
135
|
+
expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
|
|
147
136
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
|
|
148
137
|
});
|
|
149
138
|
|
|
150
139
|
it('reports failure when shutdown request fails', async () => {
|
|
151
|
-
|
|
140
|
+
fetchDaemonStatusMock.mockResolvedValue({
|
|
152
141
|
ok: true,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
port: 19825,
|
|
163
|
-
}),
|
|
164
|
-
};
|
|
165
|
-
const shutdownResponse = { ok: false };
|
|
166
|
-
|
|
167
|
-
const mockFetch = vi.fn()
|
|
168
|
-
.mockResolvedValueOnce(statusResponse)
|
|
169
|
-
.mockResolvedValueOnce(shutdownResponse);
|
|
170
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
142
|
+
pid: 12345,
|
|
143
|
+
uptime: 100,
|
|
144
|
+
extensionConnected: true,
|
|
145
|
+
pending: 0,
|
|
146
|
+
lastCliRequestTime: Date.now(),
|
|
147
|
+
memoryMB: 50,
|
|
148
|
+
port: 19825,
|
|
149
|
+
});
|
|
150
|
+
requestDaemonShutdownMock.mockResolvedValue(false);
|
|
171
151
|
|
|
172
152
|
await daemonStop();
|
|
173
153
|
|
|
@@ -188,7 +168,7 @@ describe('daemon commands', () => {
|
|
|
188
168
|
};
|
|
189
169
|
|
|
190
170
|
it('starts daemon directly when not running', async () => {
|
|
191
|
-
|
|
171
|
+
fetchDaemonStatusMock.mockResolvedValue(null);
|
|
192
172
|
mockConnect.mockResolvedValue(undefined);
|
|
193
173
|
|
|
194
174
|
await daemonRestart();
|
|
@@ -198,36 +178,22 @@ describe('daemon commands', () => {
|
|
|
198
178
|
});
|
|
199
179
|
|
|
200
180
|
it('stops then starts when daemon is running', async () => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
.mockResolvedValueOnce(
|
|
204
|
-
|
|
205
|
-
.mockResolvedValueOnce({ ok: true })
|
|
206
|
-
// Subsequent calls: polling fetchStatus until unreachable
|
|
207
|
-
.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
208
|
-
|
|
209
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
181
|
+
fetchDaemonStatusMock
|
|
182
|
+
.mockResolvedValueOnce(statusData)
|
|
183
|
+
.mockResolvedValueOnce(null);
|
|
184
|
+
requestDaemonShutdownMock.mockResolvedValue(true);
|
|
210
185
|
mockConnect.mockResolvedValue(undefined);
|
|
211
186
|
|
|
212
187
|
await daemonRestart();
|
|
213
188
|
|
|
214
|
-
|
|
215
|
-
const shutdownCall = mockFetch.mock.calls[1];
|
|
216
|
-
expect(shutdownCall[0]).toContain('/shutdown');
|
|
217
|
-
expect(shutdownCall[1]).toMatchObject({ method: 'POST' });
|
|
218
|
-
|
|
189
|
+
expect(requestDaemonShutdownMock).toHaveBeenCalledTimes(1);
|
|
219
190
|
expect(mockConnect).toHaveBeenCalledWith({ timeout: 10 });
|
|
220
191
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Daemon restarted'));
|
|
221
192
|
});
|
|
222
193
|
|
|
223
194
|
it('aborts when shutdown fails', async () => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(statusData) })
|
|
227
|
-
// requestShutdown — failure
|
|
228
|
-
.mockResolvedValueOnce({ ok: false });
|
|
229
|
-
|
|
230
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
195
|
+
fetchDaemonStatusMock.mockResolvedValue(statusData);
|
|
196
|
+
requestDaemonShutdownMock.mockResolvedValue(false);
|
|
231
197
|
|
|
232
198
|
await daemonRestart();
|
|
233
199
|
|
package/src/commands/daemon.ts
CHANGED
|
@@ -6,55 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
12
|
-
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
13
|
-
|
|
14
|
-
interface DaemonStatus {
|
|
15
|
-
ok: boolean;
|
|
16
|
-
pid: number;
|
|
17
|
-
uptime: number;
|
|
18
|
-
extensionConnected: boolean;
|
|
19
|
-
pending: number;
|
|
20
|
-
lastCliRequestTime: number;
|
|
21
|
-
memoryMB: number;
|
|
22
|
-
port: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function fetchStatus(): Promise<DaemonStatus | null> {
|
|
26
|
-
const controller = new AbortController();
|
|
27
|
-
const timer = setTimeout(() => controller.abort(), 2000);
|
|
28
|
-
try {
|
|
29
|
-
const res = await fetch(`${DAEMON_URL}/status`, {
|
|
30
|
-
headers: { 'X-OpenCLI': '1' },
|
|
31
|
-
signal: controller.signal,
|
|
32
|
-
});
|
|
33
|
-
if (!res.ok) return null;
|
|
34
|
-
return await res.json() as DaemonStatus;
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
} finally {
|
|
38
|
-
clearTimeout(timer);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function requestShutdown(): Promise<boolean> {
|
|
43
|
-
const controller = new AbortController();
|
|
44
|
-
const timer = setTimeout(() => controller.abort(), 5000);
|
|
45
|
-
try {
|
|
46
|
-
const res = await fetch(`${DAEMON_URL}/shutdown`, {
|
|
47
|
-
method: 'POST',
|
|
48
|
-
headers: { 'X-OpenCLI': '1' },
|
|
49
|
-
signal: controller.signal,
|
|
50
|
-
});
|
|
51
|
-
return res.ok;
|
|
52
|
-
} catch {
|
|
53
|
-
return false;
|
|
54
|
-
} finally {
|
|
55
|
-
clearTimeout(timer);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
9
|
+
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
|
58
10
|
|
|
59
11
|
function formatUptime(seconds: number): string {
|
|
60
12
|
const h = Math.floor(seconds / 3600);
|
|
@@ -74,7 +26,7 @@ function formatTimeSince(timestampMs: number): string {
|
|
|
74
26
|
}
|
|
75
27
|
|
|
76
28
|
export async function daemonStatus(): Promise<void> {
|
|
77
|
-
const status = await
|
|
29
|
+
const status = await fetchDaemonStatus();
|
|
78
30
|
if (!status) {
|
|
79
31
|
console.log(`Daemon: ${chalk.dim('not running')}`);
|
|
80
32
|
return;
|
|
@@ -89,13 +41,13 @@ export async function daemonStatus(): Promise<void> {
|
|
|
89
41
|
}
|
|
90
42
|
|
|
91
43
|
export async function daemonStop(): Promise<void> {
|
|
92
|
-
const status = await
|
|
44
|
+
const status = await fetchDaemonStatus();
|
|
93
45
|
if (!status) {
|
|
94
46
|
console.log(chalk.dim('Daemon is not running.'));
|
|
95
47
|
return;
|
|
96
48
|
}
|
|
97
49
|
|
|
98
|
-
const ok = await
|
|
50
|
+
const ok = await requestDaemonShutdown();
|
|
99
51
|
if (ok) {
|
|
100
52
|
console.log(chalk.green('Daemon stopped.'));
|
|
101
53
|
} else {
|
|
@@ -105,9 +57,9 @@ export async function daemonStop(): Promise<void> {
|
|
|
105
57
|
}
|
|
106
58
|
|
|
107
59
|
export async function daemonRestart(): Promise<void> {
|
|
108
|
-
const status = await
|
|
60
|
+
const status = await fetchDaemonStatus();
|
|
109
61
|
if (status) {
|
|
110
|
-
const ok = await
|
|
62
|
+
const ok = await requestDaemonShutdown();
|
|
111
63
|
if (!ok) {
|
|
112
64
|
console.error(chalk.red('Failed to stop daemon.'));
|
|
113
65
|
process.exitCode = 1;
|
|
@@ -117,7 +69,7 @@ export async function daemonRestart(): Promise<void> {
|
|
|
117
69
|
const deadline = Date.now() + 5000;
|
|
118
70
|
while (Date.now() < deadline) {
|
|
119
71
|
await new Promise(r => setTimeout(r, 200));
|
|
120
|
-
if (!(await
|
|
72
|
+
if (!(await fetchDaemonStatus())) break;
|
|
121
73
|
}
|
|
122
74
|
}
|
|
123
75
|
|
package/src/discovery.ts
CHANGED
|
@@ -76,6 +76,28 @@ export async function ensureUserCliCompatShims(baseDir: string = USER_OPENCLI_DI
|
|
|
76
76
|
`${JSON.stringify({ name: 'opencli-user-runtime', private: true, type: 'module' }, null, 2)}\n`,
|
|
77
77
|
),
|
|
78
78
|
]);
|
|
79
|
+
|
|
80
|
+
// Create node_modules/@jackwener/opencli symlink so user TS CLIs can import
|
|
81
|
+
// from '@jackwener/opencli/registry' (the package export).
|
|
82
|
+
// This is needed because ~/.opencli/clis/ is outside opencli's node_modules tree.
|
|
83
|
+
const opencliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
84
|
+
const symlinkDir = path.join(baseDir, 'node_modules', '@jackwener');
|
|
85
|
+
const symlinkPath = path.join(symlinkDir, 'opencli');
|
|
86
|
+
try {
|
|
87
|
+
// Only recreate if symlink is missing or points to wrong target
|
|
88
|
+
let needsUpdate = true;
|
|
89
|
+
try {
|
|
90
|
+
const existing = await fs.promises.readlink(symlinkPath);
|
|
91
|
+
if (existing === opencliRoot) needsUpdate = false;
|
|
92
|
+
} catch { /* doesn't exist */ }
|
|
93
|
+
if (needsUpdate) {
|
|
94
|
+
await fs.promises.mkdir(symlinkDir, { recursive: true });
|
|
95
|
+
try { await fs.promises.unlink(symlinkPath); } catch { /* doesn't exist */ }
|
|
96
|
+
await fs.promises.symlink(opencliRoot, symlinkPath, 'dir');
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Non-fatal: npm-linked installs or permission issues may prevent this
|
|
100
|
+
}
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
/**
|
package/src/doctor.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type ConnectivityResult = {
|
|
|
25
25
|
durationMs: number;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
|
|
28
29
|
export type DoctorReport = {
|
|
29
30
|
cliVersion?: string;
|
|
30
31
|
daemonRunning: boolean;
|
|
@@ -93,7 +94,6 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
93
94
|
if (connectivity && !connectivity.ok) {
|
|
94
95
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
95
96
|
}
|
|
96
|
-
|
|
97
97
|
if (status.extensionVersion && opts.cliVersion) {
|
|
98
98
|
const extMajor = status.extensionVersion.split('.')[0];
|
|
99
99
|
const cliMajor = opts.cliVersion.split('.')[0];
|
package/src/types.ts
CHANGED
|
@@ -77,4 +77,12 @@ export interface IPage {
|
|
|
77
77
|
getCurrentUrl?(): Promise<string | null>;
|
|
78
78
|
/** Returns the active tab ID, or undefined if not yet resolved. */
|
|
79
79
|
getActiveTabId?(): number | undefined;
|
|
80
|
+
/** Send a raw CDP command via chrome.debugger passthrough. */
|
|
81
|
+
cdp?(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
82
|
+
/** Click at native coordinates via CDP Input.dispatchMouseEvent. */
|
|
83
|
+
nativeClick?(x: number, y: number): Promise<void>;
|
|
84
|
+
/** Type text via CDP Input.insertText. */
|
|
85
|
+
nativeType?(text: string): Promise<void>;
|
|
86
|
+
/** Press a key via CDP Input.dispatchKeyEvent. */
|
|
87
|
+
nativeKeyPress?(key: string, modifiers?: string[]): Promise<void>;
|
|
80
88
|
}
|