open-classify 0.9.2 → 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,177 +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
- classifierDir: "classifiers",
164
- };
165
-
166
- for (let i = 0; i < args.length; i++) {
167
- const arg = args[i];
168
- if (arg === "--yes" || arg === "-y") flags.yes = true;
169
65
  else if (arg === "--dry-run") flags.dryRun = true;
170
- else if (arg === "--force") flags.force = true;
171
- else if (arg === "--classifier-dir" && args[i + 1]) flags.classifierDir = args[++i];
172
- else if (arg.startsWith("--classifier-dir=")) flags.classifierDir = arg.split("=")[1];
173
66
  }
174
-
175
67
  return flags;
176
68
  }
177
69
 
@@ -179,161 +71,69 @@ function printHelp() {
179
71
  process.stdout.write(`open-classify — runtime CLI
180
72
 
181
73
  Commands:
182
- init [options] Scaffold open-classify.config.json and classifiers/ in the
183
- current directory. Re-run safe: existing files are skipped.
184
-
185
- uninstall Remove open-classify scaffold files from the current
186
- directory. Use --force to remove the whole classifiers/
187
- directory, including active/custom classifiers.
188
-
189
- doctor Check that the install, config, Ollama, and classifiers are
190
- all working. Exits non-zero on failure.
191
-
192
- try <message> Run the pipeline against a single message and print the
193
- result. Useful for verifying your setup without touching
194
- application code.
195
-
196
- Options for init:
197
- --minimal Write runtime config/catalog only; skip classifiers/
198
- --dry-run Preview what would be created; don't write anything
199
- --force Overwrite existing files without prompting
200
- --no-install Skip the "add to package.json" prompt
201
- --package-manager <m> npm | pnpm | yarn | bun (default: auto-detect)
202
- --classifier-dir <p> Directory for classifiers (default: ./classifiers)
203
- --yes, -y Accept all prompts (CI mode)
204
-
205
- Options for uninstall:
206
- --dry-run Preview what would be removed; don't delete anything
207
- --force Remove the whole classifiers/ directory
208
- --classifier-dir <p> Directory for classifiers (default: ./classifiers)
209
- --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.
210
76
 
211
- `);
212
- }
77
+ eject <name> Copy a stock classifier into ./open-classify/classifiers/<name>/
78
+ so you can edit it. Stock classifiers:
79
+ ${STOCK_NAMES.join(", ")}
213
80
 
214
- function detectPackageManager(cwd) {
215
- if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
216
- if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
217
- if (existsSync(join(cwd, "bun.lockb"))) return "bun";
218
- return "npm";
219
- }
81
+ doctor Verify install, config, Ollama, and classifiers.
82
+ Exits non-zero on failure.
220
83
 
221
- function isOpenClassifyDep(pkg) {
222
- return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some(
223
- (f) => pkg[f]?.["open-classify"],
224
- );
225
- }
84
+ try <message> Run the pipeline against a single message and print
85
+ the result.
226
86
 
