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,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `imprint cron <site>` — polling daemon for a generated tool. Loads
|
|
3
|
+
* <IMPRINT_HOME>/<site>/<toolName>/cron.json, schedules via node-cron, runs the tool
|
|
4
|
+
* through the configured backend ladder per tick, and pushes via
|
|
5
|
+
* notify.ts on failure (or on a notifyWhen predicate match).
|
|
6
|
+
*
|
|
7
|
+
* One process per schedule by design — matches how systemd timers /
|
|
8
|
+
* launchd are organized and keeps failure isolation clean.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { resolve as pathResolve } from 'node:path';
|
|
13
|
+
import cron from 'node-cron';
|
|
14
|
+
import { resolveLadder, runWithLadder } from './backend-ladder.ts';
|
|
15
|
+
import { loadJsonFile } from './load-json.ts';
|
|
16
|
+
import { createLog, isDebug } from './log.ts';
|
|
17
|
+
import { evaluateNotifyWhen, notify } from './notify.ts';
|
|
18
|
+
import { imprintHomeDir } from './paths.ts';
|
|
19
|
+
import { loadBackendsCache } from './probe-backends.ts';
|
|
20
|
+
import { checkSiteCredentialsReady } from './runtime.ts';
|
|
21
|
+
import { availableSitesHint } from './sites.ts';
|
|
22
|
+
import type { StealthFetch } from './stealth-fetch.ts';
|
|
23
|
+
import { type ResolvedTool, buildZodValidator, discoverTools } from './tool-loader.ts';
|
|
24
|
+
import { selectGeneratedTool } from './tool-selection.ts';
|
|
25
|
+
import {
|
|
26
|
+
type ConcreteBackend,
|
|
27
|
+
type CronConfig,
|
|
28
|
+
CronConfigSchema,
|
|
29
|
+
type NotifyWhen,
|
|
30
|
+
type ToolResult,
|
|
31
|
+
} from './types.ts';
|
|
32
|
+
|
|
33
|
+
interface RunCronOptions {
|
|
34
|
+
site: string;
|
|
35
|
+
/** Override generated asset root. Defaults to IMPRINT_HOME (~/.imprint). */
|
|
36
|
+
assetRoot?: string;
|
|
37
|
+
/** Override config path. Defaults to <assetRoot>/<site>/<toolName>/cron.json. */
|
|
38
|
+
configPath?: string;
|
|
39
|
+
/** Select a specific generated tool when a site has more than one. */
|
|
40
|
+
toolName?: string;
|
|
41
|
+
/** Run a single tick and exit. Mutually exclusive with runNow. */
|
|
42
|
+
once?: boolean;
|
|
43
|
+
/** Run immediately on startup AND continue scheduling. */
|
|
44
|
+
runNow?: boolean;
|
|
45
|
+
/** Suppress info logs on success — failures still go to stderr.
|
|
46
|
+
* Implementation note: temporarily sets IMPRINT_QUIET=1 for the
|
|
47
|
+
* lifetime of this call (restored on exit) so other code in the
|
|
48
|
+
* same process isn't affected. */
|
|
49
|
+
quiet?: boolean;
|
|
50
|
+
/** Inject for tests; defaults to global fetch. Used by Pushover/ntfy notifications. */
|
|
51
|
+
notifyFetchImpl?: typeof fetch;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const log = createLog('cron');
|
|
55
|
+
|
|
56
|
+
function loadCronConfig(configPath: string): CronConfig {
|
|
57
|
+
return loadJsonFile(
|
|
58
|
+
configPath,
|
|
59
|
+
CronConfigSchema,
|
|
60
|
+
{
|
|
61
|
+
notFound:
|
|
62
|
+
'→ create one with: {"schedule":"0 9 * * *","params":{},"replayBackend":"auto"}\n→ see docs/getting-started.md for full schema.',
|
|
63
|
+
notJson: '→ check for a stray comma or unquoted key.',
|
|
64
|
+
badSchema:
|
|
65
|
+
'→ minimum required: {"schedule":"0 9 * * *","params":{}}\n→ full schema: docs/getting-started.md (look for "Schedule it").',
|
|
66
|
+
},
|
|
67
|
+
'cron.json',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One tool tick: walk the ladder, log, push notification on result. */
|
|
72
|
+
async function runOnce(
|
|
73
|
+
tool: ResolvedTool,
|
|
74
|
+
params: Record<string, string | number | boolean>,
|
|
75
|
+
notifyFetchImpl: typeof fetch | undefined,
|
|
76
|
+
notifyWhen: NotifyWhen | undefined,
|
|
77
|
+
ladder: ConcreteBackend[],
|
|
78
|
+
assetRoot: string,
|
|
79
|
+
stealthCache: Map<string, StealthFetch>,
|
|
80
|
+
): Promise<ToolResult> {
|
|
81
|
+
const startedAt = new Date();
|
|
82
|
+
log(
|
|
83
|
+
`${startedAt.toISOString()} ${tool.workflow.toolName} starting (ladder: ${ladder.join(' → ')})`,
|
|
84
|
+
);
|
|
85
|
+
const t0 = Date.now();
|
|
86
|
+
|
|
87
|
+
const { result, usedBackend, attempts } = await runWithLadder(
|
|
88
|
+
ladder,
|
|
89
|
+
tool,
|
|
90
|
+
params,
|
|
91
|
+
assetRoot,
|
|
92
|
+
stealthCache,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const elapsed = Date.now() - t0;
|
|
96
|
+
for (const a of attempts) {
|
|
97
|
+
if (a.outcome === 'escalate') log(` ${a.backend} → ${a.detail} (escalating)`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (result.ok) {
|
|
101
|
+
const data = typeof result.data === 'string' ? result.data : JSON.stringify(result.data);
|
|
102
|
+
// Cap the inline preview at ~500 chars; full payload available via
|
|
103
|
+
// IMPRINT_DEBUG=1. Long-running daemons flood stderr otherwise.
|
|
104
|
+
const preview =
|
|
105
|
+
isDebug() || data.length <= 500
|
|
106
|
+
? data
|
|
107
|
+
: `${data.slice(0, 500)}…(${data.length - 500} more chars; set IMPRINT_DEBUG=1 to log full payload)`;
|
|
108
|
+
log(` OK in ${elapsed}ms via ${usedBackend}: ${preview}`);
|
|
109
|
+
if (notifyWhen) {
|
|
110
|
+
try {
|
|
111
|
+
const decision = evaluateNotifyWhen(notifyWhen, result.data, tool.workflow.toolName);
|
|
112
|
+
if (decision.notify) {
|
|
113
|
+
log(` notifyWhen ${notifyWhen.type}: matched → pushing`);
|
|
114
|
+
await notify(
|
|
115
|
+
decision.title ?? `imprint: ${tool.workflow.toolName}`,
|
|
116
|
+
decision.message ?? '(no message)',
|
|
117
|
+
notifyFetchImpl,
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
// Silent no-match used to confuse users ("did the predicate
|
|
121
|
+
// even fire?"). Surface a one-liner so they can confirm.
|
|
122
|
+
log(` notifyWhen ${notifyWhen.type}: no match (predicate ran, threshold not crossed)`);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
126
|
+
log(` notifyWhen evaluation failed: ${msg}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// Failures must surface even in --quiet mode — that's the whole point
|
|
131
|
+
// (cron runs silently on success, mails on failure). Bypass createLog's
|
|
132
|
+
// quiet-aware path and write directly to stderr.
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
`[imprint cron] FAILED [${result.error}] via ${usedBackend} in ${elapsed}ms: ${result.message}\n`,
|
|
135
|
+
);
|
|
136
|
+
if (result.error === 'STATE_MISSING' && result.missing?.length) {
|
|
137
|
+
for (const item of result.missing) {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`[imprint cron] - ${item.name}: ${item.failure} (${item.capability})${item.message ? ` — ${item.message}` : ''}\n`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (result.remediation) {
|
|
144
|
+
process.stderr.write(`[imprint cron] → ${result.remediation}\n`);
|
|
145
|
+
}
|
|
146
|
+
await notify(
|
|
147
|
+
`imprint: ${tool.workflow.toolName} failed`,
|
|
148
|
+
`[${result.error}] ${result.message}${result.remediation ? `\n→ ${result.remediation}` : ''}`,
|
|
149
|
+
notifyFetchImpl,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function runCron(opts: RunCronOptions): Promise<void> {
|
|
156
|
+
if (opts.once && opts.runNow) {
|
|
157
|
+
throw new Error('cannot combine --once with --run-now (use one or the other)');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Scope the IMPRINT_QUIET env mutation to this call only — restore on
|
|
161
|
+
// exit so other code in the same process (e.g. an in-process MCP server,
|
|
162
|
+
// or test harnesses) isn't silenced by a leaked env var.
|
|
163
|
+
const prevQuiet = process.env.IMPRINT_QUIET;
|
|
164
|
+
if (opts.quiet) process.env.IMPRINT_QUIET = '1';
|
|
165
|
+
try {
|
|
166
|
+
return await runCronImpl(opts);
|
|
167
|
+
} finally {
|
|
168
|
+
if (opts.quiet) {
|
|
169
|
+
if (prevQuiet === undefined) {
|
|
170
|
+
// biome-ignore lint/performance/noDelete: env restoration needs real deletion
|
|
171
|
+
delete process.env.IMPRINT_QUIET;
|
|
172
|
+
} else {
|
|
173
|
+
process.env.IMPRINT_QUIET = prevQuiet;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function runCronImpl(opts: RunCronOptions): Promise<void> {
|
|
180
|
+
const assetRoot = opts.assetRoot ?? imprintHomeDir();
|
|
181
|
+
// Discover tool first so we know the workflow directory.
|
|
182
|
+
const discovered = await discoverTools(assetRoot, opts.site, '[imprint cron]');
|
|
183
|
+
const tool = selectGeneratedTool({
|
|
184
|
+
site: opts.site,
|
|
185
|
+
tools: discovered,
|
|
186
|
+
purpose: 'cron',
|
|
187
|
+
toolName: opts.toolName,
|
|
188
|
+
pathHint: opts.configPath,
|
|
189
|
+
pathHintLabel: '--config',
|
|
190
|
+
});
|
|
191
|
+
if (!tool) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`No generated tool found for site "${opts.site}".\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/${opts.site}/<toolName>/workflow.json\` first.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const configPath = opts.configPath ?? pathResolve(tool.dir, 'cron.json');
|
|
197
|
+
if (!existsSync(configPath)) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`cron.json not found at ${configPath}\n${availableSitesHint(assetRoot, opts.site)}\n→ create one with: {"schedule":"0 9 * * *","params":{},"replayBackend":"auto"}\n→ see docs/getting-started.md for full schema.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const config = loadCronConfig(configPath);
|
|
203
|
+
log(`config: ${configPath}`);
|
|
204
|
+
|
|
205
|
+
if (!cron.validate(config.schedule)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Invalid cron expression in ${configPath}: "${config.schedule}"\n→ format: "min hour dom month dow" (e.g., "0 9 * * *" = 9am daily)\n→ test expressions at https://crontab.guru`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const replayBackend = config.replayBackend ?? 'auto';
|
|
212
|
+
const playbookPath = pathResolve(tool.dir, 'playbook.yaml');
|
|
213
|
+
if (replayBackend === 'playbook' && !existsSync(playbookPath)) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`replayBackend="playbook" but ${playbookPath} doesn't exist. Run \`imprint compile-playbook\` first.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Pre-flight: cron runs unattended, so a missing credential at runtime
|
|
220
|
+
// means a silent failure (or a noisy failure mid-tick). Fail loud at
|
|
221
|
+
// startup with the exact set/import commands the user needs.
|
|
222
|
+
const credCheck = await checkSiteCredentialsReady(opts.site);
|
|
223
|
+
if (!credCheck.ok) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`cron cannot start for "${opts.site}" — credentials are missing.\n\n${credCheck.message}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Probe cache reorders the 'auto' ladder to start with the empirically
|
|
230
|
+
// cheapest known-working backend.
|
|
231
|
+
const cached = loadBackendsCache(opts.site, assetRoot, tool.dir);
|
|
232
|
+
if (cached) {
|
|
233
|
+
log(
|
|
234
|
+
`backends.json: probed ${cached.probedAt}, preferred order: ${cached.preferredOrder.join(' → ')}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Validate params against the API workflow only when API replay
|
|
239
|
+
// is in the ladder; playbook has its own param schema with different names.
|
|
240
|
+
const ladder = resolveLadder(replayBackend, cached?.preferredOrder);
|
|
241
|
+
let params: Record<string, string | number | boolean>;
|
|
242
|
+
if (
|
|
243
|
+
ladder.includes('fetch') ||
|
|
244
|
+
ladder.includes('fetch-bootstrap') ||
|
|
245
|
+
ladder.includes('stealth-fetch')
|
|
246
|
+
) {
|
|
247
|
+
const validator = buildZodValidator(tool.workflow.parameters);
|
|
248
|
+
const parsed = validator.safeParse(config.params);
|
|
249
|
+
if (!parsed.success) {
|
|
250
|
+
const issues = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ');
|
|
251
|
+
throw new Error(`cron.json params invalid for ${tool.workflow.toolName}: ${issues}`);
|
|
252
|
+
}
|
|
253
|
+
params = parsed.data;
|
|
254
|
+
} else {
|
|
255
|
+
params = config.params;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
log(`tool: ${tool.workflow.toolName} (${tool.workflow.parameters.length} param(s))`);
|
|
259
|
+
log(`schedule: ${config.schedule}`);
|
|
260
|
+
if (config.notifyWhen) log(`notifyWhen: ${config.notifyWhen.type}`);
|
|
261
|
+
log(
|
|
262
|
+
`replayBackend: ${replayBackend}${ladder.length > 1 ? ` (ladder: ${ladder.join(' → ')})` : ''}`,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Per-site stealth-fetch cache — bootstrap cost paid once per process.
|
|
266
|
+
const stealthCache = new Map<string, StealthFetch>();
|
|
267
|
+
|
|
268
|
+
const tickArgs = [
|
|
269
|
+
tool,
|
|
270
|
+
params,
|
|
271
|
+
opts.notifyFetchImpl,
|
|
272
|
+
config.notifyWhen,
|
|
273
|
+
ladder,
|
|
274
|
+
assetRoot,
|
|
275
|
+
stealthCache,
|
|
276
|
+
] as const;
|
|
277
|
+
|
|
278
|
+
if (opts.once) {
|
|
279
|
+
await runOnce(...tickArgs);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (opts.runNow) {
|
|
284
|
+
await runOnce(...tickArgs);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// node-cron's callbacks are sync; we kick off the async work and let it
|
|
288
|
+
// run, swallowing the promise locally (errors are already logged in
|
|
289
|
+
// runOnce). Two ticks could theoretically overlap if the workflow takes
|
|
290
|
+
// longer than the schedule period — fine for v0.1, callers picking
|
|
291
|
+
// sub-second cadences should handle their own concurrency.
|
|
292
|
+
const task = cron.schedule(config.schedule, () => {
|
|
293
|
+
void runOnce(...tickArgs);
|
|
294
|
+
});
|
|
295
|
+
task.start();
|
|
296
|
+
log('scheduled — Ctrl-C to stop');
|
|
297
|
+
|
|
298
|
+
await new Promise<void>((resolve) => {
|
|
299
|
+
const shutdown = (sig: NodeJS.Signals): void => {
|
|
300
|
+
log(`received ${sig}, stopping schedule`);
|
|
301
|
+
task.stop();
|
|
302
|
+
// Clean up StealthFetch instances (no-op currently, but future-
|
|
303
|
+
// proof for if we add long-lived browser support).
|
|
304
|
+
for (const sf of stealthCache.values()) {
|
|
305
|
+
void sf.close();
|
|
306
|
+
}
|
|
307
|
+
resolve();
|
|
308
|
+
};
|
|
309
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
310
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/** `imprint doctor` — check that the environment can actually run imprint.
|
|
2
|
+
* Reports pass/fail per prerequisite plus a one-line fix when failed. */
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join as pathJoin } from 'node:path';
|
|
7
|
+
import { findChromium } from './chromium.ts';
|
|
8
|
+
import { getProviderStatuses } from './llm.ts';
|
|
9
|
+
import { VERSION } from './version.ts';
|
|
10
|
+
|
|
11
|
+
export interface CheckResult {
|
|
12
|
+
name: string;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
detail: string;
|
|
15
|
+
fix?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function doctor(): CheckResult[] {
|
|
19
|
+
return [
|
|
20
|
+
checkBun(),
|
|
21
|
+
checkChromium(),
|
|
22
|
+
checkPlaywrightChromium(),
|
|
23
|
+
checkLLMProvider(),
|
|
24
|
+
checkPushOptional(),
|
|
25
|
+
checkClaudeCode(),
|
|
26
|
+
checkHermes(),
|
|
27
|
+
checkOpenClaw(),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkBun(): CheckResult {
|
|
32
|
+
const v = process.versions.bun;
|
|
33
|
+
if (!v) {
|
|
34
|
+
return {
|
|
35
|
+
name: 'Bun runtime',
|
|
36
|
+
ok: false,
|
|
37
|
+
detail: 'not detected (process.versions.bun is undefined)',
|
|
38
|
+
fix: 'install Bun ≥ 1.3 from https://bun.sh',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return { name: 'Bun runtime', ok: true, detail: `v${v}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkChromium(): CheckResult {
|
|
45
|
+
try {
|
|
46
|
+
const path = findChromium();
|
|
47
|
+
return { name: 'Chromium binary', ok: true, detail: path };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return {
|
|
50
|
+
name: 'Chromium binary',
|
|
51
|
+
ok: false,
|
|
52
|
+
detail: err instanceof Error ? (err.message.split('\n')[0] ?? '') : String(err),
|
|
53
|
+
fix: 'run: bunx playwright install chromium',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function checkPlaywrightChromium(): CheckResult {
|
|
59
|
+
// Playwright's bundled "Chrome for Testing" lives under ms-playwright/.
|
|
60
|
+
// findChromium() prefers it, so this is mostly a duplicate signal — but
|
|
61
|
+
// useful as a separate line so users see whether the Playwright path
|
|
62
|
+
// specifically is set up (matters for stealth-fetch + playbook backends).
|
|
63
|
+
const cacheRoots = [
|
|
64
|
+
pathJoin(homedir(), 'Library/Caches/ms-playwright'),
|
|
65
|
+
pathJoin(homedir(), '.cache/ms-playwright'),
|
|
66
|
+
];
|
|
67
|
+
for (const root of cacheRoots) {
|
|
68
|
+
if (!existsSync(root)) continue;
|
|
69
|
+
try {
|
|
70
|
+
const dirs = readdirSync(root).filter((d) => /^chromium-\d+$/.test(d));
|
|
71
|
+
if (dirs.length > 0) {
|
|
72
|
+
return {
|
|
73
|
+
name: 'Playwright Chromium',
|
|
74
|
+
ok: true,
|
|
75
|
+
detail: `${dirs.length} install(s) at ${root}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// ignore
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
name: 'Playwright Chromium',
|
|
84
|
+
ok: false,
|
|
85
|
+
detail: 'no chromium-* install under ~/Library/Caches/ms-playwright or ~/.cache/ms-playwright',
|
|
86
|
+
fix: 'run: bunx playwright install chromium (needed for stealth-fetch + playbook)',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkLLMProvider(): CheckResult {
|
|
91
|
+
const statuses = getProviderStatuses();
|
|
92
|
+
const detected = statuses.filter((s) => s.detected);
|
|
93
|
+
const teachCompatible = detected.filter((s) => s.availableForTeach);
|
|
94
|
+
|
|
95
|
+
if (teachCompatible.length > 0) {
|
|
96
|
+
const names = detected
|
|
97
|
+
.map((s) => `${s.name}${s.availableForTeach ? '' : ' (not teach-compatible)'}`)
|
|
98
|
+
.join(', ');
|
|
99
|
+
return { name: 'LLM provider', ok: true, detail: `detected: ${names}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (detected.length > 0) {
|
|
103
|
+
return {
|
|
104
|
+
name: 'LLM provider',
|
|
105
|
+
ok: false,
|
|
106
|
+
detail: `detected: ${detected.map((s) => s.name).join(', ')}; none are compatible with teach compile`,
|
|
107
|
+
fix: 'install Claude Code / Codex CLI, or set ANTHROPIC_API_KEY',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
name: 'LLM provider',
|
|
113
|
+
ok: false,
|
|
114
|
+
detail: 'no provider detected',
|
|
115
|
+
fix: 'install Claude Code / Codex / Cursor CLI, or set ANTHROPIC_API_KEY',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function checkPushOptional(): CheckResult {
|
|
120
|
+
const pushover = !!(process.env.PUSHOVER_TOKEN && process.env.PUSHOVER_USER);
|
|
121
|
+
const ntfy = !!process.env.NTFY_URL;
|
|
122
|
+
if (pushover || ntfy) {
|
|
123
|
+
const which = [pushover && 'Pushover', ntfy && 'ntfy'].filter(Boolean).join(' + ');
|
|
124
|
+
return { name: 'Push notifications', ok: true, detail: which };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
name: 'Push notifications',
|
|
128
|
+
ok: true, // optional — not a failure
|
|
129
|
+
detail: 'none configured (cron will only push to stderr)',
|
|
130
|
+
fix: 'set PUSHOVER_TOKEN+PUSHOVER_USER or NTFY_URL — see docs/notifications.md',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function checkClaudeCode(): CheckResult {
|
|
135
|
+
// Look for ~/.claude/settings.json
|
|
136
|
+
const configPath = pathJoin(homedir(), '.claude', 'settings.json');
|
|
137
|
+
if (!existsSync(configPath)) {
|
|
138
|
+
return {
|
|
139
|
+
name: 'Claude Code',
|
|
140
|
+
ok: true,
|
|
141
|
+
detail: 'not detected',
|
|
142
|
+
fix: 'install Claude Code, then run `imprint teach <site>` to connect',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Check if any imprint-* MCP servers are registered
|
|
146
|
+
try {
|
|
147
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
148
|
+
const servers = config?.mcpServers ?? {};
|
|
149
|
+
const imprintServers = Object.keys(servers).filter((k) => k.startsWith('imprint-'));
|
|
150
|
+
if (imprintServers.length > 0) {
|
|
151
|
+
return {
|
|
152
|
+
name: 'Claude Code',
|
|
153
|
+
ok: true,
|
|
154
|
+
detail: `${imprintServers.length} imprint tool(s): ${imprintServers.join(', ')}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
name: 'Claude Code',
|
|
159
|
+
ok: true,
|
|
160
|
+
detail: 'installed, no imprint tools registered',
|
|
161
|
+
fix: 'run `imprint teach <site>` to record a workflow and connect it',
|
|
162
|
+
};
|
|
163
|
+
} catch {
|
|
164
|
+
return {
|
|
165
|
+
name: 'Claude Code',
|
|
166
|
+
ok: true,
|
|
167
|
+
detail: 'installed (could not parse settings)',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function checkHermes(): CheckResult {
|
|
173
|
+
const configPath = pathJoin(homedir(), '.hermes', 'config.yaml');
|
|
174
|
+
if (!existsSync(configPath)) {
|
|
175
|
+
return {
|
|
176
|
+
name: 'Hermes Agent',
|
|
177
|
+
ok: true,
|
|
178
|
+
detail: 'not detected',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
name: 'Hermes Agent',
|
|
183
|
+
ok: true,
|
|
184
|
+
detail: `config at ${configPath}`,
|
|
185
|
+
fix: 'run `imprint teach <site>` and select Hermes to connect',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function checkOpenClaw(): CheckResult {
|
|
190
|
+
const configPath = pathJoin(homedir(), '.openclaw', 'openclaw.json');
|
|
191
|
+
if (!existsSync(configPath)) {
|
|
192
|
+
return {
|
|
193
|
+
name: 'OpenClaw',
|
|
194
|
+
ok: true,
|
|
195
|
+
detail: 'not detected',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
name: 'OpenClaw',
|
|
200
|
+
ok: true,
|
|
201
|
+
detail: `config at ${configPath}`,
|
|
202
|
+
fix: 'run `imprint teach <site>` and select OpenClaw to connect',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function reportDoctor(checks: CheckResult[]): { ok: boolean; lines: string[] } {
|
|
207
|
+
const lines: string[] = [`imprint v${VERSION} doctor`, ''];
|
|
208
|
+
let allOk = true;
|
|
209
|
+
for (const c of checks) {
|
|
210
|
+
const mark = c.ok ? '✓' : '✗';
|
|
211
|
+
lines.push(` ${mark} ${c.name.padEnd(22)} ${c.detail}`);
|
|
212
|
+
if (!c.ok) {
|
|
213
|
+
allOk = false;
|
|
214
|
+
if (c.fix) lines.push(` → ${c.fix}`);
|
|
215
|
+
} else if (c.fix) {
|
|
216
|
+
// Optional check that's not configured; advise but don't fail.
|
|
217
|
+
lines.push(` hint: ${c.fix}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
lines.push('');
|
|
221
|
+
lines.push(allOk ? 'All required checks passed.' : 'Some required checks failed — fix above.');
|
|
222
|
+
return { ok: allOk, lines };
|
|
223
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** `imprint emit` — generate <assetRoot>/<site>/<toolName>/index.ts: a thin wrapper
|
|
2
|
+
* around runtime.executeWorkflow with the workflow JSON embedded inline. */
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, join as pathJoin, resolve as pathResolve } from 'node:path';
|
|
6
|
+
import { loadJsonFile } from './load-json.ts';
|
|
7
|
+
import { ensureImprintRuntimeLink } from './runtime-link.ts';
|
|
8
|
+
import { type Workflow, WorkflowSchema } from './types.ts';
|
|
9
|
+
|
|
10
|
+
interface EmitOptions {
|
|
11
|
+
/** Path to workflow.json */
|
|
12
|
+
workflowPath: string;
|
|
13
|
+
/** Output dir; defaults to dirname(workflowPath). File: <outDir>/index.ts. */
|
|
14
|
+
outDir?: string;
|
|
15
|
+
/** Overwrite an existing index.ts. Default false. */
|
|
16
|
+
force?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface EmitResult {
|
|
20
|
+
workflowPath: string;
|
|
21
|
+
outPath: string;
|
|
22
|
+
toolName: string;
|
|
23
|
+
/** Summary of the parameters the generated tool accepts. */
|
|
24
|
+
parameters: Workflow['parameters'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function emit(opts: EmitOptions): EmitResult {
|
|
28
|
+
const workflow = loadJsonFile(
|
|
29
|
+
opts.workflowPath,
|
|
30
|
+
WorkflowSchema,
|
|
31
|
+
{
|
|
32
|
+
notFound: '→ run `imprint generate <session>` to create one.',
|
|
33
|
+
badSchema:
|
|
34
|
+
'→ regenerate with `imprint generate <session>` (the LLM may have produced bad output).',
|
|
35
|
+
},
|
|
36
|
+
'workflow.json',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const outDir = opts.outDir ?? defaultOutDir(opts.workflowPath, workflow);
|
|
40
|
+
|
|
41
|
+
mkdirSync(outDir, { recursive: true });
|
|
42
|
+
const outPath = pathJoin(outDir, 'index.ts');
|
|
43
|
+
|
|
44
|
+
if (existsSync(outPath) && !opts.force) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`${outPath} already exists. Pass --force to overwrite, or move/delete the existing file first.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ensure IMPRINT_HOME has a node_modules/imprint symlink so generated
|
|
51
|
+
// tools can `import 'imprint/runtime'` via standard module resolution.
|
|
52
|
+
// discoverTools also calls this at runtime so dangling links self-heal
|
|
53
|
+
// without re-emitting.
|
|
54
|
+
ensureImprintRuntimeLink(pathResolve(outDir, '..', '..'));
|
|
55
|
+
|
|
56
|
+
const source = renderModule(workflow);
|
|
57
|
+
writeFileSync(outPath, source, 'utf8');
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
workflowPath: opts.workflowPath,
|
|
61
|
+
outPath,
|
|
62
|
+
toolName: workflow.toolName,
|
|
63
|
+
parameters: workflow.parameters,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function defaultOutDir(workflowPath: string, workflow: Workflow): string {
|
|
68
|
+
const workflowDir = pathResolve(dirname(workflowPath));
|
|
69
|
+
if (basename(workflowDir) === workflow.toolName) return workflowDir;
|
|
70
|
+
return pathJoin(workflowDir, workflow.toolName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderModule(workflow: Workflow): string {
|
|
74
|
+
const paramTypeFields = workflow.parameters
|
|
75
|
+
.map((p) => {
|
|
76
|
+
const optional = p.default !== undefined ? '?' : '';
|
|
77
|
+
return ` /** ${escapeJsdoc(p.description)} */\n ${p.name}${optional}: ${p.type};`;
|
|
78
|
+
})
|
|
79
|
+
.join('\n');
|
|
80
|
+
|
|
81
|
+
const defaultsBlock = workflow.parameters
|
|
82
|
+
.filter((p) => p.default !== undefined)
|
|
83
|
+
.map((p) => ` ${p.name}: input.${p.name} ?? ${JSON.stringify(p.default)},`)
|
|
84
|
+
.join('\n');
|
|
85
|
+
|
|
86
|
+
const requiredCopies = workflow.parameters
|
|
87
|
+
.filter((p) => p.default === undefined)
|
|
88
|
+
.map((p) => ` ${p.name}: input.${p.name},`)
|
|
89
|
+
.join('\n');
|
|
90
|
+
|
|
91
|
+
const workflowJson = JSON.stringify(workflow, null, 2);
|
|
92
|
+
|
|
93
|
+
return `/**
|
|
94
|
+
* GENERATED by \`imprint emit\` — DO NOT EDIT BY HAND.
|
|
95
|
+
*
|
|
96
|
+
* Tool: ${workflow.toolName}
|
|
97
|
+
* Site: ${workflow.site}
|
|
98
|
+
* Intent: ${workflow.intent.description}
|
|
99
|
+
*
|
|
100
|
+
* To regenerate: imprint emit ~/.imprint/${workflow.site}/${workflow.toolName}/workflow.json --force
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
import { fileURLToPath } from 'node:url';
|
|
104
|
+
import { dirname, join } from 'node:path';
|
|
105
|
+
import {
|
|
106
|
+
executeWorkflow,
|
|
107
|
+
type CredentialStore,
|
|
108
|
+
} from 'imprint/runtime';
|
|
109
|
+
import type { ToolResult, Workflow } from 'imprint/types';
|
|
110
|
+
|
|
111
|
+
const WORKFLOW: Workflow = ${workflowJson};
|
|
112
|
+
|
|
113
|
+
export interface ${pascalCase(workflow.toolName)}Input {
|
|
114
|
+
${paramTypeFields}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function ${camelCase(workflow.toolName)}(
|
|
118
|
+
${workflow.parameters.length === 0 ? '_input' : 'input'}: ${pascalCase(workflow.toolName)}Input,
|
|
119
|
+
opts: { credentials?: CredentialStore; fetchImpl?: typeof fetch; initialState?: Record<string, unknown> } = {},
|
|
120
|
+
): Promise<ToolResult> {
|
|
121
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
122
|
+
const params: Record<string, string | number | boolean> = {
|
|
123
|
+
${defaultsBlock || requiredCopies || ' // (no parameters)'}
|
|
124
|
+
${defaultsBlock && requiredCopies ? requiredCopies : ''}
|
|
125
|
+
};
|
|
126
|
+
return executeWorkflow({
|
|
127
|
+
workflow: WORKFLOW,
|
|
128
|
+
params,
|
|
129
|
+
credentials: opts.credentials,
|
|
130
|
+
fetchImpl: opts.fetchImpl,
|
|
131
|
+
initialState: opts.initialState,
|
|
132
|
+
workflowPath: join(__dirname, 'workflow.json'),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { WORKFLOW };
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pascalCase(s: string): string {
|
|
141
|
+
return s
|
|
142
|
+
.split(/[_-]+/)
|
|
143
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
|
144
|
+
.join('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function camelCase(s: string): string {
|
|
148
|
+
const pc = pascalCase(s);
|
|
149
|
+
return pc.charAt(0).toLowerCase() + pc.slice(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function escapeJsdoc(s: string): string {
|
|
153
|
+
return s.replace(/\*\//g, '*\\/');
|
|
154
|
+
}
|