open-classify 0.6.0 → 0.8.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.
@@ -0,0 +1,657 @@
1
+ #!/usr/bin/env node
2
+ // open-classify CLI. Subcommands: init, doctor, try.
3
+ //
4
+ // init: scaffold the standard project layout for a consumer.
5
+ // doctor: verify the install, config, Ollama, and classifiers are all working.
6
+ // try: run the pipeline against a single message and print the result.
7
+
8
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { createInterface } from "node:readline";
10
+ import { basename, dirname, join, relative, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { spawnSync } from "node:child_process";
13
+
14
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
15
+ const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
16
+ const TEMPLATES_DIR = join(PACKAGE_ROOT, "templates");
17
+
18
+ const TEMPLATE_NAMES = ["conversation_digest", "context_shift", "memory_retrieval_queries", "tools"];
19
+
20
+ const TEMPLATE_DESCRIPTIONS = {
21
+ conversation_digest: "rolling summary of recent turns",
22
+ context_shift: "detects topic changes",
23
+ memory_retrieval_queries: "generates queries for a memory store",
24
+ tools: "tool-call routing",
25
+ };
26
+
27
+ const CLASSIFIERS_README = `# classifiers/
28
+
29
+ Drop a folder here per classifier. Each folder needs:
30
+
31
+ - \`manifest.json\` — see [open-classify docs](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md)
32
+ - \`prompt.md\` — the classifier-specific instructions
33
+
34
+ ## Quickstart
35
+
36
+ \`\`\`js
37
+ import { createClassifier } from "open-classify";
38
+
39
+ const { classify } = createClassifier({
40
+ extraClassifierDirs: ["./classifiers"],
41
+ });
42
+ \`\`\`
43
+
44
+ Place this in your server entry point. Call \`classify(input)\` for each user message.
45
+ \`extraClassifierDirs\` is resolved relative to the current working directory.
46
+
47
+ ## Activating templates
48
+
49
+ The four \`_<name>/\` directories below are templates copied from the package — they ship inactive (the loader skips any folder starting with \`_\`). Activate one by dropping the underscore:
50
+
51
+ \`\`\`sh
52
+ mv _tools tools
53
+ \`\`\`
54
+
55
+ You probably also want to edit its \`manifest.json\` first to fit your app (e.g. trim the \`allowed_tools\` list).
56
+
57
+ ## Deactivating without deleting
58
+
59
+ Same trick in reverse — rename \`my_classifier\` → \`_my_classifier\` to take it out of the active set without losing your work.
60
+ `;
61
+
62
+ const DEFAULT_CONFIG = {
63
+ runner: {
64
+ provider: "ollama",
65
+ host: "http://127.0.0.1:11434",
66
+ defaultModel: "gemma4:e4b-it-q4_K_M",
67
+ },
68
+ catalog: "downstream-models.json",
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Entry point
73
+ // ---------------------------------------------------------------------------
74
+
75
+ async function main() {
76
+ const args = process.argv.slice(2);
77
+ const subcommand = args[0];
78
+
79
+ if (!subcommand || subcommand === "-h" || subcommand === "--help") {
80
+ printHelp();
81
+ process.exit(subcommand ? 0 : 1);
82
+ }
83
+
84
+ if (subcommand === "init") {
85
+ const flags = parseInitFlags(args.slice(1));
86
+ await runInit({ cwd: process.cwd(), ...flags });
87
+ return;
88
+ }
89
+
90
+ if (subcommand === "doctor") {
91
+ await runDoctor({ cwd: process.cwd() });
92
+ return;
93
+ }
94
+
95
+ if (subcommand === "try") {
96
+ const message = args.slice(1).join(" ");
97
+ await runTry({ cwd: process.cwd(), message });
98
+ return;
99
+ }
100
+
101
+ console.error(`Unknown subcommand: ${subcommand}`);
102
+ printHelp();
103
+ process.exit(1);
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Shared helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function parseInitFlags(args) {
111
+ const flags = {
112
+ yes: false,
113
+ minimal: false,
114
+ dryRun: false,
115
+ force: false,
116
+ noInstall: false,
117
+ packageManager: null,
118
+ classifierDir: "classifiers",
119
+ };
120
+
121
+ for (let i = 0; i < args.length; i++) {
122
+ const arg = args[i];
123
+ if (arg === "--yes" || arg === "-y") flags.yes = true;
124
+ else if (arg === "--minimal") flags.minimal = true;
125
+ else if (arg === "--dry-run") flags.dryRun = true;
126
+ else if (arg === "--force") flags.force = true;
127
+ else if (arg === "--no-install") flags.noInstall = true;
128
+ else if (arg === "--package-manager" && args[i + 1]) flags.packageManager = args[++i];
129
+ else if (arg.startsWith("--package-manager=")) flags.packageManager = arg.split("=")[1];
130
+ else if (arg === "--classifier-dir" && args[i + 1]) flags.classifierDir = args[++i];
131
+ else if (arg.startsWith("--classifier-dir=")) flags.classifierDir = arg.split("=")[1];
132
+ }
133
+
134
+ return flags;
135
+ }
136
+
137
+ function printHelp() {
138
+ process.stdout.write(`open-classify — runtime CLI
139
+
140
+ Commands:
141
+ init [options] Scaffold open-classify.config.json and classifiers/ in the
142
+ current directory. Re-run safe: existing files are skipped.
143
+
144
+ doctor Check that the install, config, Ollama, and classifiers are
145
+ all working. Exits non-zero on failure.
146
+
147
+ try <message> Run the pipeline against a single message and print the
148
+ result. Useful for verifying your setup without touching
149
+ application code.
150
+
151
+ Options for init:
152
+ --minimal Write only open-classify.config.json; skip classifiers/
153
+ --dry-run Preview what would be created; don't write anything
154
+ --force Overwrite existing files without prompting
155
+ --no-install Skip the "add to package.json" prompt
156
+ --package-manager <m> npm | pnpm | yarn | bun (default: auto-detect)
157
+ --classifier-dir <p> Directory for classifiers (default: ./classifiers)
158
+ --yes, -y Accept all prompts (CI mode)
159
+
160
+ `);
161
+ }
162
+
163
+ function detectPackageManager(cwd) {
164
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
165
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
166
+ if (existsSync(join(cwd, "bun.lockb"))) return "bun";
167
+ return "npm";
168
+ }
169
+
170
+ function isOpenClassifyDep(pkg) {
171
+ return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some(
172
+ (f) => pkg[f]?.["open-classify"],
173
+ );
174
+ }
175
+
176
+ function getCliVersion() {
177
+ try {
178
+ return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // init
186
+ // ---------------------------------------------------------------------------
187
+
188
+ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageManager, classifierDir }) {
189
+ // 1. Preflight: require a host project.
190
+ const pkgPath = join(cwd, "package.json");
191
+ if (!existsSync(pkgPath)) {
192
+ process.stderr.write(
193
+ `✖ No package.json found in ${cwd}.\n` +
194
+ ` open-classify scaffolds code that imports the library, so it needs a\n` +
195
+ ` Node project to live in.\n\n` +
196
+ ` Create one first: npm init -y\n`,
197
+ );
198
+ process.exit(1);
199
+ }
200
+
201
+ let pkg;
202
+ try {
203
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
204
+ } catch {
205
+ process.stderr.write(`✖ Could not parse package.json at ${pkgPath}\n`);
206
+ process.exit(1);
207
+ }
208
+
209
+ // 2. Offer to install if not yet a dependency (skip in --yes / --no-install mode).
210
+ let installedNow = false;
211
+ if (!isOpenClassifyDep(pkg) && !noInstall && !yes) {
212
+ process.stdout.write(`ℹ open-classify is not yet a dependency of this project.\n\n`);
213
+ const doInstall = await confirm("? Add open-classify to package.json and install it now? (Y/n) ", true);
214
+ if (doInstall) {
215
+ const pm = packageManager || detectPackageManager(cwd);
216
+ const installCmd = pm === "npm" ? ["install", "open-classify"] : ["add", "open-classify"];
217
+ process.stdout.write(`\n Running: ${pm} ${installCmd.join(" ")}\n\n`);
218
+ const result = spawnSync(pm, installCmd, { cwd, stdio: "inherit" });
219
+ if (result.status !== 0) {
220
+ process.stderr.write(`\n✖ Install failed. Run manually: ${pm} ${installCmd.join(" ")}\n`);
221
+ process.exit(1);
222
+ }
223
+ installedNow = true;
224
+ process.stdout.write("\n");
225
+ } else {
226
+ process.stdout.write(
227
+ ` Skipped. You'll need to run \`npm install open-classify\` before importing.\n\n`,
228
+ );
229
+ }
230
+ }
231
+
232
+ // 3. Plan.
233
+ const resolvedClassifierDir = resolve(cwd, classifierDir);
234
+ const wrote = { config: false, readme: false, templateCount: 0 };
235
+ let plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force, wrote });
236
+
237
+ // Nothing to do.
238
+ if (plan.toCreate.length === 0) {
239
+ process.stdout.write("Nothing to do — your project already has all the scaffolded files.\n");
240
+ if (plan.toSkip.length > 0) {
241
+ process.stdout.write("\nAlready in place:\n");
242
+ for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
243
+ }
244
+ return;
245
+ }
246
+
247
+ // 4. Preview.
248
+ process.stdout.write(`\nThe following will be created in ${cwd}:\n\n`);
249
+ for (const item of plan.preview) {
250
+ if (item.isGroupHeader) {
251
+ process.stdout.write(` ${item.label}\n`);
252
+ } else if (item.indent) {
253
+ process.stdout.write(` ${item.label.padEnd(32)} ${item.description}\n`);
254
+ } else {
255
+ process.stdout.write(` ${item.label.padEnd(34)} ${item.description}\n`);
256
+ }
257
+ }
258
+
259
+ if (plan.toSkip.length > 0) {
260
+ process.stdout.write(`\n⚠ These files already exist and will be skipped:\n`);
261
+ for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
262
+ }
263
+
264
+ // 5. Stop here on --dry-run.
265
+ if (dryRun) {
266
+ process.stdout.write("\n(dry run — nothing written)\n");
267
+ return;
268
+ }
269
+
270
+ // 6. Conflict handling: interactive only (not --yes, not --force).
271
+ if (plan.toSkip.length > 0 && !yes && !force) {
272
+ const choice = await promptConflict();
273
+ if (choice === "diff") {
274
+ showDiffs(plan.toSkip, cwd, resolvedClassifierDir);
275
+ const choice2 = await promptConflict();
276
+ if (choice2 === "y") {
277
+ plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote });
278
+ }
279
+ } else if (choice === "y") {
280
+ plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote });
281
+ }
282
+ }
283
+
284
+ // 7. Confirm (skip in --yes mode).
285
+ if (!yes) {
286
+ const proceed = await confirm("\n? Continue? (Y/n) ", true);
287
+ if (!proceed) {
288
+ process.stdout.write("Aborted.\n");
289
+ process.exit(1);
290
+ }
291
+ }
292
+
293
+ // 8. Execute.
294
+ process.stdout.write("\n");
295
+ for (const action of plan.actions) action();
296
+
297
+ // 9. Summary + next steps.
298
+ process.stdout.write("\n");
299
+ if (installedNow) {
300
+ const v = getCliVersion();
301
+ process.stdout.write(`✓ open-classify installed${v ? ` (v${v})` : ""}\n`);
302
+ }
303
+ if (wrote.config) process.stdout.write("✓ wrote open-classify.config.json\n");
304
+ if (wrote.readme || wrote.templateCount > 0) {
305
+ const classifierDirRel = relative(cwd, resolvedClassifierDir);
306
+ if (wrote.templateCount > 0) {
307
+ process.stdout.write(`✓ scaffolded ${wrote.templateCount} classifier(s) in ./${classifierDirRel}/\n`);
308
+ } else {
309
+ process.stdout.write(`✓ wrote ./${classifierDirRel}/README.md\n`);
310
+ }
311
+ }
312
+
313
+ const classifierDirRel = relative(cwd, resolvedClassifierDir);
314
+ process.stdout.write(`
315
+ Next steps:
316
+
317
+ 1. Pull the default model:
318
+ ollama pull ${DEFAULT_CONFIG.runner.defaultModel}
319
+
320
+ 2. Wire it into your server (example for a Node entrypoint):
321
+ see ./${classifierDirRel}/README.md → "Quickstart"
322
+
323
+ 3. Verify the install:
324
+ npx open-classify doctor
325
+
326
+ 4. Run a one-shot classification against your config:
327
+ npx open-classify try "hello world"
328
+
329
+ Docs: https://github.com/taylorbayouth/open-classify#readme
330
+ `);
331
+ }
332
+
333
+ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote }) {
334
+ const toCreate = [];
335
+ const toSkip = [];
336
+ const actions = [];
337
+ const preview = [];
338
+
339
+ // Config file.
340
+ const configPath = join(cwd, "open-classify.config.json");
341
+ const configRel = relative(cwd, configPath);
342
+ if (existsSync(configPath) && !force) {
343
+ toSkip.push(configRel);
344
+ } else {
345
+ toCreate.push(configRel);
346
+ preview.push({ label: configRel, description: `(default Ollama setup, ${DEFAULT_CONFIG.runner.defaultModel})` });
347
+ actions.push(() => {
348
+ writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
349
+ process.stdout.write(` wrote ${configRel}\n`);
350
+ wrote.config = true;
351
+ });
352
+ }
353
+
354
+ if (!minimal) {
355
+ const classifierPreviewItems = [];
356
+
357
+ // Ensure the directory exists (prerequisite for all classifier actions).
358
+ if (!existsSync(classifierDir)) {
359
+ toCreate.push(`${relative(cwd, classifierDir)}/`);
360
+ actions.push(() => {
361
+ mkdirSync(classifierDir, { recursive: true });
362
+ });
363
+ }
364
+
365
+ // README.md.
366
+ const readmePath = join(classifierDir, "README.md");
367
+ const readmeRel = relative(cwd, readmePath);
368
+ if (existsSync(readmePath) && !force) {
369
+ toSkip.push(readmeRel);
370
+ } else {
371
+ toCreate.push(readmeRel);
372
+ classifierPreviewItems.push({ label: "README.md", indent: true, description: "how to author your own classifier" });
373
+ actions.push(() => {
374
+ mkdirSync(classifierDir, { recursive: true });
375
+ writeFileSync(readmePath, CLASSIFIERS_README);
376
+ process.stdout.write(` wrote ${readmeRel}\n`);
377
+ wrote.readme = true;
378
+ });
379
+ }
380
+
381
+ // Template classifier directories.
382
+ for (const name of TEMPLATE_NAMES) {
383
+ const inactivePath = join(classifierDir, `_${name}`);
384
+ const activePath = join(classifierDir, name);
385
+ const inactiveRel = relative(cwd, inactivePath);
386
+ const activeRel = relative(cwd, activePath);
387
+
388
+ // Never overwrite an activated (user-renamed) template.
389
+ if (existsSync(activePath)) {
390
+ toSkip.push(`${activeRel}/`);
391
+ continue;
392
+ }
393
+
394
+ if (existsSync(inactivePath) && !force) {
395
+ toSkip.push(`${inactiveRel}/`);
396
+ continue;
397
+ }
398
+
399
+ toCreate.push(`${inactiveRel}/`);
400
+ classifierPreviewItems.push({
401
+ label: `_${name}/`,
402
+ indent: true,
403
+ description: TEMPLATE_DESCRIPTIONS[name],
404
+ });
405
+ actions.push(() => {
406
+ mkdirSync(classifierDir, { recursive: true });
407
+ if (force && existsSync(inactivePath)) {
408
+ rmSync(inactivePath, { recursive: true, force: true });
409
+ }
410
+ cpSync(join(TEMPLATES_DIR, name), inactivePath, { recursive: true });
411
+ process.stdout.write(` wrote ${inactiveRel}/\n`);
412
+ wrote.templateCount++;
413
+ });
414
+ }
415
+
416
+ if (classifierPreviewItems.length > 0) {
417
+ preview.push({ label: `${relative(cwd, classifierDir)}/`, isGroupHeader: true });
418
+ preview.push(...classifierPreviewItems);
419
+ }
420
+ }
421
+
422
+ return { toCreate, toSkip, actions, preview };
423
+ }
424
+
425
+ function showDiffs(conflicts, cwd, classifierDir) {
426
+ for (const p of conflicts) {
427
+ const isDir = p.endsWith("/");
428
+ const relPath = isDir ? p.slice(0, -1) : p;
429
+ const fullPath = join(cwd, relPath);
430
+
431
+ process.stdout.write(`\n--- ${p} ---\n`);
432
+
433
+ if (!isDir) {
434
+ process.stdout.write("\n current:\n");
435
+ try {
436
+ const lines = readFileSync(fullPath, "utf8").split("\n");
437
+ for (const line of lines) process.stdout.write(` ${line}\n`);
438
+ } catch {
439
+ process.stdout.write(" (could not read)\n");
440
+ }
441
+ process.stdout.write("\n would become:\n");
442
+ for (const line of JSON.stringify(DEFAULT_CONFIG, null, 2).split("\n")) {
443
+ process.stdout.write(` ${line}\n`);
444
+ }
445
+ } else {
446
+ process.stdout.write("\n current files:\n");
447
+ try {
448
+ for (const f of readdirSync(fullPath)) process.stdout.write(` ${f}\n`);
449
+ } catch {
450
+ process.stdout.write(" (could not read)\n");
451
+ }
452
+ const templateName = basename(relPath).replace(/^_/, "");
453
+ const templatePath = join(TEMPLATES_DIR, templateName);
454
+ if (existsSync(templatePath)) {
455
+ process.stdout.write("\n template files:\n");
456
+ for (const f of readdirSync(templatePath)) process.stdout.write(` ${f}\n`);
457
+ }
458
+ }
459
+ }
460
+ process.stdout.write("\n");
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // doctor
465
+ // ---------------------------------------------------------------------------
466
+
467
+ async function runDoctor({ cwd }) {
468
+ let allGood = true;
469
+
470
+ // 1. package.json + open-classify dep.
471
+ const pkgPath = join(cwd, "package.json");
472
+ if (!existsSync(pkgPath)) {
473
+ process.stdout.write("✖ No package.json — not a Node project\n");
474
+ allGood = false;
475
+ } else {
476
+ let pkg;
477
+ try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { pkg = {}; }
478
+ if (isOpenClassifyDep(pkg)) {
479
+ process.stdout.write("✓ open-classify found in package.json\n");
480
+ } else {
481
+ process.stdout.write("⚠ open-classify not listed as a dependency\n");
482
+ allGood = false;
483
+ }
484
+ }
485
+
486
+ // 2. Config parses.
487
+ const configPath = join(cwd, "open-classify.config.json");
488
+ if (!existsSync(configPath)) {
489
+ process.stdout.write("✖ No open-classify.config.json — run: npx open-classify init\n");
490
+ allGood = false;
491
+ } else {
492
+ let config;
493
+ try {
494
+ config = JSON.parse(readFileSync(configPath, "utf8"));
495
+ process.stdout.write("✓ open-classify.config.json parses OK\n");
496
+
497
+ // 3. Ollama reachable.
498
+ const host = config.runner?.host || DEFAULT_CONFIG.runner.host;
499
+ try {
500
+ const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
501
+ if (res.ok) {
502
+ process.stdout.write(`✓ Ollama reachable at ${host}\n`);
503
+
504
+ // 4. Default model pulled.
505
+ const data = await res.json();
506
+ const model = config.runner?.defaultModel || DEFAULT_CONFIG.runner.defaultModel;
507
+ const pulled = data.models?.some((m) => m.name === model || m.model === model);
508
+ if (pulled) {
509
+ process.stdout.write(`✓ Model ${model} is available\n`);
510
+ } else {
511
+ process.stdout.write(`✖ Model ${model} not found — run: ollama pull ${model}\n`);
512
+ allGood = false;
513
+ }
514
+ } else {
515
+ process.stdout.write(`✖ Ollama responded ${res.status} at ${host}\n`);
516
+ allGood = false;
517
+ }
518
+ } catch {
519
+ process.stdout.write(`✖ Ollama not reachable at ${host} — is it running?\n`);
520
+ allGood = false;
521
+ }
522
+ } catch {
523
+ process.stdout.write("✖ open-classify.config.json is not valid JSON\n");
524
+ allGood = false;
525
+ }
526
+ }
527
+
528
+ // 5. Classifiers directory.
529
+ const classifiersDir = join(cwd, "classifiers");
530
+ if (existsSync(classifiersDir)) {
531
+ let active = 0;
532
+ let bad = 0;
533
+ try {
534
+ for (const entry of readdirSync(classifiersDir, { withFileTypes: true })) {
535
+ if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
536
+ const dir = join(classifiersDir, entry.name);
537
+ const ok =
538
+ existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "prompt.md"));
539
+ if (ok) active++;
540
+ else {
541
+ process.stdout.write(`✖ classifiers/${entry.name}/ is missing manifest.json or prompt.md\n`);
542
+ bad++;
543
+ allGood = false;
544
+ }
545
+ }
546
+ } catch { /* skip */ }
547
+ if (bad === 0) {
548
+ process.stdout.write(
549
+ active > 0
550
+ ? `✓ ${active} active classifier(s) in classifiers/\n`
551
+ : "ℹ No active classifiers in classifiers/ (activate a template with: mv _name name)\n",
552
+ );
553
+ }
554
+ } else {
555
+ process.stdout.write("ℹ No classifiers/ directory — run: npx open-classify init\n");
556
+ }
557
+
558
+ if (!allGood) process.exit(1);
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // try
563
+ // ---------------------------------------------------------------------------
564
+
565
+ async function runTry({ cwd, message }) {
566
+ if (!message) {
567
+ process.stderr.write("Usage: open-classify try <message>\n");
568
+ process.exit(1);
569
+ }
570
+
571
+ const configPath = join(cwd, "open-classify.config.json");
572
+ if (!existsSync(configPath)) {
573
+ process.stderr.write("✖ No open-classify.config.json — run: npx open-classify init\n");
574
+ process.exit(1);
575
+ }
576
+
577
+ // Try loading from the consumer's node_modules first, then fall back to the
578
+ // package root (useful when running from the development checkout).
579
+ let createClassifier;
580
+ const candidates = [
581
+ join(cwd, "node_modules", "open-classify", "dist", "src", "index.js"),
582
+ join(PACKAGE_ROOT, "dist", "src", "index.js"),
583
+ ];
584
+ for (const candidate of candidates) {
585
+ if (!existsSync(candidate)) continue;
586
+ try {
587
+ const mod = await import(candidate);
588
+ createClassifier = mod.createClassifier;
589
+ break;
590
+ } catch { /* try next */ }
591
+ }
592
+
593
+ if (!createClassifier) {
594
+ process.stderr.write(
595
+ "✖ Could not load the open-classify runtime.\n" +
596
+ " Is the package installed? Run: npm install open-classify\n",
597
+ );
598
+ process.exit(1);
599
+ }
600
+
601
+ const classifiersDir = join(cwd, "classifiers");
602
+ let classifier;
603
+ try {
604
+ classifier = createClassifier({
605
+ configPath,
606
+ extraClassifierDirs: existsSync(classifiersDir) ? [classifiersDir] : [],
607
+ skipResourceCheck: false,
608
+ });
609
+ } catch (err) {
610
+ process.stderr.write(`✖ Failed to initialise classifier: ${err.message}\n`);
611
+ process.exit(1);
612
+ }
613
+
614
+ try {
615
+ const result = await classifier.classify({
616
+ messages: [{ role: "user", text: message }],
617
+ });
618
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
619
+ } catch (err) {
620
+ process.stderr.write(`✖ Classification failed: ${err.message}\n`);
621
+ process.exit(1);
622
+ }
623
+ }
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // Prompt helpers
627
+ // ---------------------------------------------------------------------------
628
+
629
+ function confirm(prompt, defaultYes = false) {
630
+ return new Promise((resolve) => {
631
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
632
+ rl.question(prompt, (answer) => {
633
+ rl.close();
634
+ const v = (answer || "").trim().toLowerCase();
635
+ resolve(defaultYes ? (v === "" || v === "y" || v === "yes") : (v === "y" || v === "yes"));
636
+ });
637
+ });
638
+ }
639
+
640
+ function promptConflict() {
641
+ return new Promise((resolve) => {
642
+ process.stdout.write("\n? Overwrite them?\n y overwrite all\n N keep existing (default)\n diff show what would change\n\n");
643
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
644
+ rl.question(" Choice (y/N/diff): ", (answer) => {
645
+ rl.close();
646
+ const v = (answer || "").trim().toLowerCase();
647
+ if (v === "y" || v === "yes") resolve("y");
648
+ else if (v === "diff") resolve("diff");
649
+ else resolve("N");
650
+ });
651
+ });
652
+ }
653
+
654
+ main().catch((err) => {
655
+ console.error(err instanceof Error ? err.message : String(err));
656
+ process.exit(1);
657
+ });
@@ -1,14 +1,21 @@
1
1
  import type { ClassifierInput } from "./types.js";