227
- function getCliVersion() {
228
- try {
229
- return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
230
- } catch {
231
- return null;
232
- }
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
+ `);
233
102
  }
234
103
 
235
104
  // ---------------------------------------------------------------------------
236
105
  // init
237
106
  // ---------------------------------------------------------------------------
238
107
 
239
- async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageManager, classifierDir }) {
240
- // 1. Preflight: require a host project.
241
- const pkgPath = join(cwd, "package.json");
242
- if (!existsSync(pkgPath)) {
243
- process.stderr.write(
244
- `✖ No package.json found in ${cwd}.\n` +
245
- ` open-classify scaffolds code that imports the library, so it needs a\n` +
246
- ` Node project to live in.\n\n` +
247
- ` Create one first: npm init -y\n`,
248
- );
249
- process.exit(1);
250
- }
108
+ async function runInit({ cwd, yes, force, dryRun }) {
109
+ requireHostProject(cwd);
110
+ warnIfPackageMissing(cwd);
251
111
 
252
- let pkg;
253
- try {
254
- pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
255
- } catch {
256
- process.stderr.write(`✖ Could not parse package.json at ${pkgPath}\n`);
257
- process.exit(1);
258
- }
112
+ const destRoot = join(cwd, PROJECT_DIRNAME);
113
+ const plan = planScaffoldCopy(SCAFFOLD_DIR, destRoot, cwd, force);
259
114
 
260
- // 2. Offer to install if not yet a dependency (skip in --yes / --no-install mode).
261
- let installedNow = false;
262
- if (!isOpenClassifyDep(pkg) && !noInstall && !yes) {
263
- process.stdout.write(`ℹ open-classify is not yet a dependency of this project.\n\n`);
264
- const doInstall = await confirm("? Add open-classify to package.json and install it now? (Y/n) ", true);
265
- if (doInstall) {
266
- const pm = packageManager || detectPackageManager(cwd);
267
- const installCmd = pm === "npm" ? ["install", "open-classify"] : ["add", "open-classify"];
268
- process.stdout.write(`\n Running: ${pm} ${installCmd.join(" ")}\n\n`);
269
- const result = spawnSync(pm, installCmd, { cwd, stdio: "inherit" });
270
- if (result.status !== 0) {
271
- process.stderr.write(`\n✖ Install failed. Run manually: ${pm} ${installCmd.join(" ")}\n`);
272
- process.exit(1);
273
- }
274
- installedNow = true;
275
- process.stdout.write("\n");
276
- } else {
277
- process.stdout.write(
278
- ` Skipped. You'll need to run \`npm install open-classify\` before importing.\n\n`,
279
- );
280
- }
281
- }
282
-
283
- // 3. Plan.
284
- const resolvedClassifierDir = resolve(cwd, classifierDir);
285
- const config = configForInit({ minimal });
286
- const wrote = { config: false, catalog: false, readme: false, templateCount: 0 };
287
- let plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force, wrote, config });
288
-
289
- // Nothing to do.
290
- if (plan.toCreate.length === 0) {
291
- process.stdout.write("Nothing to do — your project already has all the scaffolded files.\n");
292
- if (plan.toSkip.length > 0) {
293
- process.stdout.write("\nAlready in place:\n");
294
- 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`);
295
120
  }
296
121
  return;
297
122
  }
298
123
 
299
- // 4. Preview.
300
124
  process.stdout.write(`\nThe following will be created in ${cwd}:\n\n`);
301
- for (const item of plan.preview) {
302
- if (item.isGroupHeader) {
303
- process.stdout.write(` ${item.label}\n`);
304
- } else if (item.indent) {
305
- process.stdout.write(` ${item.label.padEnd(32)} ${item.description}\n`);
306
- } else {
307
- process.stdout.write(` ${item.label.padEnd(34)} ${item.description}\n`);
308
- }
309
- }
125
+ for (const item of plan.preview) process.stdout.write(` ${item}\n`);
310
126
 
311
- if (plan.toSkip.length > 0) {
312
- process.stdout.write(`\n⚠ These files already exist and will be skipped:\n`);
313
- 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`);
314
130
  }
315
131
 
316
- // 5. Stop here on --dry-run.
317
132
  if (dryRun) {
318
133
  process.stdout.write("\n(dry run — nothing written)\n");
319
134
  return;
320
135
  }
321
136
 
