imprint-mcp 0.3.1 → 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/package.json +1 -1
- package/src/cli.ts +145 -0
- package/src/imprint/export-archive.ts +355 -0
- package/src/imprint/mcp-maintenance.ts +6 -12
- package/src/imprint/teach-state.ts +37 -0
- package/src/imprint/teach.ts +62 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imprint-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/cli.ts
CHANGED
|
@@ -62,6 +62,10 @@ INSTALL
|
|
|
62
62
|
install [<site>] Install an emitted MCP server into an AI platform.
|
|
63
63
|
uninstall [<site>] Remove an installed Imprint MCP server from an AI platform.
|
|
64
64
|
|
|
65
|
+
SHARE
|
|
66
|
+
export <site> [<site2>] Bundle site tools into a portable .tar.gz archive.
|
|
67
|
+
import <archive.tar.gz> Unpack an archive into ~/.imprint and set up tools.
|
|
68
|
+
|
|
65
69
|
RUN
|
|
66
70
|
mcp-server <site> Serve one site's tools as MCP (stdio default).
|
|
67
71
|
cron <site> Polling daemon for ~/.imprint/<site>/<toolName>/cron.json.
|
|
@@ -284,6 +288,39 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
284
288
|
],
|
|
285
289
|
example: 'imprint uninstall google-flights --platform claude-desktop',
|
|
286
290
|
},
|
|
291
|
+
export: {
|
|
292
|
+
summary:
|
|
293
|
+
'Bundle one or more site tool sets into a portable .tar.gz archive for sharing across machines.',
|
|
294
|
+
usage: ['imprint export <site> [<site2> ...] [--out <path>] [--include-credentials]'],
|
|
295
|
+
flags: [
|
|
296
|
+
{
|
|
297
|
+
name: '--out <path>',
|
|
298
|
+
description:
|
|
299
|
+
'Output path. Defaults to ./imprint-export-<site>.tar.gz (single) or ./imprint-export-<timestamp>.tar.gz (multi).',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: '--include-credentials',
|
|
303
|
+
description: 'Embed encrypted credential bundles (prompts for a passphrase per site).',
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
example: 'imprint export avis southwest marriott --out tools.tar.gz --include-credentials',
|
|
307
|
+
},
|
|
308
|
+
import: {
|
|
309
|
+
summary: 'Unpack an imprint export archive into ~/.imprint and set up tools for use.',
|
|
310
|
+
usage: ['imprint import <archive.tar.gz> [--force] [--platform <name>]'],
|
|
311
|
+
flags: [
|
|
312
|
+
{
|
|
313
|
+
name: '--force',
|
|
314
|
+
description: 'Overwrite existing sites instead of skipping them.',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: '--platform <name>',
|
|
318
|
+
description:
|
|
319
|
+
'Auto-install MCP servers after import: claude-code, codex, claude-desktop, openclaw, hermes.',
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
example: 'imprint import tools.tar.gz --force --platform claude-code',
|
|
323
|
+
},
|
|
287
324
|
login: {
|
|
288
325
|
summary: 'Persist auth cookies for <site> from a captured session.',
|
|
289
326
|
usage: ['imprint login <site> --from-session <session.json>'],
|
|
@@ -896,6 +933,114 @@ async function main(argv: string[]): Promise<number> {
|
|
|
896
933
|
return 0;
|
|
897
934
|
}
|
|
898
935
|
|
|
936
|
+
case 'export': {
|
|
937
|
+
const sites: string[] = [];
|
|
938
|
+
let i = 1;
|
|
939
|
+
for (; i < argv.length; i++) {
|
|
940
|
+
const arg = argv[i];
|
|
941
|
+
if (!arg || arg.startsWith('-')) break;
|
|
942
|
+
sites.push(arg);
|
|
943
|
+
}
|
|
944
|
+
if (sites.length === 0) {
|
|
945
|
+
console.error('error: `imprint export` requires at least one <site> argument.');
|
|
946
|
+
return 2;
|
|
947
|
+
}
|
|
948
|
+
const { values } = parseArgs({
|
|
949
|
+
args: argv.slice(i),
|
|
950
|
+
options: {
|
|
951
|
+
out: { type: 'string' },
|
|
952
|
+
'include-credentials': { type: 'boolean' },
|
|
953
|
+
},
|
|
954
|
+
allowPositionals: false,
|
|
955
|
+
});
|
|
956
|
+
const defaultOut =
|
|
957
|
+
sites.length === 1
|
|
958
|
+
? `imprint-export-${sites[0]}.tar.gz`
|
|
959
|
+
: `imprint-export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.tar.gz`;
|
|
960
|
+
const out = values.out ?? defaultOut;
|
|
961
|
+
const { exportArchive } = await import('./imprint/export-archive.ts');
|
|
962
|
+
const result = await exportArchive({
|
|
963
|
+
sites,
|
|
964
|
+
out,
|
|
965
|
+
includeCredentials: values['include-credentials'],
|
|
966
|
+
});
|
|
967
|
+
console.log(`[imprint] exported → ${result.archivePath}`);
|
|
968
|
+
for (const s of result.sites) {
|
|
969
|
+
console.log(
|
|
970
|
+
`[imprint] ${s.name}: ${s.tools.length} tool${s.tools.length === 1 ? '' : 's'} (${s.tools.join(', ')})`,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
const kb = (result.byteSize / 1024).toFixed(1);
|
|
974
|
+
console.log(`[imprint] archive size: ${kb} KB`);
|
|
975
|
+
console.log('');
|
|
976
|
+
console.log('next step:');
|
|
977
|
+
console.log(` imprint import ${out} # on the target machine`);
|
|
978
|
+
return 0;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
case 'import': {
|
|
982
|
+
const archivePath = requirePositional(argv, 'import', 'an <archive.tar.gz> argument');
|
|
983
|
+
if (archivePath === null) return 2;
|
|
984
|
+
const { values } = parseArgs({
|
|
985
|
+
args: argv.slice(2),
|
|
986
|
+
options: {
|
|
987
|
+
force: { type: 'boolean' },
|
|
988
|
+
platform: { type: 'string' },
|
|
989
|
+
},
|
|
990
|
+
allowPositionals: false,
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
if (values.platform) {
|
|
994
|
+
const { PLATFORMS } = await import('./imprint/integrations.ts');
|
|
995
|
+
if (!PLATFORMS.includes(values.platform as (typeof PLATFORMS)[number])) {
|
|
996
|
+
console.error(
|
|
997
|
+
`error: unknown platform '${values.platform}' — valid: ${PLATFORMS.join(', ')}`,
|
|
998
|
+
);
|
|
999
|
+
return 2;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const { importArchive } = await import('./imprint/export-archive.ts');
|
|
1004
|
+
const result = await importArchive({
|
|
1005
|
+
archivePath,
|
|
1006
|
+
force: values.force,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
for (const s of result.sites) {
|
|
1010
|
+
if (s.skipped) {
|
|
1011
|
+
console.log(`[imprint] ${s.name}: skipped (already exists)`);
|
|
1012
|
+
} else {
|
|
1013
|
+
console.log(
|
|
1014
|
+
`[imprint] ${s.name}: imported ${s.tools.length} tool${s.tools.length === 1 ? '' : 's'} (${s.tools.join(', ')})${s.credentialsImported ? ' + credentials' : ''}`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const imported = result.sites.filter((s) => !s.skipped);
|
|
1020
|
+
if (imported.length > 0 && !values.platform) {
|
|
1021
|
+
console.log('');
|
|
1022
|
+
console.log('next steps:');
|
|
1023
|
+
for (const s of imported) {
|
|
1024
|
+
console.log(` imprint install ${s.name} # register MCP server`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (values.platform) {
|
|
1029
|
+
const { install } = await import('./imprint/install.ts');
|
|
1030
|
+
const { PLATFORMS } = await import('./imprint/integrations.ts');
|
|
1031
|
+
for (const s of imported) {
|
|
1032
|
+
const installResult = await install({
|
|
1033
|
+
site: s.name,
|
|
1034
|
+
platform: values.platform as (typeof PLATFORMS)[number],
|
|
1035
|
+
noInteractive: true,
|
|
1036
|
+
});
|
|
1037
|
+
console.log(`[imprint] ${installResult.message}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return 0;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
899
1044
|
case 'login': {
|
|
900
1045
|
const site = requirePositional(argv, 'login', 'a <site> argument');
|
|
901
1046
|
if (site === null) return 2;
|
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -427,7 +428,7 @@ function fixIssue(issue: McpIssue, status: McpStatus, ctx: MaintenanceContext):
|
|
|
427
428
|
};
|
|
428
429
|
}
|
|
429
430
|
|
|
430
|
-
if (
|
|
431
|
+
if (issue.kind === 'missing-session' && issue.workflow) {
|
|
431
432
|
return pruneSingleTeachWorkflow(issue.site, issue.workflow);
|
|
432
433
|
}
|
|
433
434
|
|
|
@@ -688,8 +689,6 @@ function issueFixHint(issue: McpIssue): string | null {
|
|
|
688
689
|
switch (issue.kind) {
|
|
689
690
|
case 'stale-registration':
|
|
690
691
|
return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
|
|
691
|
-
case 'incomplete':
|
|
692
|
-
return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --incomplete --yes`;
|
|
693
692
|
case 'missing-session':
|
|
694
693
|
return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
|
|
695
694
|
}
|
|
@@ -737,6 +736,9 @@ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
|
|
|
737
736
|
}
|
|
738
737
|
|
|
739
738
|
const state = loadTeachState(site);
|
|
739
|
+
if (pruneStalePendingTeachWorkflows(site, state)) {
|
|
740
|
+
saveTeachState(site, state);
|
|
741
|
+
}
|
|
740
742
|
const workflows = Object.entries(state.workflows)
|
|
741
743
|
.map(([name, ws]) => workflowStatus(site, name, ws, tools))
|
|
742
744
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -803,14 +805,6 @@ function collectIssues(opts: {
|
|
|
803
805
|
path: wf.sessionPath ?? undefined,
|
|
804
806
|
});
|
|
805
807
|
}
|
|
806
|
-
if (wf.incomplete) {
|
|
807
|
-
issues.push({
|
|
808
|
-
kind: 'incomplete',
|
|
809
|
-
site: site.site,
|
|
810
|
-
workflow: wf.name,
|
|
811
|
-
message: `${site.site}/${wf.name} is incomplete (${wf.completedSteps.join(', ') || 'no completed steps'})`,
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
808
|
}
|
|
815
809
|
}
|
|
816
810
|
|
|
@@ -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);
|
package/src/imprint/teach.ts
CHANGED
|
@@ -82,7 +82,9 @@ import {
|
|
|
82
82
|
isExistingTeachFile as isExistingFile,
|
|
83
83
|
loadTeachState,
|
|
84
84
|
nextTeachStep as nextStep,
|
|
85
|
+
pruneStalePendingTeachWorkflows,
|
|
85
86
|
resolveTeachStatePath,
|
|
87
|
+
resolveWorkflowTriagedPath,
|
|
86
88
|
saveTeachState,
|
|
87
89
|
toRelativeTeachStatePath as toRelative,
|
|
88
90
|
} from './teach-state.ts';
|
|
@@ -98,7 +100,11 @@ import { setSpanAttributes, traced } from './tracing.ts';
|
|
|
98
100
|
import { CronConfigSchema, SessionSchema, WorkflowSchema } from './types.ts';
|
|
99
101
|
import type { CronConfig, Playbook, Session, Workflow } from './types.ts';
|
|
100
102
|
|
|
101
|
-
export {
|
|
103
|
+
export {
|
|
104
|
+
buildTeachStateFromSession,
|
|
105
|
+
resolveTeachStatePath,
|
|
106
|
+
resolveWorkflowTriagedPath,
|
|
107
|
+
} from './teach-state.ts';
|
|
102
108
|
|
|
103
109
|
/**
|
|
104
110
|
* How many compile agents run in parallel when more than one tool is selected.
|
|
@@ -396,6 +402,9 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
396
402
|
|
|
397
403
|
const completedWorkflows = discoverCompletedWorkflows(site);
|
|
398
404
|
const completedSet = new Set(completedWorkflows);
|
|
405
|
+
if (pruneStalePendingTeachWorkflows(site, state)) {
|
|
406
|
+
saveTeachState(site, state);
|
|
407
|
+
}
|
|
399
408
|
const incompleteWorkflows = Object.entries(state.workflows).filter(
|
|
400
409
|
([name]) => !completedSet.has(name),
|
|
401
410
|
);
|
|
@@ -725,7 +734,6 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
725
734
|
// Run them in parallel so the user can select tools while replay runs.
|
|
726
735
|
let siteClassifications: ClassifiedValue[] | undefined;
|
|
727
736
|
let triageResult: TriageResult | undefined;
|
|
728
|
-
let triagedPath: string | null = null;
|
|
729
737
|
let plans: CandidateCompilePlan[];
|
|
730
738
|
|
|
731
739
|
let needsReplay = startIdx <= STEPS.indexOf('replay-and-diff') && !opts.skipReplay;
|
|
@@ -783,7 +791,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
783
791
|
|
|
784
792
|
// Branch B: triage → detect-candidates → user selection (fast, ~30s)
|
|
785
793
|
type CandidateChainResult = {
|
|
786
|
-
|
|
794
|
+
triageResult?: TriageResult;
|
|
787
795
|
plans: CandidateCompilePlan[];
|
|
788
796
|
};
|
|
789
797
|
const candidatePromise = (async (): Promise<CandidateChainResult> => {
|
|
@@ -836,8 +844,11 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
836
844
|
);
|
|
837
845
|
} else {
|
|
838
846
|
const ws = state.workflows[workflowKey];
|
|
839
|
-
|
|
840
|
-
|
|
847
|
+
localTriagedPath = resolveWorkflowTriagedPath(site, ws);
|
|
848
|
+
if (ws && localTriagedPath && !ws.triagedPath) {
|
|
849
|
+
updateCheckpoint(site, state, workflowKey, 'triage', {
|
|
850
|
+
triagedPath: toRelative(site, localTriagedPath),
|
|
851
|
+
});
|
|
841
852
|
}
|
|
842
853
|
}
|
|
843
854
|
|
|
@@ -875,6 +886,9 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
875
886
|
kind: 'raw',
|
|
876
887
|
});
|
|
877
888
|
const baseState = buildTeachStateFromSession(site, rawSessionPath, redactedPath);
|
|
889
|
+
if (localTriagedPath) {
|
|
890
|
+
baseState.triagedPath = toRelative(site, localTriagedPath);
|
|
891
|
+
}
|
|
878
892
|
const candidatePlans = selected.map((candidate) => {
|
|
879
893
|
checkpoint(site, state, candidate.toolName, {
|
|
880
894
|
...baseState,
|
|
@@ -896,9 +910,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
896
910
|
}
|
|
897
911
|
|
|
898
912
|
return {
|
|
899
|
-
|
|
900
|
-
? { result: localTriageResult, sessionPath: replaySessionPath }
|
|
901
|
-
: undefined,
|
|
913
|
+
triageResult: localTriageResult,
|
|
902
914
|
plans: candidatePlans,
|
|
903
915
|
};
|
|
904
916
|
})();
|
|
@@ -907,13 +919,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
907
919
|
const candidateResult = await candidatePromise;
|
|
908
920
|
plans = candidateResult.plans;
|
|
909
921
|
|
|
910
|
-
|
|
911
|
-
triageResult = candidateResult.triageRes.result;
|
|
912
|
-
triagedPath = candidateResult.triageRes.sessionPath.replace(
|
|
913
|
-
/\.redacted\.json$/,
|
|
914
|
-
'.triaged.json',
|
|
915
|
-
);
|
|
916
|
-
}
|
|
922
|
+
triageResult = candidateResult.triageResult;
|
|
917
923
|
|
|
918
924
|
// Wait for replay — may already be done, or show progress while waiting
|
|
919
925
|
let replaySettled = false;
|
|
@@ -938,18 +944,19 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
938
944
|
mp.clear();
|
|
939
945
|
|
|
940
946
|
// Checkpoints — write sequentially after both complete
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
947
|
+
updateCandidateStageCheckpoints({
|
|
948
|
+
site,
|
|
949
|
+
state,
|
|
950
|
+
plans,
|
|
951
|
+
fallbackWorkflowKey: workflowKey,
|
|
952
|
+
replay: needsReplay
|
|
953
|
+
? {
|
|
954
|
+
classificationsPath: siteClassifications
|
|
955
|
+
? toRelative(site, pathJoin(localSiteDir(site), '.classifications.json'))
|
|
956
|
+
: undefined,
|
|
957
|
+
}
|
|
958
|
+
: undefined,
|
|
959
|
+
});
|
|
953
960
|
} finally {
|
|
954
961
|
unmuteLog();
|
|
955
962
|
}
|
|
@@ -964,8 +971,11 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
|
|
|
964
971
|
}
|
|
965
972
|
}
|
|
966
973
|
const ws = state.workflows[workflowKey];
|
|
967
|
-
|
|
968
|
-
|
|
974
|
+
const resolvedTriagedPath = resolveWorkflowTriagedPath(site, ws);
|
|
975
|
+
if (ws && resolvedTriagedPath && !ws.triagedPath) {
|
|
976
|
+
updateCheckpoint(site, state, workflowKey, 'triage', {
|
|
977
|
+
triagedPath: toRelative(site, resolvedTriagedPath),
|
|
978
|
+
});
|
|
969
979
|
}
|
|
970
980
|
plans = [
|
|
971
981
|
{
|
|
@@ -1251,6 +1261,29 @@ export interface CandidateCompilePlan {
|
|
|
1251
1261
|
sharedContext?: SharedCompileContext;
|
|
1252
1262
|
}
|
|
1253
1263
|
|
|
1264
|
+
function candidateStageCheckpointKeys(
|
|
1265
|
+
plans: CandidateCompilePlan[],
|
|
1266
|
+
fallbackWorkflowKey: string,
|
|
1267
|
+
): string[] {
|
|
1268
|
+
const keys = plans.map((plan) => plan.workflowKey).filter((key) => key.length > 0);
|
|
1269
|
+
return [...new Set(keys.length > 0 ? keys : [fallbackWorkflowKey])];
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export function updateCandidateStageCheckpoints(opts: {
|
|
1273
|
+
site: string;
|
|
1274
|
+
state: TeachState;
|
|
1275
|
+
plans: CandidateCompilePlan[];
|
|
1276
|
+
fallbackWorkflowKey: string;
|
|
1277
|
+
replay?: Partial<WorkflowState>;
|
|
1278
|
+
triage?: Partial<WorkflowState>;
|
|
1279
|
+
}): void {
|
|
1280
|
+
const keys = candidateStageCheckpointKeys(opts.plans, opts.fallbackWorkflowKey);
|
|
1281
|
+
for (const key of keys) {
|
|
1282
|
+
if (opts.replay) updateCheckpoint(opts.site, opts.state, key, 'replay-and-diff', opts.replay);
|
|
1283
|
+
if (opts.triage) updateCheckpoint(opts.site, opts.state, key, 'triage', opts.triage);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1254
1287
|
async function detectTeachCandidates(opts: {
|
|
1255
1288
|
sessionPath: string;
|
|
1256
1289
|
providerName: ProviderName;
|