open-classify 0.9.3 → 1.0.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.
@@ -1,182 +1,69 @@
1
1
  #!/usr/bin/env node
2
- // open-classify CLI. Subcommands: init, uninstall, doctor, try.
2
+ // open-classify CLI.
3
3
  //
4
- // init: scaffold the standard project layout for a consumer.
5
- // uninstall: remove the files created by init.
6
- // doctor: verify the install, config, Ollama, and classifiers are all working.
7
- // try: run the pipeline against a single message and print the result.
4
+ // init Copy the scaffold (open-classify/) into the current directory.
5
+ // eject <name> Copy a stock classifier into open-classify/classifiers/<name>/.
6
+ // doctor Verify install, config, Ollama, and classifiers.
7
+ // try <message> Run the pipeline against a single message.
8
+ //
9
+ // Removal is intentionally not a subcommand — `rm -rf open-classify/` and
10
+ // `npm uninstall open-classify` cover it, and bundling them creates more
11
+ // confusion than convenience (notably the npx "needs to install" prompt
12
+ // when the package isn't a dep yet).
8
13
 
9
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
15
  import { createInterface } from "node:readline";
11
- import { basename, dirname, join, relative, resolve } from "node:path";
16
+ import { dirname, join, relative, resolve } from "node:path";
12
17
  import { fileURLToPath } from "node:url";
13
- import { spawnSync } from "node:child_process";
14
18
 
15
19
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
16
20
  const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
17
- const TEMPLATES_DIR = join(PACKAGE_ROOT, "templates");
18
- const DOWNSTREAM_MODELS_FILENAME = "downstream-models.json";
19
- const DOWNSTREAM_MODELS_PATH = join(PACKAGE_ROOT, DOWNSTREAM_MODELS_FILENAME);
20
-
21
- const TEMPLATE_NAMES = ["conversation_digest", "context_shift", "memory_retrieval_queries", "tools"];
22
- const STOCK_CONFIG = {
23
- tools: false,
24
- memory_retrieval_queries: false,
25
- conversation_digest: false,
26
- context_shift: false,
27
- };
28
-
29
- const TEMPLATE_DESCRIPTIONS = {
30
- conversation_digest: "rolling summary of recent turns",
31
- context_shift: "detects topic changes",
32
- memory_retrieval_queries: "generates queries for a memory store",
33
- tools: "tool-call routing",
34
- };
35
-
36
- const CLASSIFIERS_README = `# classifiers/
37
-
38
- Each classifier is a folder with two files:
39
-
40
- - \`manifest.json\` — declares the output shape and fallback
41
- - \`prompt.md\` — the classification instructions
42
-
43
- The loader skips any folder whose name starts with \`_\`. That's how the
44
- four \`_<name>/\` templates here stay inactive until you opt in: drop the
45
- underscore (\`mv _tools tools\`) and the classifier runs on the next start.
46
-
47
- Each template mirrors a package-owned stock classifier. You have two ways
48
- to use them:
49
-
50
- 1. **Enable in place** — set \`classifiers.stock.<name>: true\` in
51
- \`open-classify.config.json\`. The package-owned version runs and is
52
- updated by \`npm update open-classify\`.
53
- 2. **Customize a local copy** — keep the stock toggle off, drop the
54
- underscore on the template here, and edit \`prompt.md\` /
55
- \`manifest.json\` to taste.
56
-
57
- To write your own classifier, drop a new \`<name>/\` folder here with its
58
- own \`manifest.json\` and \`prompt.md\`. The folder name must match the
59
- manifest's \`name\` field. See the
60
- [author guide](https://github.com/taylorbayouth/open-classify/blob/main/docs/adding-a-classifier.md).
61
- `;
62
-
63
- const DEFAULT_CONFIG = {
64
- runner: {
65
- provider: "ollama",
66
- host: "http://127.0.0.1:11434",
67
- defaultModel: "gemma4:e4b-it-q4_K_M",
68
- },
69
- catalog: DOWNSTREAM_MODELS_FILENAME,
70
- classifiers: {
71
- dirs: ["classifiers"],
72
- stock: STOCK_CONFIG,
73
- },
74
- };
75
-
76
- function configForInit({ minimal }) {
77
- if (!minimal) return DEFAULT_CONFIG;
78
- return {
79
- ...DEFAULT_CONFIG,
80
- classifiers: {
81
- stock: STOCK_CONFIG,
82
- },
83
- };
84
- }
21
+ const SCAFFOLD_DIR = join(PACKAGE_ROOT, "templates", "scaffold", "open-classify");
22
+ const STOCK_DIR = join(PACKAGE_ROOT, "templates", "stock");
23
+ const PROJECT_DIRNAME = "open-classify";
24
+ const STOCK_NAMES = ["tools", "memory_retrieval_queries", "conversation_digest", "context_shift"];
85
25
 
86
26
  // ---------------------------------------------------------------------------
87
27
  // Entry point
88
28
  // ---------------------------------------------------------------------------
89
29
 
90
30
  async function main() {
91
- const args = process.argv.slice(2);
92
- const subcommand = args[0];
31
+ const [subcommand, ...rest] = process.argv.slice(2);
93
32
 
94
33
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
95
34
  printHelp();
96
35
  process.exit(subcommand ? 0 : 1);
97
36
  }
98
37
 
99
- if (subcommand === "init") {
100
- const flags = parseInitFlags(args.slice(1));
101
- await runInit({ cwd: process.cwd(), ...flags });
102
- return;
103
- }
104
-
105
- if (subcommand === "uninstall") {
106
- const flags = parseUninstallFlags(args.slice(1));
107
- await runUninstall({ cwd: process.cwd(), ...flags });
108
- return;
109
- }
110
-
111
- if (subcommand === "doctor") {
112
- await runDoctor({ cwd: process.cwd() });
113
- return;
114
- }
115
-
116
- if (subcommand === "try") {
117
- const message = args.slice(1).join(" ");
118
- await runTry({ cwd: process.cwd(), message });
119
- return;
38
+ switch (subcommand) {
39
+ case "init":
40
+ await runInit({ cwd: process.cwd(), ...parseFlags(rest) });
41
+ return;
42
+ case "eject":
43
+ await runEject({ cwd: process.cwd(), name: rest[0], ...parseFlags(rest.slice(1)) });
44
+ return;
45
+ case "doctor":
46
+ await runDoctor({ cwd: process.cwd() });
47
+ return;
48
+ case "try": {
49
+ const message = rest.join(" ");
50
+ await runTry({ cwd: process.cwd(), message });
51
+ return;
52
+ }
53
+ default:
54
+ process.stderr.write(`Unknown subcommand: ${subcommand}\n\n`);
55
+ printHelp();
56
+ process.exit(1);
120
57
  }
121
-
122
- console.error(`Unknown subcommand: ${subcommand}`);
123
- printHelp();
124
- process.exit(1);
125
58
  }