322
- // 6. Conflict handling: interactive only (not --yes, not --force).
323
- if (plan.toSkip.length > 0 && !yes && !force) {
324
- const choice = await promptConflict();
325
- if (choice === "diff") {
326
- showDiffs(plan.toSkip, cwd, resolvedClassifierDir, config);
327
- const choice2 = await promptConflict();
328
- if (choice2 === "y") {
329
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
330
- }
331
- } else if (choice === "y") {
332
- plan = planInit(cwd, { minimal, classifierDir: resolvedClassifierDir, force: true, wrote, config });
333
- }
334
- }
335
-
336
- // 7. Confirm (skip in --yes mode).
337
137
  if (!yes) {
338
138
  const proceed = await confirm("\n? Continue? (Y/n) ", true);
339
139
  if (!proceed) {
@@ -342,235 +142,116 @@ async function runInit({ cwd, yes, minimal, dryRun, force, noInstall, packageMan
342
142
  }
343
143
  }
344
144
 
345
- // 8. Execute.
346
145
  process.stdout.write("\n");
347
146
  for (const action of plan.actions) action();
348
147
 
349
- // 9. Summary + next steps.
350
- process.stdout.write("\n");
351
- if (installedNow) {
352
- const v = getCliVersion();
353
- process.stdout.write(`✓ open-classify installed${v ? ` (v${v})` : ""}\n`);
354
- }
355
- if (wrote.config) process.stdout.write("✓ wrote open-classify.config.json\n");
356
- if (wrote.catalog) process.stdout.write(`✓ wrote ${DOWNSTREAM_MODELS_FILENAME}\n`);
357
- if (wrote.readme || wrote.templateCount > 0) {
358
- const classifierDirRel = relative(cwd, resolvedClassifierDir);
359
- if (wrote.templateCount > 0) {
360
- process.stdout.write(`✓ scaffolded ${wrote.templateCount} classifier(s) in ./${classifierDirRel}/\n`);
361
- } else {
362
- process.stdout.write(`✓ wrote ./${classifierDirRel}/README.md\n`);
363
- }
364
- }
365
-
148
+ const cfg = readScaffoldConfig();
366
149
  process.stdout.write(`
367
150
  Next steps:
368
151
 
369
152
  1. Pull the default classifier model:
370
-
371
- ollama pull ${config.runner.defaultModel}
153
+ ollama pull ${cfg.runner.defaultModel}
372
154
 
373
155
  2. Verify everything is wired up:
374
-
375
156
  npx open-classify doctor
376
157
 
377
- 3. Try it without writing any code:
378
-
158
+ 3. Try it without writing code:
379
159
  npx open-classify try "hello"
380
160
 
381
161
  4. Use it from your code:
382
-
383
162
  import { createClassifier } from "open-classify";
384
163
  const { classify } = createClassifier();
385
- const result = await classify({
386
- messages: [{ role: "user", text: "hello" }],
387
- });
388
164
 
389
- The factory finds open-classify.config.json in your working
390
- directory and wires in the classifiers/ folder automatically.
165
+ createClassifier() finds ./${PROJECT_DIRNAME}/config.json and wires
166
+ in ./${PROJECT_DIRNAME}/classifiers/ automatically.
391
167
 
392
168
  Docs: https://github.com/taylorbayouth/open-classify#readme
393
169
  `);
394
170
  }
395
171
 
396
- function planInit(cwd, { minimal = false, classifierDir, force = false, wrote, config }) {
397
- const toCreate = [];
398
- 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) {
399
175
  const actions = [];
400
176
  const preview = [];
177
+ const skipped = [];
401
178
 
402
- // Config file.
403
- const configPath = join(cwd, "open-classify.config.json");
404
- const configRel = relative(cwd, configPath);
405
- if (existsSync(configPath) && !force) {
406
- toSkip.push(configRel);
407
- } else {
408
- toCreate.push(configRel);
409
- preview.push({ label: configRel, description: `(default Ollama setup, ${config.runner.defaultModel})` });
410
- actions.push(() => {
411
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
412
- process.stdout.write(` wrote ${configRel}\n`);
413
- wrote.config = true;
414
- });
415
- }
416
-
417
- // Downstream model catalog.
418
- const catalogPath = join(cwd, DOWNSTREAM_MODELS_FILENAME);
419
- const catalogRel = relative(cwd, catalogPath);
420
- if (existsSync(catalogPath) && !force) {
421
- toSkip.push(catalogRel);
422
- } else {
423
- toCreate.push(catalogRel);
424
- preview.push({ label: catalogRel, description: "default downstream model catalog" });
425
- actions.push(() => {
426
- cpSync(DOWNSTREAM_MODELS_PATH, catalogPath);
427
- process.stdout.write(` wrote ${catalogRel}\n`);
428
- wrote.catalog = true;
429
- });
430
- }
179
+ walk(sourceDir, destDir);
431
180
 
432
- if (!minimal) {
433
- const classifierPreviewItems = [];
181
+ return { actions, preview, skipped };
434
182
 
435
- // Ensure the directory exists (prerequisite for all classifier actions).
436
- if (!existsSync(classifierDir)) {
437
- toCreate.push(`${relative(cwd, classifierDir)}/`);
183
+ function walk(src, dst) {
184
+ if (!existsSync(dst)) {
438
185
  actions.push(() => {
439
- mkdirSync(classifierDir, { recursive: true });
186
+ mkdirSync(dst, { recursive: true });
187
+ process.stdout.write(` created ${relative(projectCwd, dst)}/\n`);
440
188
  });
189
+ preview.push(`${relative(projectCwd, dst)}/`);
441
190
  }
442
191
 
443
- // README.md.
444
- const readmePath = join(classifierDir, "README.md");
445
- const readmeRel = relative(cwd, readmePath);
446
- if (existsSync(readmePath) && !force) {
447
- toSkip.push(readmeRel);
448
- } else {
449
- toCreate.push(readmeRel);
450
- classifierPreviewItems.push({ label: "README.md", indent: true, description: "how to author your own classifier" });
451
- actions.push(() => {
452
- mkdirSync(classifierDir, { recursive: true });
453
- writeFileSync(readmePath, CLASSIFIERS_README);
454
- process.stdout.write(` wrote ${readmeRel}\n`);
455
- wrote.readme = true;
456
- });
457
- }
458
-
459
- // Template classifier directories.
460
- for (const name of TEMPLATE_NAMES) {
461
- const inactivePath = join(classifierDir, `_${name}`);
462
- const activePath = join(classifierDir, name);
463
- const inactiveRel = relative(cwd, inactivePath);
464
- const activeRel = relative(cwd, activePath);
465
-
466
- // Never overwrite an activated (user-renamed) template.
467
- if (existsSync(activePath)) {
468
- toSkip.push(`${activeRel}/`);
469
- continue;
470
- }
471
-
472
- if (existsSync(inactivePath) && !force) {
473
- toSkip.push(`${inactiveRel}/`);
474
- continue;
475
- }
476
-
477
- toCreate.push(`${inactiveRel}/`);
478
- classifierPreviewItems.push({
479
- label: `_${name}/`,
480
- indent: true,
481
- description: TEMPLATE_DESCRIPTIONS[name],
482
- });
483
- actions.push(() => {
484
- mkdirSync(classifierDir, { recursive: true });
485
- if (force && existsSync(inactivePath)) {
486
- 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;
487
202
  }
488
- cpSync(join(TEMPLATES_DIR, name), inactivePath, { recursive: true });
489
- process.stdout.write(` wrote ${inactiveRel}/\n`);
490
- wrote.templateCount++;
491
- });
492
- }
493
-
494
- if (classifierPreviewItems.length > 0) {
495
- preview.push({ label: `${relative(cwd, classifierDir)}/`, isGroupHeader: true });
496
- preview.push(...classifierPreviewItems);
497
- }
498
- }
499
-
500
- return { toCreate, toSkip, actions, preview };
501
- }
502
-
503
- function showDiffs(conflicts, cwd, classifierDir, config = DEFAULT_CONFIG) {
504
- for (const p of conflicts) {
505
- const isDir = p.endsWith("/");
506
- const relPath = isDir ? p.slice(0, -1) : p;
507
- const fullPath = join(cwd, relPath);
508
-
509
- process.stdout.write(`\n--- ${p} ---\n`);
510
-
511
- if (!isDir) {
512
- process.stdout.write("\n current:\n");
513
- try {
514
- const lines = readFileSync(fullPath, "utf8").split("\n");
515
- for (const line of lines) process.stdout.write(` ${line}\n`);
516
- } catch {
517
- process.stdout.write(" (could not read)\n");
518
- }
519
- process.stdout.write("\n would become:\n");
520
- const replacement =
521
- basename(relPath) === DOWNSTREAM_MODELS_FILENAME
522
- ? readFileSync(DOWNSTREAM_MODELS_PATH, "utf8")
523
- : JSON.stringify(config, null, 2);
524
- for (const line of replacement.split("\n")) {
525
- process.stdout.write(` ${line}\n`);
526
- }
527
- } else {
528
- process.stdout.write("\n current files:\n");
529
- try {
530
- for (const f of readdirSync(fullPath)) process.stdout.write(` ${f}\n`);
531
- } catch {
532
- process.stdout.write(" (could not read)\n");
533
- }
534
- const templateName = basename(relPath).replace(/^_/, "");
535
- const templatePath = join(TEMPLATES_DIR, templateName);
536
- if (existsSync(templatePath)) {
537
- process.stdout.write("\n template files:\n");
538
- 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));
539
208
  }
540
209
  }
541
210
  }
542
- process.stdout.write("\n");
543
211
  }
544
212
 
545
213
  // ---------------------------------------------------------------------------
546
- // uninstall
214
+ // eject
547
215
  // ---------------------------------------------------------------------------
548
216
 
549
- async function runUninstall({ cwd, yes, dryRun, force, classifierDir }) {
550
- const resolvedClassifierDir = resolve(cwd, classifierDir);
551
- const plan = planUninstall(cwd, { classifierDir: resolvedClassifierDir, force });
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);
221
+ }
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);
225
+ }
552
226
 
