@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
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* Parse a single byte chunk into zero-or-more complete events, given
|
|
19
|
+
* the running buffer left over from the previous chunk. Returns the
|
|
20
|
+
* new buffer (any trailing partial line) alongside the parsed events.
|
|
21
|
+
*
|
|
22
|
+
* Pure — exported for testing.
|
|
23
|
+
*/
|
|
24
|
+
export function parsePullChunk(buffer, chunk) {
|
|
25
|
+
const combined = buffer + chunk;
|
|
26
|
+
const lines = combined.split('\n');
|
|
27
|
+
const trailing = lines.pop() ?? ''; // partial line carries to the next chunk
|
|
28
|
+
const events = [];
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (!trimmed)
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
events.push(JSON.parse(trimmed));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Malformed line — skip. Ollama has never been observed to emit
|
|
38
|
+
// malformed lines in practice, but we never crash on stream noise.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { buffer: trailing, events };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Pull the named model from the Ollama registry, invoking `onEvent`
|
|
45
|
+
* for each event the stream emits. Resolves to a success/failure
|
|
46
|
+
* result; never throws on protocol-level errors (network failures,
|
|
47
|
+
* abnormal stream closure surface as `{ ok: false }`).
|
|
48
|
+
*/
|
|
49
|
+
export async function pullModel(host, model, onEvent) {
|
|
50
|
+
let response;
|
|
51
|
+
try {
|
|
52
|
+
response = await fetch(`${host}/api/pull`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ name: model, stream: true }),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
60
|
+
}
|
|
61
|
+
if (!response.ok || !response.body) {
|
|
62
|
+
return { ok: false, error: `Pull request failed (HTTP ${response.status}).` };
|
|
63
|
+
}
|
|
64
|
+
const reader = response.body.getReader();
|
|
65
|
+
const decoder = new TextDecoder('utf8');
|
|
66
|
+
let buffer = '';
|
|
67
|
+
let sawSuccess = false;
|
|
68
|
+
let lastError = null;
|
|
69
|
+
try {
|
|
70
|
+
while (true) {
|
|
71
|
+
const { value, done } = await reader.read();
|
|
72
|
+
if (done)
|
|
73
|
+
break;
|
|
74
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
75
|
+
const parsed = parsePullChunk(buffer, chunk);
|
|
76
|
+
buffer = parsed.buffer;
|
|
77
|
+
for (const event of parsed.events) {
|
|
78
|
+
onEvent(event);
|
|
79
|
+
if (event.error)
|
|
80
|
+
lastError = event.error;
|
|
81
|
+
if (event.status === 'success')
|
|
82
|
+
sawSuccess = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Flush any final partial line that turned out to be complete.
|
|
86
|
+
const tail = parsePullChunk(buffer, '\n');
|
|
87
|
+
for (const event of tail.events) {
|
|
88
|
+
onEvent(event);
|
|
89
|
+
if (event.error)
|
|
90
|
+
lastError = event.error;
|
|
91
|
+
if (event.status === 'success')
|
|
92
|
+
sawSuccess = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
97
|
+
}
|
|
98
|
+
if (lastError)
|
|
99
|
+
return { ok: false, error: lastError };
|
|
100
|
+
if (!sawSuccess) {
|
|
101
|
+
return { ok: false, error: 'Pull stream ended without a success event.' };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal progress bar rendering for the model pull (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions — no I/O. The caller writes the returned string to
|
|
5
|
+
* stderr with a leading `\r` for in-place updates, or to stdout when
|
|
6
|
+
* not in a TTY. The renderer is split into formatters (`formatBytes`,
|
|
7
|
+
* `buildBar`) and a composer (`buildProgressLine`) so each piece is
|
|
8
|
+
* independently testable.
|
|
9
|
+
*
|
|
10
|
+
* @module setup/progress-bar
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Format a byte count as a human-readable string with one decimal place.
|
|
14
|
+
* Uses 1024-based units (KiB / MiB / GiB) but labels them KB / MB / GB
|
|
15
|
+
* since that's what most users expect to see from a CLI.
|
|
16
|
+
*/
|
|
17
|
+
export declare function formatBytes(n: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Build a fixed-width progress bar from a 0–1 fraction. Uses
|
|
20
|
+
* solid + light blocks (▰ + ▱) — both BMP characters that render in
|
|
21
|
+
* every modern terminal. Falls back to ASCII (# + -) when
|
|
22
|
+
* `asciiOnly` is true.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildBar(fraction: number, width?: number, asciiOnly?: boolean): string;
|
|
25
|
+
/**
|
|
26
|
+
* Compose the full progress line for one ongoing download. Shape:
|
|
27
|
+
*
|
|
28
|
+
* [▰▰▰▰▰▰▰▱▱▱▱▱] 58% 450.2 MB / 600.0 MB downloading
|
|
29
|
+
*
|
|
30
|
+
* Caller is responsible for any leading `\r` (in-place update) or
|
|
31
|
+
* trailing `\n` (final flush).
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildProgressLine(completed: number, total: number, label: string, options?: {
|
|
34
|
+
width?: number;
|
|
35
|
+
asciiOnly?: boolean;
|
|
36
|
+
}): string;
|
|
37
|
+
/**
|
|
38
|
+
* Build a line for indeterminate phases (manifest, verify, etc.) where
|
|
39
|
+
* no `completed/total` is available. Shape:
|
|
40
|
+
*
|
|
41
|
+
* ⋯ verifying sha256 digest
|
|
42
|
+
*
|
|
43
|
+
* The leading glyph is a unicode horizontal ellipsis; falls back to
|
|
44
|
+
* `...` when `asciiOnly`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildSpinnerLine(label: string, asciiOnly?: boolean): string;
|
|
47
|
+
/**
|
|
48
|
+
* Clear the current line so the next write starts fresh. Used between
|
|
49
|
+
* in-place progress updates and a final ✓ line.
|
|
50
|
+
*/
|
|
51
|
+
export declare function clearLineSequence(): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal progress bar rendering for the model pull (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions — no I/O. The caller writes the returned string to
|
|
5
|
+
* stderr with a leading `\r` for in-place updates, or to stdout when
|
|
6
|
+
* not in a TTY. The renderer is split into formatters (`formatBytes`,
|
|
7
|
+
* `buildBar`) and a composer (`buildProgressLine`) so each piece is
|
|
8
|
+
* independently testable.
|
|
9
|
+
*
|
|
10
|
+
* @module setup/progress-bar
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Format a byte count as a human-readable string with one decimal place.
|
|
14
|
+
* Uses 1024-based units (KiB / MiB / GiB) but labels them KB / MB / GB
|
|
15
|
+
* since that's what most users expect to see from a CLI.
|
|
16
|
+
*/
|
|
17
|
+
export function formatBytes(n) {
|
|
18
|
+
if (!Number.isFinite(n) || n < 0)
|
|
19
|
+
return '0 B';
|
|
20
|
+
if (n < 1024)
|
|
21
|
+
return `${n} B`;
|
|
22
|
+
const kb = n / 1024;
|
|
23
|
+
if (kb < 1024)
|
|
24
|
+
return `${kb.toFixed(1)} KB`;
|
|
25
|
+
const mb = kb / 1024;
|
|
26
|
+
if (mb < 1024)
|
|
27
|
+
return `${mb.toFixed(1)} MB`;
|
|
28
|
+
const gb = mb / 1024;
|
|
29
|
+
return `${gb.toFixed(2)} GB`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build a fixed-width progress bar from a 0–1 fraction. Uses
|
|
33
|
+
* solid + light blocks (▰ + ▱) — both BMP characters that render in
|
|
34
|
+
* every modern terminal. Falls back to ASCII (# + -) when
|
|
35
|
+
* `asciiOnly` is true.
|
|
36
|
+
*/
|
|
37
|
+
export function buildBar(fraction, width = 30, asciiOnly = false) {
|
|
38
|
+
const f = Math.max(0, Math.min(1, fraction));
|
|
39
|
+
const filled = Math.round(f * width);
|
|
40
|
+
const empty = width - filled;
|
|
41
|
+
if (asciiOnly) {
|
|
42
|
+
return `[${'#'.repeat(filled)}${'-'.repeat(empty)}]`;
|
|
43
|
+
}
|
|
44
|
+
return `[${'▰'.repeat(filled)}${'▱'.repeat(empty)}]`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compose the full progress line for one ongoing download. Shape:
|
|
48
|
+
*
|
|
49
|
+
* [▰▰▰▰▰▰▰▱▱▱▱▱] 58% 450.2 MB / 600.0 MB downloading
|
|
50
|
+
*
|
|
51
|
+
* Caller is responsible for any leading `\r` (in-place update) or
|
|
52
|
+
* trailing `\n` (final flush).
|
|
53
|
+
*/
|
|
54
|
+
export function buildProgressLine(completed, total, label, options = {}) {
|
|
55
|
+
const width = options.width ?? 30;
|
|
56
|
+
const fraction = total > 0 ? completed / total : 0;
|
|
57
|
+
const percent = Math.round(fraction * 100);
|
|
58
|
+
const bar = buildBar(fraction, width, options.asciiOnly);
|
|
59
|
+
const bytes = `${formatBytes(completed)} / ${formatBytes(total)}`;
|
|
60
|
+
const percentStr = `${percent.toString().padStart(3)}%`;
|
|
61
|
+
return `${bar} ${percentStr} ${bytes} ${label}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a line for indeterminate phases (manifest, verify, etc.) where
|
|
65
|
+
* no `completed/total` is available. Shape:
|
|
66
|
+
*
|
|
67
|
+
* ⋯ verifying sha256 digest
|
|
68
|
+
*
|
|
69
|
+
* The leading glyph is a unicode horizontal ellipsis; falls back to
|
|
70
|
+
* `...` when `asciiOnly`.
|
|
71
|
+
*/
|
|
72
|
+
export function buildSpinnerLine(label, asciiOnly = false) {
|
|
73
|
+
const glyph = asciiOnly ? '...' : '⋯';
|
|
74
|
+
return `${glyph} ${label}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Clear the current line so the next write starts fresh. Used between
|
|
78
|
+
* in-place progress updates and a final ✓ line.
|
|
79
|
+
*/
|
|
80
|
+
export function clearLineSequence() {
|
|
81
|
+
// \r returns to column 0; the spaces overwrite anything previously
|
|
82
|
+
// written on this line; the second \r returns to column 0 again so
|
|
83
|
+
// the next print starts at the left.
|
|
84
|
+
return `\r${' '.repeat(80)}\r`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates the LLM-setup phase of `init` (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* Both `init` (when not run with `--no-llm`) and the standalone
|
|
5
|
+
* `setup-llm` command call this function. The shape of the work:
|
|
6
|
+
*
|
|
7
|
+
* 1. Probe Ollama CLI + API.
|
|
8
|
+
* 2. If neither — offer to install (platform-specific).
|
|
9
|
+
* 3. If installed but API not reachable — print start instructions.
|
|
10
|
+
* 4. Probe for `qwen3-embedding:0.6b`.
|
|
11
|
+
* 5. If missing — pull it with a live progress bar.
|
|
12
|
+
* 6. Return a discriminated `LlmSetupResult`.
|
|
13
|
+
*
|
|
14
|
+
* I/O is injectable via the options bag so tests can run the whole
|
|
15
|
+
* orchestrator with mocked probes and prompts.
|
|
16
|
+
*
|
|
17
|
+
* @module setup/run-llm-setup
|
|
18
|
+
*/
|
|
19
|
+
import type { InstallOffer, LlmSetupResult, OllamaApiProbe, OllamaCliProbe, ModelProbe, Platform, PullEvent } from './types.js';
|
|
20
|
+
import { OLLAMA_DOWNLOAD_URL } from './ollama-install.js';
|
|
21
|
+
/** The default embedding model — matches `src/embedder/ollama.ts`. */
|
|
22
|
+
export declare const DEFAULT_EMBEDDING_MODEL = "qwen3-embedding:0.6b";
|
|
23
|
+
/** Injectable dependencies for testing. */
|
|
24
|
+
export interface RunLlmSetupDeps {
|
|
25
|
+
platform: Platform;
|
|
26
|
+
detectCli: (p: Platform) => Promise<OllamaCliProbe>;
|
|
27
|
+
detectBrewCli: () => Promise<OllamaCliProbe>;
|
|
28
|
+
detectApi: (host: string) => Promise<OllamaApiProbe>;
|
|
29
|
+
detectModel: (host: string, model: string) => Promise<ModelProbe>;
|
|
30
|
+
installer: (offer: InstallOffer, onLine: (l: string) => void) => Promise<{
|
|
31
|
+
ok: true;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
error: string;
|
|
35
|
+
}>;
|
|
36
|
+
opener: (url: string, p: Platform) => Promise<void>;
|
|
37
|
+
pull: (host: string, model: string, onEvent: (e: PullEvent) => void) => Promise<{
|
|
38
|
+
ok: true;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
error: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
/** Public options the caller (init / setup-llm) passes in. */
|
|
45
|
+
export interface RunLlmSetupOptions {
|
|
46
|
+
host?: string;
|
|
47
|
+
model?: string;
|
|
48
|
+
/**
|
|
49
|
+
* When true, no prompts are issued and any "would you like to install" gate
|
|
50
|
+
* defaults to NO. Use this in CI / unattended contexts. Default: false (we
|
|
51
|
+
* ask the user).
|
|
52
|
+
*/
|
|
53
|
+
nonInteractive?: boolean;
|
|
54
|
+
/** Output sink — defaults to writing to process.stderr. */
|
|
55
|
+
out?: (text: string) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Prompt callback — defaults to a readline-backed yes/no prompt. Tests
|
|
58
|
+
* provide a mock. Returns true for yes, false for no.
|
|
59
|
+
*/
|
|
60
|
+
confirm?: (question: string) => Promise<boolean>;
|
|
61
|
+
/** Dependency injection — defaults to the real Ollama probes. */
|
|
62
|
+
deps?: Partial<RunLlmSetupDeps>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run the LLM-setup phase. Pure-result return — never throws on user-
|
|
66
|
+
* facing failures. The caller turns the result into a one-line summary
|
|
67
|
+
* for the surrounding command's output.
|
|
68
|
+
*/
|
|
69
|
+
export declare function runLlmSetup(opts?: RunLlmSetupOptions): Promise<LlmSetupResult>;
|
|
70
|
+
/** Re-export of the download URL for callers that want to print it. */
|
|
71
|
+
export { OLLAMA_DOWNLOAD_URL };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates the LLM-setup phase of `init` (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* Both `init` (when not run with `--no-llm`) and the standalone
|
|
5
|
+
* `setup-llm` command call this function. The shape of the work:
|
|
6
|
+
*
|
|
7
|
+
* 1. Probe Ollama CLI + API.
|
|
8
|
+
* 2. If neither — offer to install (platform-specific).
|
|
9
|
+
* 3. If installed but API not reachable — print start instructions.
|
|
10
|
+
* 4. Probe for `qwen3-embedding:0.6b`.
|
|
11
|
+
* 5. If missing — pull it with a live progress bar.
|
|
12
|
+
* 6. Return a discriminated `LlmSetupResult`.
|
|
13
|
+
*
|
|
14
|
+
* I/O is injectable via the options bag so tests can run the whole
|
|
15
|
+
* orchestrator with mocked probes and prompts.
|
|
16
|
+
*
|
|
17
|
+
* @module setup/run-llm-setup
|
|
18
|
+
*/
|
|
19
|
+
import { narrowPlatform } from './types.js';
|
|
20
|
+
import { DEFAULT_OLLAMA_HOST, detectModelPresent, detectOllamaApi, detectOllamaCli, } from './ollama-detect.js';
|
|
21
|
+
import { buildInstallOffer, openInBrowser, OLLAMA_DOWNLOAD_URL, runInstaller, } from './ollama-install.js';
|
|
22
|
+
import { pullModel } from './ollama-pull.js';
|
|
23
|
+
import { buildProgressLine, buildSpinnerLine, clearLineSequence, } from './progress-bar.js';
|
|
24
|
+
/** The default embedding model — matches `src/embedder/ollama.ts`. */
|
|
25
|
+
export const DEFAULT_EMBEDDING_MODEL = 'qwen3-embedding:0.6b';
|
|
26
|
+
/** Approximate on-disk size of the default model, used in user-facing copy. */
|
|
27
|
+
const DEFAULT_MODEL_SIZE_LABEL = '~600 MB';
|
|
28
|
+
/**
|
|
29
|
+
* Default deps wired against the real Ollama functions. Tests override
|
|
30
|
+
* any subset of these via the `deps` field on the options.
|
|
31
|
+
*/
|
|
32
|
+
function defaultDeps() {
|
|
33
|
+
return {
|
|
34
|
+
platform: narrowPlatform(process.platform),
|
|
35
|
+
detectCli: detectOllamaCli,
|
|
36
|
+
detectBrewCli: async () => detectOllamaCli(narrowPlatform(process.platform)).then(() => ({ found: false }))
|
|
37
|
+
.catch(() => ({ found: false })),
|
|
38
|
+
detectApi: detectOllamaApi,
|
|
39
|
+
detectModel: detectModelPresent,
|
|
40
|
+
installer: runInstaller,
|
|
41
|
+
opener: openInBrowser,
|
|
42
|
+
pull: pullModel,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Probe `brew` specifically (separate from the generic CLI probe so we
|
|
47
|
+
* can use the same `detectOllamaCli` machinery and just check a
|
|
48
|
+
* different binary name).
|
|
49
|
+
*/
|
|
50
|
+
async function detectBrew(platform) {
|
|
51
|
+
if (platform !== 'darwin')
|
|
52
|
+
return false;
|
|
53
|
+
const probe = await detectBrewBinary();
|
|
54
|
+
return probe.found;
|
|
55
|
+
}
|
|
56
|
+
async function detectBrewBinary() {
|
|
57
|
+
// Inline duplicate of detectOllamaCli with the binary name swapped —
|
|
58
|
+
// keeps ollama-detect.ts narrowly scoped to its name.
|
|
59
|
+
const { spawn } = await import('node:child_process');
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const child = spawn('which', ['brew'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
62
|
+
let stdout = '';
|
|
63
|
+
child.stdout.on('data', (c) => { stdout += c.toString('utf8'); });
|
|
64
|
+
child.on('error', () => resolve({ found: false }));
|
|
65
|
+
child.on('close', (code) => {
|
|
66
|
+
if (code === 0 && stdout.trim()) {
|
|
67
|
+
resolve({ found: true, path: stdout.split(/\r?\n/)[0]?.trim() });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
resolve({ found: false });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Default yes/no prompt. Returns false on EOF (non-TTY input) so unattended
|
|
77
|
+
* runs can't hang.
|
|
78
|
+
*/
|
|
79
|
+
async function defaultConfirm(question) {
|
|
80
|
+
if (!process.stdin.isTTY)
|
|
81
|
+
return false;
|
|
82
|
+
const { createInterface } = await import('node:readline/promises');
|
|
83
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
84
|
+
try {
|
|
85
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
86
|
+
return answer === 'y' || answer === 'yes';
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
rl.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Default output sink — process.stderr so the bar doesn't pollute stdout pipes. */
|
|
93
|
+
function defaultOut(text) {
|
|
94
|
+
process.stderr.write(text);
|
|
95
|
+
}
|
|
96
|
+
// ─── Orchestrator ────────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* Run the LLM-setup phase. Pure-result return — never throws on user-
|
|
99
|
+
* facing failures. The caller turns the result into a one-line summary
|
|
100
|
+
* for the surrounding command's output.
|
|
101
|
+
*/
|
|
102
|
+
export async function runLlmSetup(opts = {}) {
|
|
103
|
+
const out = opts.out ?? defaultOut;
|
|
104
|
+
const confirm = opts.confirm ?? defaultConfirm;
|
|
105
|
+
const host = opts.host ?? process.env.NUOS_CATALOGUE_OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST;
|
|
106
|
+
const model = opts.model ?? process.env.NUOS_CATALOGUE_OLLAMA_MODEL ?? DEFAULT_EMBEDDING_MODEL;
|
|
107
|
+
const deps = { ...defaultDeps(), ...opts.deps };
|
|
108
|
+
// Step 1 — Probe Ollama CLI + API.
|
|
109
|
+
out('\nSetting up local semantic search…\n');
|
|
110
|
+
const cliProbe = await deps.detectCli(deps.platform);
|
|
111
|
+
const apiProbe = await deps.detectApi(host);
|
|
112
|
+
if (!cliProbe.found && !apiProbe.reachable) {
|
|
113
|
+
// Ollama is not installed.
|
|
114
|
+
const hasBrew = await detectBrew(deps.platform);
|
|
115
|
+
const offer = buildInstallOffer(deps.platform, hasBrew);
|
|
116
|
+
out(`\nOllama is not installed. ${offer.primaryDescription}.\n`);
|
|
117
|
+
out(`Reference: ${offer.fallbackUrl}\n`);
|
|
118
|
+
if (!offer.canAutoInstall) {
|
|
119
|
+
// Windows / brew-less macOS / unknown platforms: offer to open the page.
|
|
120
|
+
if (opts.nonInteractive) {
|
|
121
|
+
out('Skipping (non-interactive). Install Ollama, then run `nuos-catalogue setup-llm`.\n');
|
|
122
|
+
return { kind: 'install_offered_declined' };
|
|
123
|
+
}
|
|
124
|
+
const openIt = await confirm(`Open ${offer.fallbackUrl} in your browser?`);
|
|
125
|
+
if (openIt) {
|
|
126
|
+
await deps.opener(offer.fallbackUrl, deps.platform);
|
|
127
|
+
out('Browser opened. After installing Ollama, run `nuos-catalogue setup-llm` to finish.\n');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
out('Skipped. After installing Ollama, run `nuos-catalogue setup-llm`.\n');
|
|
131
|
+
}
|
|
132
|
+
return { kind: 'install_offered_declined' };
|
|
133
|
+
}
|
|
134
|
+
// We have a reliable CLI install path. Offer to run it.
|
|
135
|
+
if (opts.nonInteractive) {
|
|
136
|
+
out(`Skipping (non-interactive). Run \`${offer.primaryCommand}\` then re-run setup.\n`);
|
|
137
|
+
return { kind: 'install_offered_declined' };
|
|
138
|
+
}
|
|
139
|
+
const elevationNote = offer.requiresElevation ? ' (you will be asked for your password)' : '';
|
|
140
|
+
const runIt = await confirm(`Run \`${offer.primaryCommand}\` now?${elevationNote}`);
|
|
141
|
+
if (!runIt) {
|
|
142
|
+
out('Skipped. Install Ollama, then run `nuos-catalogue setup-llm` to finish.\n');
|
|
143
|
+
return { kind: 'install_offered_declined' };
|
|
144
|
+
}
|
|
145
|
+
out(`Running: ${offer.primaryCommand}\n`);
|
|
146
|
+
const installResult = await deps.installer(offer, (line) => out(` ${line}\n`));
|
|
147
|
+
if (!installResult.ok) {
|
|
148
|
+
out(`\nInstall failed: ${installResult.error}\n`);
|
|
149
|
+
out(`Try installing manually from ${offer.fallbackUrl}, then run \`nuos-catalogue setup-llm\`.\n`);
|
|
150
|
+
return { kind: 'install_failed', error: installResult.error };
|
|
151
|
+
}
|
|
152
|
+
out('\nOllama installed.\n');
|
|
153
|
+
// After install, the API may not be running yet (the user needs to
|
|
154
|
+
// start the app on macOS, or the systemd unit might be queued on
|
|
155
|
+
// Linux). Re-probe and steer the user appropriately.
|
|
156
|
+
const postInstallApi = await deps.detectApi(host);
|
|
157
|
+
if (!postInstallApi.reachable) {
|
|
158
|
+
out('\nOllama is installed but not running yet.\n');
|
|
159
|
+
if (deps.platform === 'darwin') {
|
|
160
|
+
out('Open the Ollama app (Spotlight → Ollama), then run `nuos-catalogue setup-llm`.\n');
|
|
161
|
+
}
|
|
162
|
+
else if (deps.platform === 'linux') {
|
|
163
|
+
out('Start Ollama with `ollama serve` (or `systemctl start ollama` on systems where the unit is installed), then run `nuos-catalogue setup-llm`.\n');
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
out('Launch the Ollama app, then run `nuos-catalogue setup-llm`.\n');
|
|
167
|
+
}
|
|
168
|
+
return { kind: 'ollama_installed_but_not_running' };
|
|
169
|
+
}
|
|
170
|
+
// API is reachable — fall through to the model-pull step below.
|
|
171
|
+
}
|
|
172
|
+
else if (cliProbe.found && !apiProbe.reachable) {
|
|
173
|
+
out('\nOllama is installed but not running.\n');
|
|
174
|
+
if (deps.platform === 'darwin') {
|
|
175
|
+
out('Open the Ollama app, then run `nuos-catalogue setup-llm`.\n');
|
|
176
|
+
}
|
|
177
|
+
else if (deps.platform === 'linux') {
|
|
178
|
+
out('Start Ollama with `ollama serve`, then run `nuos-catalogue setup-llm`.\n');
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
out('Launch Ollama, then run `nuos-catalogue setup-llm`.\n');
|
|
182
|
+
}
|
|
183
|
+
return { kind: 'ollama_installed_but_not_running' };
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// API is reachable (with or without CLI on PATH — Docker / remote
|
|
187
|
+
// Ollama would have API but no local CLI).
|
|
188
|
+
const detail = cliProbe.path ? ` (CLI at ${cliProbe.path})` : '';
|
|
189
|
+
out(`✓ Ollama detected at ${host}${detail}.\n`);
|
|
190
|
+
}
|
|
191
|
+
// Step 2 — Check the model.
|
|
192
|
+
const modelProbe = await deps.detectModel(host, model);
|
|
193
|
+
if (modelProbe.present) {
|
|
194
|
+
out(`✓ ${model} already pulled (${DEFAULT_MODEL_SIZE_LABEL}).\n`);
|
|
195
|
+
return { kind: 'already_ready' };
|
|
196
|
+
}
|
|
197
|
+
// Step 3 — Pull the model with a progress bar.
|
|
198
|
+
out(`\nPulling ${model} (${DEFAULT_MODEL_SIZE_LABEL})…\n`);
|
|
199
|
+
// We render the bar to the same line repeatedly. Outside a TTY we
|
|
200
|
+
// fall back to line-per-status to keep logs readable.
|
|
201
|
+
const isTty = !!process.stderr.isTTY;
|
|
202
|
+
let lastLineLength = 0;
|
|
203
|
+
function renderPullLine(line) {
|
|
204
|
+
if (isTty) {
|
|
205
|
+
out(`\r${' '.repeat(Math.max(lastLineLength, line.length))}\r${line}`);
|
|
206
|
+
lastLineLength = line.length;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
out(`${line}\n`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const pullResult = await deps.pull(host, model, (event) => {
|
|
213
|
+
if (event.error) {
|
|
214
|
+
// Error events are emitted instead of status; let the wrapper handle.
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (event.status === 'downloading' && typeof event.total === 'number' && typeof event.completed === 'number') {
|
|
218
|
+
const shortDigest = event.digest ? event.digest.slice(0, 12) : '';
|
|
219
|
+
renderPullLine(buildProgressLine(event.completed, event.total, `downloading ${shortDigest}`));
|
|
220
|
+
}
|
|
221
|
+
else if (event.status) {
|
|
222
|
+
renderPullLine(buildSpinnerLine(event.status));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// Close the bar line cleanly.
|
|
226
|
+
if (isTty)
|
|
227
|
+
out(clearLineSequence());
|
|
228
|
+
if (!pullResult.ok) {
|
|
229
|
+
out(`\nPull failed: ${pullResult.error}\n`);
|
|
230
|
+
out('Re-run `nuos-catalogue setup-llm` to retry — Ollama resumes partial pulls automatically.\n');
|
|
231
|
+
return { kind: 'pull_failed', error: pullResult.error };
|
|
232
|
+
}
|
|
233
|
+
out(`✓ ${model} ready.\n`);
|
|
234
|
+
out(`\nLocal semantic search is ready. Try \`nuos-catalogue search 'your query'\` after the first index.\n`);
|
|
235
|
+
// Differentiate the result based on whether we just installed Ollama
|
|
236
|
+
// this run, or only pulled the model.
|
|
237
|
+
return cliProbe.found || apiProbe.reachable
|
|
238
|
+
? { kind: 'pulled_only' }
|
|
239
|
+
: { kind: 'installed_and_pulled' };
|
|
240
|
+
}
|
|
241
|
+
/** Re-export of the download URL for callers that want to print it. */
|
|
242
|
+
export { OLLAMA_DOWNLOAD_URL };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the LLM-setup phase of `init` (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* The setup phase runs after the scaffold and is responsible for
|
|
5
|
+
* detecting Ollama, offering to install it where reliable, and pulling
|
|
6
|
+
* the default embedding model (`qwen3-embedding:0.6b`, ~600MB) with a
|
|
7
|
+
* live progress bar.
|
|
8
|
+
*
|
|
9
|
+
* @module setup/types
|
|
10
|
+
*/
|
|
11
|
+
/** Node `process.platform` narrowed to the cases we handle. */
|
|
12
|
+
export type Platform = 'darwin' | 'linux' | 'win32' | 'other';
|
|
13
|
+
/** Result of probing for the Ollama CLI binary on PATH. */
|
|
14
|
+
export interface OllamaCliProbe {
|
|
15
|
+
found: boolean;
|
|
16
|
+
/** Resolved path when `found`. */
|
|
17
|
+
path?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Result of probing the Ollama HTTP API. */
|
|
20
|
+
export interface OllamaApiProbe {
|
|
21
|
+
reachable: boolean;
|
|
22
|
+
/** Host the probe used (e.g. "http://localhost:11434"). */
|
|
23
|
+
host: string;
|
|
24
|
+
/** Error message when `!reachable`. */
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Result of querying the local model list. */
|
|
28
|
+
export interface ModelProbe {
|
|
29
|
+
present: boolean;
|
|
30
|
+
/** Model identifier we probed for (e.g. "qwen3-embedding:0.6b"). */
|
|
31
|
+
model: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One event from the Ollama `/api/pull` NDJSON stream. The status
|
|
35
|
+
* strings come straight from Ollama; we treat them as opaque except for
|
|
36
|
+
* the distinguished cases used to render the progress bar.
|
|
37
|
+
*
|
|
38
|
+
* Known status values observed in practice:
|
|
39
|
+
* - "pulling manifest"
|
|
40
|
+
* - "downloading" (carries digest + total + completed)
|
|
41
|
+
* - "verifying sha256 digest"
|
|
42
|
+
* - "writing manifest"
|
|
43
|
+
* - "removing any unused layers"
|
|
44
|
+
* - "success"
|
|
45
|
+
*
|
|
46
|
+
* An `error` field is set instead of `status` on failure.
|
|
47
|
+
*/
|
|
48
|
+
export interface PullEvent {
|
|
49
|
+
status?: string;
|
|
50
|
+
digest?: string;
|
|
51
|
+
total?: number;
|
|
52
|
+
completed?: number;
|
|
53
|
+
error?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Outcome of the LLM-setup phase. Returned to the caller (init or the
|
|
57
|
+
* standalone `setup-llm` command) so the surrounding UX can render an
|
|
58
|
+
* appropriate summary line.
|
|
59
|
+
*/
|
|
60
|
+
export type LlmSetupResult = {
|
|
61
|
+
kind: 'already_ready';
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'installed_and_pulled';
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'pulled_only';
|
|
66
|
+
} | {
|
|
67
|
+
kind: 'install_offered_declined';
|
|
68
|
+
} | {
|
|
69
|
+
kind: 'install_failed';
|
|
70
|
+
error: string;
|
|
71
|
+
} | {
|
|
72
|
+
kind: 'ollama_installed_but_not_running';
|
|
73
|
+
} | {
|
|
74
|
+
kind: 'pull_failed';
|
|
75
|
+
error: string;
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'skipped';
|
|
78
|
+
reason: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Per-platform install offer the CLI can present to the user. The
|
|
82
|
+
* `canAutoInstall` flag is the gate for offering to run the install
|
|
83
|
+
* command directly — false on Windows (no reliable CLI install path).
|
|
84
|
+
*/
|
|
85
|
+
export interface InstallOffer {
|
|
86
|
+
platform: Platform;
|
|
87
|
+
/** The shell command the offer would run (empty string when !canAutoInstall). */
|
|
88
|
+
primaryCommand: string;
|
|
89
|
+
/** Plain-English description of the primary path. */
|
|
90
|
+
primaryDescription: string;
|
|
91
|
+
/** Download page URL — always present as the safe fallback. */
|
|
92
|
+
fallbackUrl: string;
|
|
93
|
+
/** Whether we have a reliable CLI install path to offer running. */
|
|
94
|
+
canAutoInstall: boolean;
|
|
95
|
+
/** Whether the primary command needs sudo. Used to phrase the prompt. */
|
|
96
|
+
requiresElevation: boolean;
|
|
97
|
+
}
|
|
98
|
+
/** Narrow Node's process.platform string to our Platform union. */
|
|
99
|
+
export declare function narrowPlatform(p: NodeJS.Platform): Platform;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the LLM-setup phase of `init` (WU 135).
|
|
3
|
+
*
|
|
4
|
+
* The setup phase runs after the scaffold and is responsible for
|
|
5
|
+
* detecting Ollama, offering to install it where reliable, and pulling
|
|
6
|
+
* the default embedding model (`qwen3-embedding:0.6b`, ~600MB) with a
|
|
7
|
+
* live progress bar.
|
|
8
|
+
*
|
|
9
|
+
* @module setup/types
|
|
10
|
+
*/
|
|
11
|
+
/** Narrow Node's process.platform string to our Platform union. */
|
|
12
|
+
export function narrowPlatform(p) {
|
|
13
|
+
if (p === 'darwin')
|
|
14
|
+
return 'darwin';
|
|
15
|
+
if (p === 'linux')
|
|
16
|
+
return 'linux';
|
|
17
|
+
if (p === 'win32')
|
|
18
|
+
return 'win32';
|
|
19
|
+
return 'other';
|
|
20
|
+
}
|