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.
- package/bin/open-classify.mjs +533 -77
- package/package.json +1 -1
package/bin/open-classify.mjs
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// open-classify CLI.
|
|
2
|
+
// open-classify CLI. Subcommands: init, doctor, try.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
69
|
-
await runInit({ cwd: process.cwd(),
|
|
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 [
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
239
|
+
process.stdout.write("Nothing to do — your project already has all the scaffolded files.\n");
|
|
93
240
|
if (plan.toSkip.length > 0) {
|
|
94
|
-
|
|
95
|
-
for (const p of plan.toSkip)
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
for (const p of plan.toSkip)
|
|
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("\
|
|
286
|
+
const proceed = await confirm("\n? Continue? (Y/n) ", true);
|
|
109
287
|
if (!proceed) {
|
|
110
|
-
|
|
288
|
+
process.stdout.write("Aborted.\n");
|
|
111
289
|
process.exit(1);
|
|
112
290
|
}
|
|
113
291
|
}
|
|
114
292
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
133
|
-
|
|
341
|
+
const configRel = relative(cwd, configPath);
|
|
342
|
+
if (existsSync(configPath) && !force) {
|
|
343
|
+
toSkip.push(configRel);
|
|
134
344
|
} else {
|
|
135
|
-
toCreate.push(
|
|
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
|
-
|
|
349
|
+
process.stdout.write(` wrote ${configRel}\n`);
|
|
350
|
+
wrote.config = true;
|
|
139
351
|
});
|
|
140
352
|
}
|
|
141
353
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
193
|
-
|
|
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
|
}
|