126
59
 
127
- // ---------------------------------------------------------------------------
128
- // Shared helpers
129
- // ---------------------------------------------------------------------------
130
-
131
- function parseInitFlags(args) {
132
- const flags = {
133
- yes: false,
134
- minimal: false,
135
- dryRun: false,
136
- force: false,
137
- noInstall: false,
138
- packageManager: null,
139
- classifierDir: "classifiers",
140
- };
141
-
142
- for (let i = 0; i < args.length; i++) {
143
- const arg = args[i];
60
+ function parseFlags(args) {
61
+ const flags = { yes: false, force: false, dryRun: false };
62
+ for (const arg of args) {
144
63
  if (arg === "--yes" || arg === "-y") flags.yes = true;
145
- else if (arg === "--minimal") flags.minimal = true;
146
- else if (arg === "--dry-run") flags.dryRun = true;
147
64
  else if (arg === "--force") flags.force = true;
148
- else if (arg === "--no-install") flags.noInstall = true;
149
- else if (arg === "--package-manager" && args[i + 1]) flags.packageManager = args[++i];
150
- else if (arg.startsWith("--package-manager=")) flags.packageManager = arg.split("=")[1];
151
- else if (arg === "--classifier-dir" && args[i + 1]) flags.classifierDir = args[++i];
152
- else if (arg.startsWith("--classifier-dir=")) flags.classifierDir = arg.split("=")[1];
153
- }
154
-
155
- return flags;
156
- }
157
-
158
- function parseUninstallFlags(args) {
159
- const flags = {
160
- yes: false,
161
- dryRun: false,
162
- force: false,
163
- keepPackage: false,
164
- packageManager: null,
165
- classifierDir: "classifiers",
166
- };
167
-
168
- for (let i = 0; i < args.length; i++) {
169
- const arg = args[i];
170
- if (arg === "--yes" || arg === "-y") flags.yes = true;
171
65
  else if (arg === "--dry-run") flags.dryRun = true;
172
- else if (arg === "--force") flags.force = true;
173
- else if (arg === "--keep-package") flags.keepPackage = true;
174
- else if (arg === "--package-manager" && args[i + 1]) flags.packageManager = args[++i];
175
- else if (arg.startsWith("--package-manager=")) flags.packageManager = arg.split("=")[1];
176
- else if (arg === "--classifier-dir" && args[i + 1]) flags.classifierDir = args[++i];
177
- else if (arg.startsWith("--classifier-dir=")) flags.classifierDir = arg.split("=")[1];
178
66
  }
179
-
180
67
  return flags;
181
68
  }
182
69
 
@@ -184,164 +71,69 @@ function printHelp() {
184
71
  process.stdout.write(`open-classify — runtime CLI
185
72
 
186
73
  Commands:
187
- init [options] Scaffold open-classify.config.json and classifiers/ in the
188
- current directory. Re-run safe: existing files are skipped.
189
-
190
- uninstall Remove the open-classify scaffold and uninstall the
191
- package. Use --force to also delete active/custom
192
- classifiers, --keep-package to leave the npm dependency
193
- in place.
194
-
195
- doctor Check that the install, config, Ollama, and classifiers are
196
- all working. Exits non-zero on failure.
197
-
198
- try <message> Run the pipeline against a single message and print the
199
- result. Useful for verifying your setup without touching
200
- application code.
201
-
202
- Options for init:
203
- --minimal Write runtime config/catalog only; skip classifiers/
204
- --dry-run Preview what would be created; don't write anything
205
- --force Overwrite existing files without prompting
206
- --no-install Skip the "add to package.json" prompt
207
- --package-manager <m> npm | pnpm | yarn | bun (default: auto-detect)
208
- --classifier-dir <p> Directory for classifiers (default: ./classifiers)
209
- --yes, -y Accept all prompts (CI mode)
210
-
211
- Options for uninstall:
212
- --dry-run Preview what would be removed; don't delete anything
213
- --force Remove the whole classifiers/ directory
214
- --keep-package Don't run the package manager uninstall step
215
- --package-manager <m> npm | pnpm | yarn | bun (default: auto-detect)
216
- --classifier-dir <p> Directory for classifiers (default: ./classifiers)
217
- --yes, -y Accept all prompts (CI mode)
74
+ init Scaffold ./open-classify/ in the current directory.
75
+ Re-run safe: existing files are skipped unless --force.
218
76
 
219
- `);
220
- }
77
+ eject <name> Copy a stock classifier into ./open-classify/classifiers/<name>/
78
+ so you can edit it. Stock classifiers:
79
+ ${STOCK_NAMES.join(", ")}
221
80
 
222
- function detectPackageManager(cwd) {
223
- if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
224
- if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
225
- if (existsSync(join(cwd, "bun.lockb"))) return "bun";
226
- return "npm";
227
- }
81
+ doctor Verify install, config, Ollama, and classifiers.
82
+ Exits non-zero on failure.
228
83
 
229
- function isOpenClassifyDep(pkg) {
230
- return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some(
231
- (f) => pkg[f]?.["open-classify"],
232
- );
233
- }
84
+ try <message> Run the pipeline against a single message and print
85
+ the result.
234
86
 
235
- function getCliVersion() {
236
- try {
237
- return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
238
- } catch {
239
- return null;
240
- }
87
+ Options:
88
+ --yes, -y Accept all prompts (CI mode)
89
+ --force Overwrite existing files
90
+ --dry-run Preview what would change; don't write anything
91
+
92
+ Setup:
93
+ npm install open-classify
94
+ npx open-classify init
95
+
96
+ Removal:
97
+ rm -rf open-classify/
98
+ npm uninstall open-classify
99
+
100
+ Docs: https://github.com/taylorbayouth/open-classify#readme
101
+ `);
241
102
  }
242
103
 
243
104
  // ---------------------------------------------------------------------------
244
105
  // init
245
106
  // ---------------------------------------------------------------------------
246
107
 
247
- async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageManager, classifierDir }) {
248
- // 1. Preflight: require a host project.
249
- const pkgPath = join(cwd, "package.json");
250
- if (!existsSync(pkgPath)) {
251
- process.stderr.write(
252
- `✖ No package.json found in ${cwd}.\n` +
253
- ` open-classify scaffolds code that imports the library, so it needs a\n` +
254
- ` Node project to live in.\n\n` +
255
- ` Create one first: npm init -y\n`,
256
- );
257
- process.exit(1);
258
- }
259
-
260
- let pkg;
261
- try {
262
- pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
263
- } catch {
264
- process.stderr.write(`✖ Could not parse package.json at ${pkgPath}\n`);
265
- process.exit(1);
266
- }
108
+ async function runInit({ cwd, yes, force, dryRun }) {
109
+ requireHostProject(cwd);
110
+ warnIfPackageMissing(cwd);
267
111
 
268
- // 2. Offer to install if not yet a dependency (skip in --yes / --no-install mode).
269
- let installedNow = false;
270
- if (!isOpenClassifyDep(pkg) && !noInstall && !yes) {
271
- process.stdout.write(`ℹ open-classify is not yet a dependency of this project.\n\n`);
272
- const doInstall = await confirm("? Add open-classify to package.json and install it now? (Y/n) ", true);
273
- if (doInstall) {
274
- const pm = packageManager || detectPackageManager(cwd);
275
- const installCmd = pm === "npm" ? ["install", "open-classify"] : ["add", "open-classify"];
276
- process.stdout.write(`\n Running: ${pm} ${installCmd.join(" ")}\n\n`);
277
- const result = spawnSync(pm, installCmd, { cwd, stdio: "inherit" });
278
- if (result.status !== 0) {
279
- process.stderr.write(`\n✖ Install failed. Run manually: ${pm} ${installCmd.join(" ")}\n`);
280
- process.exit(1);
281
- }
282
- installedNow = true;
283
- process.stdout.write("\n");
284
- } else {
285
- process.stdout.write(
286
- ` Skipped. You'll need to run \`npm install open-classify\` before importing.\n\n`,
287
- );
288
- }
289
- }
112
+ const destRoot = join(cwd, PROJECT_DIRNAME);
113
+ const plan = planScaffoldCopy(SCAFFOLD_DIR, destRoot, cwd, force);
290
114
 
