@nusoft/nuos-build-catalogue 0.17.1 → 0.19.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/dist/cli.js +49 -2
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +25 -0
- package/dist/commands/memory.d.ts +42 -0
- package/dist/commands/memory.js +116 -0
- package/dist/setup/ollama-detect.d.ts +35 -0
- package/dist/setup/ollama-detect.js +83 -0
- package/dist/setup/ollama-install.d.ts +52 -0
- package/dist/setup/ollama-install.js +140 -0
- package/dist/setup/ollama-pull.d.ts +40 -0
- package/dist/setup/ollama-pull.js +104 -0
- package/dist/setup/progress-bar.d.ts +51 -0
- package/dist/setup/progress-bar.js +85 -0
- package/dist/setup/run-llm-setup.d.ts +71 -0
- package/dist/setup/run-llm-setup.js +242 -0
- package/dist/setup/types.d.ts +99 -0
- package/dist/setup/types.js +20 -0
- package/package.json +2 -2
- package/templates/agents/architect.md +16 -0
- package/templates/agents/coder.md +15 -0
- package/templates/agents/debugger.md +15 -0
- package/templates/agents/researcher.md +14 -0
- package/templates/agents/reviewer.md +16 -0
- package/templates/agents/tester.md +15 -0
- package/templates/protocols/build-wu.md +30 -1
- package/templates/starter-kit/methodfile.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -362,8 +362,10 @@ function cmdHelp() {
|
|
|
362
362
|
console.log(`nuos-catalogue — NuOS build-catalogue tooling (WU 110, WU 111)
|
|
363
363
|
|
|
364
364
|
Usage:
|
|
365
|
-
nuos-catalogue init [--name=X --tagline="Y" --role=consumer --interactive]
|
|
366
|
-
(
|
|
365
|
+
nuos-catalogue init [--name=X --tagline="Y" --role=consumer --interactive] [--no-llm]
|
|
366
|
+
(bootstrap docs/build/, methodfile.json, .claude/commands/<protocols>, CLAUDE.md, .gitignore overrides; then probe Ollama and pull qwen3-embedding:0.6b for semantic search. --no-llm skips the LLM step. Refuses if docs/build/ already exists)
|
|
367
|
+
nuos-catalogue setup-llm
|
|
368
|
+
(run the LLM-setup phase outside 'init': detect Ollama, offer to install where reliable, pull qwen3-embedding:0.6b with a progress bar. Idempotent — safe to re-run)
|
|
367
369
|
nuos-catalogue install-protocols
|
|
368
370
|
(refresh .claude/commands/<protocols> from this CLI's bundled canonical bodies)
|
|
369
371
|
|
|
@@ -396,6 +398,9 @@ Usage:
|
|
|
396
398
|
nuos-catalogue swarm status [--limit=N] list recent /build-wu runs
|
|
397
399
|
nuos-catalogue swarm cost aggregate cost across swarm runs
|
|
398
400
|
|
|
401
|
+
nuos-catalogue memory store --value="..." [--wu=wu-007] [--agent=architect] [--key="label"]
|
|
402
|
+
nuos-catalogue memory search --query="..." [--limit=N] [--wu=wu-007] [--agent=architect]
|
|
403
|
+
|
|
399
404
|
nuos-catalogue help
|
|
400
405
|
|
|
401
406
|
Handles accepted: canonical (wu-111, D046, Q009, P001) or friendly
|
|
@@ -438,6 +443,7 @@ async function main() {
|
|
|
438
443
|
domain: args.flags['domain'] ? String(args.flags['domain']) : undefined,
|
|
439
444
|
role: args.flags['role'] ? String(args.flags['role']) : undefined,
|
|
440
445
|
interactive: Boolean(args.flags['interactive']),
|
|
446
|
+
noLlm: Boolean(args.flags['no-llm']),
|
|
441
447
|
});
|
|
442
448
|
if (result.output)
|
|
443
449
|
console.log(result.output);
|
|
@@ -448,6 +454,19 @@ async function main() {
|
|
|
448
454
|
}
|
|
449
455
|
break;
|
|
450
456
|
}
|
|
457
|
+
case 'setup-llm': {
|
|
458
|
+
// WU 135 — standalone re-entry into the LLM-setup phase. Useful
|
|
459
|
+
// when `init` was run with --no-llm, when an install failed, or
|
|
460
|
+
// when the user switched machines and needs to pull the model
|
|
461
|
+
// freshly. Same orchestrator that `init` calls internally.
|
|
462
|
+
const { runLlmSetup } = await import('./setup/run-llm-setup.js');
|
|
463
|
+
const result = await runLlmSetup({ nonInteractive: false });
|
|
464
|
+
// Most failure paths emit guidance in-band; we exit non-zero only
|
|
465
|
+
// when a pull actually failed (so CI scripting can branch on it).
|
|
466
|
+
const exitCode = result.kind === 'pull_failed' || result.kind === 'install_failed' ? 1 : 0;
|
|
467
|
+
process.exit(exitCode);
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
451
470
|
case 'install-protocols': {
|
|
452
471
|
const prompt = openPrompt();
|
|
453
472
|
try {
|
|
@@ -509,6 +528,34 @@ async function main() {
|
|
|
509
528
|
console.error('available: swarm status [--limit=N], swarm cost');
|
|
510
529
|
process.exit(1);
|
|
511
530
|
}
|
|
531
|
+
case 'memory': {
|
|
532
|
+
const sub = args.positional[0];
|
|
533
|
+
const { cmdMemoryStore, cmdMemorySearch } = await import('./commands/memory.js');
|
|
534
|
+
if (sub === 'store') {
|
|
535
|
+
const value = args.flags['value'] ? String(args.flags['value']) : '';
|
|
536
|
+
const wu = args.flags['wu'] ? String(args.flags['wu']) : undefined;
|
|
537
|
+
const agent = args.flags['agent'] ? String(args.flags['agent']) : undefined;
|
|
538
|
+
const key = args.flags['key'] ? String(args.flags['key']) : undefined;
|
|
539
|
+
const code = await cmdMemoryStore({ value, wu, agent, key, cwd: process.cwd() });
|
|
540
|
+
if (code !== 0)
|
|
541
|
+
process.exit(code);
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
if (sub === 'search') {
|
|
545
|
+
const query = args.flags['query'] ? String(args.flags['query']) : '';
|
|
546
|
+
const limit = args.flags['limit'] ? Number(args.flags['limit']) : undefined;
|
|
547
|
+
const wu = args.flags['wu'] ? String(args.flags['wu']) : undefined;
|
|
548
|
+
const agent = args.flags['agent'] ? String(args.flags['agent']) : undefined;
|
|
549
|
+
const code = await cmdMemorySearch({ query, limit, wu, agent, cwd: process.cwd() });
|
|
550
|
+
if (code !== 0)
|
|
551
|
+
process.exit(code);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
console.error(`unknown memory subcommand: ${sub ?? '(none)'}`);
|
|
555
|
+
console.error('available: memory store --value="..." [--wu=...] [--agent=...] [--key=...]');
|
|
556
|
+
console.error(' memory search --query="..." [--limit=N] [--wu=...] [--agent=...]');
|
|
557
|
+
process.exit(1);
|
|
558
|
+
}
|
|
512
559
|
case 'help':
|
|
513
560
|
case '--help':
|
|
514
561
|
case '-h':
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -47,6 +47,13 @@ export interface InitOptions {
|
|
|
47
47
|
* has no effect — init is always non-interactive unless `interactive` is set.
|
|
48
48
|
*/
|
|
49
49
|
nonInteractive?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Skip the post-scaffold LLM-setup phase (WU 135). When true, `init`
|
|
52
|
+
* scaffolds the catalogue and exits without probing for Ollama or
|
|
53
|
+
* offering to pull the embedding model. Users who skip can run
|
|
54
|
+
* `nuos-catalogue setup-llm` later. Default: false.
|
|
55
|
+
*/
|
|
56
|
+
noLlm?: boolean;
|
|
50
57
|
}
|
|
51
58
|
export interface InitResult {
|
|
52
59
|
output: string;
|
package/dist/commands/init.js
CHANGED
|
@@ -204,6 +204,31 @@ export async function cmdInit(prompt, options = {}) {
|
|
|
204
204
|
// Step 5: .gitignore
|
|
205
205
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
206
206
|
await ensureGitignoreEntries(gitignorePath, log_line);
|
|
207
|
+
// Step 6: LLM setup (WU 135). Probes Ollama, offers to install where
|
|
208
|
+
// reliable, pulls the default embedding model with a live progress bar.
|
|
209
|
+
// Skipped when `noLlm` is set, leaving the catalogue scaffolded and
|
|
210
|
+
// usable for markdown-only workflows — the user can run
|
|
211
|
+
// `nuos-catalogue setup-llm` later.
|
|
212
|
+
if (!options.noLlm) {
|
|
213
|
+
const { runLlmSetup } = await import('../setup/run-llm-setup.js');
|
|
214
|
+
await runLlmSetup({
|
|
215
|
+
// The setup module writes its own progress directly to stderr; we
|
|
216
|
+
// don't route through `prompt.print` because the in-place progress
|
|
217
|
+
// bar needs unbuffered control of the line.
|
|
218
|
+
//
|
|
219
|
+
// We always allow prompts here, even though `init` overall is
|
|
220
|
+
// zero-prompt by default. The LLM setup's only prompts are
|
|
221
|
+
// single-key consent gates ("install Ollama?", "open download
|
|
222
|
+
// page?") and they need the user's input on a fresh machine.
|
|
223
|
+
// `runLlmSetup` falls back to no-on-EOF when stdin isn't a TTY,
|
|
224
|
+
// so this is safe in unattended runs too.
|
|
225
|
+
nonInteractive: false,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
prompt.print('');
|
|
230
|
+
prompt.print(' · LLM setup skipped (--no-llm). Run `nuos-catalogue setup-llm` later to enable semantic search.');
|
|
231
|
+
}
|
|
207
232
|
prompt.print('');
|
|
208
233
|
prompt.print('✅ Done.');
|
|
209
234
|
prompt.print('');
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nuos-catalogue memory store` — embed a finding and persist it to NuVector.
|
|
3
|
+
* `nuos-catalogue memory search` — retrieve relevant past findings by query.
|
|
4
|
+
*
|
|
5
|
+
* Cross-agent memory: every agent in a swarm can write findings here and
|
|
6
|
+
* any future agent (in this run or a later one) can retrieve them by
|
|
7
|
+
* semantic query. Uses the same NuVector store as the catalogue index,
|
|
8
|
+
* distinguished by kind: 'agent_memory'.
|
|
9
|
+
*
|
|
10
|
+
* CLI:
|
|
11
|
+
* memory store --value="..." [--wu=wu-007] [--agent=architect] [--key="label"]
|
|
12
|
+
* memory search --query="..." [--limit=N] [--wu=wu-007] [--agent=architect]
|
|
13
|
+
*/
|
|
14
|
+
export interface MemoryStoreOptions {
|
|
15
|
+
value: string;
|
|
16
|
+
wu?: string;
|
|
17
|
+
agent?: string;
|
|
18
|
+
key?: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
buildRoot?: string | boolean;
|
|
21
|
+
index?: string | boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface MemorySearchOptions {
|
|
24
|
+
query: string;
|
|
25
|
+
limit?: number;
|
|
26
|
+
wu?: string;
|
|
27
|
+
agent?: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
buildRoot?: string | boolean;
|
|
30
|
+
index?: string | boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface MemoryHit {
|
|
33
|
+
id: string;
|
|
34
|
+
score: number;
|
|
35
|
+
text: string;
|
|
36
|
+
agentRole: string;
|
|
37
|
+
workUnit: string;
|
|
38
|
+
key: string;
|
|
39
|
+
timestamp: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function cmdMemoryStore(opts: MemoryStoreOptions): Promise<number>;
|
|
42
|
+
export declare function cmdMemorySearch(opts: MemorySearchOptions): Promise<number>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nuos-catalogue memory store` — embed a finding and persist it to NuVector.
|
|
3
|
+
* `nuos-catalogue memory search` — retrieve relevant past findings by query.
|
|
4
|
+
*
|
|
5
|
+
* Cross-agent memory: every agent in a swarm can write findings here and
|
|
6
|
+
* any future agent (in this run or a later one) can retrieve them by
|
|
7
|
+
* semantic query. Uses the same NuVector store as the catalogue index,
|
|
8
|
+
* distinguished by kind: 'agent_memory'.
|
|
9
|
+
*
|
|
10
|
+
* CLI:
|
|
11
|
+
* memory store --value="..." [--wu=wu-007] [--agent=architect] [--key="label"]
|
|
12
|
+
* memory search --query="..." [--limit=N] [--wu=wu-007] [--agent=architect]
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { resolveBuildRoot, resolveIndexPath } from '../path-resolution.js';
|
|
16
|
+
// NuVector's MemoryRecordKind union doesn't include a swarm-specific kind yet.
|
|
17
|
+
// 'workflow_provenance' is the closest semantic match — agent memories are
|
|
18
|
+
// provenance of the swarm workflow. NuFlow isn't wired (harness.runtime.nuflow
|
|
19
|
+
// is null) so there's no collision today; records are further distinguished by
|
|
20
|
+
// the presence of an `agent_role` metadata field (absent on NuFlow provenance).
|
|
21
|
+
const MEMORY_KIND = 'workflow_provenance';
|
|
22
|
+
export async function cmdMemoryStore(opts) {
|
|
23
|
+
const { value, wu, agent, key } = opts;
|
|
24
|
+
if (!value || value.trim().length === 0) {
|
|
25
|
+
console.error('memory store: --value is required and must be non-empty');
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const { selectEmbedderFromEnv } = await import('../embedder/select.js');
|
|
29
|
+
const { openStore, TENANT } = await import('../store/open.js');
|
|
30
|
+
const buildRoot = resolveBuildRoot(opts.buildRoot, { cwd: opts.cwd ?? process.cwd() });
|
|
31
|
+
const indexPath = resolveIndexPath(buildRoot, opts.index);
|
|
32
|
+
const embedder = await selectEmbedderFromEnv();
|
|
33
|
+
const store = await openStore({ storagePath: indexPath, dimensions: embedder.dimensions });
|
|
34
|
+
const [embedding] = await embedder.embed([value]);
|
|
35
|
+
await store.upsert({
|
|
36
|
+
id: randomUUID(),
|
|
37
|
+
kind: MEMORY_KIND,
|
|
38
|
+
embedding,
|
|
39
|
+
text: value,
|
|
40
|
+
tenant: TENANT,
|
|
41
|
+
metadata: {
|
|
42
|
+
agent_role: agent ?? '',
|
|
43
|
+
work_unit: wu ?? '',
|
|
44
|
+
key: key ?? '',
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const label = key ? ` [${key}]` : '';
|
|
49
|
+
const context = [agent, wu].filter(Boolean).join(' / ');
|
|
50
|
+
console.log(`memory stored${label}${context ? ` (${context})` : ''}`);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
export async function cmdMemorySearch(opts) {
|
|
54
|
+
const { query, limit = 5, wu, agent } = opts;
|
|
55
|
+
if (!query || query.trim().length === 0) {
|
|
56
|
+
console.error('memory search: --query is required and must be non-empty');
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
const { selectEmbedderFromEnv } = await import('../embedder/select.js');
|
|
60
|
+
const { openStore, TENANT } = await import('../store/open.js');
|
|
61
|
+
const buildRoot = resolveBuildRoot(opts.buildRoot, { cwd: opts.cwd ?? process.cwd() });
|
|
62
|
+
const indexPath = resolveIndexPath(buildRoot, opts.index);
|
|
63
|
+
const embedder = await selectEmbedderFromEnv();
|
|
64
|
+
const store = await openStore({ storagePath: indexPath, dimensions: embedder.dimensions });
|
|
65
|
+
const [queryEmbedding] = await embedder.embed([query]);
|
|
66
|
+
const result = await store.retrieveContext({
|
|
67
|
+
embedding: queryEmbedding,
|
|
68
|
+
tenant: TENANT,
|
|
69
|
+
topK: Math.max(limit * 4, 20),
|
|
70
|
+
filters: { kind: MEMORY_KIND },
|
|
71
|
+
});
|
|
72
|
+
const raw = (result?.items ?? []);
|
|
73
|
+
let hits = raw
|
|
74
|
+
.filter((r) => typeof r.score === 'number' && r.score > 0.3)
|
|
75
|
+
.map((r) => ({
|
|
76
|
+
id: r.ref ?? '',
|
|
77
|
+
score: r.score ?? 0,
|
|
78
|
+
text: r.text ?? '',
|
|
79
|
+
agentRole: String(r.metadata?.agent_role ?? ''),
|
|
80
|
+
workUnit: String(r.metadata?.work_unit ?? ''),
|
|
81
|
+
key: String(r.metadata?.key ?? ''),
|
|
82
|
+
timestamp: String(r.metadata?.timestamp ?? ''),
|
|
83
|
+
}));
|
|
84
|
+
// Post-filter by wu / agent when requested
|
|
85
|
+
if (wu) {
|
|
86
|
+
hits = hits.filter((h) => h.workUnit === wu || h.workUnit === normaliseWu(wu));
|
|
87
|
+
}
|
|
88
|
+
if (agent) {
|
|
89
|
+
hits = hits.filter((h) => h.agentRole === agent);
|
|
90
|
+
}
|
|
91
|
+
hits = hits.slice(0, limit);
|
|
92
|
+
if (hits.length === 0) {
|
|
93
|
+
console.log(`no memories found (query: "${query}")`);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
console.log(`${hits.length} memor${hits.length === 1 ? 'y' : 'ies'} found (query: "${query}")\n`);
|
|
97
|
+
for (let i = 0; i < hits.length; i++) {
|
|
98
|
+
const h = hits[i];
|
|
99
|
+
const score = h.score.toFixed(2);
|
|
100
|
+
const ctx = [h.workUnit, h.agentRole].filter(Boolean).join(' | ');
|
|
101
|
+
const date = h.timestamp ? h.timestamp.slice(0, 10) : '';
|
|
102
|
+
const header = [ctx, date].filter(Boolean).join(' | ');
|
|
103
|
+
const keyLabel = h.key ? ` [${h.key}]` : '';
|
|
104
|
+
console.log(`[${i + 1}] (score: ${score})${keyLabel}${header ? ` — ${header}` : ''}`);
|
|
105
|
+
console.log(` ${h.text.replace(/\n/g, '\n ')}`);
|
|
106
|
+
if (i < hits.length - 1)
|
|
107
|
+
console.log('');
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
function normaliseWu(handle) {
|
|
112
|
+
const m = handle.match(/(\d+)/);
|
|
113
|
+
if (!m)
|
|
114
|
+
return handle;
|
|
115
|
+
return `wu-${m[1].padStart(3, '0')}`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probes for the Ollama CLI binary, the HTTP API, and a specific model.
|
|
3
|
+
*
|
|
4
|
+
* All three probes are non-throwing — they return result objects with a
|
|
5
|
+
* `found` / `reachable` / `present` boolean so the caller can compose a
|
|
6
|
+
* branching setup flow without try/catch noise.
|
|
7
|
+
*
|
|
8
|
+
* @module setup/ollama-detect
|
|
9
|
+
*/
|
|
10
|
+
import type { OllamaApiProbe, OllamaCliProbe, ModelProbe, Platform } from './types.js';
|
|
11
|
+
/** Default API host used by the existing Ollama embedder. */
|
|
12
|
+
export declare const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
|
|
13
|
+
/**
|
|
14
|
+
* Resolve `ollama` on PATH and capture its absolute path if present.
|
|
15
|
+
*
|
|
16
|
+
* Uses `which` on Unix-likes and `where` on Windows. We do not parse
|
|
17
|
+
* version output here — the caller only needs the boolean and the path.
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectOllamaCli(platform: Platform): Promise<OllamaCliProbe>;
|
|
20
|
+
/**
|
|
21
|
+
* Probe the Ollama HTTP API at the given host. Uses `/api/tags` which
|
|
22
|
+
* is cheap and returns a 200 even on an empty model list.
|
|
23
|
+
*
|
|
24
|
+
* Returns `reachable: false` plus an error string for any non-200 or
|
|
25
|
+
* network failure. Times out after 1500ms so a hung daemon doesn't
|
|
26
|
+
* stall the setup flow.
|
|
27
|
+
*/
|
|
28
|
+
export declare function detectOllamaApi(host?: string): Promise<OllamaApiProbe>;
|
|
29
|
+
/**
|
|
30
|
+
* Check whether a specific model has been pulled into the local Ollama
|
|
31
|
+
* instance. Calls `/api/tags` and matches by exact name. Returns
|
|
32
|
+
* `present: false` on any failure — the caller's next step (pull) will
|
|
33
|
+
* surface the underlying error.
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectModelPresent(host: string, model: string): Promise<ModelProbe>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probes for the Ollama CLI binary, the HTTP API, and a specific model.
|
|
3
|
+
*
|
|
4
|
+
* All three probes are non-throwing — they return result objects with a
|
|
5
|
+
* `found` / `reachable` / `present` boolean so the caller can compose a
|
|
6
|
+
* branching setup flow without try/catch noise.
|
|
7
|
+
*
|
|
8
|
+
* @module setup/ollama-detect
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
/** Default API host used by the existing Ollama embedder. */
|
|
12
|
+
export const DEFAULT_OLLAMA_HOST = 'http://localhost:11434';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve `ollama` on PATH and capture its absolute path if present.
|
|
15
|
+
*
|
|
16
|
+
* Uses `which` on Unix-likes and `where` on Windows. We do not parse
|
|
17
|
+
* version output here — the caller only needs the boolean and the path.
|
|
18
|
+
*/
|
|
19
|
+
export async function detectOllamaCli(platform) {
|
|
20
|
+
const command = platform === 'win32' ? 'where' : 'which';
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const child = spawn(command, ['ollama'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
23
|
+
let stdout = '';
|
|
24
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
25
|
+
child.on('error', () => resolve({ found: false }));
|
|
26
|
+
child.on('close', (code) => {
|
|
27
|
+
if (code === 0 && stdout.trim()) {
|
|
28
|
+
// `where` on Windows may return multiple lines; take the first.
|
|
29
|
+
const firstLine = stdout.split(/\r?\n/)[0]?.trim() ?? '';
|
|
30
|
+
resolve({ found: true, path: firstLine });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
resolve({ found: false });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Probe the Ollama HTTP API at the given host. Uses `/api/tags` which
|
|
40
|
+
* is cheap and returns a 200 even on an empty model list.
|
|
41
|
+
*
|
|
42
|
+
* Returns `reachable: false` plus an error string for any non-200 or
|
|
43
|
+
* network failure. Times out after 1500ms so a hung daemon doesn't
|
|
44
|
+
* stall the setup flow.
|
|
45
|
+
*/
|
|
46
|
+
export async function detectOllamaApi(host = DEFAULT_OLLAMA_HOST) {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(`${host}/api/tags`, { signal: controller.signal });
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
return { reachable: false, host, error: `HTTP ${response.status}` };
|
|
53
|
+
}
|
|
54
|
+
return { reachable: true, host };
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
58
|
+
return { reachable: false, host, error: message };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check whether a specific model has been pulled into the local Ollama
|
|
66
|
+
* instance. Calls `/api/tags` and matches by exact name. Returns
|
|
67
|
+
* `present: false` on any failure — the caller's next step (pull) will
|
|
68
|
+
* surface the underlying error.
|
|
69
|
+
*/
|
|
70
|
+
export async function detectModelPresent(host, model) {
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(`${host}/api/tags`);
|
|
73
|
+
if (!response.ok)
|
|
74
|
+
return { present: false, model };
|
|
75
|
+
const data = (await response.json());
|
|
76
|
+
const present = Array.isArray(data.models)
|
|
77
|
+
&& data.models.some((m) => m.name === model);
|
|
78
|
+
return { present, model };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { present: false, model };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-platform install offers for Ollama (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* Three platforms; three install paths with very different
|
|
5
|
+
* reliability:
|
|
6
|
+
*
|
|
7
|
+
* - macOS — `brew install --cask ollama` is reliable when Homebrew
|
|
8
|
+
* is present. Without Homebrew, the only path is the download
|
|
9
|
+
* page.
|
|
10
|
+
* - Linux — `curl -fsSL https://ollama.com/install.sh | sh` is the
|
|
11
|
+
* official one-liner and works on every Linux distro Ollama
|
|
12
|
+
* supports. It writes to /usr/local and asks for sudo.
|
|
13
|
+
* - Windows — there is no reliable CLI install path. Open the
|
|
14
|
+
* download page.
|
|
15
|
+
*
|
|
16
|
+
* The pure offer-builder is split from the spawn-the-installer
|
|
17
|
+
* function so it's unit-testable.
|
|
18
|
+
*
|
|
19
|
+
* @module setup/ollama-install
|
|
20
|
+
*/
|
|
21
|
+
import type { InstallOffer, Platform } from './types.js';
|
|
22
|
+
/** The canonical download page used as the safe fallback on every platform. */
|
|
23
|
+
export declare const OLLAMA_DOWNLOAD_URL = "https://ollama.com/download";
|
|
24
|
+
/**
|
|
25
|
+
* Build the install offer for the current platform. `hasBrew` is
|
|
26
|
+
* relevant only on macOS — the caller probes for `brew` separately
|
|
27
|
+
* via the standard CLI detection path and passes the result in.
|
|
28
|
+
*
|
|
29
|
+
* Pure — no I/O.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildInstallOffer(platform: Platform, hasBrew: boolean): InstallOffer;
|
|
32
|
+
/**
|
|
33
|
+
* Run the offer's primary command, streaming the installer's stdout
|
|
34
|
+
* and stderr to the caller's handler. Resolves with a result the
|
|
35
|
+
* caller turns into a `LlmSetupResult`.
|
|
36
|
+
*
|
|
37
|
+
* The command is invoked via the user's shell so pipes (`curl | sh`)
|
|
38
|
+
* work as expected. The user already consented to running it; the
|
|
39
|
+
* harness does not silently auto-run installers.
|
|
40
|
+
*/
|
|
41
|
+
export declare function runInstaller(offer: InstallOffer, onOutput: (line: string) => void): Promise<{
|
|
42
|
+
ok: true;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
error: string;
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* Open a URL in the user's default browser. Used as the Windows /
|
|
49
|
+
* brew-less macOS fallback so the user can grab the installer
|
|
50
|
+
* manually. Best-effort — never throws.
|
|
51
|
+
*/
|
|
52
|
+
export declare function openInBrowser(url: string, platform: Platform): Promise<void>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-platform install offers for Ollama (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* Three platforms; three install paths with very different
|
|
5
|
+
* reliability:
|
|
6
|
+
*
|
|
7
|
+
* - macOS — `brew install --cask ollama` is reliable when Homebrew
|
|
8
|
+
* is present. Without Homebrew, the only path is the download
|
|
9
|
+
* page.
|
|
10
|
+
* - Linux — `curl -fsSL https://ollama.com/install.sh | sh` is the
|
|
11
|
+
* official one-liner and works on every Linux distro Ollama
|
|
12
|
+
* supports. It writes to /usr/local and asks for sudo.
|
|
13
|
+
* - Windows — there is no reliable CLI install path. Open the
|
|
14
|
+
* download page.
|
|
15
|
+
*
|
|
16
|
+
* The pure offer-builder is split from the spawn-the-installer
|
|
17
|
+
* function so it's unit-testable.
|
|
18
|
+
*
|
|
19
|
+
* @module setup/ollama-install
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
/** The canonical download page used as the safe fallback on every platform. */
|
|
23
|
+
export const OLLAMA_DOWNLOAD_URL = 'https://ollama.com/download';
|
|
24
|
+
/**
|
|
25
|
+
* Build the install offer for the current platform. `hasBrew` is
|
|
26
|
+
* relevant only on macOS — the caller probes for `brew` separately
|
|
27
|
+
* via the standard CLI detection path and passes the result in.
|
|
28
|
+
*
|
|
29
|
+
* Pure — no I/O.
|
|
30
|
+
*/
|
|
31
|
+
export function buildInstallOffer(platform, hasBrew) {
|
|
32
|
+
switch (platform) {
|
|
33
|
+
case 'darwin':
|
|
34
|
+
if (hasBrew) {
|
|
35
|
+
return {
|
|
36
|
+
platform,
|
|
37
|
+
primaryCommand: 'brew install --cask ollama',
|
|
38
|
+
primaryDescription: 'Install Ollama via Homebrew (brew install --cask ollama)',
|
|
39
|
+
fallbackUrl: OLLAMA_DOWNLOAD_URL,
|
|
40
|
+
canAutoInstall: true,
|
|
41
|
+
requiresElevation: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
platform,
|
|
46
|
+
primaryCommand: '',
|
|
47
|
+
primaryDescription: `Download the Ollama app from ${OLLAMA_DOWNLOAD_URL}`,
|
|
48
|
+
fallbackUrl: OLLAMA_DOWNLOAD_URL,
|
|
49
|
+
canAutoInstall: false,
|
|
50
|
+
requiresElevation: false,
|
|
51
|
+
};
|
|
52
|
+
case 'linux':
|
|
53
|
+
return {
|
|
54
|
+
platform,
|
|
55
|
+
primaryCommand: 'curl -fsSL https://ollama.com/install.sh | sh',
|
|
56
|
+
primaryDescription: 'Install Ollama via the official install script (asks for sudo)',
|
|
57
|
+
fallbackUrl: OLLAMA_DOWNLOAD_URL,
|
|
58
|
+
canAutoInstall: true,
|
|
59
|
+
requiresElevation: true,
|
|
60
|
+
};
|
|
61
|
+
case 'win32':
|
|
62
|
+
return {
|
|
63
|
+
platform,
|
|
64
|
+
primaryCommand: '',
|
|
65
|
+
primaryDescription: `Download the Ollama installer from ${OLLAMA_DOWNLOAD_URL}`,
|
|
66
|
+
fallbackUrl: OLLAMA_DOWNLOAD_URL,
|
|
67
|
+
canAutoInstall: false,
|
|
68
|
+
requiresElevation: false,
|
|
69
|
+
};
|
|
70
|
+
case 'other':
|
|
71
|
+
default:
|
|
72
|
+
return {
|
|
73
|
+
platform,
|
|
74
|
+
primaryCommand: '',
|
|
75
|
+
primaryDescription: `See ${OLLAMA_DOWNLOAD_URL} for install options on your platform`,
|
|
76
|
+
fallbackUrl: OLLAMA_DOWNLOAD_URL,
|
|
77
|
+
canAutoInstall: false,
|
|
78
|
+
requiresElevation: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Run the offer's primary command, streaming the installer's stdout
|
|
84
|
+
* and stderr to the caller's handler. Resolves with a result the
|
|
85
|
+
* caller turns into a `LlmSetupResult`.
|
|
86
|
+
*
|
|
87
|
+
* The command is invoked via the user's shell so pipes (`curl | sh`)
|
|
88
|
+
* work as expected. The user already consented to running it; the
|
|
89
|
+
* harness does not silently auto-run installers.
|
|
90
|
+
*/
|
|
91
|
+
export async function runInstaller(offer, onOutput) {
|
|
92
|
+
if (!offer.canAutoInstall || !offer.primaryCommand) {
|
|
93
|
+
return { ok: false, error: 'No auto-install path is available for this platform.' };
|
|
94
|
+
}
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
// Spawn through the user's shell so the Linux curl-pipe-sh form
|
|
97
|
+
// works. macOS brew is also fine via shell. We never spawn this
|
|
98
|
+
// without explicit user consent at the prompt layer above.
|
|
99
|
+
const child = spawn(offer.primaryCommand, {
|
|
100
|
+
shell: true,
|
|
101
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
102
|
+
});
|
|
103
|
+
child.stdout.on('data', (chunk) => {
|
|
104
|
+
for (const line of chunk.toString('utf8').split(/\r?\n/)) {
|
|
105
|
+
if (line)
|
|
106
|
+
onOutput(line);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
child.stderr.on('data', (chunk) => {
|
|
110
|
+
for (const line of chunk.toString('utf8').split(/\r?\n/)) {
|
|
111
|
+
if (line)
|
|
112
|
+
onOutput(line);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
child.on('error', (err) => {
|
|
116
|
+
resolve({ ok: false, error: err.message });
|
|
117
|
+
});
|
|
118
|
+
child.on('close', (code) => {
|
|
119
|
+
if (code === 0)
|
|
120
|
+
resolve({ ok: true });
|
|
121
|
+
else
|
|
122
|
+
resolve({ ok: false, error: `Installer exited with code ${code ?? 'null'}.` });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Open a URL in the user's default browser. Used as the Windows /
|
|
128
|
+
* brew-less macOS fallback so the user can grab the installer
|
|
129
|
+
* manually. Best-effort — never throws.
|
|
130
|
+
*/
|
|
131
|
+
export async function openInBrowser(url, platform) {
|
|
132
|
+
const command = platform === 'darwin' ? 'open'
|
|
133
|
+
: platform === 'win32' ? 'start'
|
|
134
|
+
: 'xdg-open';
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const child = spawn(command, [url], { stdio: 'ignore', shell: platform === 'win32' });
|
|
137
|
+
child.on('error', () => resolve());
|
|
138
|
+
child.on('close', () => resolve());
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streams a model pull from Ollama and emits one `PullEvent` per
|
|
3
|
+
* NDJSON line.
|
|
4
|
+
*
|
|
5
|
+
* The Ollama `/api/pull` endpoint returns a stream of newline-delimited
|
|
6
|
+
* JSON objects describing the pull's progress — `pulling manifest`
|
|
7
|
+
* first, then one or more `downloading` events per blob (each with
|
|
8
|
+
* `digest`, `total`, `completed`), then `verifying`, `writing manifest`,
|
|
9
|
+
* and finally `success`. On failure an object with `error` is emitted
|
|
10
|
+
* instead.
|
|
11
|
+
*
|
|
12
|
+
* The pure parser is exported separately so it can be unit-tested with
|
|
13
|
+
* a fixed byte stream.
|
|
14
|
+
*
|
|
15
|
+
* @module setup/ollama-pull
|
|
16
|
+
*/
|
|
17
|
+
import type { PullEvent } from './types.js';
|
|
18
|
+
/**
|
|
19
|
+
* Parse a single byte chunk into zero-or-more complete events, given
|
|
20
|
+
* the running buffer left over from the previous chunk. Returns the
|
|
21
|
+
* new buffer (any trailing partial line) alongside the parsed events.
|
|
22
|
+
*
|
|
23
|
+
* Pure — exported for testing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parsePullChunk(buffer: string, chunk: string): {
|
|
26
|
+
buffer: string;
|
|
27
|
+
events: PullEvent[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Pull the named model from the Ollama registry, invoking `onEvent`
|
|
31
|
+
* for each event the stream emits. Resolves to a success/failure
|
|
32
|
+
* result; never throws on protocol-level errors (network failures,
|
|
33
|
+
* abnormal stream closure surface as `{ ok: false }`).
|
|
34
|
+
*/
|
|
35
|
+
export declare function pullModel(host: string, model: string, onEvent: (event: PullEvent) => void): Promise<{
|
|
36
|
+
ok: true;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
error: string;
|
|
40
|
+
}>;
|