open-classify 0.7.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.
Files changed (2) hide show
  1. package/bin/open-classify.mjs +533 -77
  2. package/package.json +1 -1
@@ -1,22 +1,15 @@
1
1
  #!/usr/bin/env node
2
- // open-classify CLI. Currently exposes a single subcommand: `init`.
2
+ // open-classify CLI. Subcommands: init, doctor, try.
3
3
  //
4
- // `init` scaffolds the standard project layout for a consumer:
5
- // - open-classify.config.json (minimal)
6
- // - classifiers/
7
- // - README.md
8
- // - _conversation_digest/ (templates, prefix means inactive)
9
- // - _context_shift/
10
- // - _memory_retrieval_queries/
11
- // - _tools/
12
- //
13
- // Re-run safe: existing files are skipped, never overwritten. Use
14
- // `--yes` to skip the confirmation prompt (for scripted setup).
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.
15
7
 
16
- import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
9
  import { createInterface } from "node:readline";
18
- import { dirname, join, relative, resolve } from "node:path";
10
+ import { basename, dirname, join, relative, resolve } from "node:path";
19
11
  import { fileURLToPath } from "node:url";
12
+ import { spawnSync } from "node:child_process";
20
13
 
21
14
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
22
15
  const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
@@ -24,6 +17,13 @@ const TEMPLATES_DIR = join(PACKAGE_ROOT, "templates");
24
17
 
25
18
  const TEMPLATE_NAMES = ["conversation_digest", "context_shift", "memory_retrieval_queries", "tools"];
26
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
27
  const CLASSIFIERS_README = `# classifiers/
28
28
 
29
29
  Drop a folder here per classifier. Each folder needs:
@@ -31,6 +31,19 @@ Drop a folder here per classifier. Each folder needs:
31
31
  - \`manifest.json\` — see [open-classify docs](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md)
32
32
  - \`prompt.md\` — the classifier-specific instructions
33
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
+
34
47
  ## Activating templates
35
48
 
36
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:
@@ -55,6 +68,10 @@ const DEFAULT_CONFIG = {
55
68
  catalog: "downstream-models.json",
56
69
  };
57
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // Entry point
73
+ // ---------------------------------------------------------------------------
74
+
58
75
  async function main() {
59
76
  const args = process.argv.slice(2);
60
77
  const subcommand = args[0];
@@ -65,8 +82,19 @@ async function main() {
65
82
  }
66
83
 
67
84
  if (subcommand === "init") {
68
- const yes = args.includes("--yes") || args.includes("-y");
69
- await runInit({ cwd: process.cwd(), yes });
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 });
70
98
  return;
71
99
  }
72
100
 
@@ -75,122 +103,550 @@ async function main() {
75
103
  process.exit(1);
76
104
  }
77
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
+
78
137
  function printHelp() {
79
138
  process.stdout.write(`open-classify — runtime CLI
80
139
 
81
140
  Commands:
82
- init [--yes] Scaffold open-classify.config.json and classifiers/ in the
83
- current directory. Re-run safe: existing files are skipped.
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)
84
159
 
85
160
  `);
86
161
  }
87
162
 
88
- async function runInit({ cwd, yes }) {
89
- const plan = planInit(cwd);
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
+ }
90
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.
91
238
  if (plan.toCreate.length === 0) {
92
- console.log("Nothing to do — your project already has all the scaffolded files.");
239
+ process.stdout.write("Nothing to do — your project already has all the scaffolded files.\n");
93
240
  if (plan.toSkip.length > 0) {
94
- console.log("\nAlready in place:");
95
- for (const p of plan.toSkip) console.log(` ${p}`);
241
+ process.stdout.write("\nAlready in place:\n");
242
+ for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
96
243
  }
97
244
  return;
98
245
  }
99
246
 
100
- console.log("This will create:");
101
- for (const p of plan.toCreate) console.log(` ${p}`);
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
+
102
259
  if (plan.toSkip.length > 0) {
103
- console.log("\nAlready exists (will skip):");
104
- for (const p of plan.toSkip) console.log(` ${p}`);
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`);
105
262
  }
106
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).
107
285
  if (!yes) {
108
- const proceed = await confirm("\nContinue? [Y/n] ");
286
+ const proceed = await confirm("\n? Continue? (Y/n) ", true);
109
287
  if (!proceed) {
110
- console.log("Aborted.");
288
+ process.stdout.write("Aborted.\n");
111
289
  process.exit(1);
112
290
  }
113
291
  }
114
292
 