291
- // 3. Plan.
292
- const resolvedClassifierDir = resolve(cwd, classifierDir);
293
- const config = configForInit({ minimal });
294
- const wrote = { config: false, catalog: false, readme: false, templateCount: 0 };
295
- let plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force, wrote, config });
296
-
297
- // Nothing to do.
298
- if (plan.toCreate.length === 0) {
299
- process.stdout.write("Nothing to do — your project already has all the scaffolded files.\n");
300
- if (plan.toSkip.length > 0) {
301
- process.stdout.write("\nAlready in place:\n");
302
- for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
115
+ if (plan.actions.length === 0) {
116
+ process.stdout.write(`Nothing to do ./${PROJECT_DIRNAME}/ already has every scaffold file.\n`);
117
+ if (plan.skipped.length > 0) {
118
+ process.stdout.write("\nAlready present (use --force to overwrite):\n");
119
+ for (const p of plan.skipped) process.stdout.write(` ${p}\n`);
303
120
  }
304
121
  return;
305
122
  }
306
123
 
307
- // 4. Preview.
308
124
  process.stdout.write(`\nThe following will be created in ${cwd}:\n\n`);
309
- for (const item of plan.preview) {
310
- if (item.isGroupHeader) {
311
- process.stdout.write(` ${item.label}\n`);
312
- } else if (item.indent) {
313
- process.stdout.write(` ${item.label.padEnd(32)} ${item.description}\n`);
314
- } else {
315
- process.stdout.write(` ${item.label.padEnd(34)} ${item.description}\n`);
316
- }
317
- }
125
+ for (const item of plan.preview) process.stdout.write(` ${item}\n`);
318
126
 
319
- if (plan.toSkip.length > 0) {
320
- process.stdout.write(`\n⚠ These files already exist and will be skipped:\n`);
321
- for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
127
+ if (plan.skipped.length > 0) {
128
+ process.stdout.write(`\nAlready present (use --force to overwrite):\n`);
129
+ for (const p of plan.skipped) process.stdout.write(` ${p}\n`);
322
130
  }
323
131
 
324
- // 5. Stop here on --dry-run.
325
132
  if (dryRun) {
326
133
  process.stdout.write("\n(dry run — nothing written)\n");
327
134
  return;
328
135
  }
329
136
 
330
- // 6. Conflict handling: interactive only (not --yes, not --force).
331
- if (plan.toSkip.length > 0 && !yes && !force) {
332
- const choice = await promptConflict();
333
- if (choice === "diff") {
334
- showDiffs(plan.toSkip, cwd, resolvedClassifierDir, config);
335
- const choice2 = await promptConflict();
336
- if (choice2 === "y") {
337
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
338
- }
339
- } else if (choice === "y") {
340
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
341
- }
342
- }
343
-
344
- // 7. Confirm (skip in --yes mode).
345
137
  if (!yes) {
346
138
  const proceed = await confirm("\n? Continue? (Y/n) ", true);
347
139
  if (!proceed) {
@@ -350,251 +142,116 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
350
142
  }
351
143
  }
352
144
 
353
- // 8. Execute.
354
145
  process.stdout.write("\n");
355
146
  for (const action of plan.actions) action();
356
147
 
357
- // 9. Summary + next steps.
358
- process.stdout.write("\n");
359
- if (installedNow) {
360
- const v = getCliVersion();
361
- process.stdout.write(`✓ open-classify installed${v ? ` (v${v})` : ""}\n`);
362
- }
363
- if (wrote.config) process.stdout.write("✓ wrote open-classify.config.json\n");
364
- if (wrote.catalog) process.stdout.write(`✓ wrote ${DOWNSTREAM_MODELS_FILENAME}\n`);
365
- if (wrote.readme || wrote.templateCount > 0) {
366
- const classifierDirRel = relative(cwd, resolvedClassifierDir);
367
- if (wrote.templateCount > 0) {
368
- process.stdout.write(`✓ scaffolded ${wrote.templateCount} classifier(s) in ./${classifierDirRel}/\n`);
369
- } else {
370
- process.stdout.write(`✓ wrote ./${classifierDirRel}/README.md\n`);
371
- }
372
- }
373
-
148
+ const cfg = readScaffoldConfig();
374
149
  process.stdout.write(`
375
150
  Next steps:
376
151
 
377
152
  1. Pull the default classifier model:
378
-
379
- ollama pull ${config.runner.defaultModel}
153
+ ollama pull ${cfg.runner.defaultModel}
380
154
 
381
155
  2. Verify everything is wired up:
382
-
383
156
  npx open-classify doctor
384
157
 
385
- 3. Try it without writing any code:
386
-
158
+ 3. Try it without writing code:
387
159
  npx open-classify try "hello"
388
160
 
389
161
  4. Use it from your code:
390
-
391
162
  import { createClassifier } from "open-classify";
392
163
  const { classify } = createClassifier();
393
- const result = await classify({
394
- messages: [{ role: "user", text: "hello" }],
395
- });
396
164
 
397
- The factory finds open-classify.config.json in your working
398
- directory and wires in the classifiers/ folder automatically.
165
+ createClassifier() finds ./${PROJECT_DIRNAME}/config.json and wires
166
+ in ./${PROJECT_DIRNAME}/classifiers/ automatically.
399
167
 
400
168
  Docs: https://github.com/taylorbayouth/open-classify#readme
401
169
  `);
402
170
  }
403
171
 
404
- function planInit(cwd, { minimal = false, classifierDir, force = false, wrote, config }) {
405
- const toCreate = [];
406
- const toSkip = [];
172
+ // Recursively plan a directory copy from source dest, relative to projectCwd
173
+ // for display. Returns { actions, preview, skipped }.
174
+ function planScaffoldCopy(sourceDir, destDir, projectCwd, force) {
407
175
  const actions = [];
408
176
  const preview = [];
177
+ const skipped = [];
409
178
 
410
- // Config file.
411
- const configPath = join(cwd, "open-classify.config.json");
412
- const configRel = relative(cwd, configPath);
413
- if (existsSync(configPath) && !force) {
414
- toSkip.push(configRel);
415
- } else {
416
- toCreate.push(configRel);
417
- preview.push({ label: configRel, description: `(default Ollama setup, ${config.runner.defaultModel})` });
418
- actions.push(() => {
419
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
420
- process.stdout.write(` wrote ${configRel}\n`);
421
- wrote.config = true;
422
- });
423
- }
424
-
425
- // Downstream model catalog.
426
- const catalogPath = join(cwd, DOWNSTREAM_MODELS_FILENAME);
427
- const catalogRel = relative(cwd, catalogPath);
428
- if (existsSync(catalogPath) && !force) {
429
- toSkip.push(catalogRel);
430
- } else {
431
- toCreate.push(catalogRel);
432
- preview.push({ label: catalogRel, description: "default downstream model catalog" });
433
- actions.push(() => {
434
- cpSync(DOWNSTREAM_MODELS_PATH, catalogPath);
435
- process.stdout.write(` wrote ${catalogRel}\n`);
436
- wrote.catalog = true;
437
- });
438
- }
439
-
440
- if (!minimal) {
441
- const classifierPreviewItems = [];
179
+ walk(sourceDir, destDir);
442
180
 
443
- // Ensure the directory exists (prerequisite for all classifier actions).
444
- if (!existsSync(classifierDir)) {
445
- toCreate.push(`${relative(cwd, classifierDir)}/`);
446
- actions.push(() => {
447
- mkdirSync(classifierDir, { recursive: true });
448
- });
449
- }
181
+ return { actions, preview, skipped };
450
182
 
451
- // README.md.
452
- const readmePath = join(classifierDir, "README.md");
453
- const readmeRel = relative(cwd, readmePath);
454
- if (existsSync(readmePath) && !force) {
455
- toSkip.push(readmeRel);
456
- } else {
457
- toCreate.push(readmeRel);
458
- classifierPreviewItems.push({ label: "README.md", indent: true, description: "how to author your own classifier" });
183
+ function walk(src, dst) {
184
+ if (!existsSync(dst)) {
459
185
  actions.push(() => {
460
- mkdirSync(classifierDir, { recursive: true });
461
- writeFileSync(readmePath, CLASSIFIERS_README);
462
- process.stdout.write(` wrote ${readmeRel}\n`);
463
- wrote.readme = true;
186
+ mkdirSync(dst, { recursive: true });
187
+ process.stdout.write(` created ${relative(projectCwd, dst)}/\n`);
464
188
  });
189
+ preview.push(`${relative(projectCwd, dst)}/`);
465
190
  }
466
191
 
467
- // Template classifier directories.
468
- for (const name of TEMPLATE_NAMES) {
469
- const inactivePath = join(classifierDir, `_${name}`);
470
- const activePath = join(classifierDir, name);
471
- const inactiveRel = relative(cwd, inactivePath);
472
- const activeRel = relative(cwd, activePath);
473
-
474
- // Never overwrite an activated (user-renamed) template.
475
- if (existsSync(activePath)) {
476
- toSkip.push(`${activeRel}/`);
477
- continue;
478
- }
479
-
480
- if (existsSync(inactivePath) && !force) {
481
- toSkip.push(`${inactiveRel}/`);
482
- continue;
483
- }
484
-
485
- toCreate.push(`${inactiveRel}/`);
486
- classifierPreviewItems.push({
487
- label: `_${name}/`,
488
- indent: true,
489
- description: TEMPLATE_DESCRIPTIONS[name],
490
- });
491
- actions.push(() => {
492
- mkdirSync(classifierDir, { recursive: true });
493
- if (force && existsSync(inactivePath)) {
494
- rmSync(inactivePath, { recursive: true, force: true });
192
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
193
+ const srcChild = join(src, entry.name);
194
+ const dstChild = join(dst, entry.name);
195
+ if (entry.isDirectory()) {
196
+ walk(srcChild, dstChild);
197
+ } else {
198
+ const exists = existsSync(dstChild);
199
+ if (exists && !force) {
200
+ skipped.push(relative(projectCwd, dstChild));
201
+ continue;
495
202
  }
496
- cpSync(join(TEMPLATES_DIR, name), inactivePath, { recursive: true });
497
- process.stdout.write(` wrote ${inactiveRel}/\n`);
498
- wrote.templateCount++;
499
- });
500
- }
501
-
502
- if (classifierPreviewItems.length > 0) {
503
- preview.push({ label: `${relative(cwd, classifierDir)}/`, isGroupHeader: true });
504
- preview.push(...classifierPreviewItems);
505
- }
506
- }
507
-
508
- return { toCreate, toSkip, actions, preview };
509
- }
510
-
511
- function showDiffs(conflicts, cwd, classifierDir, config = DEFAULT_CONFIG) {
512
- for (const p of conflicts) {
513
- const isDir = p.endsWith("/");
514
- const relPath = isDir ? p.slice(0, -1) : p;
515
- const fullPath = join(cwd, relPath);
516
-
517
- process.stdout.write(`\n--- ${p} ---\n`);
518
-
519
- if (!isDir) {
520
- process.stdout.write("\n current:\n");
521
- try {
522
- const lines = readFileSync(fullPath, "utf8").split("\n");
523
- for (const line of lines) process.stdout.write(` ${line}\n`);
524
- } catch {
525
- process.stdout.write(" (could not read)\n");
526
- }
527
- process.stdout.write("\n would become:\n");
528
- const replacement =
529
- basename(relPath) === DOWNSTREAM_MODELS_FILENAME
530
- ? readFileSync(DOWNSTREAM_MODELS_PATH, "utf8")
531
- : JSON.stringify(config, null, 2);
532
- for (const line of replacement.split("\n")) {
533
- process.stdout.write(` ${line}\n`);
534
- }
535
- } else {
536
- process.stdout.write("\n current files:\n");
537
- try {
538
- for (const f of readdirSync(fullPath)) process.stdout.write(` ${f}\n`);
539
- } catch {
540
- process.stdout.write(" (could not read)\n");
541
- }
542
- const templateName = basename(relPath).replace(/^_/, "");
543
- const templatePath = join(TEMPLATES_DIR, templateName);
544
- if (existsSync(templatePath)) {
545
- process.stdout.write("\n template files:\n");
546
- for (const f of readdirSync(templatePath)) process.stdout.write(` ${f}\n`);
203
+ actions.push(() => {
204
+ cpSync(srcChild, dstChild);
205
+ process.stdout.write(` wrote ${relative(projectCwd, dstChild)}\n`);
206
+ });
207
+ preview.push(relative(projectCwd, dstChild));
547
208
  }
548
209
  }
549
210
  }
550
- process.stdout.write("\n");
551
211
  }
552
212
 
553
213
  // ---------------------------------------------------------------------------
554
- // uninstall
214
+ // eject
555
215
  // ---------------------------------------------------------------------------
556
216
 
557
- async function runUninstall({ cwd, yes, dryRun, force, keepPackage, packageManager, classifierDir }) {
558
- const resolvedClassifierDir = resolve(cwd, classifierDir);
559
- const plan = planUninstall(cwd, { classifierDir: resolvedClassifierDir, force });
560
-
561
- const pkgPath = join(cwd, "package.json");
562
- let pkg = null;
563
- if (existsSync(pkgPath)) {
564
- try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { pkg = null; }
217
+ async function runEject({ cwd, name, yes, force, dryRun }) {
218
+ if (!name) {
219
+ process.stderr.write(`Usage: open-classify eject <name>\n\nAvailable: ${STOCK_NAMES.join(", ")}\n`);
220
+ process.exit(1);
565
221
  }
566
- const packageInstalled = pkg !== null && isOpenClassifyDep(pkg);
567
- const willRemovePackage = !keepPackage && packageInstalled;
568
- const pm = packageManager || detectPackageManager(cwd);
569
-
570
- if (plan.toRemove.length === 0 && !willRemovePackage) {
571
- process.stdout.write("Nothing to remove — no open-classify scaffold or dependency found.\n");
572
- if (plan.toSkip.length > 0) {
573
- process.stdout.write("\nSkipped active/custom classifier dirs:\n");
574
- for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
575
- process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\n");
576
- }
577
- return;
222
+ if (!STOCK_NAMES.includes(name)) {
223
+ process.stderr.write(`✖ "${name}" is not a stock classifier.\n\nAvailable: ${STOCK_NAMES.join(", ")}\n`);
224
+ process.exit(1);
578
225
  }
579
226
 
580
- process.stdout.write(`\nThe following will be removed from ${cwd}:\n\n`);
581
- for (const p of plan.toRemove) process.stdout.write(` ${p}\n`);
582
- if (willRemovePackage) {
583
- process.stdout.write(` open-classify (via ${pm} uninstall)\n`);
227
+ const projectDir = join(cwd, PROJECT_DIRNAME);
228
+ if (!existsSync(projectDir)) {
229
+ process.stderr.write(
230
+ `✖ ./${PROJECT_DIRNAME}/ not found. Run \`npx open-classify init\` first.\n`,
231
+ );
232
+ process.exit(1);
584
233
  }
585
234
 
586
- if (plan.toSkip.length > 0) {
587
- process.stdout.write("\nSkipped active/custom classifier dirs:\n");
588
- for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
589
- process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\n");
235
+ const source = join(STOCK_DIR, name);
236
+ const dest = join(projectDir, "classifiers", name);
237
+ const destRel = relative(cwd, dest);
238
+
239
+ if (existsSync(dest) && !force) {
240
+ process.stderr.write(
241
+ `✖ ${destRel}/ already exists. Use --force to overwrite, or delete it first.\n`,
242
+ );
243
+ process.exit(1);
590
244
  }
591
245
 
592
- if (keepPackage && packageInstalled) {
593
- process.stdout.write("\nKeeping the open-classify package (--keep-package).\n");
246
+ process.stdout.write(`\nEjecting "${name}" ${destRel}/\n\n`);
247
+ for (const filename of ["manifest.json", "prompt.md"]) {
248
+ const srcFile = join(source, filename);
249
+ const dstFile = join(dest, filename);
250
+ process.stdout.write(` ${relative(cwd, dstFile)}\n`);
594
251
  }
595
252
 
596
253
  if (dryRun) {
597
- process.stdout.write("\n(dry run — nothing removed)\n");
254
+ process.stdout.write("\n(dry run — nothing written)\n");
598
255
  return;
599
256
  }
600
257
 
@@ -606,91 +263,18 @@ async function runUninstall({ cwd, yes, dryRun, force, keepPackage, packageManag
606
263
  }
607
264
  }
608
265
 
609
- process.stdout.write("\n");
610
- for (const action of plan.actions) action();
611
- if (plan.toRemove.length > 0) {
612
- process.stdout.write("\n✓ removed open-classify scaffold\n");
613
- }
614
-
615
- if (willRemovePackage) {
616
- process.stdout.write(`\n Running: ${pm} uninstall open-classify\n\n`);
617
- const result = spawnSync(pm, ["uninstall", "open-classify"], { cwd, stdio: "inherit" });
618
- if (result.status !== 0) {
619
- process.stderr.write(`\n✖ Package uninstall failed. Run manually: ${pm} uninstall open-classify\n`);
620
- process.exit(1);
621
- }
622
- process.stdout.write("\n✓ removed open-classify package\n");
623
- }
624
- }
625
-
626
- function planUninstall(cwd, { classifierDir, force }) {
627
- const toRemove = [];
628
- const toSkip = [];
629
- const actions = [];
630
-
631
- for (const filename of ["open-classify.config.json", DOWNSTREAM_MODELS_FILENAME]) {
632
- const path = join(cwd, filename);
633
- if (!existsSync(path)) continue;
634
- toRemove.push(filename);
635
- actions.push(() => {
636
- rmSync(path, { force: true });
637
- process.stdout.write(` removed ${filename}\n`);
638
- });
639
- }
640
-
641
- const classifierRel = relative(cwd, classifierDir);
642
- if (!existsSync(classifierDir)) {
643
- return { toRemove, toSkip, actions };
644
- }
645
-
646
- if (force) {
647
- toRemove.push(`${classifierRel}/`);
648
- actions.push(() => {
649
- rmSync(classifierDir, { recursive: true, force: true });
650
- process.stdout.write(` removed ${classifierRel}/\n`);
651
- });
652
- return { toRemove, toSkip, actions };
653
- }
654
-
655
- const readmePath = join(classifierDir, "README.md");
656
- const readmeRel = relative(cwd, readmePath);
657
- if (existsSync(readmePath)) {
658
- toRemove.push(readmeRel);
659
- actions.push(() => {
660
- rmSync(readmePath, { force: true });
661
- process.stdout.write(` removed ${readmeRel}\n`);
662
- });
663
- }
664
-
665
- for (const name of TEMPLATE_NAMES) {
666
- const inactivePath = join(classifierDir, `_${name}`);
667
- const inactiveRel = relative(cwd, inactivePath);
668
- if (existsSync(inactivePath)) {
669
- toRemove.push(`${inactiveRel}/`);
670
- actions.push(() => {
671
- rmSync(inactivePath, { recursive: true, force: true });
672
- process.stdout.write(` removed ${inactiveRel}/\n`);
673
- });
674
- }
675
-
676
- const activePath = join(classifierDir, name);
677
- if (existsSync(activePath)) {
678
- toSkip.push(`${relative(cwd, activePath)}/`);
679
- }
680
- }
266
+ mkdirSync(dest, { recursive: true });
267
+ cpSync(source, dest, { recursive: true });
681
268
 
682
- actions.push(() => {
683
- try {
684
- if (readdirSync(classifierDir).length === 0) {
685
- rmSync(classifierDir, { recursive: true, force: true });
686
- process.stdout.write(` removed ${classifierRel}/\n`);
687
- }
688
- } catch {
689
- // Non-empty: custom/active classifiers remain, which is intentional.
690
- }
691
- });
269
+ process.stdout.write(`
270
+ ejected ${name}
692
271
 
693
- return { toRemove, toSkip, actions };
272
+ The runtime now uses your local copy at ${destRel}/. Edit prompt.md or
273
+ manifest.json to taste. \`npm update open-classify\` won't touch these
274
+ files. To revert: delete the folder. If you want the package-owned
275
+ version to take over after that, add "${name}" to classifiers.stock in
276
+ ${PROJECT_DIRNAME}/config.json.
277
+ `);
694
278
  }
695
279
 
696
280
  // ---------------------------------------------------------------------------
@@ -701,123 +285,111 @@ async function runDoctor({ cwd }) {
701
285
  let allGood = true;
702
286
 
703
287
  // 1. package.json + open-classify dep.
704
- const pkgPath = join(cwd, "package.json");
705
- if (!existsSync(pkgPath)) {
288
+ const pkg = readJsonIfExists(join(cwd, "package.json"));
289
+ if (pkg === null) {
706
290
  process.stdout.write("✖ No package.json — not a Node project\n");
707
291
  allGood = false;
292
+ } else if (isOpenClassifyDep(pkg)) {
293
+ process.stdout.write("✓ open-classify found in package.json\n");
708
294
  } else {
709
- let pkg;
710
- try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { pkg = {}; }
711
- if (isOpenClassifyDep(pkg)) {
712
- process.stdout.write("✓ open-classify found in package.json\n");
713
- } else {
714
- process.stdout.write("⚠ open-classify not listed as a dependency\n");
715
- allGood = false;
716
- }
295
+ process.stdout.write("⚠ open-classify not listed as a dependency — run: npm install open-classify\n");
296
+ allGood = false;
717
297
  }
718
298
 
719
- // 2. Config parses.
720
- const configPath = join(cwd, "open-classify.config.json");
299
+ // 2. Config parses + catalog present.
300
+ const projectDir = join(cwd, PROJECT_DIRNAME);
301
+ const configPath = join(projectDir, "config.json");
302
+ let config = null;
721
303
  if (!existsSync(configPath)) {
722
- process.stdout.write("✖ No open-classify.config.json — run: npx open-classify init\n");
304
+ process.stdout.write(`✖ ./${PROJECT_DIRNAME}/config.json not found — run: npx open-classify init\n`);
723
305
  allGood = false;
724
306
  } else {
725
- let config;
726
- try {
727
- config = JSON.parse(readFileSync(configPath, "utf8"));
728
- process.stdout.write("✓ open-classify.config.json parses OK\n");
729
-
730
- // 3. Catalog exists.
731
- const catalog = config.catalog || DEFAULT_CONFIG.catalog;
732
- const catalogPath = resolve(cwd, catalog);
307
+ config = readJsonIfExists(configPath);
308
+ if (config === null) {
309
+ process.stdout.write(`✖ ./${PROJECT_DIRNAME}/config.json is not valid JSON\n`);
310
+ allGood = false;
311
+ } else {
312
+ process.stdout.write(`✓ ./${PROJECT_DIRNAME}/config.json parses OK\n`);
313
+ const catalogRel = config.catalog ?? "downstream-models.json";
314
+ const catalogPath = resolve(projectDir, catalogRel);
733
315
  if (existsSync(catalogPath)) {
734
- process.stdout.write(`✓ ${catalog} found\n`);
316
+ process.stdout.write(`✓ catalog found at ${relative(cwd, catalogPath)}\n`);
735
317
  } else {
736
- process.stdout.write(`✖ ${catalog} not found run: npx open-classify init\n`);
318
+ process.stdout.write(`✖ catalog not found at ${relative(cwd, catalogPath)}\n`);
737
319
  allGood = false;
738
320
  }
321
+ }
322
+ }
739
323
 
740
- // 4. Ollama reachable.
741
- const host = config.runner?.host || DEFAULT_CONFIG.runner.host;
742
- try {
743
- const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
744
- if (res.ok) {
745
- process.stdout.write(`✓ Ollama reachable at ${host}\n`);
746
-
747
- // 5. Default model pulled.
748
- const data = await res.json();
749
- const model = config.runner?.defaultModel || DEFAULT_CONFIG.runner.defaultModel;
750
- const pulled = data.models?.some((m) => m.name === model || m.model === model);
751
- if (pulled) {
752
- process.stdout.write(`✓ Model ${model} is available\n`);
753
- } else {
754
- process.stdout.write(`✖ Model ${model} not found — run: ollama pull ${model}\n`);
755
- allGood = false;
756
- }
324
+ // 3. Ollama reachable + default model pulled.
325
+ if (config !== null) {
326
+ const host = config.runner?.host ?? "http://127.0.0.1:11434";
327
+ const defaultModel = config.runner?.defaultModel ?? "gemma4:e4b-it-q4_K_M";
328
+ try {
329
+ const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
330
+ if (res.ok) {
331
+ process.stdout.write(`✓ Ollama reachable at ${host}\n`);
332
+ const data = await res.json();
333
+ const pulled = data.models?.some((m) => m.name === defaultModel || m.model === defaultModel);
334
+ if (pulled) {
335
+ process.stdout.write(`✓ Model ${defaultModel} is available\n`);
757
336
  } else {
758
- process.stdout.write(`✖ Ollama responded ${res.status} at ${host}\n`);
337
+ process.stdout.write(`✖ Model ${defaultModel} not found — run: ollama pull ${defaultModel}\n`);
759
338
  allGood = false;
760
339
  }
761
- } catch {
762
- process.stdout.write(`✖ Ollama not reachable at ${host} — is it running?\n`);
340
+ } else {
341
+ process.stdout.write(`✖ Ollama responded ${res.status} at ${host}\n`);
763
342
  allGood = false;
764
343
  }
765
344
  } catch {
766
- process.stdout.write("✖ open-classify.config.json is not valid JSON\n");
345
+ process.stdout.write(`✖ Ollama not reachable at ${host} — is it running?\n`);
767
346
  allGood = false;
768
347
  }
769
348
  }
770
349
 
771
- // 6. Classifiers directories.
772
- const doctorConfig = configFromFile(cwd);
773
- const configuredClassifierDirs =
774
- doctorConfig?.classifiers === undefined
775
- ? ["classifiers"]
776
- : doctorConfig.classifiers.dirs ?? [];
777
- for (const configuredDir of configuredClassifierDirs) {
778
- const classifiersDir = resolve(cwd, configuredDir);
779
- const classifiersRel = relative(cwd, classifiersDir);
780
- if (!existsSync(classifiersDir)) {
781
- process.stdout.write(`ℹ No ${classifiersRel}/ directory — run: npx open-classify init\n`);
782
- continue;
783
- }
784
-
785
- let active = 0;
786
- let bad = 0;
787
- try {
788
- for (const entry of readdirSync(classifiersDir, { withFileTypes: true })) {
789
- if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
790
- const dir = join(classifiersDir, entry.name);
791
- const ok =
792
- existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "prompt.md"));
793
- if (ok) active++;
794
- else {
795
- process.stdout.write(`✖ ${classifiersRel}/${entry.name}/ is missing manifest.json or prompt.md\n`);
796
- bad++;
797
- allGood = false;
350
+ // 4. Classifier directories.
351
+ if (config !== null) {
352
+ const dirs = config.classifiers?.dirs ?? ["classifiers"];
353
+ for (const dirRel of dirs) {
354
+ const dir = resolve(projectDir, dirRel);
355
+ const displayRel = relative(cwd, dir);
356
+ if (!existsSync(dir)) {
357
+ process.stdout.write(`ℹ No ${displayRel}/ run: npx open-classify init\n`);
358
+ continue;
359
+ }
360
+ let active = 0;
361
+ let bad = 0;
362
+ try {
363
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
364
+ if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
365
+ const sub = join(dir, entry.name);
366
+ const ok = existsSync(join(sub, "manifest.json")) && existsSync(join(sub, "prompt.md"));
367
+ if (ok) {
368
+ active++;
369
+ } else {
370
+ process.stdout.write(`✖ ${displayRel}/${entry.name}/ is missing manifest.json or prompt.md\n`);
371
+ bad++;
372
+ allGood = false;
373
+ }
374
+ }
375
+ } catch {/* skip */}
376
+ if (bad === 0) {
377
+ const stockEnabled = (config.classifiers?.stock ?? []).length;
378
+ process.stdout.write(
379
+ active > 0
380
+ ? `✓ ${active} user classifier(s) in ${displayRel}/\n`
381
+ : `ℹ No user classifiers in ${displayRel}/${stockEnabled > 0 ? "" : " (use `npx open-classify eject <name>` to customize a stock classifier)"}\n`,
382
+ );
383
+ if (stockEnabled > 0) {
384
+ process.stdout.write(`✓ ${stockEnabled} stock classifier(s) enabled in config\n`);
798
385
  }
799
386
  }
800
- } catch { /* skip */ }
801
- if (bad === 0) {
802
- process.stdout.write(
803
- active > 0
804
- ? `✓ ${active} active classifier(s) in ${classifiersRel}/\n`
805
- : `ℹ No active classifiers in ${classifiersRel}/ (enable stock in config or customize a _name template)\n`,
806
- );
807
387
  }
808
388
  }
809
389
 
810
390
  if (!allGood) process.exit(1);
811
391
  }
812
392
 
813
- function configFromFile(cwd) {
814
- try {
815
- return JSON.parse(readFileSync(join(cwd, "open-classify.config.json"), "utf8"));
816
- } catch {
817
- return null;
818
- }
819
- }
820
-
821
393
  // ---------------------------------------------------------------------------
822
394
  // try
823
395
  // ---------------------------------------------------------------------------
@@ -828,14 +400,12 @@ async function runTry({ cwd, message }) {
828
400
  process.exit(1);
829
401
  }
830
402
 
831
- const configPath = join(cwd, "open-classify.config.json");
403
+ const configPath = join(cwd, PROJECT_DIRNAME, "config.json");
832
404
  if (!existsSync(configPath)) {
833
- process.stderr.write("✖ No open-classify.config.json — run: npx open-classify init\n");
405
+ process.stderr.write(`✖ ./${PROJECT_DIRNAME}/config.json not found — run: npx open-classify init\n`);
834
406
  process.exit(1);
835
407
  }
836
408
 
837
- // Try loading from the consumer's node_modules first, then fall back to the
838
- // package root (useful when running from the development checkout).
839
409
  let createClassifier;
840
410
  const candidates = [
841
411
  join(cwd, "node_modules", "open-classify", "dist", "src", "index.js"),
@@ -844,10 +414,9 @@ async function runTry({ cwd, message }) {
844
414
  for (const candidate of candidates) {
845
415
  if (!existsSync(candidate)) continue;
846
416
  try {
847
- const mod = await import(candidate);
848
- createClassifier = mod.createClassifier;
417
+ ({ createClassifier } = await import(candidate));
849
418
  break;
850
- } catch { /* try next */ }
419
+ } catch {/* try next */}
851
420
  }
852
421
 
853
422
  if (!createClassifier) {
@@ -858,17 +427,9 @@ async function runTry({ cwd, message }) {
858
427
  process.exit(1);
859
428
  }
860
429
 
861
- const classifiersDir = join(cwd, "classifiers");
862
430
  let classifier;
863
431
  try {
864
- const config = configFromFile(cwd);
865
- const hasConfiguredClassifierDirs = Array.isArray(config?.classifiers?.dirs);
866
- classifier = createClassifier({
867
- configPath,
868
- extraClassifierDirs:
869
- hasConfiguredClassifierDirs || !existsSync(classifiersDir) ? [] : [classifiersDir],
870
- skipResourceCheck: false,
871
- });
432
+ classifier = createClassifier({ configPath });
872
433
  } catch (err) {
873
434
  process.stderr.write(`✖ Failed to initialise classifier: ${err.message}\n`);
874
435
  process.exit(1);
@@ -886,30 +447,52 @@ async function runTry({ cwd, message }) {
886
447
  }
887
448
 
888
449
  // ---------------------------------------------------------------------------
889
- // Prompt helpers
450
+ // Shared helpers
890
451
  // ---------------------------------------------------------------------------
891
452
 
892
- function confirm(prompt, defaultYes = false) {
893
- return new Promise((resolve) => {
894
- const rl = createInterface({ input: process.stdin, output: process.stdout });
895
- rl.question(prompt, (answer) => {
896
- rl.close();
897
- const v = (answer || "").trim().toLowerCase();
898
- resolve(defaultYes ? (v === "" || v === "y" || v === "yes") : (v === "y" || v === "yes"));
899
- });
900
- });
453
+ function requireHostProject(cwd) {
454
+ if (!existsSync(join(cwd, "package.json"))) {
455
+ process.stderr.write(
456
+ `✖ No package.json found in ${cwd}.\n` +
457
+ ` open-classify scaffolds into a Node project, so it needs one to live in.\n\n` +
458
+ ` Create one first: npm init -y\n`,
459
+ );
460
+ process.exit(1);
461
+ }
901
462
  }
902
463
 
903
- function promptConflict() {
904
- return new Promise((resolve) => {
905
- process.stdout.write("\n? Overwrite them?\n y overwrite all\n N keep existing (default)\n diff show what would change\n\n");
464
+ function warnIfPackageMissing(cwd) {
465
+ const pkg = readJsonIfExists(join(cwd, "package.json"));
466
+ if (pkg === null || isOpenClassifyDep(pkg)) return;
467
+ process.stdout.write(
468
+ `\n⚠ open-classify is not yet a dependency of this project.\n` +
469
+ ` Install it before importing from your code:\n\n` +
470
+ ` npm install open-classify\n`,
471
+ );
472
+ }
473
+
474
+ function readJsonIfExists(path) {
475
+ if (!existsSync(path)) return null;
476
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
477
+ }
478
+
479
+ function isOpenClassifyDep(pkg) {
480
+ return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some(
481
+ (f) => pkg[f]?.["open-classify"],
482
+ );
483
+ }
484
+
485
+ function readScaffoldConfig() {
486
+ return JSON.parse(readFileSync(join(SCAFFOLD_DIR, "config.json"), "utf8"));
487
+ }
488
+
489
+ function confirm(prompt, defaultYes = false) {
490
+ return new Promise((resolveAnswer) => {
906
491
  const rl = createInterface({ input: process.stdin, output: process.stdout });
907
- rl.question(" Choice (y/N/diff): ", (answer) => {
492
+ rl.question(prompt, (answer) => {
908
493
  rl.close();
909
494
  const v = (answer || "").trim().toLowerCase();
910
- if (v === "y" || v === "yes") resolve("y");
911
- else if (v === "diff") resolve("diff");
912
- else resolve("N");
495
+ resolveAnswer(defaultYes ? (v === "" || v === "y" || v === "yes") : (v === "y" || v === "yes"));
913
496
  });
914
497
  });
915
498
  }