553
- if (plan.toRemove.length === 0) {
554
- process.stdout.write("Nothing to remove — no open-classify scaffold found.\n");
555
- if (plan.toSkip.length > 0) {
556
- process.stdout.write("\nSkipped active/custom classifier dirs:\n");
557
- for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
558
- process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\n");
559
- }
560
- return;
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);
561
233
  }
562
234
 
563
- process.stdout.write(`\nThe following open-classify scaffold will be removed from ${cwd}:\n\n`);
564
- for (const p of plan.toRemove) process.stdout.write(` ${p}\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);
244
+ }
565
245
 
566
- if (plan.toSkip.length > 0) {
567
- process.stdout.write("\nSkipped active/custom classifier dirs:\n");
568
- for (const p of plan.toSkip) process.stdout.write(` ${p}\n`);
569
- process.stdout.write("\nUse --force to remove the whole classifiers/ directory.\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`);
570
251
  }
571
252
 
572
253
  if (dryRun) {
573
- process.stdout.write("\n(dry run — nothing removed)\n");
254
+ process.stdout.write("\n(dry run — nothing written)\n");
574
255
  return;
575
256
  }
576
257
 
@@ -582,80 +263,18 @@ async function runUninstall({ cwd, yes, dryRun, force, classifierDir }) {
582
263
  }
