imprint-mcp 0.2.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/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-session merge for `imprint teach`.
|
|
3
|
+
*
|
|
4
|
+
* When a user records a new session, they can combine it with past recordings
|
|
5
|
+
* of the same site so triage and candidate detection see the full picture.
|
|
6
|
+
* The merge produces a single valid Session object that the rest of the
|
|
7
|
+
* pipeline consumes unchanged.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join as pathJoin } from 'node:path';
|
|
12
|
+
import { localSessionsDir } from './paths.ts';
|
|
13
|
+
import { friendlySessionTimestamp } from './teach-state.ts';
|
|
14
|
+
import { type Session, SessionSchema } from './types.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format an ISO timestamp string (e.g. "2026-05-24T09:00:00.000Z") into
|
|
18
|
+
* a human-readable form like "2026-05-24 09:00". Unlike friendlySessionTimestamp
|
|
19
|
+
* which expects the dashed filename format, this handles standard ISO colons.
|
|
20
|
+
*/
|
|
21
|
+
function friendlyIsoTimestamp(iso: string): string {
|
|
22
|
+
const m = iso.match(/(\d{4}-\d{2}-\d{2})T(\d{2})[:-](\d{2})/);
|
|
23
|
+
if (!m) return iso;
|
|
24
|
+
return `${m[1]} ${m[2]}:${m[3]}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SessionInfo {
|
|
28
|
+
absPath: string;
|
|
29
|
+
filename: string;
|
|
30
|
+
friendlyTimestamp: string;
|
|
31
|
+
requestCount: number;
|
|
32
|
+
narrationCount: number;
|
|
33
|
+
url: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listSiteSessions(site: string): SessionInfo[] {
|
|
37
|
+
const sessDir = localSessionsDir(site);
|
|
38
|
+
if (!existsSync(sessDir)) return [];
|
|
39
|
+
|
|
40
|
+
const files = readdirSync(sessDir).filter(
|
|
41
|
+
(f) =>
|
|
42
|
+
f.endsWith('.json') &&
|
|
43
|
+
!f.includes('.redacted') &&
|
|
44
|
+
!f.includes('.triaged') &&
|
|
45
|
+
!f.startsWith('combined-'),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const infos: SessionInfo[] = [];
|
|
49
|
+
for (const filename of files) {
|
|
50
|
+
const absPath = pathJoin(sessDir, filename);
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(readFileSync(absPath, 'utf8'));
|
|
53
|
+
const session = SessionSchema.parse(raw);
|
|
54
|
+
infos.push({
|
|
55
|
+
absPath,
|
|
56
|
+
filename,
|
|
57
|
+
friendlyTimestamp: friendlySessionTimestamp(filename),
|
|
58
|
+
requestCount: session.requests.length,
|
|
59
|
+
narrationCount: session.narration.length,
|
|
60
|
+
url: session.url,
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// Skip malformed sessions
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
infos.sort((a, b) => b.filename.localeCompare(a.filename));
|
|
68
|
+
return infos;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface TaggedItem {
|
|
72
|
+
kind: 'request' | 'event' | 'narration';
|
|
73
|
+
absoluteTimestamp: number;
|
|
74
|
+
// biome-ignore lint/suspicious/noExplicitAny: union of different shapes
|
|
75
|
+
item: any;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function mergeSessions(sessions: Session[]): Session {
|
|
79
|
+
if (sessions.length === 0) {
|
|
80
|
+
throw new Error('mergeSessions requires at least one session');
|
|
81
|
+
}
|
|
82
|
+
if (sessions.length === 1) {
|
|
83
|
+
const only = sessions[0] as Session;
|
|
84
|
+
return { ...only };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Sort sessions chronologically by startedAt
|
|
88
|
+
const sorted = [...sessions].sort(
|
|
89
|
+
(a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime(),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const earliest = sorted[0] as Session;
|
|
93
|
+
const latest = sorted[sorted.length - 1] as Session;
|
|
94
|
+
|
|
95
|
+
const allItems: TaggedItem[] = [];
|
|
96
|
+
|
|
97
|
+
for (const session of sorted) {
|
|
98
|
+
const baseMs = new Date(session.startedAt).getTime();
|
|
99
|
+
|
|
100
|
+
// Synthetic boundary narration
|
|
101
|
+
allItems.push({
|
|
102
|
+
kind: 'narration',
|
|
103
|
+
absoluteTimestamp: baseMs,
|
|
104
|
+
item: {
|
|
105
|
+
seq: -1, // placeholder, will be reassigned
|
|
106
|
+
timestamp: 0,
|
|
107
|
+
text: `[Recording from ${friendlyIsoTimestamp(session.startedAt)}] ${session.url}`,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const request of session.requests) {
|
|
112
|
+
allItems.push({
|
|
113
|
+
kind: 'request',
|
|
114
|
+
absoluteTimestamp: baseMs + request.timestamp,
|
|
115
|
+
item: { ...request },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const event of session.events) {
|
|
120
|
+
allItems.push({
|
|
121
|
+
kind: 'event',
|
|
122
|
+
absoluteTimestamp: baseMs + event.timestamp,
|
|
123
|
+
item: { ...event },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const narration of session.narration) {
|
|
128
|
+
allItems.push({
|
|
129
|
+
kind: 'narration',
|
|
130
|
+
absoluteTimestamp: baseMs + narration.timestamp,
|
|
131
|
+
item: { ...narration },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Sort by absolute timestamp, then by kind for stable ordering
|
|
137
|
+
const kindOrder = { narration: 0, event: 1, request: 2 };
|
|
138
|
+
allItems.sort(
|
|
139
|
+
(a, b) => a.absoluteTimestamp - b.absoluteTimestamp || kindOrder[a.kind] - kindOrder[b.kind],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Reassign seq numbers monotonically
|
|
143
|
+
const earliestMs = new Date(earliest.startedAt).getTime();
|
|
144
|
+
const requests: Session['requests'] = [];
|
|
145
|
+
const events: Session['events'] = [];
|
|
146
|
+
const narration: Session['narration'] = [];
|
|
147
|
+
|
|
148
|
+
for (let seq = 0; seq < allItems.length; seq++) {
|
|
149
|
+
const tagged = allItems[seq] as TaggedItem;
|
|
150
|
+
const relativeTimestamp = tagged.absoluteTimestamp - earliestMs;
|
|
151
|
+
|
|
152
|
+
if (tagged.kind === 'request') {
|
|
153
|
+
requests.push({ ...tagged.item, seq, timestamp: relativeTimestamp });
|
|
154
|
+
} else if (tagged.kind === 'event') {
|
|
155
|
+
events.push({ ...tagged.item, seq, timestamp: relativeTimestamp });
|
|
156
|
+
} else {
|
|
157
|
+
narration.push({ ...tagged.item, seq, timestamp: relativeTimestamp });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Merge cookie and storage snapshots
|
|
162
|
+
const cookieSnapshots = sorted.flatMap((s) => {
|
|
163
|
+
const baseMs = new Date(s.startedAt).getTime();
|
|
164
|
+
return s.cookieSnapshots.map((cs) => ({
|
|
165
|
+
...cs,
|
|
166
|
+
timestamp: cs.timestamp + (baseMs - earliestMs),
|
|
167
|
+
}));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const storageSnapshots = sorted.flatMap((s) => {
|
|
171
|
+
const baseMs = new Date(s.startedAt).getTime();
|
|
172
|
+
return s.storageSnapshots.map((ss) => ({
|
|
173
|
+
...ss,
|
|
174
|
+
timestamp: ss.timestamp + (baseMs - earliestMs),
|
|
175
|
+
}));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
site: earliest.site,
|
|
180
|
+
startedAt: earliest.startedAt,
|
|
181
|
+
url: latest.url,
|
|
182
|
+
imprintVersion: latest.imprintVersion,
|
|
183
|
+
requests,
|
|
184
|
+
events,
|
|
185
|
+
narration,
|
|
186
|
+
cookieSnapshots,
|
|
187
|
+
storageSnapshots,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function writeCombinedSession(site: string, combined: Session): string {
|
|
192
|
+
const sessDir = localSessionsDir(site);
|
|
193
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
194
|
+
const filename = `combined-${timestamp}.json`;
|
|
195
|
+
const absPath = pathJoin(sessDir, filename);
|
|
196
|
+
writeFileSync(absPath, `${JSON.stringify(combined, null, 2)}\n`, 'utf8');
|
|
197
|
+
return absPath;
|
|
198
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** JSONL streaming writer (crash-safe) + sidecar Session JSON on close. */
|
|
2
|
+
|
|
3
|
+
import { type WriteStream, createWriteStream, mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
type CapturedEvent,
|
|
7
|
+
type CapturedRequest,
|
|
8
|
+
type CookieSnapshot,
|
|
9
|
+
type Narration,
|
|
10
|
+
type Session,
|
|
11
|
+
SessionSchema,
|
|
12
|
+
type StorageSnapshot,
|
|
13
|
+
} from './types.ts';
|
|
14
|
+
|
|
15
|
+
type Record =
|
|
16
|
+
| { kind: 'request'; data: CapturedRequest }
|
|
17
|
+
| { kind: 'event'; data: CapturedEvent }
|
|
18
|
+
| { kind: 'narration'; data: Narration }
|
|
19
|
+
| { kind: 'request-body'; data: { seq: number; body: string } }
|
|
20
|
+
| { kind: 'cookies'; data: CookieSnapshot }
|
|
21
|
+
| { kind: 'storage'; data: StorageSnapshot };
|
|
22
|
+
|
|
23
|
+
interface SessionWriter {
|
|
24
|
+
request(req: CapturedRequest): void;
|
|
25
|
+
/** Late-arriving response body for a request already written. Merged on assemble. */
|
|
26
|
+
requestBody(seq: number, body: string): void;
|
|
27
|
+
event(ev: CapturedEvent): void;
|
|
28
|
+
narration(n: Narration): void;
|
|
29
|
+
cookies(snapshot: CookieSnapshot): void;
|
|
30
|
+
storage(snapshot: StorageSnapshot): void;
|
|
31
|
+
/** Flush + close the JSONL stream and write the assembled Session object. */
|
|
32
|
+
close(): Promise<{ jsonlPath: string; sessionPath: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface SessionMeta {
|
|
36
|
+
site: string;
|
|
37
|
+
url: string;
|
|
38
|
+
imprintVersion: string;
|
|
39
|
+
startedAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createSessionWriter(jsonlPath: string, meta: SessionMeta): SessionWriter {
|
|
43
|
+
mkdirSync(dirname(jsonlPath), { recursive: true });
|
|
44
|
+
const stream: WriteStream = createWriteStream(jsonlPath, { flags: 'w', encoding: 'utf8' });
|
|
45
|
+
|
|
46
|
+
// First line: meta header so a partial JSONL still rehydrates.
|
|
47
|
+
stream.write(`${JSON.stringify({ kind: 'meta', data: meta })}\n`);
|
|
48
|
+
|
|
49
|
+
let closed = false;
|
|
50
|
+
|
|
51
|
+
const writeLine = (rec: Record): void => {
|
|
52
|
+
if (closed) return;
|
|
53
|
+
stream.write(`${JSON.stringify(rec)}\n`);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
request: (data) => writeLine({ kind: 'request', data }),
|
|
58
|
+
requestBody: (seq, body) => writeLine({ kind: 'request-body', data: { seq, body } }),
|
|
59
|
+
event: (data) => writeLine({ kind: 'event', data }),
|
|
60
|
+
narration: (data) => writeLine({ kind: 'narration', data }),
|
|
61
|
+
cookies: (data) => writeLine({ kind: 'cookies', data }),
|
|
62
|
+
storage: (data) => writeLine({ kind: 'storage', data }),
|
|
63
|
+
async close() {
|
|
64
|
+
if (closed) return { jsonlPath, sessionPath: jsonlPath.replace(/\.jsonl$/, '.json') };
|
|
65
|
+
closed = true;
|
|
66
|
+
await new Promise<void>((resolve, reject) => {
|
|
67
|
+
stream.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const session = assembleFromJsonl(jsonlPath);
|
|
71
|
+
const sessionPath = jsonlPath.replace(/\.jsonl$/, '.json');
|
|
72
|
+
const fs = await import('node:fs/promises');
|
|
73
|
+
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`, 'utf8');
|
|
74
|
+
return { jsonlPath, sessionPath };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Rehydrate a JSONL recording into a Session object. */
|
|
80
|
+
export function assembleFromJsonl(jsonlPath: string): Session {
|
|
81
|
+
const text = readFileSync(jsonlPath, 'utf8');
|
|
82
|
+
const lines = text.split('\n').filter((line) => line.trim().length > 0);
|
|
83
|
+
|
|
84
|
+
let meta: SessionMeta | null = null;
|
|
85
|
+
const requests: CapturedRequest[] = [];
|
|
86
|
+
const events: CapturedEvent[] = [];
|
|
87
|
+
const narration: Narration[] = [];
|
|
88
|
+
const cookieSnapshots: CookieSnapshot[] = [];
|
|
89
|
+
const storageSnapshots: StorageSnapshot[] = [];
|
|
90
|
+
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
const rec = JSON.parse(line) as
|
|
93
|
+
| { kind: 'meta'; data: SessionMeta }
|
|
94
|
+
| { kind: 'request'; data: CapturedRequest }
|
|
95
|
+
| { kind: 'request-body'; data: { seq: number; body: string } }
|
|
96
|
+
| { kind: 'event'; data: CapturedEvent }
|
|
97
|
+
| { kind: 'narration'; data: Narration }
|
|
98
|
+
| { kind: 'cookies'; data: CookieSnapshot }
|
|
99
|
+
| { kind: 'storage'; data: StorageSnapshot };
|
|
100
|
+
|
|
101
|
+
switch (rec.kind) {
|
|
102
|
+
case 'meta':
|
|
103
|
+
meta = rec.data;
|
|
104
|
+
break;
|
|
105
|
+
case 'request':
|
|
106
|
+
requests.push(rec.data);
|
|
107
|
+
break;
|
|
108
|
+
case 'request-body': {
|
|
109
|
+
const target = requests.find((r) => r.seq === rec.data.seq);
|
|
110
|
+
if (target?.response) {
|
|
111
|
+
target.response = { ...target.response, body: rec.data.body };
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case 'event':
|
|
116
|
+
events.push(rec.data);
|
|
117
|
+
break;
|
|
118
|
+
case 'narration':
|
|
119
|
+
narration.push(rec.data);
|
|
120
|
+
break;
|
|
121
|
+
case 'cookies':
|
|
122
|
+
cookieSnapshots.push(rec.data);
|
|
123
|
+
break;
|
|
124
|
+
case 'storage':
|
|
125
|
+
storageSnapshots.push(rec.data);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!meta) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Session JSONL ${jsonlPath} has no meta header — cannot rehydrate.\n→ this usually means recording was killed before the first event fired; re-record.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const session: Session = {
|
|
137
|
+
site: meta.site,
|
|
138
|
+
startedAt: meta.startedAt,
|
|
139
|
+
url: meta.url,
|
|
140
|
+
imprintVersion: meta.imprintVersion,
|
|
141
|
+
requests,
|
|
142
|
+
events,
|
|
143
|
+
narration,
|
|
144
|
+
cookieSnapshots,
|
|
145
|
+
storageSnapshots,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return SessionSchema.parse(session); // fail loud if malformed
|
|
149
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Site-discovery helpers shared by verbs that take a <site> arg.
|
|
2
|
+
* When a verb gets a site name it doesn't recognize, list what's
|
|
3
|
+
* actually under the generated asset root so the user can spot a typo. */
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { resolve as pathResolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** List the configured sites under the asset root to suggest in error messages.
|
|
9
|
+
* Returns a single line starting with "→" for inclusion in a multi-line
|
|
10
|
+
* Error message. Always returns *something* (so callers can concat
|
|
11
|
+
* unconditionally). */
|
|
12
|
+
export function availableSitesHint(assetRoot: string, badSite: string): string {
|
|
13
|
+
if (!existsSync(assetRoot)) {
|
|
14
|
+
return `→ generated asset root doesn't exist at ${assetRoot} — run \`imprint teach <site>\` or \`imprint emit <workflow.json>\` to create a generated tool.`;
|
|
15
|
+
}
|
|
16
|
+
const sites = readdirSync(assetRoot).filter((d) => {
|
|
17
|
+
try {
|
|
18
|
+
return statSync(pathResolve(assetRoot, d)).isDirectory();
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
if (sites.length === 0) {
|
|
24
|
+
return `→ generated asset root is empty at ${assetRoot} — run \`imprint teach <site>\` or \`imprint emit <workflow.json>\` to create a generated tool.`;
|
|
25
|
+
}
|
|
26
|
+
return `→ available sites: ${sites.join(', ')} (you asked for "${badSite}").`;
|
|
27
|
+
}
|