2
2
  import type { ClassifierName, ClassifierRegistry, RunClassifier } from "./manifest.js";
3
3
  import type { ClassifierOutput, RuntimeClassifierManifest } from "./stock.js";
4
+ export declare const BUILTIN_CLASSIFIERS_DIR: string;
4
5
  export declare class ClassifierManifestError extends Error {
5
6
  constructor(message: string);
6
7
  }
8
+ export type ClassifierModuleMap = Readonly<Record<string, RuntimeClassifierManifest>>;
9
+ export interface ClassifierRegistryBundle {
10
+ readonly registry: ClassifierRegistry;
11
+ readonly modulesByName: ClassifierModuleMap;
12
+ readonly names: ReadonlyArray<string>;
13
+ }
14
+ export interface BuildRegistryOptions {
15
+ readonly extraDirs?: ReadonlyArray<string>;
16
+ }
7
17
  export declare function loadClassifierRegistry(classifiersDir?: string): RuntimeClassifierManifest[];
8
- export declare const REGISTRY: ClassifierRegistry;
9
- export declare const CLASSIFIER_NAMES: string[];
10
- export declare const MODULES_BY_NAME: Record<string, RuntimeClassifierManifest>;
18
+ export declare function buildClassifierRegistry(options?: BuildRegistryOptions): ClassifierRegistryBundle;
19
+ export declare function validateClassifierOutput(manifest: RuntimeClassifierManifest, value: unknown, model: string): ClassifierOutput;
11
20
  export type { ClassifierName, RunClassifier };
12
- export type RegistryType = typeof REGISTRY;
13
- export declare function validateClassifierOutput(name: string, value: unknown, model: string): ClassifierOutput;
14
21
  export type { ClassifierInput };