583
264
  }
584
265
 
585
- process.stdout.write("\n");
586
- for (const action of plan.actions) action();
587
- process.stdout.write("\n✓ removed open-classify scaffold\n");
588
- process.stdout.write("To remove the package dependency too, run: npm uninstall open-classify\n");
589
- }
590
-
591
- function planUninstall(cwd, { classifierDir, force }) {
592
- const toRemove = [];
593
- const toSkip = [];
594
- const actions = [];
266
+ mkdirSync(dest, { recursive: true });
267
+ cpSync(source, dest, { recursive: true });
595
268
 
596
- for (const filename of ["open-classify.config.json", DOWNSTREAM_MODELS_FILENAME]) {
597
- const path = join(cwd, filename);
598
- if (!existsSync(path)) continue;
599
- toRemove.push(filename);
600
- actions.push(() => {
601
- rmSync(path, { force: true });
602
- process.stdout.write(` removed ${filename}\n`);
603
- });
604
- }
605
-
606
- const classifierRel = relative(cwd, classifierDir);
607
- if (!existsSync(classifierDir)) {
608
- return { toRemove, toSkip, actions };
609
- }
610
-
611
- if (force) {
612
- toRemove.push(`${classifierRel}/`);
613
- actions.push(() => {
614
- rmSync(classifierDir, { recursive: true, force: true });
615
- process.stdout.write(` removed ${classifierRel}/\n`);
616
- });
617
- return { toRemove, toSkip, actions };
618
- }
619
-
620
- const readmePath = join(classifierDir, "README.md");
621
- const readmeRel = relative(cwd, readmePath);
622
- if (existsSync(readmePath)) {
623
- toRemove.push(readmeRel);
624
- actions.push(() => {
625
- rmSync(readmePath, { force: true });
626
- process.stdout.write(` removed ${readmeRel}\n`);
627
- });
628
- }
629
-
630
- for (const name of TEMPLATE_NAMES) {
631
- const inactivePath = join(classifierDir, `_${name}`);
632
- const inactiveRel = relative(cwd, inactivePath);
633
- if (existsSync(inactivePath)) {
634
- toRemove.push(`${inactiveRel}/`);
635
- actions.push(() => {
636
- rmSync(inactivePath, { recursive: true, force: true });
637
- process.stdout.write(` removed ${inactiveRel}/\n`);
638
- });
639
- }
640
-
641
- const activePath = join(classifierDir, name);
642
- if (existsSync(activePath)) {
643
- toSkip.push(`${relative(cwd, activePath)}/`);
644
- }
645
- }
646
-
647
- actions.push(() => {
648
- try {
649
- if (readdirSync(classifierDir).length === 0) {
650
- rmSync(classifierDir, { recursive: true, force: true });
651
- process.stdout.write(` removed ${classifierRel}/\n`);
652
- }
653
- } catch {
654
- // Non-empty: custom/active classifiers remain, which is intentional.
655
- }
656
- });
269
+ process.stdout.write(`
270
+ ejected ${name}
657
271
 
658
- 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
+ `);
659
278
  }
