@nusoft/nuos-build-catalogue 0.17.0 → 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 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
- (interactive bootstrap of docs/build/, methodfile.json, .claude/commands/<protocols>, CLAUDE.md, .gitignore overrides; refuses if docs/build/ already exists)
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':
@@ -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;
@@ -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
+ }>;