imprint-mcp 0.3.0 → 0.4.0
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/README.md +42 -2
- package/examples/google-flights/get_flight_calendar_prices/index.ts +2 -2
- package/examples/google-flights/get_flight_calendar_prices/workflow.json +4 -2
- package/examples/southwest/README.md +3 -2
- package/examples/southwest/search_southwest_flights/index.ts +18 -1
- package/examples/southwest/search_southwest_flights/workflow.json +18 -1
- package/package.json +1 -1
- package/src/cli.ts +152 -1
- package/src/imprint/cdp-browser-fetch.ts +4 -1
- package/src/imprint/chromium.ts +279 -8
- package/src/imprint/compile-tools.ts +11 -3
- package/src/imprint/cron.ts +3 -0
- package/src/imprint/doctor.ts +9 -3
- package/src/imprint/export-archive.ts +355 -0
- package/src/imprint/install.ts +79 -4
- package/src/imprint/integrations.ts +1 -1
- package/src/imprint/mcp-maintenance.ts +15 -13
- package/src/imprint/mcp-server.ts +1 -1
- package/src/imprint/stealth-chromium.ts +6 -8
- package/src/imprint/teach-state.ts +37 -0
- package/src/imprint/teach.ts +62 -29
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `imprint export` / `imprint import` — portable .tar.gz archives of
|
|
3
|
+
* generated MCP tools, optionally including encrypted credential bundles.
|
|
4
|
+
*
|
|
5
|
+
* Archive layout:
|
|
6
|
+
* manifest.json
|
|
7
|
+
* <site>/<tool>/{ workflow.json, playbook.yaml, index.ts, ... }
|
|
8
|
+
* <site>/_shared/{ *.ts, package.json }
|
|
9
|
+
* <site>/credentials.imprintbundle (when --include-credentials)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
13
|
+
import {
|
|
14
|
+
copyFileSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
statSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { join as pathJoin, resolve as pathResolve } from 'node:path';
|
|
26
|
+
import * as p from '@clack/prompts';
|
|
27
|
+
import { type BundleEnvelope, exportBundle, importBundle } from './credential-bundle.ts';
|
|
28
|
+
import { getCredentialBackend } from './credential-store.ts';
|
|
29
|
+
import { imprintHomeDir, localSharedDir, localSiteDir } from './paths.ts';
|
|
30
|
+
import { ensureImprintRuntimeLink } from './runtime-link.ts';
|
|
31
|
+
import { availableSitesHint } from './sites.ts';
|
|
32
|
+
import { VERSION } from './version.ts';
|
|
33
|
+
|
|
34
|
+
const TOOL_FILES = [
|
|
35
|
+
'workflow.json',
|
|
36
|
+
'playbook.yaml',
|
|
37
|
+
'index.ts',
|
|
38
|
+
'parser.ts',
|
|
39
|
+
'request-transform.ts',
|
|
40
|
+
'package.json',
|
|
41
|
+
'backends.json',
|
|
42
|
+
'cron.json',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const SHARED_SKIP = new Set(['node_modules', 'bun.lock']);
|
|
46
|
+
|
|
47
|
+
interface ExportManifest {
|
|
48
|
+
version: 1;
|
|
49
|
+
imprintVersion: string;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
sites: Array<{
|
|
52
|
+
name: string;
|
|
53
|
+
tools: string[];
|
|
54
|
+
hasCredentials: boolean;
|
|
55
|
+
hasShared: boolean;
|
|
56
|
+
}>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ExportResult {
|
|
60
|
+
archivePath: string;
|
|
61
|
+
sites: Array<{ name: string; tools: string[] }>;
|
|
62
|
+
byteSize: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ImportResult {
|
|
66
|
+
sites: Array<{
|
|
67
|
+
name: string;
|
|
68
|
+
tools: string[];
|
|
69
|
+
credentialsImported: boolean;
|
|
70
|
+
skipped: boolean;
|
|
71
|
+
}>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isToolDir(dir: string): boolean {
|
|
75
|
+
return existsSync(pathJoin(dir, 'index.ts'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function discoverToolNames(siteDir: string): string[] {
|
|
79
|
+
if (!existsSync(siteDir)) return [];
|
|
80
|
+
return readdirSync(siteDir)
|
|
81
|
+
.filter((entry) => {
|
|
82
|
+
if (
|
|
83
|
+
entry.startsWith('.') ||
|
|
84
|
+
entry.startsWith('_') ||
|
|
85
|
+
entry === 'sessions' ||
|
|
86
|
+
entry === 'node_modules'
|
|
87
|
+
)
|
|
88
|
+
return false;
|
|
89
|
+
const full = pathJoin(siteDir, entry);
|
|
90
|
+
try {
|
|
91
|
+
return statSync(full).isDirectory() && isToolDir(full);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
.sort();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function collectToolFiles(toolDir: string): string[] {
|
|
100
|
+
return TOOL_FILES.filter((f) => existsSync(pathJoin(toolDir, f)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collectSharedFiles(sharedDir: string): string[] {
|
|
104
|
+
if (!existsSync(sharedDir)) return [];
|
|
105
|
+
return readdirSync(sharedDir).filter((f) => {
|
|
106
|
+
if (SHARED_SKIP.has(f)) return false;
|
|
107
|
+
if (f.endsWith('.test.ts') || f.endsWith('.plan.md')) return false;
|
|
108
|
+
const full = pathJoin(sharedDir, f);
|
|
109
|
+
try {
|
|
110
|
+
return statSync(full).isFile();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function exportArchive(opts: {
|
|
118
|
+
sites: string[];
|
|
119
|
+
out: string;
|
|
120
|
+
includeCredentials?: boolean;
|
|
121
|
+
}): Promise<ExportResult> {
|
|
122
|
+
for (const site of opts.sites) {
|
|
123
|
+
const dir = localSiteDir(site);
|
|
124
|
+
if (!existsSync(dir)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Site "${site}" not found at ${dir}.\n${availableSitesHint(imprintHomeDir(), site)}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const staging = mkdtempSync(pathJoin(tmpdir(), 'imprint-export-'));
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const manifest: ExportManifest = {
|
|
135
|
+
version: 1,
|
|
136
|
+
imprintVersion: VERSION,
|
|
137
|
+
createdAt: new Date().toISOString(),
|
|
138
|
+
sites: [],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
for (const site of opts.sites) {
|
|
142
|
+
const siteDir = localSiteDir(site);
|
|
143
|
+
const tools = discoverToolNames(siteDir);
|
|
144
|
+
if (tools.length === 0) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Site "${site}" has no tools (no subdirectories with index.ts). Nothing to export.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const stagingSite = pathJoin(staging, site);
|
|
151
|
+
mkdirSync(stagingSite, { recursive: true });
|
|
152
|
+
|
|
153
|
+
for (const tool of tools) {
|
|
154
|
+
const toolDir = pathJoin(siteDir, tool);
|
|
155
|
+
const stagingTool = pathJoin(stagingSite, tool);
|
|
156
|
+
mkdirSync(stagingTool, { recursive: true });
|
|
157
|
+
|
|
158
|
+
for (const file of collectToolFiles(toolDir)) {
|
|
159
|
+
copyFileSync(pathJoin(toolDir, file), pathJoin(stagingTool, file));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const sharedDir = localSharedDir(site);
|
|
164
|
+
const sharedFiles = collectSharedFiles(sharedDir);
|
|
165
|
+
let hasShared = false;
|
|
166
|
+
if (sharedFiles.length > 0) {
|
|
167
|
+
const stagingShared = pathJoin(stagingSite, '_shared');
|
|
168
|
+
mkdirSync(stagingShared, { recursive: true });
|
|
169
|
+
for (const file of sharedFiles) {
|
|
170
|
+
copyFileSync(pathJoin(sharedDir, file), pathJoin(stagingShared, file));
|
|
171
|
+
}
|
|
172
|
+
hasShared = true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let hasCredentials = false;
|
|
176
|
+
if (opts.includeCredentials) {
|
|
177
|
+
const backend = await getCredentialBackend();
|
|
178
|
+
const secrets = await backend.listSecrets(site);
|
|
179
|
+
const cookies = await backend.getCookies(site);
|
|
180
|
+
if (secrets.length > 0 || cookies.length > 0) {
|
|
181
|
+
const passphrase = await p.password({
|
|
182
|
+
message: `Passphrase to encrypt credentials for "${site}" (min 8 chars):`,
|
|
183
|
+
validate: (v) =>
|
|
184
|
+
(v ?? '').length < 8 ? 'Passphrase must be at least 8 characters.' : undefined,
|
|
185
|
+
});
|
|
186
|
+
if (p.isCancel(passphrase)) {
|
|
187
|
+
throw new Error('Export cancelled.');
|
|
188
|
+
}
|
|
189
|
+
const envelope = await exportBundle({
|
|
190
|
+
backend,
|
|
191
|
+
site,
|
|
192
|
+
passphrase,
|
|
193
|
+
});
|
|
194
|
+
writeFileSync(
|
|
195
|
+
pathJoin(stagingSite, 'credentials.imprintbundle'),
|
|
196
|
+
JSON.stringify(envelope, null, 2),
|
|
197
|
+
'utf8',
|
|
198
|
+
);
|
|
199
|
+
hasCredentials = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
manifest.sites.push({ name: site, tools, hasCredentials, hasShared });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
writeFileSync(pathJoin(staging, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
207
|
+
|
|
208
|
+
const archivePath = pathResolve(opts.out);
|
|
209
|
+
execSync(`tar czf ${shellEscape(archivePath)} -C ${shellEscape(staging)} .`, {
|
|
210
|
+
stdio: 'pipe',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const byteSize = statSync(archivePath).size;
|
|
214
|
+
return {
|
|
215
|
+
archivePath,
|
|
216
|
+
sites: manifest.sites.map((s) => ({ name: s.name, tools: s.tools })),
|
|
217
|
+
byteSize,
|
|
218
|
+
};
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(staging, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function importArchive(opts: {
|
|
225
|
+
archivePath: string;
|
|
226
|
+
force?: boolean;
|
|
227
|
+
}): Promise<ImportResult> {
|
|
228
|
+
const archivePath = pathResolve(opts.archivePath);
|
|
229
|
+
if (!existsSync(archivePath)) {
|
|
230
|
+
throw new Error(`Archive not found: ${archivePath}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const staging = mkdtempSync(pathJoin(tmpdir(), 'imprint-import-'));
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
execSync(`tar xzf ${shellEscape(archivePath)} -C ${shellEscape(staging)}`, {
|
|
237
|
+
stdio: 'pipe',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const manifestPath = pathJoin(staging, 'manifest.json');
|
|
241
|
+
if (!existsSync(manifestPath)) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
'Invalid archive: missing manifest.json. This does not appear to be an imprint export.',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const manifest: ExportManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
248
|
+
if (manifest.version !== 1) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Unsupported archive version ${manifest.version}. Update imprint and try again.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const home = imprintHomeDir();
|
|
255
|
+
const result: ImportResult = { sites: [] };
|
|
256
|
+
|
|
257
|
+
for (const entry of manifest.sites) {
|
|
258
|
+
const targetDir = localSiteDir(entry.name);
|
|
259
|
+
const skipped = false;
|
|
260
|
+
|
|
261
|
+
if (existsSync(targetDir) && !opts.force) {
|
|
262
|
+
console.error(
|
|
263
|
+
`warning: site "${entry.name}" already exists at ${targetDir} — skipping (use --force to overwrite).`,
|
|
264
|
+
);
|
|
265
|
+
result.sites.push({
|
|
266
|
+
name: entry.name,
|
|
267
|
+
tools: entry.tools,
|
|
268
|
+
credentialsImported: false,
|
|
269
|
+
skipped: true,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const stagedSite = pathJoin(staging, entry.name);
|
|
275
|
+
|
|
276
|
+
if (existsSync(targetDir)) {
|
|
277
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const tool of entry.tools) {
|
|
281
|
+
assertSafeSegment('tool name', tool);
|
|
282
|
+
const src = pathJoin(stagedSite, tool);
|
|
283
|
+
const dest = pathJoin(targetDir, tool);
|
|
284
|
+
mkdirSync(dest, { recursive: true });
|
|
285
|
+
for (const file of readdirSync(src)) {
|
|
286
|
+
const srcFile = pathJoin(src, file);
|
|
287
|
+
if (statSync(srcFile).isFile()) {
|
|
288
|
+
copyFileSync(srcFile, pathJoin(dest, file));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (entry.hasShared) {
|
|
294
|
+
const sharedSrc = pathJoin(stagedSite, '_shared');
|
|
295
|
+
const sharedDest = pathJoin(targetDir, '_shared');
|
|
296
|
+
if (existsSync(sharedSrc)) {
|
|
297
|
+
mkdirSync(sharedDest, { recursive: true });
|
|
298
|
+
for (const file of readdirSync(sharedSrc)) {
|
|
299
|
+
const srcFile = pathJoin(sharedSrc, file);
|
|
300
|
+
if (statSync(srcFile).isFile()) {
|
|
301
|
+
copyFileSync(srcFile, pathJoin(sharedDest, file));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let credentialsImported = false;
|
|
308
|
+
const bundlePath = pathJoin(stagedSite, 'credentials.imprintbundle');
|
|
309
|
+
if (entry.hasCredentials && existsSync(bundlePath)) {
|
|
310
|
+
const envelope: BundleEnvelope = JSON.parse(readFileSync(bundlePath, 'utf8'));
|
|
311
|
+
const passphrase = await p.password({
|
|
312
|
+
message: `Passphrase to decrypt credentials for "${entry.name}":`,
|
|
313
|
+
});
|
|
314
|
+
if (p.isCancel(passphrase)) {
|
|
315
|
+
console.error(`Skipping credential import for "${entry.name}".`);
|
|
316
|
+
} else {
|
|
317
|
+
try {
|
|
318
|
+
const backend = await getCredentialBackend();
|
|
319
|
+
await importBundle({ backend, envelope, passphrase });
|
|
320
|
+
credentialsImported = true;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.error(
|
|
323
|
+
`warning: credential import failed for "${entry.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
ensureImprintRuntimeLink(home);
|
|
330
|
+
|
|
331
|
+
result.sites.push({
|
|
332
|
+
name: entry.name,
|
|
333
|
+
tools: entry.tools,
|
|
334
|
+
credentialsImported,
|
|
335
|
+
skipped,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return result;
|
|
340
|
+
} finally {
|
|
341
|
+
rmSync(staging, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function assertSafeSegment(label: string, value: string): void {
|
|
346
|
+
if (value.includes('..') || value.includes('/') || value.includes('\\')) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Invalid ${label} in archive: "${value}". Must not contain path separators or ".." sequences.`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function shellEscape(s: string): string {
|
|
354
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
355
|
+
}
|
package/src/imprint/install.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'node:path';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
5
|
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
|
|
6
|
+
import { defaultPlaywrightBrowsersPath, ensurePlaywrightChromiumInstalled } from './chromium.ts';
|
|
6
7
|
import {
|
|
7
8
|
type McpServerConfig,
|
|
8
9
|
PLATFORMS,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
shellQuote,
|
|
17
18
|
} from './integrations.ts';
|
|
18
19
|
import { imprintHomeDir } from './paths.ts';
|
|
19
|
-
import { discoverTools } from './tool-loader.ts';
|
|
20
|
+
import { type ResolvedTool, discoverTools } from './tool-loader.ts';
|
|
20
21
|
import type { Workflow } from './types.ts';
|
|
21
22
|
|
|
22
23
|
type InstallSource = 'local' | 'examples';
|
|
@@ -28,6 +29,7 @@ interface InstallOptions {
|
|
|
28
29
|
source?: InstallSource;
|
|
29
30
|
print?: boolean;
|
|
30
31
|
noInteractive?: boolean;
|
|
32
|
+
skipBrowserInstall?: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
interface UninstallOptions {
|
|
@@ -70,6 +72,7 @@ interface InstallTarget {
|
|
|
70
72
|
source: InstallSource;
|
|
71
73
|
assetRoot: string;
|
|
72
74
|
site: string;
|
|
75
|
+
tools: ResolvedTool[];
|
|
73
76
|
workflows: Workflow[];
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -114,7 +117,11 @@ function defaultOpenClawConfigPath(): string {
|
|
|
114
117
|
return pathJoin(homedir(), '.openclaw', 'openclaw.json');
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
function defaultHermesConfigPath(): string {
|
|
120
|
+
export function defaultHermesConfigPath(): string {
|
|
121
|
+
const explicit = process.env.HERMES_CONFIG?.trim();
|
|
122
|
+
if (explicit) return explicit;
|
|
123
|
+
const hermesHome = process.env.HERMES_HOME?.trim();
|
|
124
|
+
if (hermesHome) return pathJoin(hermesHome, 'config.yaml');
|
|
118
125
|
return pathJoin(homedir(), '.hermes', 'config.yaml');
|
|
119
126
|
}
|
|
120
127
|
|
|
@@ -255,7 +262,7 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
|
|
|
255
262
|
const imprintCommand = configFilePlatform(platform)
|
|
256
263
|
? detectDirectBunImprintCommand()
|
|
257
264
|
: detectImprintCommand();
|
|
258
|
-
const env =
|
|
265
|
+
const env = buildInstallEnvironment(target);
|
|
259
266
|
const workflow = target.workflows[0];
|
|
260
267
|
if (!workflow) {
|
|
261
268
|
throw new Error(`No emitted workflows found for ${target.site}. Run \`imprint emit\` first.`);
|
|
@@ -287,6 +294,10 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
|
|
|
287
294
|
};
|
|
288
295
|
}
|
|
289
296
|
|
|
297
|
+
if (!opts.skipBrowserInstall) {
|
|
298
|
+
ensureBrowserRuntimeForInstall(target);
|
|
299
|
+
}
|
|
300
|
+
|
|
290
301
|
const regCommand = buildRegistrationCommand({
|
|
291
302
|
site: target.site,
|
|
292
303
|
platform,
|
|
@@ -422,10 +433,68 @@ async function resolveInstallTarget(opts: InstallOptions): Promise<InstallTarget
|
|
|
422
433
|
source: selected.source,
|
|
423
434
|
assetRoot: selected.assetRoot,
|
|
424
435
|
site: selected.site,
|
|
436
|
+
tools,
|
|
425
437
|
workflows,
|
|
426
438
|
};
|
|
427
439
|
}
|
|
428
440
|
|
|
441
|
+
function buildInstallEnvironment(target: InstallTarget): Record<string, string> {
|
|
442
|
+
const env: Record<string, string> = { IMPRINT_HOME: target.assetRoot };
|
|
443
|
+
const browsersPath = defaultPlaywrightBrowsersPath();
|
|
444
|
+
if (browsersPath && installTargetNeedsBrowserRuntime(target)) {
|
|
445
|
+
env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
|
|
446
|
+
}
|
|
447
|
+
return env;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function ensureBrowserRuntimeForInstall(target: InstallTarget): void {
|
|
451
|
+
if (!installTargetNeedsBrowserRuntime(target)) return;
|
|
452
|
+
const result = ensurePlaywrightChromiumInstalled({
|
|
453
|
+
log: (message) => process.stderr.write(`[imprint install] ${message}\n`),
|
|
454
|
+
});
|
|
455
|
+
if (result.installed) {
|
|
456
|
+
process.stderr.write(`[imprint install] installed Playwright Chromium at ${result.path}\n`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function installTargetNeedsBrowserRuntime(target: InstallTarget): boolean {
|
|
461
|
+
return target.tools.some(
|
|
462
|
+
(tool) => workflowNeedsBrowserRuntime(tool.workflow) || toolDirNeedsBrowserRuntime(tool.dir),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function workflowNeedsBrowserRuntime(workflow: Workflow): boolean {
|
|
467
|
+
if (workflow.bootstrap) return true;
|
|
468
|
+
if (workflow.liveVerified === false && workflow.liveVerifiedWaiver?.kind === 'waived-bot') {
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
if (workflow.requests.some((request) => request.url.includes('${state.'))) return true;
|
|
472
|
+
return workflow.requests.some((request) =>
|
|
473
|
+
(request.captures ?? []).some((capture) => captureNeedsBrowserRuntime(capture.capability)),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function toolDirNeedsBrowserRuntime(toolDir: string): boolean {
|
|
478
|
+
if (existsSync(pathJoin(toolDir, 'playbook.yaml'))) return true;
|
|
479
|
+
const backendsPath = pathJoin(toolDir, 'backends.json');
|
|
480
|
+
if (!existsSync(backendsPath)) return false;
|
|
481
|
+
try {
|
|
482
|
+
const parsed = JSON.parse(readFileSync(backendsPath, 'utf8')) as { preferredOrder?: unknown };
|
|
483
|
+
if (!Array.isArray(parsed.preferredOrder)) return false;
|
|
484
|
+
return parsed.preferredOrder.some(
|
|
485
|
+
(backend) =>
|
|
486
|
+
typeof backend === 'string' &&
|
|
487
|
+
['fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook'].includes(backend),
|
|
488
|
+
);
|
|
489
|
+
} catch {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function captureNeedsBrowserRuntime(capability: string | undefined): boolean {
|
|
495
|
+
return capability === 'browser_bootstrap' || capability === 'stealth_bootstrap';
|
|
496
|
+
}
|
|
497
|
+
|
|
429
498
|
async function resolveInstallPlatform(
|
|
430
499
|
opts: Pick<InstallOptions, 'platform' | 'noInteractive'>,
|
|
431
500
|
message = 'Install this MCP server where?',
|
|
@@ -658,7 +727,13 @@ function isPlatformDetected(platform: Platform): boolean {
|
|
|
658
727
|
case 'openclaw':
|
|
659
728
|
return commandExists('openclaw') || existsSync(pathJoin(homedir(), '.openclaw'));
|
|
660
729
|
case 'hermes':
|
|
661
|
-
return
|
|
730
|
+
return (
|
|
731
|
+
commandExists('hermes') ||
|
|
732
|
+
Boolean(process.env.HERMES_CONFIG?.trim()) ||
|
|
733
|
+
Boolean(process.env.HERMES_HOME?.trim()) ||
|
|
734
|
+
existsSync(defaultHermesConfigPath()) ||
|
|
735
|
+
existsSync(pathJoin(homedir(), '.hermes'))
|
|
736
|
+
);
|
|
662
737
|
}
|
|
663
738
|
}
|
|
664
739
|
|
|
@@ -105,7 +105,7 @@ export function generatePasteSnippet(opts: {
|
|
|
105
105
|
This gives your agent a tool that ${descLower}. Parameters: ${paramList}.`;
|
|
106
106
|
|
|
107
107
|
case 'hermes':
|
|
108
|
-
return `Add the ${toolName} tool: add to ~/.hermes/config.yaml under mcp_servers:
|
|
108
|
+
return `Add the ${toolName} tool: add to $HERMES_HOME/config.yaml (or ~/.hermes/config.yaml outside Hermes) under mcp_servers:
|
|
109
109
|
|
|
110
110
|
${toolName}:
|
|
111
111
|
command: "${ic.command}"
|
|
@@ -30,6 +30,7 @@ import { imprintHomeDir, localSiteDir } from './paths.ts';
|
|
|
30
30
|
import {
|
|
31
31
|
type WorkflowState,
|
|
32
32
|
loadTeachState,
|
|
33
|
+
pruneStalePendingTeachWorkflows,
|
|
33
34
|
resolveTeachStatePath,
|
|
34
35
|
saveTeachState,
|
|
35
36
|
teachStatePath,
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
|
|
38
39
|
type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
|
|
39
40
|
type LocalDeleteMode = 'none' | 'tool' | 'site';
|
|
40
|
-
type IssueKind = '
|
|
41
|
+
type IssueKind = 'missing-session' | 'stale-registration';
|
|
41
42
|
|
|
42
43
|
const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
|
|
43
44
|
const DISABLED_STORE_VERSION = 1;
|
|
@@ -144,6 +145,14 @@ function defaultContext(opts: Partial<MaintenanceContext> = {}): MaintenanceCont
|
|
|
144
145
|
};
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
function hermesConfigPath(ctx: MaintenanceContext): string {
|
|
149
|
+
const explicit = process.env.HERMES_CONFIG?.trim();
|
|
150
|
+
if (explicit) return explicit;
|
|
151
|
+
const hermesHome = process.env.HERMES_HOME?.trim();
|
|
152
|
+
if (hermesHome) return pathJoin(hermesHome, 'config.yaml');
|
|
153
|
+
return pathJoin(ctx.homeDir, '.hermes', 'config.yaml');
|
|
154
|
+
}
|
|
155
|
+
|
|
147
156
|
function parseSubArgs(argv: string[]): ParsedArgs {
|
|
148
157
|
const positionals: string[] = [];
|
|
149
158
|
const flags: Record<string, string | boolean> = {};
|
|
@@ -419,7 +428,7 @@ function fixIssue(issue: McpIssue, status: McpStatus, ctx: MaintenanceContext):
|
|
|
419
428
|
};
|
|
420
429
|
}
|
|
421
430
|
|
|
422
|
-
if (
|
|
431
|
+
if (issue.kind === 'missing-session' && issue.workflow) {
|
|
423
432
|
return pruneSingleTeachWorkflow(issue.site, issue.workflow);
|
|
424
433
|
}
|
|
425
434
|
|
|
@@ -680,8 +689,6 @@ function issueFixHint(issue: McpIssue): string | null {
|
|
|
680
689
|
switch (issue.kind) {
|
|
681
690
|
case 'stale-registration':
|
|
682
691
|
return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
|
|
683
|
-
case 'incomplete':
|
|
684
|
-
return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --incomplete --yes`;
|
|
685
692
|
case 'missing-session':
|
|
686
693
|
return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
|
|
687
694
|
}
|
|
@@ -729,6 +736,9 @@ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
|
|
|
729
736
|
}
|
|
730
737
|
|
|
731
738
|
const state = loadTeachState(site);
|
|
739
|
+
if (pruneStalePendingTeachWorkflows(site, state)) {
|
|
740
|
+
saveTeachState(site, state);
|
|
741
|
+
}
|
|
732
742
|
const workflows = Object.entries(state.workflows)
|
|
733
743
|
.map(([name, ws]) => workflowStatus(site, name, ws, tools))
|
|
734
744
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -795,14 +805,6 @@ function collectIssues(opts: {
|
|
|
795
805
|
path: wf.sessionPath ?? undefined,
|
|
796
806
|
});
|
|
797
807
|
}
|
|
798
|
-
if (wf.incomplete) {
|
|
799
|
-
issues.push({
|
|
800
|
-
kind: 'incomplete',
|
|
801
|
-
site: site.site,
|
|
802
|
-
workflow: wf.name,
|
|
803
|
-
message: `${site.site}/${wf.name} is incomplete (${wf.completedSteps.join(', ') || 'no completed steps'})`,
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
808
|
}
|
|
807
809
|
}
|
|
808
810
|
|
|
@@ -1419,7 +1421,7 @@ function scanJsonMap(
|
|
|
1419
1421
|
}
|
|
1420
1422
|
|
|
1421
1423
|
function scanHermes(ctx: MaintenanceContext): McpRegistration[] {
|
|
1422
|
-
const configPath =
|
|
1424
|
+
const configPath = hermesConfigPath(ctx);
|
|
1423
1425
|
if (!existsSync(configPath)) return [];
|
|
1424
1426
|
const doc = YAML.parseDocument(readFileSync(configPath, 'utf8'));
|
|
1425
1427
|
const servers = doc.get('mcp_servers', true);
|
|
@@ -179,7 +179,7 @@ function buildServer(
|
|
|
179
179
|
args,
|
|
180
180
|
assetRoot,
|
|
181
181
|
stealthCache,
|
|
182
|
-
{ cdpPool, winnerCache },
|
|
182
|
+
{ cdpPool, winnerCache, skipBootstrapSplice: Boolean(tool.preferredOrder?.length) },
|
|
183
183
|
);
|
|
184
184
|
// Reset the idle timer for this site's pooled Chrome.
|
|
185
185
|
if (result.ok && usedBackend === 'cdp-replay' && cdpPool.has(tool.site)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ensurePlaywrightChromiumInstalled } from './chromium.ts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Shared loader for Playwright's chromium with the stealth plugin applied.
|
|
@@ -69,13 +69,11 @@ export async function isStealthPluginAvailable(): Promise<boolean> {
|
|
|
69
69
|
* replay browser using chrome-headless-shell looks like a bot. Using the
|
|
70
70
|
* SAME binary for both eliminates the binary asymmetry.
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* Throws if Chromium cannot be installed or started; callers translate the
|
|
73
|
+
* error into their own result shape.
|
|
74
74
|
*/
|
|
75
75
|
export function getStealthExecutablePath(): string | undefined {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
return undefined;
|
|
80
|
-
}
|
|
76
|
+
return ensurePlaywrightChromiumInstalled({
|
|
77
|
+
log: (message) => process.stderr.write(`[imprint] ${message}\n`),
|
|
78
|
+
}).path;
|
|
81
79
|
}
|
|
@@ -148,6 +148,24 @@ export function resolveTeachStatePath(
|
|
|
148
148
|
return resolveLocalSitePath(site, value);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export function resolveWorkflowTriagedPath(
|
|
152
|
+
site: string,
|
|
153
|
+
ws: WorkflowState | undefined,
|
|
154
|
+
): string | null {
|
|
155
|
+
if (!ws) return null;
|
|
156
|
+
|
|
157
|
+
const explicitPath = resolveTeachStatePath(site, ws.triagedPath);
|
|
158
|
+
if (explicitPath) return explicitPath;
|
|
159
|
+
|
|
160
|
+
if (!ws.completedSteps.includes('triage')) return null;
|
|
161
|
+
|
|
162
|
+
const redactedPath = resolveTeachStatePath(site, ws.redactedPath);
|
|
163
|
+
if (!redactedPath?.endsWith('.redacted.json')) return null;
|
|
164
|
+
|
|
165
|
+
const derivedPath = redactedPath.replace(/\.redacted\.json$/, '.triaged.json');
|
|
166
|
+
return existsSync(derivedPath) ? derivedPath : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
151
169
|
export function toRelativeTeachStatePath(site: string, absPath: string): string {
|
|
152
170
|
const localRelative = relativeToLocalSite(site, absPath);
|
|
153
171
|
if (localRelative) return localRelative;
|
|
@@ -245,6 +263,25 @@ export function isExistingTeachFile(path: string | null | undefined): path is st
|
|
|
245
263
|
}
|
|
246
264
|
}
|
|
247
265
|
|
|
266
|
+
function hasRecoverableRawOrRedactedSession(site: string, ws: WorkflowState): boolean {
|
|
267
|
+
return (
|
|
268
|
+
isExistingTeachFile(resolveTeachStatePath(site, ws.sessionPath)) ||
|
|
269
|
+
isExistingTeachFile(resolveTeachStatePath(site, ws.redactedPath))
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function pruneStalePendingTeachWorkflows(site: string, state: TeachState): boolean {
|
|
274
|
+
let changed = false;
|
|
275
|
+
for (const [key, ws] of Object.entries(state.workflows)) {
|
|
276
|
+
if (!key.startsWith('_pending_')) continue;
|
|
277
|
+
if (hasRecoverableRawOrRedactedSession(site, ws)) continue;
|
|
278
|
+
delete state.workflows[key];
|
|
279
|
+
changed = true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return changed;
|
|
283
|
+
}
|
|
284
|
+
|
|
248
285
|
export function friendlySessionTimestamp(sessionPath: string): string {
|
|
249
286
|
const m = sessionPath.match(/(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})/);
|
|
250
287
|
if (!m) return pathBasename(sessionPath);
|