660
279
 
661
280
  // ---------------------------------------------------------------------------
@@ -666,123 +285,111 @@ async function runDoctor({ cwd }) {
666
285
  let allGood = true;
667
286
 
668
287
  // 1. package.json + open-classify dep.
669
- const pkgPath = join(cwd, "package.json");
670
- if (!existsSync(pkgPath)) {
288
+ const pkg = readJsonIfExists(join(cwd, "package.json"));
289
+ if (pkg === null) {
671
290
  process.stdout.write("✖ No package.json — not a Node project\n");
672
291
  allGood = false;
292
+ } else if (isOpenClassifyDep(pkg)) {
293
+ process.stdout.write("✓ open-classify found in package.json\n");
673
294
  } else {
674
- let pkg;
675
- try { pkg = JSON.parse(readFileSync(pkgPath, "utf8")); } catch { pkg = {}; }
676
- if (isOpenClassifyDep(pkg)) {
677
- process.stdout.write("✓ open-classify found in package.json\n");
678
- } else {
679
- process.stdout.write("⚠ open-classify not listed as a dependency\n");
680
- allGood = false;
681
- }
295
+ process.stdout.write("⚠ open-classify not listed as a dependency — run: npm install open-classify\n");
296
+ allGood = false;
682
297
  }
683
298
 
684
- // 2. Config parses.
685
- 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;
686
303
  if (!existsSync(configPath)) {
687
- 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`);
688
305
  allGood = false;
689
306
  } else {
690
- let config;
691
- try {
692
- config = JSON.parse(readFileSync(configPath, "utf8"));
693
- process.stdout.write("✓ open-classify.config.json parses OK\n");
694
-
695
- // 3. Catalog exists.
696
- const catalog = config.catalog || DEFAULT_CONFIG.catalog;
697
- 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);
698
315
  if (existsSync(catalogPath)) {
699
- process.stdout.write(`✓ ${catalog} found\n`);
316
+ process.stdout.write(`✓ catalog found at ${relative(cwd, catalogPath)}\n`);
700
317
  } else {
701
- process.stdout.write(`✖ ${catalog} not found run: npx open-classify init\n`);
318
+ process.stdout.write(`✖ catalog not found at ${relative(cwd, catalogPath)}\n`);
702
319
  allGood = false;
703
320
  }
321
+ }
322
+ }
704
323
 
705
- // 4. Ollama reachable.
706
- const host = config.runner?.host || DEFAULT_CONFIG.runner.host;
707
- try {
708
- const res = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) });
709
- if (res.ok) {
710
- process.stdout.write(`✓ Ollama reachable at ${host}\n`);
711
-
712
- // 5. Default model pulled.
713
- const data = await res.json();
714
- const model = config.runner?.defaultModel || DEFAULT_CONFIG.runner.defaultModel;
715
- const pulled = data.models?.some((m) => m.name === model || m.model === model);
716
- if (pulled) {
717
- process.stdout.write(`✓ Model ${model} is available\n`);
718
- } else {
719
- process.stdout.write(`✖ Model ${model} not found — run: ollama pull ${model}\n`);
720
- allGood = false;
721
- }
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`);
722
336
  } else {
723
- process.stdout.write(`✖ Ollama responded ${res.status} at ${host}\n`);
337
+ process.stdout.write(`✖ Model ${defaultModel} not found — run: ollama pull ${defaultModel}\n`);
724
338
  allGood = false;
725
339
  }
726
- } catch {
727
- 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`);
728
342
  allGood = false;
729
343
  }
730
344
  } catch {
731
- 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`);
732
346
  allGood = false;
