ollanet 0.6.24 → 0.7.1

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/README.md CHANGED
@@ -4,9 +4,7 @@
4
4
 
5
5
  # ollanet
6
6
 
7
- Find, manage, and use Ollama models on the hosts you choose.
8
-
9
- **CLI** for humans · **MCP** for agents · **Node** for apps.
7
+ Find, manage, and use Ollama models on the hosts you choose, from a CLI, an MCP server for agents, or a Node library.
10
8
 
11
9
  Discover hosts, give models convenient aliases, and prompt or compare them. A reachable host is not a trusted host. LAN scan is opt-in. Node 20+, zero runtime deps.
12
10
 
@@ -39,7 +37,7 @@ Host-first: `ollanet <cmd> <machine> …`. The machine is a MagicDNS name, a con
39
37
 
40
38
  **Manage.** `pull`, `show`, `rm`, and `ps` on a named machine. Comparisons and benches are deeper workflows on [ollanet.dev/docs](https://ollanet.dev/docs).
41
39
 
42
- **Use.** Prompt, continue a chat by hash, or call the same inventory from MCP and the Node library.
40
+ **Use.** Prompt, continue a chat by hash, or call the same inventory from MCP and the Node library. Agents off your network can read a token-gated, read-only model catalog with `ollanet serve` (no hostnames or IPs, no prompt proxying). `-q` keeps stderr for errors only, and `--stdin` reads a piped prompt.
43
41
 
44
42
  ## Reachability and trust
45
43
 
@@ -59,6 +57,7 @@ This package does not include vendor telemetry. Commands still cause network tra
59
57
  | `rm` | Delete request (`--yes`) | Remove that model from the host | Model removed on the remote host |
60
58
  | `alias` | None to Ollama | None | Local `config.json` |
61
59
  | `chats` | None to Ollama | None | Reads local transcripts |
60
+ | `serve` | Tag and metadata probes when it refreshes | Answer with tags and model metadata | Token hashes in `~/.ollanet/serve.json` |
62
61
 
63
62
  Other machines see a saved chat only if they share the responses directory (or the same `~/.ollanet/` when installed). This table covers the operations inspected in this repo. It does not prove every possible data path is listed.
64
63
 
@@ -94,6 +93,8 @@ pnpm test
94
93
  pnpm ollanet -- help
95
94
  ```
96
95
 
96
+ A checkout reads `./config.json` (gitignored, optional). Copy `config.example.json` to start. `config.json` was tracked before 0.7.0: if your checkout has one with your hosts or aliases, copy it somewhere safe before pulling, since the pull removes it (or refuses if you edited it), then put it back.
97
+
97
98
  Site (FilePress + docs mount): `pnpm --dir site ship`
98
99
 
99
100
  ## License
package/dist/argv.d.ts CHANGED
@@ -6,6 +6,24 @@ export type UsageFn = () => never;
6
6
  export declare function isHelpFlag(arg: string): boolean;
7
7
  /** Intentional help: stdout, exit 0. */
8
8
  export declare function printHelp(text: string): never;
9
+ /**
10
+ * Quiet mode (`-q` / `--quiet` on any command, or OLLANET_QUIET=1): results on
11
+ * stdout only. stderr keeps real errors and warnings (and --think reasoning,
12
+ * which you asked for); saved paths, hints, notices, and progress are dropped.
13
+ */
14
+ export declare function isQuiet(): boolean;
15
+ /**
16
+ * Remove `-q` / `--quiet` from a command's argv and switch quiet mode on.
17
+ * Done once in the CLI entry so every command accepts it in any position.
18
+ */
19
+ export declare function takeQuietFlag(argv: string[]): string[];
20
+ /**
21
+ * Decorative progress lines (`→ model (1/2)`) are for people watching a
22
+ * terminal. Piped and agent runs skip them so stderr only carries results and
23
+ * real errors (PowerShell 5 wraps every native stderr line in a red
24
+ * NativeCommandError). OLLANET_PROGRESS=1 forces them on, 0 forces them off.
25
+ */
26
+ export declare function showProgress(): boolean;
9
27
  /** Usage error: stderr, exit 1. */
10
28
  export declare function failUsage(text: string): never;
11
29
  export declare function takeValue(args: string[], flag: string, usage: UsageFn): string;
package/dist/argv.js CHANGED
@@ -6,6 +6,41 @@ export function printHelp(text) {
6
6
  process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
7
7
  process.exit(0);
8
8
  }
9
+ /**
10
+ * Quiet mode (`-q` / `--quiet` on any command, or OLLANET_QUIET=1): results on
11
+ * stdout only. stderr keeps real errors and warnings (and --think reasoning,
12
+ * which you asked for); saved paths, hints, notices, and progress are dropped.
13
+ */
14
+ export function isQuiet() {
15
+ const flag = process.env.OLLANET_QUIET?.trim().toLowerCase();
16
+ return flag === "1" || flag === "true";
17
+ }
18
+ /**
19
+ * Remove `-q` / `--quiet` from a command's argv and switch quiet mode on.
20
+ * Done once in the CLI entry so every command accepts it in any position.
21
+ */
22
+ export function takeQuietFlag(argv) {
23
+ const rest = argv.filter((arg) => arg !== "-q" && arg !== "--quiet");
24
+ if (rest.length !== argv.length)
25
+ process.env.OLLANET_QUIET = "1";
26
+ return rest;
27
+ }
28
+ /**
29
+ * Decorative progress lines (`→ model (1/2)`) are for people watching a
30
+ * terminal. Piped and agent runs skip them so stderr only carries results and
31
+ * real errors (PowerShell 5 wraps every native stderr line in a red
32
+ * NativeCommandError). OLLANET_PROGRESS=1 forces them on, 0 forces them off.
33
+ */
34
+ export function showProgress() {
35
+ if (isQuiet())
36
+ return false;
37
+ const flag = process.env.OLLANET_PROGRESS?.trim().toLowerCase();
38
+ if (flag === "1" || flag === "true")
39
+ return true;
40
+ if (flag === "0" || flag === "false")
41
+ return false;
42
+ return Boolean(process.stderr.isTTY);
43
+ }
9
44
  /** Usage error: stderr, exit 1. */
10
45
  export function failUsage(text) {
11
46
  console.error(text);
package/dist/bench.js CHANGED
@@ -6,7 +6,7 @@ import { comparabilityKey, getSuiteCases, LONG_THROUGHPUT_NUM_PREDICT, median, s
6
6
  import { benchmarksDir, newBenchId, saveBenchmark } from "./bench-store.js";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
9
- import { consumeSettingsFlag, failUsage, isHelpFlag, parseNumberFlag, printHelp, takeFlag } from "./argv.js";
9
+ import { consumeSettingsFlag, failUsage, isHelpFlag, isQuiet, parseNumberFlag, printHelp, showProgress, takeFlag, } from "./argv.js";
10
10
  import { resolveTarget } from "./target.js";
11
11
  import { projectPath } from "./paths.js";
12
12
  import { contextLengthForModel, isCompletionCapable, isVisionCapable, ollamaChat, ollamaShow, ollamaTags, ollamaUnload, ollamaVersion, ollamaPs, psHasModel, shouldWarnNoThinking, vramForModel, waitUntilUnloaded, } from "./ollama-chat.js";
@@ -43,6 +43,7 @@ Options:
43
43
  --judge Second pass: score check answers 1–5
44
44
  --judge-model <name> Required with --judge (no default)
45
45
  --json Machine-readable stdout
46
+ -q, --quiet Table only; no plan, notes, or saved paths on stderr
46
47
  --save / --no-save Persist under benchmarks/ (default on)
47
48
  --fail-fast Stop remaining models after the first hard error
48
49
  --fail-on-error Exit 2 if any model/case errored
@@ -642,7 +643,8 @@ export async function main() {
642
643
  const pin = pinnedNumPredict(c, parsed.throughputNumPredict);
643
644
  return n + caseReps(c, parsed.runs, parsed.hot) * attemptTimeoutMs(pin);
644
645
  }, 0);
645
- if (!parsed.json) {
646
+ const quiet = isQuiet();
647
+ if (!parsed.json && !quiet) {
646
648
  const skipBits = [
647
649
  skipped.filter((s) => s.reason === "non-completion").length
648
650
  ? `${skipped.filter((s) => s.reason === "non-completion").length} embedding/other`
@@ -669,7 +671,7 @@ export async function main() {
669
671
  }
670
672
  console.error("Proceeding…");
671
673
  }
672
- if (parsed.hot) {
674
+ if (parsed.hot && !quiet) {
673
675
  console.error("Hot: discarding the first throughput run; models stay loaded (no unload between models).");
674
676
  }
675
677
  if (parsed.settings.think === true) {
@@ -688,7 +690,7 @@ export async function main() {
688
690
  const info = tags.find((t) => t.name === model);
689
691
  const caps = await fetchCapabilities(baseUrl, model);
690
692
  const selfJudge = Boolean(judgeModel && judgeModel === model);
691
- if (!parsed.json) {
693
+ if (!parsed.json && showProgress()) {
692
694
  console.error(`\n→ ${model} (${mi + 1}/${selected.length})`);
693
695
  }
694
696
  const cold = parsed.coldLoad
@@ -833,7 +835,7 @@ export async function main() {
833
835
  };
834
836
  if (parsed.save) {
835
837
  const file = await saveBenchmark(result);
836
- if (!parsed.json)
838
+ if (!parsed.json && !quiet)
837
839
  console.error(`saved ${file}`);
838
840
  }
839
841
  if (parsed.json) {
@@ -842,7 +844,7 @@ export async function main() {
842
844
  else {
843
845
  process.stdout.write("\n");
844
846
  printTable(machineLabel, parsed.suite, parsed.runs, revision, compKey, modelResults, skipped, parsed.coldLoad, parsed.hot);
845
- if (parsed.save)
847
+ if (parsed.save && !quiet)
846
848
  console.error(`benchmarks dir: ${benchmarksDir()}`);
847
849
  }
848
850
  if (parsed.failOnError && anyError) {
package/dist/cli.d.ts CHANGED
@@ -12,5 +12,6 @@
12
12
  * ollanet bench <machine> [model...]
13
13
  * ollanet chats
14
14
  * ollanet mcp
15
+ * ollanet serve [--funnel]
15
16
  */
16
17
  export {};
package/dist/cli.js CHANGED
@@ -12,11 +12,10 @@
12
12
  * ollanet bench <machine> [model...]
13
13
  * ollanet chats
14
14
  * ollanet mcp
15
+ * ollanet serve [--funnel]
15
16
  */
16
17
  const HELP = `ollanet: find, manage, and use Ollama on hosts you choose
17
-
18
- Discover hosts, manage models, prompt and compare, bench tok/s, and continue
19
- chats by hash. CLI for humans, MCP for agents, Node for apps.
18
+ CLI for people, MCP for agents (ollanet mcp), Node library for apps (import from "ollanet").
20
19
  A reachable host is not a trusted host. pull and rm change the remote host.
21
20
 
22
21
  Usage:
@@ -24,7 +23,8 @@ Usage:
24
23
  ollanet prompt <machine|ip> [model] <prompt...>
25
24
  ollanet prompt --chat <hash> <prompt...>
26
25
  ollanet prompt <machine|ip> --file <path.txt|.md>
27
- ollanet compare <machine> <model> <model> [model...] [--prompt <text> | --file <path>]
26
+ <command> | ollanet prompt <machine|ip> [model] --stdin
27
+ ollanet compare <machine> <model> <model> [model...] [--prompt <text> | --file <path> | --stdin]
28
28
  ollanet pull <machine|ip> <model>
29
29
  ollanet show <machine|ip> <model>
30
30
  ollanet rm <machine|ip> <model> --yes
@@ -33,36 +33,64 @@ Usage:
33
33
  ollanet bench <machine|ip> [model...] [--all] [options]
34
34
  ollanet alias list|add|rm …
35
35
  ollanet mcp
36
+ ollanet serve [--listen <host:port>] [--funnel] (read-only catalog)
37
+ ollanet serve status
38
+ ollanet serve token add|list|rm|prune …
39
+ ollanet serve label set|rm|list …
36
40
  ollanet help [command]
37
41
 
38
42
  Examples:
39
- ollanet scan
40
- ollanet scan --lan
41
- ollanet scan --last # print the last saved scan (no network)
42
- ollanet prompt localhost "What is MagicDNS?"
43
- ollanet prompt localhost --file ./notes.md
44
- ollanet compare studio gemma3:12b llama3.2:3b
45
- ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
46
- ollanet prompt --chat a1b2c3d4e5f6 "Tell me more"
47
- ollanet pull studio gemma3:12b
48
- ollanet show studio gemma4-ctx32k
49
- ollanet rm studio llama3.2:1b --yes
50
- ollanet ps studio
51
- ollanet chats
52
- ollanet alias add desk studio gemma3:12b
53
- ollanet prompt desk "hello" # expands machine + model
54
- ollanet bench localhost --all
55
- ollanet bench localhost llama3.2:1b --runs 3
56
- ollanet bench localhost gemma3:12b --hot --runs 5
57
- ollanet help bench # flags for one command (also: ollanet bench help)
58
- ollanet mcp # stdio MCP server for agents
43
+ Find hosts
44
+ ollanet scan
45
+ ollanet scan --lan # also sweep your subnet on port 11434 (opt-in)
46
+ ollanet scan --last # print the last saved scan (no network)
47
+
48
+ Prompt and chat
49
+ ollanet prompt localhost "What is MagicDNS?"
50
+ ollanet prompt localhost --file ./notes.md
51
+ ollanet alias add desk studio gemma3:12b
52
+ ollanet prompt desk "hello" # expands machine + model
53
+ git diff | ollanet prompt desk "Review this" --stdin -q
54
+ ollanet prompt --chat a1b2c3d4e5f6 "Tell me more"
55
+ ollanet chats
56
+
57
+ Compare and bench
58
+ ollanet compare studio gemma3:12b llama3.2:3b
59
+ ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
60
+ ollanet bench localhost --all
61
+ ollanet bench localhost llama3.2:1b --runs 3
62
+ ollanet bench localhost gemma3:12b --hot --runs 5
63
+
64
+ Manage models (pull and rm change the remote host)
65
+ ollanet pull studio gemma3:12b
66
+ ollanet show studio gemma4-ctx32k
67
+ ollanet rm studio llama3.2:1b --yes
68
+ ollanet ps studio
69
+
70
+ Share a read-only catalog
71
+ ollanet serve token add cloud-agent --expires 30d
72
+ ollanet serve --funnel # catalog on https://<device>.<tailnet>.ts.net/
73
+ ollanet serve status # local and Funnel URLs of the running server
74
+ ollanet serve label set studio gpu-box # agents see gpu-box, not the host name
75
+ ollanet serve token prune # expired, or unused 30+ days (asks first)
59
76
 
77
+ Agents and scripts
78
+ ollanet mcp # stdio MCP server for agents
79
+ ollanet scan --json -q # machine-readable hosts and models
80
+
81
+ Every flag and more examples: ollanet help <command> (or ollanet <command> --help)
82
+
83
+ Quiet: -q / --quiet on any command (or OLLANET_QUIET=1): results on stdout,
84
+ stderr only for errors and warnings
85
+ Progress: compare/bench progress lines only when stderr is a terminal
86
+ (OLLANET_PROGRESS=1 forces on, 0 off)
60
87
  Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
61
88
  (override with OLLANET_CONFIG)
62
89
  Hosts: config.hosts, OLLANET_HOSTS, optional Tailscale + --lan
63
90
  Aliases: config.aliases — ollanet alias add|list|rm
64
91
  Chats: ~/.ollanet/responses or ./responses (override with OLLANET_RESPONSES_DIR)
65
92
  Scan: ~/.ollanet/last-scan.json or ./last-scan.json (override with OLLANET_LAST_SCAN)
93
+ Serve: ~/.ollanet/serve.json, token hashes and labels (override with OLLANET_SERVE_FILE)
66
94
  `;
67
95
  async function main() {
68
96
  // pnpm/npm often forward a bare `--`; ignore it.
@@ -105,8 +133,14 @@ async function main() {
105
133
  console.log(`ollanet ${pkg.version ?? "0.0.0"}`);
106
134
  return;
107
135
  }
108
- // Strip the subcommand so command modules see their own argv.
109
- process.argv = [process.argv[0] ?? "node", process.argv[1] ?? "ollanet", ...argv.slice(1)];
136
+ // Strip the subcommand so command modules see their own argv. -q / --quiet
137
+ // is global: it is removed here and read through isQuiet().
138
+ const { takeQuietFlag } = await import("./argv.js");
139
+ process.argv = [
140
+ process.argv[0] ?? "node",
141
+ process.argv[1] ?? "ollanet",
142
+ ...takeQuietFlag(argv.slice(1)),
143
+ ];
110
144
  switch (cmd) {
111
145
  case "scan":
112
146
  case "ls-servers": {
@@ -170,6 +204,11 @@ async function main() {
170
204
  await run();
171
205
  return;
172
206
  }
207
+ case "serve": {
208
+ const { main: run } = await import("./serve.js");
209
+ await run();
210
+ return;
211
+ }
173
212
  case "alias":
174
213
  case "aliases": {
175
214
  const { main: run } = await import("./alias.js");
package/dist/compare.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readStdinIfPiped } from "./prompt.js";
1
+ import { readPromptStdin } from "./prompt.js";
2
2
  /**
3
3
  * Head-to-head: one prompt, 2–5 models on the same host.
4
4
  *
@@ -7,7 +7,7 @@ import { readStdinIfPiped } from "./prompt.js";
7
7
  */
8
8
  import { configFromPartial, loadConfig, lookupAlias, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
9
9
  import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
10
- import { consumeSettingsFlag, failUsage, isHelpFlag, printHelp, takeFlag, takeValue } from "./argv.js";
10
+ import { consumeSettingsFlag, failUsage, isHelpFlag, isQuiet, printHelp, showProgress, takeFlag, takeValue, } from "./argv.js";
11
11
  import { resolveTarget } from "./target.js";
12
12
  import { ollamaChat, ollamaTags, ollamaUnload, waitUntilUnloaded, } from "./ollama-chat.js";
13
13
  import { assemblePrompt, readPromptFile } from "./prompt-input.js";
@@ -29,6 +29,7 @@ export function helpText() {
29
29
  ollanet compare <machine> <model> <model> [model...]
30
30
  ollanet compare <machine> <model> <model> [model...] --prompt <text>
31
31
  ollanet compare <machine> <model> <model> [model...] --file <path.txt|.md>
32
+ <command> | ollanet compare <machine> <model> <model> [model...] --stdin
32
33
 
33
34
  Examples:
34
35
  ollanet compare studio gemma3:12b llama3.2:3b
@@ -42,6 +43,8 @@ Omit --prompt/--file to use the built-in mesh-host tasting prompt.
42
43
  Options:
43
44
  --prompt <text> Prompt text (default: built-in mesh-host prompt)
44
45
  --file <path> Prompt from a .txt or .md file
46
+ --stdin, - Wait for stdin to close and use it as the prompt
47
+ (no 100ms cutoff for slow producers)
45
48
  --system <text> System prompt
46
49
  --temperature <n>
47
50
  --num-predict <n>
@@ -50,7 +53,8 @@ Options:
50
53
  --think / --no-think
51
54
  --unload Unload each model before the next (fairer tok/s)
52
55
  --no-save Do not write compares/*
53
- --json Emit the full record on stdout`;
56
+ --json Emit the full record on stdout
57
+ -q, --quiet Table only; no notices or saved paths on stderr`;
54
58
  }
55
59
  function usage() {
56
60
  failUsage(helpText());
@@ -62,6 +66,7 @@ function parseArgs(argv) {
62
66
  let json = false;
63
67
  let save = true;
64
68
  let unload = false;
69
+ let stdin = false;
65
70
  const settings = {};
66
71
  const positional = [];
67
72
  while (args.length > 0) {
@@ -82,6 +87,10 @@ function parseArgs(argv) {
82
87
  unload = true;
83
88
  continue;
84
89
  }
90
+ if (arg === "--stdin" || arg === "-") {
91
+ stdin = true;
92
+ continue;
93
+ }
85
94
  if (consumeSettingsFlag(arg, args, settings, usage, {
86
95
  temperature: true,
87
96
  numPredict: true,
@@ -105,7 +114,7 @@ function parseArgs(argv) {
105
114
  }
106
115
  const machine = positional[0];
107
116
  const models = positional.slice(1);
108
- return { machine, models, prompt, file, json, save, unload, settings };
117
+ return { machine, models, prompt, file, json, save, unload, stdin, settings };
109
118
  }
110
119
  function tokPerSec(chunk) {
111
120
  if (chunk.eval_count == null || chunk.eval_duration == null || chunk.eval_duration <= 0) {
@@ -121,19 +130,20 @@ function resolveModelName(available, query) {
121
130
  const prefix = available.filter((n) => n.toLowerCase().startsWith(`${q}:`) || q.startsWith(`${n.toLowerCase()}:`));
122
131
  return prefix.length === 1 ? prefix[0] : undefined;
123
132
  }
133
+ /** Result table on stdout (like bench); notices and saved paths stay on stderr. */
124
134
  function printSummary(record) {
125
135
  const nameWidth = Math.max(8, ...record.results.map((r) => r.model.length));
126
- process.stderr.write(`Compare on ${record.machine} ${record.results.length} models ${record.prompt.length} char prompt\n`);
127
- process.stderr.write(`${"model".padEnd(nameWidth)} ${"tok/s".padStart(7)} ${"tokens".padStart(6)} ${"wall".padStart(7)} done\n`);
136
+ process.stdout.write(`Compare on ${record.machine} ${record.results.length} models ${record.prompt.length} char prompt\n`);
137
+ process.stdout.write(`${"model".padEnd(nameWidth)} ${"tok/s".padStart(7)} ${"tokens".padStart(6)} ${"wall".padStart(7)} done\n`);
128
138
  for (const r of record.results) {
129
139
  if (r.error) {
130
- process.stderr.write(`${r.model.padEnd(nameWidth)} ERROR ${r.error}\n`);
140
+ process.stdout.write(`${r.model.padEnd(nameWidth)} ERROR ${r.error}\n`);
131
141
  continue;
132
142
  }
133
143
  const toks = r.tok_s != null ? r.tok_s.toFixed(1) : "—";
134
144
  const tokens = r.eval_count != null ? String(r.eval_count) : "—";
135
145
  const wall = r.wall_ms != null ? `${(r.wall_ms / 1000).toFixed(2)}s` : "—";
136
- process.stderr.write(`${r.model.padEnd(nameWidth)} ${toks.padStart(7)} ${tokens.padStart(6)} ${wall.padStart(7)} ${r.done_reason ?? "—"}\n`);
146
+ process.stdout.write(`${r.model.padEnd(nameWidth)} ${toks.padStart(7)} ${tokens.padStart(6)} ${wall.padStart(7)} ${r.done_reason ?? "—"}\n`);
137
147
  }
138
148
  }
139
149
  export async function runCompare(opts) {
@@ -173,7 +183,7 @@ export async function runCompare(opts) {
173
183
  }
174
184
  }
175
185
  const settings = mergeSettings({ think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), opts.settings);
176
- const quiet = opts.quiet === true || opts.writeStdout === false;
186
+ const quiet = opts.quiet === true || opts.writeStdout === false || !showProgress();
177
187
  const results = [];
178
188
  for (let i = 0; i < resolved.length; i += 1) {
179
189
  const model = resolved[i];
@@ -253,8 +263,17 @@ export async function main() {
253
263
  // `compare desk other:7b` → alias model vs other on alias host
254
264
  models = [alias.model, models[0]];
255
265
  }
256
- // Same guarded read as prompt: a never-closing pipe (agents, CI) must not hang.
257
- const fromStdin = await readStdinIfPiped();
266
+ // Same stdin rules as prompt: --stdin waits for EOF; otherwise a guarded read
267
+ // so a never-closing pipe (agents, CI) cannot hang.
268
+ let fromStdin;
269
+ try {
270
+ fromStdin = await readPromptStdin(parsed.stdin);
271
+ }
272
+ catch (err) {
273
+ console.error(err instanceof Error ? err.message : err);
274
+ process.exitCode = 1;
275
+ return;
276
+ }
258
277
  let fileText;
259
278
  if (parsed.file) {
260
279
  try {
@@ -267,6 +286,10 @@ export async function main() {
267
286
  }
268
287
  }
269
288
  const prompt = assemblePrompt({ argv: parsed.prompt, file: fileText, stdin: fromStdin });
289
+ const quiet = isQuiet();
290
+ if (!prompt && !quiet) {
291
+ process.stderr.write("Using default compare prompt (pass --prompt or --file to override).\n");
292
+ }
270
293
  const record = await runCompare({
271
294
  machine,
272
295
  models,
@@ -278,15 +301,12 @@ export async function main() {
278
301
  quiet: parsed.json,
279
302
  config,
280
303
  });
281
- if (record.default_prompt) {
282
- process.stderr.write("Using default compare prompt (pass --prompt or --file to override).\n");
283
- }
284
304
  if (parsed.json) {
285
305
  process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
286
306
  return;
287
307
  }
288
308
  printSummary(record);
289
- if (record.files) {
309
+ if (record.files && !quiet) {
290
310
  process.stderr.write(`saved ${record.files.md}\n`);
291
311
  process.stderr.write(` ${record.files.json}\n`);
292
312
  process.stderr.write(`compares dir: ${comparesDir()}\n`);
@@ -0,0 +1,70 @@
1
+ export interface ExecResult {
2
+ /** Exit code, or null when the process could not start or was killed. */
3
+ code: number | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ timedOut: boolean;
7
+ /** The binary was not found. */
8
+ missing: boolean;
9
+ }
10
+ export interface ExecOptions {
11
+ timeoutMs: number;
12
+ /** Stop early (kill the process) once combined output matches. */
13
+ until?: RegExp;
14
+ }
15
+ export type TailscaleExec = (args: string[], opts: ExecOptions) => Promise<ExecResult>;
16
+ export type TailscaleExecSync = (args: string[], opts: {
17
+ timeoutMs: number;
18
+ }) => ExecResult;
19
+ export declare const FUNNEL_PORTS: readonly [443, 8443, 10000];
20
+ export declare const HTTPS_ADMIN_URL = "https://login.tailscale.com/admin/dns";
21
+ export declare const FUNNEL_DOCS_URL = "https://tailscale.com/kb/1223/funnel";
22
+ export declare const defaultExec: TailscaleExec;
23
+ export declare const defaultExecSync: TailscaleExecSync;
24
+ /** A Funnel setup problem with a message meant for the person at the terminal. */
25
+ export declare class FunnelError extends Error {
26
+ readonly enableUrl?: string | undefined;
27
+ constructor(message: string, enableUrl?: string | undefined);
28
+ }
29
+ interface TailscaleSelf {
30
+ DNSName?: string;
31
+ CapMap?: Record<string, unknown>;
32
+ Capabilities?: string[];
33
+ }
34
+ export interface Preflight {
35
+ /** MagicDNS name without the trailing dot. */
36
+ dnsName: string;
37
+ /** Funnel node attribute present (Self.CapMap / Capabilities). */
38
+ funnelAllowed: boolean;
39
+ }
40
+ export declare function hasFunnelCap(self: TailscaleSelf | undefined): boolean;
41
+ /** `tailscale status --json`: installed, running, MagicDNS name, HTTPS certs, Funnel attr. */
42
+ export declare function preflight(exec?: TailscaleExec): Promise<Preflight>;
43
+ export declare function funnelOnArgs(funnelPort: number, target: string): string[];
44
+ export declare function funnelOffArgs(funnelPort: number): string[];
45
+ export declare function publicUrl(dnsName: string, funnelPort: number): string;
46
+ /**
47
+ * What `tailscale funnel status --json` serves at https://<dns>:<port>/.
48
+ * Returns the proxy target, null when nothing is configured there, or
49
+ * undefined when the status could not be read.
50
+ */
51
+ export declare function currentHandler(exec: TailscaleExec, dnsName: string, funnelPort: number): Promise<string | null | undefined>;
52
+ export interface FunnelHandle {
53
+ publicUrl: string;
54
+ /** False when an identical funnel was already up; stop() then leaves it alone. */
55
+ added: boolean;
56
+ /** Turn off only what we added. Synchronous so it can run in an exit handler. */
57
+ stop(): void;
58
+ }
59
+ export interface StartFunnelOptions {
60
+ localPort: number;
61
+ /** Local URL Funnel proxies to, e.g. http://127.0.0.1:11500 */
62
+ target: string;
63
+ funnelPort: number;
64
+ exec?: TailscaleExec;
65
+ execSync?: TailscaleExecSync;
66
+ /** Time allowed for `tailscale funnel --bg` (default 30s). */
67
+ timeoutMs?: number;
68
+ }
69
+ export declare function startFunnel(opts: StartFunnelOptions): Promise<FunnelHandle>;
70
+ export {};