115
- for (const action of plan.actions) {
116
- action();
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`);
117
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"
118
328
 
119
- console.log("\nDone. Wire it into your code:\n");
120
- console.log(" import { createClassifier } from \"open-classify\";");
121
- console.log(" const { classify } = createClassifier({");
122
- console.log(" extraClassifierDirs: [\"./classifiers\"],");
123
- console.log(" });");
329
+ Docs: https://github.com/taylorbayouth/open-classify#readme
330
+ `);
124
331
  }
125
332
 
126
- function planInit(cwd) {
333
+ function planInit(cwd, { minimal = false, classifierDir, force = false, wrote }) {
127
334
  const toCreate = [];
128
335
  const toSkip = [];
129
336
  const actions = [];
337
+ const preview = [];
130
338
 
339
+ // Config file.
131
340
  const configPath = join(cwd, "open-classify.config.json");
132
- if (existsSync(configPath)) {
133
- toSkip.push(relative(cwd, configPath));
341
+ const configRel = relative(cwd, configPath);
342
+ if (existsSync(configPath) && !force) {
343
+ toSkip.push(configRel);
134
344
  } else {
135
- toCreate.push(relative(cwd, configPath));
345
+ toCreate.push(configRel);
346
+ preview.push({ label: configRel, description: `(default Ollama setup, ${DEFAULT_CONFIG.runner.defaultModel})` });
136
347
  actions.push(() => {
137
348
  writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
138
- console.log(`wrote ${relative(cwd, configPath)}`);
349
+ process.stdout.write(` wrote ${configRel}\n`);
350
+ wrote.config = true;
139
351
  });
140
352
  }
141
353
 
142
- const classifiersDir = join(cwd, "classifiers");
143
- if (!existsSync(classifiersDir)) {
144
- toCreate.push(relative(cwd, classifiersDir) + "/");
145
- actions.push(() => {
146
- mkdirSync(classifiersDir, { recursive: true });
147
- console.log(`created ${relative(cwd, classifiersDir)}/`);
148
- });
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
+ }
149
420
  }
150
421
 
151
- const readmePath = join(classifiersDir, "README.md");
152
- if (existsSync(readmePath)) {
153
- toSkip.push(relative(cwd, readmePath));
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;
154
475
  } else {
155
- toCreate.push(relative(cwd, readmePath));
156
- actions.push(() => {
157
- // The classifiers dir may not yet exist when we generated the plan,
158
- // but it will by the time this action runs.
159
- mkdirSync(classifiersDir, { recursive: true });
160
- writeFileSync(readmePath, CLASSIFIERS_README);
161
- console.log(`wrote ${relative(cwd, readmePath)}`);
162
- });
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
+ }
163
484
  }
164
485
 
165
- for (const name of TEMPLATE_NAMES) {
166
- const inactivePath = join(classifiersDir, `_${name}`);
167
- const activePath = join(classifiersDir, name);
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
+ }
168
527
 
169
- if (existsSync(inactivePath) || existsSync(activePath)) {
170
- // Either already scaffolded (inactive) or already activated by the
171
- // consumer. Either way, leave it alone.
172
- toSkip.push(relative(cwd, existsSync(activePath) ? activePath : inactivePath) + "/");
173
- continue;
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
+ );
174
553
  }
554
+ } else {
555
+ process.stdout.write("ℹ No classifiers/ directory — run: npx open-classify init\n");
556
+ }
175
557
 
176
- toCreate.push(relative(cwd, inactivePath) + "/");
177
- actions.push(() => {
178
- mkdirSync(classifiersDir, { recursive: true });
179
- cpSync(join(TEMPLATES_DIR, name), inactivePath, { recursive: true });
180
- console.log(`wrote ${relative(cwd, inactivePath)}/`);
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,
181
608
  });
609
+ } catch (err) {
610
+ process.stderr.write(`✖ Failed to initialise classifier: ${err.message}\n`);
611
+ process.exit(1);
182
612
  }
183
613
 
184
- return { toCreate, toSkip, actions };
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
+ }
185
623
  }
186
624
 
187
- function confirm(prompt) {
188
- return new Promise((resolveAnswer) => {
625
+ // ---------------------------------------------------------------------------
626
+ // Prompt helpers
627
+ // ---------------------------------------------------------------------------
628
+
629
+ function confirm(prompt, defaultYes = false) {
630
+ return new Promise((resolve) => {
189
631
  const rl = createInterface({ input: process.stdin, output: process.stdout });
190
632
  rl.question(prompt, (answer) => {
191
633
  rl.close();
192
- const normalized = (answer || "").trim().toLowerCase();
193
- resolveAnswer(normalized === "" || normalized === "y" || normalized === "yes");
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");
194
650
  });
195
651
  });
196
652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-classify",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Manifest-driven classifier runtime for routing user messages to downstream AI models",
5
5
  "license": "MIT",
6
6
  "author": "Taylor Bayouth",