733
347
  }
734
348
  }
735
349
 
736
- // 6. Classifiers directories.
737
- const doctorConfig = configFromFile(cwd);
738
- const configuredClassifierDirs =
739
- doctorConfig?.classifiers === undefined
740
- ? ["classifiers"]
741
- : doctorConfig.classifiers.dirs ?? [];
742
- for (const configuredDir of configuredClassifierDirs) {
743
- const classifiersDir = resolve(cwd, configuredDir);
744
- const classifiersRel = relative(cwd, classifiersDir);
745
- if (!existsSync(classifiersDir)) {
746
- process.stdout.write(`ℹ No ${classifiersRel}/ directory — run: npx open-classify init\n`);
747
- continue;
748
- }
749
-
750
- let active = 0;
751
- let bad = 0;
752
- try {
753
- for (const entry of readdirSync(classifiersDir, { withFileTypes: true })) {
754
- if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
755
- const dir = join(classifiersDir, entry.name);
756
- const ok =
757
- existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "prompt.md"));
758
- if (ok) active++;
759
- else {
760
- process.stdout.write(`✖ ${classifiersRel}/${entry.name}/ is missing manifest.json or prompt.md\n`);
761
- bad++;
762
- 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`);
763
385
  }
764
386
  }
765
- } catch { /* skip */ }
766
- if (bad === 0) {
767
- process.stdout.write(
768
- active > 0
769
- ? `✓ ${active} active classifier(s) in ${classifiersRel}/\n`
770
- : `ℹ No active classifiers in ${classifiersRel}/ (enable stock in config or customize a _name template)\n`,
771
- );
772
387
  }
773
388
  }
774
389
 
775
390
  if (!allGood) process.exit(1);
776
391
  }
777
392
 
778
- function configFromFile(cwd) {
779
- try {
780
- return JSON.parse(readFileSync(join(cwd, "open-classify.config.json"), "utf8"));
781
- } catch {
782
- return null;
783
- }
784
- }
785
-
786
393
  // ---------------------------------------------------------------------------
787
394
  // try
788
395
  // ---------------------------------------------------------------------------
@@ -793,14 +400,12 @@ async function runTry({ cwd, message }) {
793
400
  process.exit(1);
794
401
  }
795
402
 
796
- const configPath = join(cwd, "open-classify.config.json");
403
+ const configPath = join(cwd, PROJECT_DIRNAME, "config.json");
797
404
  if (!existsSync(configPath)) {
798
- 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`);
799
406
  process.exit(1);
800
407
  }
