@openparachute/hub 0.7.3-rc.6 → 0.7.3-rc.8

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.
@@ -11,6 +11,7 @@ import {
11
11
  KNOWN_MODULES,
12
12
  type ServiceSpec,
13
13
  composeServiceSpec,
14
+ focusForShort,
14
15
  knownServices,
15
16
  } from "../service-spec.ts";
16
17
  import type { ServiceEntry } from "../services-manifest.ts";
@@ -108,10 +109,14 @@ function defaultAvailability(): InteractiveAvailability {
108
109
  }
109
110
 
110
111
  /**
111
- * Survey the eligible services. We include the four first-party shortnames
112
- * (vault / notes / scribe / agent + runner) but flag agent as exploratory
113
- * in the blurb so operators don't grab it by reflex. `installed` is true when
114
- * the service has a row in services.json.
112
+ * Survey ALL known first-party shortnames (vault / notes / scribe / agent /
113
+ * runner / surface) regardless of tier `installed` is true when the service
114
+ * has a row in services.json. The fresh-install OFFER is narrowed downstream
115
+ * by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
116
+ * notes / runner); agent (`experimental`) is flagged exploratory in its blurb
117
+ * but stays offered. Surveying everything keeps `installed` detection complete
118
+ * (the "already installed" banner still lists a deprecated module an operator
119
+ * has on disk).
115
120
  *
116
121
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
117
122
  * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
@@ -149,10 +154,30 @@ function surveyServices(manifestPath: string): ServiceChoice[] {
149
154
  });
150
155
  }
151
156
 
157
+ /**
158
+ * A surveyed service is OFFERED on a fresh setup iff it is not already
159
+ * installed AND its discovery tier is not `deprecated` (2026-06-25). The
160
+ * deprecated tier (notes-daemon, runner) stays resolvable + manageable for an
161
+ * existing install — it just isn't pushed on a fresh box. `agent`
162
+ * (`experimental`) is still offered. Exported so the setup tests can pin the
163
+ * exclusion directly.
164
+ */
165
+ export function isOfferable(choice: { short: string; installed: boolean }): boolean {
166
+ if (choice.installed) return false;
167
+ // No `declared` arg: the survey fires PRE-install, before any module.json is
168
+ // on disk to read a self-declared `focus` from — so the static default map is
169
+ // the only signal available + the right one here.
170
+ return focusForShort(choice.short) !== "deprecated";
171
+ }
172
+
152
173
  const BLURBS: Record<string, string> = {
153
174
  vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
175
+ surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
176
+ // `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
154
177
  app: "Parachute UI host — auto-installs Notes on first boot (recommended over notes-daemon)",
155
- notes: "Notes PWA web/mobile UI on top of vault (notes-daemon; superseded by `app`)",
178
+ // notes / runner are `deprecated` (not offered on a fresh setup) these
179
+ // blurbs only render if a legacy install surfaces them in the survey.
180
+ notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
156
181
  scribe: "audio transcription for dictation + recordings",
157
182
  runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
158
183
  agent:
@@ -306,7 +331,7 @@ export async function setup(opts: SetupOpts = {}): Promise<number> {
306
331
 
307
332
  const survey = surveyServices(manifestPath);
308
333
  const installed = survey.filter((s) => s.installed);
309
- const offered = survey.filter((s) => !s.installed);
334
+ const offered = survey.filter(isOfferable);
310
335
 
311
336
  if (installed.length > 0) {
312
337
  log("Already installed:");
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Transcription step for the CLI setup wizard (`parachute setup-wizard` /
3
+ * `parachute init`).
4
+ *
5
+ * Until now the CLI wizard NEVER asked about transcription — it walked
6
+ * Account → Vault → Expose and stopped, while the browser wizard's vault step
7
+ * folds a full scribe sub-form (none / cloud + key / local). That divergence
8
+ * is the "asks different questions in its CLI vs browser forms" root cause from
9
+ * the onboarding-streamline arc. This module brings the CLI to parity.
10
+ *
11
+ * Crucially it follows the arc's "never ask without doing" rule: when the
12
+ * operator picks a provider we ACTUALLY set it up, or honestly say we couldn't
13
+ * and point at the alternative — we never record a dead provider string.
14
+ *
15
+ * - **none** → write nothing; say transcription is off + how to turn it on.
16
+ * - **cloud** (groq / openai) → write provider + key (scribe-config.ts), then
17
+ * install + start scribe via the hub's own `parachute install scribe`
18
+ * one-shot. The very-first scribe boot reads the provider we just wrote.
19
+ * - **local** → RAM/platform-gate FIRST (decideLocalProvider). If the box
20
+ * can't run a local model (no backend for the platform, or < 2 GB RAM) we
21
+ * do NOT write `local` — we explain why + steer to the cloud one-shot. If
22
+ * it can, we install scribe, then run scribe's own runnable install routine
23
+ * (`parachute-scribe install-backend --provider <onnx-asr|parakeet-mlx>`,
24
+ * scribe PR #79) which apt/pip-installs the engine + warm-pulls the model
25
+ * and exits non-zero on hard failure. On success we record the resolved
26
+ * platform provider; on failure we say so + point at cloud, recording
27
+ * nothing.
28
+ *
29
+ * Everything that touches the host (subprocess spawn, RAM probe, the prompt)
30
+ * goes through an injected seam so tests exercise every branch WITHOUT
31
+ * installing anything or shelling out.
32
+ */
33
+
34
+ import { createInterface } from "node:readline/promises";
35
+ import {
36
+ type ScribeProviderKey,
37
+ apiKeyEnvFor,
38
+ clearScribeProvider,
39
+ decideLocalProvider,
40
+ readAvailableRamMib,
41
+ } from "../scribe-config.ts";
42
+
43
+ /** Outcome of one subprocess. Exit code only — stdio is inherited / streamed. */
44
+ export type WizardCommandRunner = (cmd: readonly string[]) => Promise<number>;
45
+
46
+ export interface TranscriptionStepOpts {
47
+ /** `~/.parachute` (or the PARACHUTE_HOME override). Where scribe config lives. */
48
+ configDir: string;
49
+ /** Log shim — production prints to stdout; tests capture into an array. */
50
+ log: (line: string) => void;
51
+ /** Prompt seam — production uses readline; tests inject a scripted queue. */
52
+ prompt?: (question: string) => Promise<string>;
53
+ /**
54
+ * Pre-supply the choice non-interactively (mirrors the wizard's other
55
+ * run-from-flag escapes). `none` | `local` | a cloud provider name.
56
+ */
57
+ transcribeMode?: "none" | "local" | "groq" | "openai";
58
+ /** Pre-supplied cloud API key (for `groq` / `openai`). */
59
+ transcribeApiKey?: string;
60
+ /**
61
+ * Command runner seam. Production spawns the real binary inheriting stdio;
62
+ * tests inject a recorder so nothing installs. Receives a full argv —
63
+ * `["parachute", "install", "scribe", ...]` or
64
+ * `["parachute-scribe", "install-backend", "--provider", "onnx-asr"]`.
65
+ */
66
+ runCommand?: WizardCommandRunner;
67
+ /** Platform override (test seam). Defaults to the real host platform. */
68
+ platform?: NodeJS.Platform;
69
+ /** Available-RAM override in MiB (test seam). Defaults to the real probe. */
70
+ availableRamMib?: number | null;
71
+ }
72
+
73
+ /** Default readline prompt (matches wizard.ts's defaultPrompt). */
74
+ async function defaultPrompt(question: string): Promise<string> {
75
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
76
+ try {
77
+ return await rl.question(question);
78
+ } finally {
79
+ rl.close();
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Default command runner: spawn the binary, inherit stdio so the operator sees
85
+ * apt/pip progress in real time, resolve to the exit code. Never throws — a
86
+ * spawn failure (binary not on PATH) surfaces as a non-zero code, which the
87
+ * caller treats as "couldn't install."
88
+ */
89
+ const defaultRunCommand: WizardCommandRunner = async (cmd) => {
90
+ try {
91
+ const proc = Bun.spawn([...cmd], {
92
+ stdout: "inherit",
93
+ stderr: "inherit",
94
+ stdin: "inherit",
95
+ });
96
+ return await proc.exited;
97
+ } catch {
98
+ return 127; // ENOENT / spawn failure → "command not found"
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Walk the transcription step. Returns 0 always — a transcription that
104
+ * couldn't be set up is reported honestly but does NOT fail the wizard (the
105
+ * operator can finish setup and add it later; it's not a blocking step).
106
+ */
107
+ export async function walkTranscriptionStep(opts: TranscriptionStepOpts): Promise<number> {
108
+ const log = opts.log;
109
+ const prompt = opts.prompt ?? defaultPrompt;
110
+ const runCommand = opts.runCommand ?? defaultRunCommand;
111
+ const platform = opts.platform ?? process.platform;
112
+
113
+ log("");
114
+ log("Step — Transcription (scribe)");
115
+ log(" Parachute can transcribe voice notes + audio attachments. Pick a");
116
+ log(" transcription engine, or skip and add one later.");
117
+
118
+ // Resolve the choice (flag or prompt).
119
+ const choice = await resolveChoice(opts, prompt, log);
120
+ if (choice === "none") {
121
+ log("");
122
+ log(" Transcription off. Turn it on later with `parachute install scribe`.");
123
+ return 0;
124
+ }
125
+
126
+ if (choice === "local") {
127
+ return await handleLocal(opts, prompt, runCommand, platform, log);
128
+ }
129
+
130
+ // Cloud provider (groq / openai).
131
+ return await handleCloud(opts, choice, prompt, runCommand, log);
132
+ }
133
+
134
+ type ResolvedChoice = "none" | "local" | "groq" | "openai";
135
+
136
+ async function resolveChoice(
137
+ opts: TranscriptionStepOpts,
138
+ prompt: (q: string) => Promise<string>,
139
+ log: (l: string) => void,
140
+ ): Promise<ResolvedChoice> {
141
+ if (opts.transcribeMode !== undefined) return opts.transcribeMode;
142
+ log("");
143
+ log(" 1) None — skip transcription (default)");
144
+ log(" 2) Local — run the engine on this box (no API key, needs ~2 GB RAM)");
145
+ log(" 3) Cloud — Groq or OpenAI (fast, needs an API key, ~$0.04/hr of audio)");
146
+ for (let attempt = 0; attempt < 5; attempt++) {
147
+ const raw = (await prompt(" Pick [1]: ")).trim().toLowerCase();
148
+ if (raw === "" || raw === "1" || raw === "none" || raw === "n") return "none";
149
+ if (raw === "2" || raw === "local" || raw === "l") return "local";
150
+ if (raw === "3" || raw === "cloud" || raw === "c") {
151
+ // Sub-pick which cloud provider.
152
+ for (let inner = 0; inner < 5; inner++) {
153
+ const which = (await prompt(" Cloud provider — [g]roq (default) or [o]penai: "))
154
+ .trim()
155
+ .toLowerCase();
156
+ if (which === "" || which === "g" || which === "groq") return "groq";
157
+ if (which === "o" || which === "openai") return "openai";
158
+ log(` Sorry — expected groq or openai (got "${which}"). Try again.`);
159
+ }
160
+ log(" Too many invalid entries; skipping transcription.");
161
+ return "none";
162
+ }
163
+ if (raw === "groq" || raw === "g") return "groq";
164
+ if (raw === "openai" || raw === "o") return "openai";
165
+ log(` Sorry — expected 1, 2, or 3 (got "${raw}"). Try again.`);
166
+ }
167
+ log(" Too many invalid entries; skipping transcription.");
168
+ return "none";
169
+ }
170
+
171
+ async function handleCloud(
172
+ opts: TranscriptionStepOpts,
173
+ provider: "groq" | "openai",
174
+ prompt: (q: string) => Promise<string>,
175
+ runCommand: WizardCommandRunner,
176
+ log: (l: string) => void,
177
+ ): Promise<number> {
178
+ const envKey = apiKeyEnvFor(provider as ScribeProviderKey);
179
+ let apiKey = opts.transcribeApiKey;
180
+ if (apiKey === undefined && envKey) {
181
+ apiKey = (await prompt(` Paste your ${envKey} (or blank to set later): `)).trim();
182
+ }
183
+
184
+ // Install + start scribe via the EXISTING one-shot path, handing it the
185
+ // chosen provider + key. `parachute install scribe --scribe-provider <p>
186
+ // [--scribe-key <k>]` writes the provider into scribe's config + the key into
187
+ // scribe/.env and starts the module — the same wiring the bare CLI install
188
+ // does. Passing the provider also suppresses install's own interactive
189
+ // provider prompt (it's already an explicit choice).
190
+ const cmd = ["parachute", "install", "scribe", "--scribe-provider", provider];
191
+ if (envKey && apiKey && apiKey.length > 0) {
192
+ cmd.push("--scribe-key", apiKey);
193
+ }
194
+ log("");
195
+ log(` Installing scribe with the ${provider} cloud provider…`);
196
+ const code = await runCommand(cmd);
197
+ if (code !== 0) {
198
+ log(` ✗ scribe install returned ${code}. Retry: \`${cmd.join(" ")}\`.`);
199
+ return 0;
200
+ }
201
+ if (envKey && !(apiKey && apiKey.length > 0)) {
202
+ log(
203
+ ` ✓ Recorded ${provider}. Add ${envKey} later: \`echo '${envKey}=<value>' >> ${opts.configDir}/scribe/.env\` then \`parachute restart scribe\`.`,
204
+ );
205
+ } else {
206
+ log(` ✓ Scribe installed and running with the ${provider} cloud provider.`);
207
+ }
208
+ return 0;
209
+ }
210
+
211
+ async function handleLocal(
212
+ opts: TranscriptionStepOpts,
213
+ prompt: (q: string) => Promise<string>,
214
+ runCommand: WizardCommandRunner,
215
+ platform: NodeJS.Platform,
216
+ log: (l: string) => void,
217
+ ): Promise<number> {
218
+ const ramMib =
219
+ opts.availableRamMib !== undefined ? opts.availableRamMib : readAvailableRamMib(platform);
220
+ const decision = decideLocalProvider(platform, ramMib);
221
+
222
+ if (!decision.ok) {
223
+ // Can't install local here — say EXACTLY why + point at the cloud one-shot.
224
+ // Do NOT record a dead `local` provider string.
225
+ log("");
226
+ log(` ✗ Local transcription isn't possible on this box: ${decision.reason}`);
227
+ log("");
228
+ log(" One-shot cloud alternative — get a free Groq key at https://console.groq.com,");
229
+ log(" then run:");
230
+ log(" parachute install scribe --scribe-provider groq --scribe-key gsk_…");
231
+ log(" (or re-run this wizard and choose Cloud).");
232
+ return 0;
233
+ }
234
+
235
+ const provider = decision.provider as "parakeet-mlx" | "onnx-asr";
236
+ log("");
237
+ log(` This box can run ${provider} locally.`);
238
+
239
+ // Confirm before the (slow, apt/pip) install unless pre-supplied.
240
+ if (opts.transcribeMode === undefined) {
241
+ const ok = (await prompt(` Install ${provider} now? [Y/n]: `)).trim().toLowerCase();
242
+ if (ok === "n" || ok === "no") {
243
+ log(
244
+ " Skipped. Install later with `parachute-scribe install-backend` or re-run this wizard.",
245
+ );
246
+ return 0;
247
+ }
248
+ }
249
+
250
+ // Install the scribe module first, recording the resolved provider so install
251
+ // doesn't prompt for one. (We UNDO this record below if the engine install
252
+ // fails — so a failure never leaves a dead provider string.)
253
+ log("");
254
+ log(" Installing the scribe module…");
255
+ const moduleCode = await runCommand([
256
+ "parachute",
257
+ "install",
258
+ "scribe",
259
+ "--scribe-provider",
260
+ provider,
261
+ ]);
262
+ if (moduleCode !== 0) {
263
+ clearScribeProvider(opts.configDir);
264
+ log(` ✗ scribe module install returned ${moduleCode} — not recording a local provider.`);
265
+ return 0;
266
+ }
267
+
268
+ // Run scribe's OWN runnable install routine (scribe PR #79). It apt/pip-
269
+ // installs the engine, warm-pulls the model, and exits non-zero on hard
270
+ // failure (no engine on PATH, too little RAM on its own re-check, etc.).
271
+ log("");
272
+ log(` Installing the ${provider} engine via scribe (this can take a few minutes)…`);
273
+ // The `--provider <p>` FLAG form is intentional (not the positional
274
+ // `install-backend <p>`): scribe's cmdInstallBackend reads it via getFlag
275
+ // when args[1] starts with "-". If scribe ever moves off its module-level
276
+ // `args` global, re-confirm this flag is still honored.
277
+ const code = await runCommand(["parachute-scribe", "install-backend", "--provider", provider]);
278
+ if (code !== 0) {
279
+ // HONEST skip — undo the provisional provider record; do NOT leave a dead
280
+ // provider string scribe can't honor.
281
+ clearScribeProvider(opts.configDir);
282
+ log("");
283
+ log(` ✗ ${provider} install failed (exit ${code}); not recording it as the provider.`);
284
+ log(
285
+ " Cloud alternative: `parachute install scribe --scribe-provider groq --scribe-key gsk_…`",
286
+ );
287
+ return 0;
288
+ }
289
+
290
+ // Engine installed + verified by scribe — keep the provider recorded (the
291
+ // install step already wrote it) and restart so the running scribe picks it up.
292
+ log("");
293
+ log(` ✓ ${provider} installed and recorded as the transcription provider.`);
294
+ await runCommand(["parachute", "restart", "scribe"]);
295
+ return 0;
296
+ }
@@ -39,8 +39,10 @@
39
39
  */
40
40
 
41
41
  import { createInterface } from "node:readline/promises";
42
+ import { configDir } from "../config.ts";
42
43
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
43
44
  import { SESSION_COOKIE_NAME } from "../sessions.ts";
45
+ import { type WizardCommandRunner, walkTranscriptionStep } from "./wizard-transcription.ts";
44
46
 
45
47
  const POLL_INTERVAL_MS = 2000;
46
48
  const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous enough for a slow `bun add` over a flaky connection.
@@ -111,6 +113,29 @@ export interface RunCliWizardOpts {
111
113
  * the wizard's.
112
114
  */
113
115
  exposeMode?: "localhost" | "tailnet" | "public";
116
+ /**
117
+ * `~/.parachute` (or the PARACHUTE_HOME override) — where scribe's config
118
+ * lives. Required for the transcription step to write the chosen provider +
119
+ * key. Init passes its resolved configDir; when absent the transcription
120
+ * step is skipped (the wizard can still walk account / vault / expose).
121
+ */
122
+ configDir?: string;
123
+ /**
124
+ * Pre-supply the transcription answer (non-interactive escape, mirrors the
125
+ * browser wizard's `scribe_provider`). `none` | `local` | `groq` | `openai`.
126
+ */
127
+ transcribeMode?: "none" | "local" | "groq" | "openai";
128
+ /** Pre-supplied cloud transcription API key (groq / openai). */
129
+ transcribeApiKey?: string;
130
+ /**
131
+ * Test seam: replace the transcription step's subprocess runner so tests
132
+ * never install scribe / shell out. Threaded into `walkTranscriptionStep`.
133
+ */
134
+ transcribeRunCommand?: WizardCommandRunner;
135
+ /** Test seam: platform override for the transcription step. */
136
+ platform?: NodeJS.Platform;
137
+ /** Test seam: available-RAM override (MiB) for the transcription step's gate. */
138
+ availableRamMib?: number | null;
114
139
  }
115
140
 
116
141
  /** Cookie jar — tiny, in-memory, no persistence across runs. */
@@ -395,9 +420,14 @@ async function pollOperation(
395
420
  }
396
421
  }
397
422
 
398
- /** Validate password length up front so we don't bounce off a 400 the operator can't see. */
423
+ /**
424
+ * Validate password length up front so we don't bounce off a 400 the operator
425
+ * can't see. Floor is 12 to match the server (`PASSWORD_MIN_LEN`) AND this
426
+ * wizard's own "min 12 chars" copy at the password prompt — the validator was
427
+ * stuck at 8, contradicting both (onboarding-streamline hub PR1).
428
+ */
399
429
  function validatePassword(pw: string): string | undefined {
400
- if (pw.length < 8) return "Password must be at least 8 characters.";
430
+ if (pw.length < 12) return "Password must be at least 12 characters.";
401
431
  return undefined;
402
432
  }
403
433
 
@@ -743,6 +773,24 @@ export async function runCliWizard(opts: RunCliWizardOpts): Promise<number> {
743
773
  if (code !== 0) return code;
744
774
  state = await fetchWizardState(hubUrl, jar, fetchImpl);
745
775
  }
776
+ // Transcription step (onboarding-streamline hub PR1) — the CLI's parity with
777
+ // the browser wizard's folded scribe sub-form. The hub's setup-state machine
778
+ // has no "transcription" step (scribe is a module install, not a wizard gate),
779
+ // so this runs unconditionally between vault and expose rather than off
780
+ // `state.step`. Skipped without a configDir (nowhere to write scribe config).
781
+ // Non-fatal: a transcription that couldn't be set up never blocks setup.
782
+ if (opts.configDir !== undefined) {
783
+ await walkTranscriptionStep({
784
+ configDir: opts.configDir,
785
+ log,
786
+ prompt,
787
+ ...(opts.transcribeMode !== undefined ? { transcribeMode: opts.transcribeMode } : {}),
788
+ ...(opts.transcribeApiKey !== undefined ? { transcribeApiKey: opts.transcribeApiKey } : {}),
789
+ ...(opts.transcribeRunCommand !== undefined ? { runCommand: opts.transcribeRunCommand } : {}),
790
+ ...(opts.platform !== undefined ? { platform: opts.platform } : {}),
791
+ ...(opts.availableRamMib !== undefined ? { availableRamMib: opts.availableRamMib } : {}),
792
+ });
793
+ }
746
794
  if (state.step === "expose") {
747
795
  const code = await walkExposeStep(hubUrl, jar, state, ctx);
748
796
  if (code !== 0) return code;
@@ -852,6 +900,21 @@ export function parseWizardArgs(args: readonly string[]): ParsedWizardArgs | { e
852
900
  }
853
901
  out.opts.exposeMode = value;
854
902
  break;
903
+ case "--transcribe-mode":
904
+ if (!consumeValue()) return { error: `${key} requires a value` };
905
+ if (value !== "none" && value !== "local" && value !== "groq" && value !== "openai") {
906
+ return { error: `${key} must be one of none, local, groq, openai` };
907
+ }
908
+ out.opts.transcribeMode = value;
909
+ break;
910
+ case "--transcribe-key":
911
+ if (!consumeValue()) return { error: `${key} requires a value` };
912
+ out.opts.transcribeApiKey = value;
913
+ break;
914
+ case "--config-dir":
915
+ if (!consumeValue()) return { error: `${key} requires a path` };
916
+ out.opts.configDir = value;
917
+ break;
855
918
  default:
856
919
  if (a.startsWith("--")) return { error: `unknown argument "${a}"` };
857
920
  return { error: `unexpected positional argument "${a}"` };
@@ -877,12 +940,19 @@ export async function runSetupWizardCommand(args: readonly string[]): Promise<nu
877
940
  " [--bootstrap-token <token>]\n" +
878
941
  " [--vault-mode create|import|skip] [--vault-name <name>]\n" +
879
942
  " [--vault-import-url <url>] [--vault-import-pat <pat>] [--vault-import-replace]\n" +
943
+ " [--transcribe-mode none|local|groq|openai] [--transcribe-key <key>]\n" +
944
+ " [--config-dir <path>]\n" +
880
945
  " [--expose-mode localhost|tailnet|public]",
881
946
  );
882
947
  return 1;
883
948
  }
949
+ // Default configDir from PARACHUTE_HOME (matching the rest of the CLI) so the
950
+ // standalone `parachute setup-wizard` invocation can run the transcription
951
+ // step. An explicit `--config-dir` wins.
952
+ const wizardOpts = { ...parsed.opts };
953
+ if (wizardOpts.configDir === undefined) wizardOpts.configDir = configDir();
884
954
  return await runCliWizard({
885
- ...parsed.opts,
955
+ ...wizardOpts,
886
956
  log: (line) => console.log(line),
887
957
  });
888
958
  }
@@ -66,16 +66,26 @@ export interface ConfigSchema {
66
66
  }
67
67
 
68
68
  /**
69
- * Discovery tier (2026-06-09 modular-UI architecture). `core` modules are the
70
- * product surface (vault / scribe / hub / surface); `experimental` modules
71
- * (channel / runner / others) render in a de-emphasized group on the Modules
72
- * screen. **Show all; never hide** `focus` only sorts + labels.
69
+ * Discovery tier (2026-06-09 modular-UI architecture). Three tiers today:
70
+ * - `core` — the product surface (vault / scribe / hub / surface).
71
+ * - `experimental` legit previews (agent) that render in a de-emphasized
72
+ * group but ARE offered on a fresh install.
73
+ * - `deprecated` — modules retained for back-compat (notes-daemon, runner)
74
+ * that stay resolvable + shown-if-installed (so an existing operator can
75
+ * still manage / uninstall them) but are NOT offered on a fresh setup
76
+ * (2026-06-25). Distinct from a fully retired module (`RETIRED_MODULES`),
77
+ * whose services.json row is GC'd on load.
78
+ *
79
+ * **Show all installed; never hide** — `focus` only sorts + labels for an
80
+ * installed/known module. The one behavioral lever it pulls is the fresh-install
81
+ * OFFER: `deprecated` shorts are dropped from the setup wizard + the admin SPA's
82
+ * "available to install" set.
73
83
  *
74
84
  * Absent in a `module.json` ⇒ the hub falls back to its default map (see
75
85
  * `service-spec.focusForShort`), which defaults unlisted modules to
76
86
  * `experimental`.
77
87
  */
78
- export type ModuleFocus = "core" | "experimental";
88
+ export type ModuleFocus = "core" | "experimental" | "deprecated";
79
89
 
80
90
  /**
81
91
  * An event a module EMITS — the left-hand side of a Connection (2026-06-09
@@ -324,8 +334,10 @@ export interface ModuleManifest {
324
334
  * Discovery tier (2026-06-09 modular-UI architecture). When a module
325
335
  * declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
326
336
  * falls back to `service-spec.focusForShort` (vault/scribe/hub/surface →
327
- * `core`, everything else → `experimental`). **Show all; never hide** —
328
- * `focus` only groups + de-emphasizes. Additive + back-compatible.
337
+ * `core`, notes/runner → `deprecated`, everything else → `experimental`).
338
+ * **Show all installed; never hide** — `focus` groups + de-emphasizes; the
339
+ * one behavioral lever is the fresh-install OFFER, which excludes
340
+ * `deprecated`. Additive + back-compatible.
329
341
  */
330
342
  readonly focus?: ModuleFocus;
331
343
  /**
@@ -711,12 +723,14 @@ function asCredentials(v: unknown, where: string): readonly ModuleCredential[] |
711
723
  });
712
724
  }
713
725
 
714
- const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
726
+ const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental", "deprecated"]);
715
727
 
716
728
  function asFocus(v: unknown, where: string): ModuleFocus | undefined {
717
729
  if (v === undefined) return undefined;
718
730
  if (typeof v !== "string" || !MODULE_FOCUS_VALUES.has(v as ModuleFocus)) {
719
- throw new ModuleManifestError(`${where}: "focus" must be "core" | "experimental" if present`);
731
+ throw new ModuleManifestError(
732
+ `${where}: "focus" must be "core" | "experimental" | "deprecated" if present`,
733
+ );
720
734
  }
721
735
  return v as ModuleFocus;
722
736
  }