801
408
 
802
- // Try loading from the consumer's node_modules first, then fall back to the
803
- // package root (useful when running from the development checkout).
804
409
  let createClassifier;
805
410
  const candidates = [
806
411
  join(cwd, "node_modules", "open-classify", "dist", "src", "index.js"),
@@ -809,10 +414,9 @@ async function runTry({ cwd, message }) {
809
414
  for (const candidate of candidates) {
810
415
  if (!existsSync(candidate)) continue;
811
416
  try {
812
- const mod = await import(candidate);
813
- createClassifier = mod.createClassifier;
417
+ ({ createClassifier } = await import(candidate));
814
418
  break;
815
- } catch { /* try next */ }
419
+ } catch {/* try next */}
816
420
  }
817
421
 
818
422
  if (!createClassifier) {
@@ -823,17 +427,9 @@ async function runTry({ cwd, message }) {
823
427
  process.exit(1);
824
428
  }
825
429
 
826
- const classifiersDir = join(cwd, "classifiers");
827
430
  let classifier;
828
431
  try {
829
- const config = configFromFile(cwd);
830
- const hasConfiguredClassifierDirs = Array.isArray(config?.classifiers?.dirs);
831
- classifier = createClassifier({
832
- configPath,
833
- extraClassifierDirs:
834
- hasConfiguredClassifierDirs || !existsSync(classifiersDir) ? [] : [classifiersDir],
835
- skipResourceCheck: false,
836
- });
432
+ classifier = createClassifier({ configPath });
837
433
  } catch (err) {
838
434
  process.stderr.write(`✖ Failed to initialise classifier: ${err.message}\n`);
839
435
  process.exit(1);
@@ -851,30 +447,52 @@ async function runTry({ cwd, message }) {
851
447
  }
852
448
 
853
449
  // ---------------------------------------------------------------------------
854
- // Prompt helpers
450
+ // Shared helpers
855
451
  // ---------------------------------------------------------------------------
856
452
 
857
- function confirm(prompt, defaultYes = false) {
858
- return new Promise((resolve) => {
859
- const rl = createInterface({ input: process.stdin, output: process.stdout });
860
- rl.question(prompt, (answer) => {
861
- rl.close();
862
- const v = (answer || "").trim().toLowerCase();
863
- resolve(defaultYes ? (v === "" || v === "y" || v === "yes") : (v === "y" || v === "yes"));
864
- });
865
- });
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
+ }
866
462
  }
867
463
 
868
- function promptConflict() {
869
- return new Promise((resolve) => {
870
- 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) => {
871
491
  const rl = createInterface({ input: process.stdin, output: process.stdout });
872
- rl.question(" Choice (y/N/diff): ", (answer) => {
492
+ rl.question(prompt, (answer) => {
873
493
  rl.close();
874
494
  const v = (answer || "").trim().toLowerCase();
875
- if (v === "y" || v === "yes") resolve("y");
876
- else if (v === "diff") resolve("diff");
877
- else resolve("N");
495
+ resolveAnswer(defaultYes ? (v === "" || v === "y" || v === "yes") : (v === "y" || v === "yes"));
878
496
  });
879
497
  });
880
498
  }