muonroi-cli 1.8.5 → 1.9.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.
Files changed (206) hide show
  1. package/dist/packages/agent-harness-core/src/event-filter.js +11 -0
  2. package/dist/packages/agent-harness-core/src/event-redact.js +7 -0
  3. package/dist/packages/agent-harness-core/src/event-tee.d.ts +20 -4
  4. package/dist/packages/agent-harness-core/src/event-tee.js +32 -5
  5. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +14 -0
  6. package/dist/packages/agent-harness-core/src/mcp-server.js +86 -10
  7. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  8. package/dist/packages/agent-harness-core/src/protocol.d.ts +24 -2
  9. package/dist/src/agent-harness/mock-model.d.ts +10 -0
  10. package/dist/src/agent-harness/mock-model.js +6 -2
  11. package/dist/src/chat/chat-keychain.d.ts +7 -12
  12. package/dist/src/chat/chat-keychain.js +19 -86
  13. package/dist/src/cli/keys.d.ts +8 -45
  14. package/dist/src/cli/keys.js +18 -324
  15. package/dist/src/council/clarifier.d.ts +4 -2
  16. package/dist/src/council/clarifier.js +158 -36
  17. package/dist/src/council/debate-planner.js +3 -2
  18. package/dist/src/council/debate.js +59 -5
  19. package/dist/src/council/index.d.ts +23 -0
  20. package/dist/src/council/index.js +37 -2
  21. package/dist/src/council/llm.d.ts +62 -0
  22. package/dist/src/council/llm.js +123 -23
  23. package/dist/src/council/panel-select.js +13 -3
  24. package/dist/src/council/planner.js +17 -0
  25. package/dist/src/council/preflight.d.ts +10 -0
  26. package/dist/src/council/preflight.js +36 -0
  27. package/dist/src/council/prompts.d.ts +9 -1
  28. package/dist/src/council/prompts.js +23 -6
  29. package/dist/src/council/types.d.ts +10 -0
  30. package/dist/src/ee/auth.d.ts +19 -0
  31. package/dist/src/ee/auth.js +39 -0
  32. package/dist/src/ee/client.js +28 -3
  33. package/dist/src/ee/ee-onboarding.js +6 -26
  34. package/dist/src/flow/compaction/compress.d.ts +2 -2
  35. package/dist/src/flow/compaction/compress.js +21 -8
  36. package/dist/src/flow/compaction/extract.d.ts +3 -3
  37. package/dist/src/flow/compaction/extract.js +6 -6
  38. package/dist/src/flow/compaction/index.d.ts +2 -1
  39. package/dist/src/flow/compaction/index.js +29 -3
  40. package/dist/src/flow/compaction/progress.d.ts +35 -0
  41. package/dist/src/flow/compaction/progress.js +35 -0
  42. package/dist/src/generated/version.d.ts +1 -1
  43. package/dist/src/generated/version.js +1 -1
  44. package/dist/src/gsd/flags.d.ts +11 -0
  45. package/dist/src/gsd/flags.js +19 -0
  46. package/dist/src/gsd/plan-council.js +104 -72
  47. package/dist/src/gsd/verdict-schema.d.ts +1 -1
  48. package/dist/src/headless/council-answers.js +4 -0
  49. package/dist/src/index.js +129 -260
  50. package/dist/src/lsp/builtins.js +3 -1
  51. package/dist/src/lsp/manager.d.ts +5 -1
  52. package/dist/src/lsp/manager.js +249 -3
  53. package/dist/src/lsp/npm-cache.d.ts +11 -1
  54. package/dist/src/lsp/npm-cache.js +17 -1
  55. package/dist/src/lsp/runtime.d.ts +6 -1
  56. package/dist/src/lsp/runtime.js +17 -1
  57. package/dist/src/lsp/types.d.ts +83 -1
  58. package/dist/src/lsp/types.js +10 -0
  59. package/dist/src/mcp/client-pool.js +43 -15
  60. package/dist/src/mcp/lsp-tools.d.ts +5 -1
  61. package/dist/src/mcp/lsp-tools.js +93 -2
  62. package/dist/src/mcp/mcp-keychain.d.ts +3 -5
  63. package/dist/src/mcp/mcp-keychain.js +9 -49
  64. package/dist/src/mcp/setup-guide-text.d.ts +1 -1
  65. package/dist/src/mcp/setup-guide-text.js +22 -2
  66. package/dist/src/mcp/tools-server.d.ts +10 -0
  67. package/dist/src/mcp/tools-server.js +10 -2
  68. package/dist/src/models/catalog.json +19 -19
  69. package/dist/src/orchestrator/ask-user.d.ts +61 -0
  70. package/dist/src/orchestrator/ask-user.js +65 -0
  71. package/dist/src/orchestrator/compaction.d.ts +2 -3
  72. package/dist/src/orchestrator/compaction.js +8 -8
  73. package/dist/src/orchestrator/council-manager.js +9 -8
  74. package/dist/src/orchestrator/council-request.d.ts +49 -0
  75. package/dist/src/orchestrator/council-request.js +62 -0
  76. package/dist/src/orchestrator/interactive-pause.d.ts +26 -0
  77. package/dist/src/orchestrator/interactive-pause.js +36 -0
  78. package/dist/src/orchestrator/message-processor.d.ts +4 -0
  79. package/dist/src/orchestrator/message-processor.js +26 -8
  80. package/dist/src/orchestrator/orchestrator.d.ts +25 -0
  81. package/dist/src/orchestrator/orchestrator.js +204 -50
  82. package/dist/src/orchestrator/preprocessor.js +2 -2
  83. package/dist/src/orchestrator/safety-askcard.d.ts +1 -1
  84. package/dist/src/orchestrator/safety-askcard.js +5 -2
  85. package/dist/src/orchestrator/safety-intercept.d.ts +5 -0
  86. package/dist/src/orchestrator/safety-intercept.js +7 -0
  87. package/dist/src/orchestrator/stall-watchdog.d.ts +8 -1
  88. package/dist/src/orchestrator/stall-watchdog.js +24 -3
  89. package/dist/src/orchestrator/stream-runner.d.ts +13 -3
  90. package/dist/src/orchestrator/stream-runner.js +54 -21
  91. package/dist/src/orchestrator/tool-engine.d.ts +19 -0
  92. package/dist/src/orchestrator/tool-engine.js +241 -25
  93. package/dist/src/orchestrator/turn-watchdog.d.ts +7 -0
  94. package/dist/src/orchestrator/turn-watchdog.js +38 -9
  95. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  96. package/dist/src/pil/agent-operating-contract.js +6 -4
  97. package/dist/src/pil/discovery.d.ts +1 -1
  98. package/dist/src/pil/discovery.js +2 -2
  99. package/dist/src/pil/layer1_5-complexity-size.d.ts +7 -0
  100. package/dist/src/pil/layer1_5-complexity-size.js +31 -5
  101. package/dist/src/pil/llm-classify.d.ts +78 -3
  102. package/dist/src/pil/llm-classify.js +351 -111
  103. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  104. package/dist/src/pil/native-capabilities-workbook.js +7 -0
  105. package/dist/src/pil/pipeline.js +2 -0
  106. package/dist/src/pil/repo-grounding-probe.d.ts +15 -0
  107. package/dist/src/pil/repo-grounding-probe.js +136 -0
  108. package/dist/src/pil/repo-structure-hints.d.ts +7 -0
  109. package/dist/src/pil/repo-structure-hints.js +45 -0
  110. package/dist/src/product-loop/artifact-io.js +4 -0
  111. package/dist/src/product-loop/criteria-seed.d.ts +51 -0
  112. package/dist/src/product-loop/criteria-seed.js +200 -0
  113. package/dist/src/product-loop/discovery-interview.d.ts +9 -0
  114. package/dist/src/product-loop/discovery-interview.js +37 -18
  115. package/dist/src/product-loop/discovery-recommender.js +2 -1
  116. package/dist/src/product-loop/discovery-schema.js +14 -1
  117. package/dist/src/product-loop/discovery-triage.d.ts +23 -0
  118. package/dist/src/product-loop/discovery-triage.js +109 -0
  119. package/dist/src/product-loop/gather.js +150 -2
  120. package/dist/src/product-loop/index.js +7 -0
  121. package/dist/src/product-loop/loop-driver.js +21 -8
  122. package/dist/src/product-loop/phase-plan.d.ts +16 -0
  123. package/dist/src/product-loop/phase-plan.js +42 -4
  124. package/dist/src/product-loop/phase-rituals.d.ts +3 -0
  125. package/dist/src/product-loop/phase-rituals.js +8 -3
  126. package/dist/src/product-loop/phase-runner.js +30 -11
  127. package/dist/src/product-loop/plan-adherence-review.d.ts +26 -0
  128. package/dist/src/product-loop/plan-adherence-review.js +144 -0
  129. package/dist/src/product-loop/sprint-runner.d.ts +62 -0
  130. package/dist/src/product-loop/sprint-runner.js +309 -8
  131. package/dist/src/product-loop/types.d.ts +25 -0
  132. package/dist/src/providers/anthropic.d.ts +9 -8
  133. package/dist/src/providers/anthropic.js +13 -47
  134. package/dist/src/providers/auth/grok-oauth.d.ts +1 -0
  135. package/dist/src/providers/auth/grok-oauth.js +30 -5
  136. package/dist/src/providers/auth/openai-oauth.d.ts +1 -0
  137. package/dist/src/providers/auth/openai-oauth.js +14 -0
  138. package/dist/src/providers/auth/token-store.d.ts +9 -9
  139. package/dist/src/providers/auth/token-store.js +8 -70
  140. package/dist/src/providers/auth/types.d.ts +8 -0
  141. package/dist/src/providers/env-store.d.ts +17 -0
  142. package/dist/src/providers/env-store.js +228 -0
  143. package/dist/src/providers/keychain.d.ts +21 -17
  144. package/dist/src/providers/keychain.js +124 -135
  145. package/dist/src/providers/runtime.d.ts +24 -9
  146. package/dist/src/providers/runtime.js +48 -37
  147. package/dist/src/providers/strategies/thinking-mode.js +9 -1
  148. package/dist/src/providers/strategies/xai.strategy.js +27 -0
  149. package/dist/src/providers/warm.d.ts +65 -0
  150. package/dist/src/providers/warm.js +145 -0
  151. package/dist/src/self-qa/agentic-loop.js +3 -2
  152. package/dist/src/storage/transcript.js +56 -2
  153. package/dist/src/tools/git-safety.d.ts +19 -0
  154. package/dist/src/tools/git-safety.js +168 -0
  155. package/dist/src/tools/native-tools.d.ts +1 -1
  156. package/dist/src/tools/native-tools.js +76 -1
  157. package/dist/src/tools/registry.d.ts +17 -0
  158. package/dist/src/tools/registry.js +116 -1
  159. package/dist/src/types/index.d.ts +29 -1
  160. package/dist/src/ui/app.js +67 -4
  161. package/dist/src/ui/components/agent-rail-activities.d.ts +26 -0
  162. package/dist/src/ui/components/agent-rail-activities.js +47 -0
  163. package/dist/src/ui/components/compact-progress-card.d.ts +24 -0
  164. package/dist/src/ui/components/compact-progress-card.js +42 -0
  165. package/dist/src/ui/components/council-phase-timeline.js +17 -2
  166. package/dist/src/ui/components/council-question-card.js +1 -0
  167. package/dist/src/ui/components/message-view.d.ts +15 -0
  168. package/dist/src/ui/components/message-view.js +50 -1
  169. package/dist/src/ui/components/tool-group.d.ts +15 -3
  170. package/dist/src/ui/components/tool-group.js +69 -11
  171. package/dist/src/ui/containers/modals-layer.d.ts +2 -1
  172. package/dist/src/ui/containers/modals-layer.js +2 -2
  173. package/dist/src/ui/council-harness-event.d.ts +57 -0
  174. package/dist/src/ui/council-harness-event.js +46 -0
  175. package/dist/src/ui/heartbeat-debug.d.ts +29 -0
  176. package/dist/src/ui/heartbeat-debug.js +45 -0
  177. package/dist/src/ui/modals/api-key-modal.js +1 -1
  178. package/dist/src/ui/modals/model-picker-modal.d.ts +8 -18
  179. package/dist/src/ui/modals/model-picker-modal.js +8 -10
  180. package/dist/src/ui/slash/ee.js +81 -0
  181. package/dist/src/ui/slash/menu-items.js +11 -2
  182. package/dist/src/ui/use-app-logic.js +354 -224
  183. package/dist/src/ui/utils/agent-activities.d.ts +39 -0
  184. package/dist/src/ui/utils/agent-activities.js +96 -0
  185. package/dist/src/ui/utils/group-tool-entries.d.ts +26 -0
  186. package/dist/src/ui/utils/group-tool-entries.js +111 -0
  187. package/dist/src/ui/utils/tool-summary.d.ts +21 -0
  188. package/dist/src/ui/utils/tool-summary.js +91 -0
  189. package/dist/src/utils/event-loop-monitor.d.ts +85 -0
  190. package/dist/src/utils/event-loop-monitor.js +107 -0
  191. package/dist/src/utils/llm-deadline.d.ts +14 -0
  192. package/dist/src/utils/llm-deadline.js +19 -0
  193. package/dist/src/utils/loop-profiler.d.ts +102 -0
  194. package/dist/src/utils/loop-profiler.js +202 -0
  195. package/dist/src/utils/settings.d.ts +27 -0
  196. package/dist/src/utils/settings.js +38 -2
  197. package/dist/src/utils/side-question.d.ts +1 -2
  198. package/dist/src/utils/side-question.js +2 -2
  199. package/dist/src/verify/entrypoint.js +51 -16
  200. package/dist/src/verify/orchestrator.d.ts +1 -1
  201. package/dist/src/verify/orchestrator.js +20 -3
  202. package/package.json +1 -2
  203. package/dist/src/cli/bw-vault.d.ts +0 -55
  204. package/dist/src/cli/bw-vault.js +0 -133
  205. package/dist/src/mcp/ee-tools.d.ts +0 -46
  206. package/dist/src/mcp/ee-tools.js +0 -194
@@ -0,0 +1,228 @@
1
+ /**
2
+ * src/providers/env-store.ts
3
+ *
4
+ * Canonical CLI key store: a `~/.muonroi-cli/.env` file (mode 0600) that is
5
+ * loaded into process.env at startup and written when the user sets a key.
6
+ * On Windows we ALSO mirror to the User-scope registry env so other OS
7
+ * processes see the key. Replaces the OS keychain (keytar).
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { redactor } from "../utils/redactor.js";
14
+ export function envFilePath() {
15
+ return process.env.MUONROI_ENV_FILE ?? join(homedir(), ".muonroi-cli", ".env");
16
+ }
17
+ function readLines() {
18
+ try {
19
+ return readFileSync(envFilePath(), "utf8").split(/\r?\n/);
20
+ }
21
+ catch (err) {
22
+ // Missing file is the normal first-run case — no key store yet.
23
+ if (err?.code === "ENOENT")
24
+ return [];
25
+ // Anything else (locked, permissions, corrupt) must NOT read as "no keys":
26
+ // persistEnvVar rebuilds the file from these lines, so returning [] here
27
+ // silently rewrites the store WITHOUT the keys it failed to read — the user
28
+ // sets one key and the others are gone. Fail loudly instead.
29
+ throw new Error(`[env-store] cannot read ${envFilePath()}: ${err instanceof Error ? err.message : String(err)}. ` +
30
+ `Refusing to continue — writing now would drop the keys already in it.`);
31
+ }
32
+ }
33
+ function writeLines(lines) {
34
+ const p = envFilePath();
35
+ mkdirSync(dirname(p), { recursive: true });
36
+ const body = lines.filter((l, i) => !(l === "" && i === lines.length - 1)).join("\n");
37
+ writeFileSync(p, body === "" ? "" : `${body}\n`, { encoding: "utf8", mode: 0o600 });
38
+ try {
39
+ chmodSync(p, 0o600);
40
+ }
41
+ catch (err) {
42
+ // Non-fatal on filesystems without POSIX perms (e.g. some Windows FS).
43
+ if (process.env.MUONROI_DEBUG_ENVSTORE) {
44
+ console.error(`[env-store] chmod failed for ${p}: ${err instanceof Error ? err.message : String(err)}`);
45
+ }
46
+ }
47
+ }
48
+ /** Parse "KEY=value" — value may contain "="; comments/blank lines ignored. */
49
+ function parseLine(line) {
50
+ if (!line || line.trimStart().startsWith("#"))
51
+ return null;
52
+ const eq = line.indexOf("=");
53
+ if (eq <= 0)
54
+ return null;
55
+ return { key: line.slice(0, eq).trim(), value: line.slice(eq + 1) };
56
+ }
57
+ /** Env-var names we will ever mirror are plain identifiers; reject anything else. */
58
+ function isValidEnvName(name) {
59
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
60
+ }
61
+ /**
62
+ * Credentials this CLI owns: it writes them itself (`/login`, `keys set` →
63
+ * persistEnvVar) so its own store is the user's latest intent. Matched by the
64
+ * `*_API_KEY` naming convention shared by ENV_BY_PROVIDER / ENV_BY_CHAT /
65
+ * ENV_BY_MCP — a structural test, not a provider-id list, and importing
66
+ * keychain.ts here would be circular (keychain imports this module).
67
+ */
68
+ function isCliOwnedSecret(name) {
69
+ return /_API_KEY$/.test(name);
70
+ }
71
+ /** Last 4 chars + length — enough to tell two keys apart, never enough to use one. */
72
+ function maskSecret(value) {
73
+ return `len=${value.length} …${value.slice(-4)}`;
74
+ }
75
+ /**
76
+ * Which side wins when `.env` and the ambient OS environment disagree.
77
+ *
78
+ * Default `store`: for `*_API_KEY` only, `.env` wins. A stale Windows User-scope
79
+ * var silently beating the key the user just set through the CLI cost a full
80
+ * session of debugging — two council panelists died on a forgotten placeholder
81
+ * key while `.env` held the working one, and nothing said so.
82
+ *
83
+ * `MUONROI_ENV_PRECEDENCE=ambient` restores the old behaviour for CI and
84
+ * scripts that intentionally inject a key for one run.
85
+ */
86
+ function ambientWinsForSecrets() {
87
+ return process.env.MUONROI_ENV_PRECEDENCE === "ambient";
88
+ }
89
+ /**
90
+ * True while a test runner is driving. Vitest sets both `VITEST` and
91
+ * `NODE_ENV=test`; either is enough.
92
+ */
93
+ function isTestRunner() {
94
+ return process.env.VITEST !== undefined || process.env.NODE_ENV === "test";
95
+ }
96
+ function mirrorToWindowsRegistry(name, value) {
97
+ if (process.platform !== "win32")
98
+ return;
99
+ // A unit test must never write the developer's OS-global environment. It did:
100
+ // `bunx vitest run` on Windows left the fixture values from env-store.test.ts,
101
+ // migrate-legacy-keys.test.ts and auth-exclusivity.test.ts sitting in
102
+ // HKCU:\Environment. Those tests point MUONROI_ENV_FILE at a temp dir and
103
+ // clean it up, so they look hermetic — but persistEnvVar also mirrors here,
104
+ // none of them mocks child_process, and nothing cleaned the registry.
105
+ //
106
+ // The credential fixtures then outlived the suite and shadowed the real
107
+ // provider credentials for every NEW process: sub-agents 401'd, the AI SDK
108
+ // reported the resulting empty stream as AI_NoOutputGeneratedError, and
109
+ // /ideal's implementation stage died in 0.6s across three runs — bug "G1",
110
+ // whose own comment blames "gpt-5.4 reasoning models". The model was never
111
+ // involved. Guarding here rather than mocking per-test: the next test to call
112
+ // persistEnvVar would reintroduce the leak, and this is invisible on POSIX.
113
+ if (isTestRunner())
114
+ return;
115
+ if (!isValidEnvName(name)) {
116
+ // Defense-in-depth: never let a non-identifier name reach the shell.
117
+ if (process.env.MUONROI_DEBUG_ENVSTORE) {
118
+ console.error(`[env-store] refusing to mirror invalid env name: ${name}`);
119
+ }
120
+ return;
121
+ }
122
+ try {
123
+ // Neither name nor value is interpolated into the script — both are read
124
+ // from the child's environment as literal strings, so no PowerShell
125
+ // metacharacters in either can be interpreted as commands (injection-safe).
126
+ const script = value === null
127
+ ? "[Environment]::SetEnvironmentVariable($env:MUONROI_ENVSTORE_NAME, $null, 'User')"
128
+ : "[Environment]::SetEnvironmentVariable($env:MUONROI_ENVSTORE_NAME, $env:MUONROI_ENVSTORE_VALUE, 'User')";
129
+ execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
130
+ env: value === null
131
+ ? { ...process.env, MUONROI_ENVSTORE_NAME: name }
132
+ : { ...process.env, MUONROI_ENVSTORE_NAME: name, MUONROI_ENVSTORE_VALUE: value },
133
+ stdio: "ignore",
134
+ });
135
+ }
136
+ catch (err) {
137
+ // .env + in-memory write already succeeded; OS-wide mirror is best-effort.
138
+ if (process.env.MUONROI_DEBUG_ENVSTORE) {
139
+ console.error(`[env-store] registry mirror failed for ${name}: ${err instanceof Error ? err.message : String(err)}`);
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Upsert `name=value` in the `.env` store, set `process.env[name]` for the
145
+ * current process, and (on Windows) mirror to the User registry env.
146
+ */
147
+ export function persistEnvVar(name, value) {
148
+ redactor.enrollSecret(value);
149
+ const lines = readLines();
150
+ const out = [];
151
+ let replaced = false;
152
+ for (const line of lines) {
153
+ const parsed = parseLine(line);
154
+ if (parsed?.key === name) {
155
+ if (!replaced) {
156
+ out.push(`${name}=${value}`);
157
+ replaced = true;
158
+ }
159
+ }
160
+ else if (line !== "") {
161
+ out.push(line);
162
+ }
163
+ }
164
+ if (!replaced)
165
+ out.push(`${name}=${value}`);
166
+ writeLines(out);
167
+ process.env[name] = value;
168
+ mirrorToWindowsRegistry(name, value);
169
+ }
170
+ /** Remove `name` from the `.env` store, `process.env`, and Windows registry. */
171
+ export function clearEnvVar(name) {
172
+ const lines = readLines();
173
+ const out = lines.filter((line) => parseLine(line)?.key !== name && line !== "");
174
+ writeLines(out);
175
+ delete process.env[name];
176
+ mirrorToWindowsRegistry(name, null);
177
+ }
178
+ /**
179
+ * Load the `.env` store into `process.env` at startup.
180
+ *
181
+ * For ordinary variables the ambient OS environment stays authoritative and
182
+ * `.env` only fills gaps. For `*_API_KEY` credentials the CLI writes itself,
183
+ * `.env` wins and the conflict is logged — see {@link ambientWinsForSecrets}
184
+ * for why, and `MUONROI_ENV_PRECEDENCE=ambient` to opt out.
185
+ */
186
+ export function loadEnvFileIntoProcess() {
187
+ let lines;
188
+ try {
189
+ lines = readLines();
190
+ }
191
+ catch (err) {
192
+ // Boot must survive an unreadable store, but never in silence: every
193
+ // provider would 401 and the CLI would look like it lost the keys.
194
+ console.error(`${err instanceof Error ? err.message : String(err)} — continuing without the stored keys.`);
195
+ return;
196
+ }
197
+ for (const line of lines) {
198
+ const parsed = parseLine(line);
199
+ if (!parsed)
200
+ continue;
201
+ const ambient = process.env[parsed.key];
202
+ if (ambient === undefined) {
203
+ process.env[parsed.key] = parsed.value;
204
+ redactor.enrollSecret(parsed.value);
205
+ continue;
206
+ }
207
+ if (ambient === parsed.value)
208
+ continue;
209
+ // The two disagree. For non-secrets the ambient stays authoritative, as
210
+ // before. For a CLI-owned credential a silent divergence is exactly the
211
+ // failure that must never happen again, so it is always reported.
212
+ if (!isCliOwnedSecret(parsed.key))
213
+ continue;
214
+ redactor.enrollSecret(ambient);
215
+ if (ambientWinsForSecrets()) {
216
+ console.error(`[env-store] ${parsed.key}: OS environment (${maskSecret(ambient)}) overrides the CLI store ` +
217
+ `(${maskSecret(parsed.value)}) — MUONROI_ENV_PRECEDENCE=ambient is set. If auth fails, the ` +
218
+ `OS variable is the one being used.`);
219
+ continue;
220
+ }
221
+ console.error(`[env-store] ${parsed.key}: the CLI store (${maskSecret(parsed.value)}) takes precedence over a ` +
222
+ `conflicting OS environment variable (${maskSecret(ambient)}). Remove the OS variable to silence ` +
223
+ `this, or set MUONROI_ENV_PRECEDENCE=ambient to let it win.`);
224
+ process.env[parsed.key] = parsed.value;
225
+ redactor.enrollSecret(parsed.value);
226
+ }
227
+ }
228
+ //# sourceMappingURL=env-store.js.map
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * src/providers/keychain.ts
3
3
  *
4
- * Per-provider keychain loader with env-var fallback.
5
- * Extends the Phase 0 loadAnthropicKey pattern to all 6 providers.
6
- * PROV-02 / PROV-03 requirements.
4
+ * Per-provider key store. Backed by the env-store (`.env` file + process.env +
5
+ * Windows registry mirror) the OS keychain (keytar) has been removed.
7
6
  *
8
- * Priority: OS keychain (keytar) > environment variable > settings.json > ProviderKeyMissingError.
7
+ * Source of truth: `process.env[ENV_BY_PROVIDER[provider]]`. Writes go through
8
+ * the env-store; reads come straight from process.env.
9
9
  * Exception: ollama is keyless by default.
10
10
  */
11
11
  import type { ProviderId } from "./types.js";
12
+ export declare const ENV_BY_PROVIDER: Record<ProviderId, string>;
12
13
  /**
13
14
  * Thrown when no API key can be found for a provider.
14
15
  */
@@ -17,37 +18,33 @@ export declare class ProviderKeyMissingError extends Error {
17
18
  constructor(provider: ProviderId);
18
19
  }
19
20
  /**
20
- * Providers that store an API key in the OS keychain.
21
- * Phase 12.2-G5: derived from `ALL_PROVIDER_IDS` by excluding ollama
22
- * (keyless local server). Preserves the original ordering.
21
+ * Providers that store an API key (all except keyless ollama). Derived from
22
+ * `ALL_PROVIDER_IDS`; preserves the original ordering.
23
23
  */
24
24
  export declare const KEYCHAIN_PROVIDER_IDS: readonly ProviderId[];
25
25
  /**
26
- * Store a provider API key in the OS keychain. Returns true on success.
27
- * Falls back to false (silent) if keytar is unavailable on this platform.
26
+ * Store a provider API key in the OS environment (via the env-store: `.env`
27
+ * file + process.env + Windows registry mirror). Returns true on success.
28
28
  */
29
29
  export declare function setKeyForProvider(provider: string | ProviderId, key: string): Promise<boolean>;
30
30
  /**
31
- * Delete a stored key. Returns true if a key was deleted, false if none was
32
- * present or keytar is unavailable.
31
+ * Delete a stored key from the env-store. Returns true if a key was present.
33
32
  */
34
33
  export declare function deleteKeyForProvider(provider: string | ProviderId): Promise<boolean>;
35
34
  /**
36
- * List provider IDs that currently have a key stored in the keychain.
37
- * Empty array if keytar is unavailable.
35
+ * List provider IDs that currently have a key set in the environment.
38
36
  */
39
37
  export declare function listStoredProviders(): Promise<ProviderId[]>;
40
38
  /**
41
- * Load the API key for a given provider.
42
- * Priority: OS keychain (keytar) > environment variable.
39
+ * Load the API key for a given provider from the environment.
43
40
  * Ollama returns '' when no key is found (keyless).
44
41
  *
45
42
  * @throws {ProviderKeyMissingError} when no key found for non-ollama providers.
46
43
  */
47
44
  export declare function loadKeyForProvider(provider: ProviderId): Promise<string>;
48
45
  /**
49
- * Return the list of providers that currently have credentials available — checked across
50
- * OS keychain, environment variables, and user-settings.json. `ollama` is always included
46
+ * Return the list of providers that currently have credentials available —
47
+ * either an env API key or a stored OAuth token. `ollama` is always included
51
48
  * because it is keyless. Order is stable for UI rendering.
52
49
  */
53
50
  export declare function getConfiguredProviders(): Promise<ProviderId[]>;
@@ -64,3 +61,10 @@ export declare function getOAuthTokens(provider: ProviderId): Promise<import("./
64
61
  * Returns null if no provider has a key (unlikely — ollama is keyless fallback).
65
62
  */
66
63
  export declare function firstAvailableProvider(): Promise<ProviderId | null>;
64
+ /**
65
+ * One-time migration: move any legacy API keys — OS keychain (keytar) or
66
+ * `settings.json` (`providers.<p>.apiKey`) — into the env-store, then remove
67
+ * the legacy copies. Best-effort, idempotent, and never throws. Guarded by
68
+ * `settings.keysMigratedToEnv`. Runs once at startup before key resolution.
69
+ */
70
+ export declare function migrateLegacyKeysToEnv(): Promise<void>;
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * src/providers/keychain.ts
3
3
  *
4
- * Per-provider keychain loader with env-var fallback.
5
- * Extends the Phase 0 loadAnthropicKey pattern to all 6 providers.
6
- * PROV-02 / PROV-03 requirements.
4
+ * Per-provider key store. Backed by the env-store (`.env` file + process.env +
5
+ * Windows registry mirror) the OS keychain (keytar) has been removed.
7
6
  *
8
- * Priority: OS keychain (keytar) > environment variable > settings.json > ProviderKeyMissingError.
7
+ * Source of truth: `process.env[ENV_BY_PROVIDER[provider]]`. Writes go through
8
+ * the env-store; reads come straight from process.env.
9
9
  * Exception: ollama is keyless by default.
10
10
  */
11
11
  import { redactor } from "../utils/redactor.js";
12
+ import { clearEnvVar, persistEnvVar } from "./env-store.js";
12
13
  import { ALL_PROVIDER_IDS } from "./types.js";
13
14
  function normalizeKeychainProvider(p) {
14
15
  const lower = p.toLowerCase();
@@ -16,25 +17,7 @@ function normalizeKeychainProvider(p) {
16
17
  return lower;
17
18
  return null;
18
19
  }
19
- const SETTINGS_KEY_MAP = {
20
- anthropic: "anthropic",
21
- openai: "openai",
22
- deepseek: "deepseek",
23
- xai: "xai",
24
- zai: "zai",
25
- "opencode-go": "opencode-go",
26
- };
27
- const KEYCHAIN_SERVICE = "muonroi-cli";
28
- const ACCOUNT_BY_PROVIDER = {
29
- anthropic: "anthropic",
30
- openai: "openai",
31
- deepseek: "deepseek",
32
- xai: "xai",
33
- ollama: "ollama",
34
- zai: "zai",
35
- "opencode-go": "opencode-go",
36
- };
37
- const ENV_BY_PROVIDER = {
20
+ export const ENV_BY_PROVIDER = {
38
21
  anthropic: "ANTHROPIC_API_KEY",
39
22
  openai: "OPENAI_API_KEY",
40
23
  deepseek: "DEEPSEEK_API_KEY",
@@ -54,147 +37,83 @@ export class ProviderKeyMissingError extends Error {
54
37
  this.name = "ProviderKeyMissingError";
55
38
  }
56
39
  }
57
- async function loadKeytar() {
58
- try {
59
- return (await import("keytar"));
60
- }
61
- catch {
62
- return null;
63
- }
64
- }
65
40
  /**
66
- * Providers that store an API key in the OS keychain.
67
- * Phase 12.2-G5: derived from `ALL_PROVIDER_IDS` by excluding ollama
68
- * (keyless local server). Preserves the original ordering.
41
+ * Providers that store an API key (all except keyless ollama). Derived from
42
+ * `ALL_PROVIDER_IDS`; preserves the original ordering.
69
43
  */
70
44
  export const KEYCHAIN_PROVIDER_IDS = ALL_PROVIDER_IDS.filter((p) => p !== "ollama");
71
45
  /**
72
- * Store a provider API key in the OS keychain. Returns true on success.
73
- * Falls back to false (silent) if keytar is unavailable on this platform.
46
+ * Store a provider API key in the OS environment (via the env-store: `.env`
47
+ * file + process.env + Windows registry mirror). Returns true on success.
74
48
  */
75
49
  export async function setKeyForProvider(provider, key) {
76
50
  const norm = normalizeKeychainProvider(provider) ?? provider;
77
51
  if (!key || key.length < 20) {
78
52
  throw new Error(`Key for '${provider}' is too short (< 20 chars).`);
79
53
  }
80
- const kt = await loadKeytar();
81
- if (!kt?.setPassword)
82
- return false;
83
- redactor.enrollSecret(key);
54
+ persistEnvVar(ENV_BY_PROVIDER[norm], key);
55
+ // One auth mode per provider (OAuth XOR API key): setting an API key logs out
56
+ // any OAuth session for this provider so the two can never coexist.
84
57
  try {
85
- await kt.setPassword(KEYCHAIN_SERVICE, ACCOUNT_BY_PROVIDER[norm], key);
86
- return true;
58
+ const { listOAuthProviderIds } = await import("./auth/registry.js");
59
+ if ((await listOAuthProviderIds()).includes(norm)) {
60
+ const { deleteTokens } = await import("./auth/token-store.js");
61
+ await deleteTokens(norm);
62
+ }
87
63
  }
88
64
  catch (err) {
89
- // Runtime backend failure is common on Linux when libsecret / secret service
90
- // (gnome-keyring, kwallet/ksecretd, etc.) is not installed, the collection is
91
- // locked, or no D-Bus session/keyring is active for this user.
92
- // The caller (e.g. keys import-bw / keys set) will print the friendly message.
93
- if (process.env.DEBUG || process.env.MUONROI_DEBUG_KEYCHAIN) {
94
- console.error(`[keychain] setPassword backend error for ${provider}:`, err?.message || err);
65
+ // Best-effort: the key is already persisted; failing to clear tokens is
66
+ // non-fatal (resolution still prefers the freshly-set key path only if no
67
+ // token but we log so a lingering token is diagnosable).
68
+ if (process.env.MUONROI_DEBUG_ENVSTORE) {
69
+ console.error(`[keychain] failed to clear OAuth for ${norm}: ${err instanceof Error ? err.message : String(err)}`);
95
70
  }
96
- return false;
97
71
  }
72
+ return true;
98
73
  }
99
74
  /**
100
- * Delete a stored key. Returns true if a key was deleted, false if none was
101
- * present or keytar is unavailable.
75
+ * Delete a stored key from the env-store. Returns true if a key was present.
102
76
  */
103
77
  export async function deleteKeyForProvider(provider) {
104
78
  const norm = normalizeKeychainProvider(provider) ?? provider;
105
- const kt = await loadKeytar();
106
- if (!kt?.deletePassword)
107
- return false;
108
- try {
109
- return await kt.deletePassword(KEYCHAIN_SERVICE, ACCOUNT_BY_PROVIDER[norm]);
110
- }
111
- catch (err) {
112
- if (process.env.DEBUG || process.env.MUONROI_DEBUG_KEYCHAIN) {
113
- console.error(`[keychain] deletePassword backend error for ${provider}:`, err?.message || err);
114
- }
115
- return false;
116
- }
79
+ const had = !!process.env[ENV_BY_PROVIDER[norm]];
80
+ clearEnvVar(ENV_BY_PROVIDER[norm]);
81
+ return had;
117
82
  }
118
83
  /**
119
- * List provider IDs that currently have a key stored in the keychain.
120
- * Empty array if keytar is unavailable.
84
+ * List provider IDs that currently have a key set in the environment.
121
85
  */
122
86
  export async function listStoredProviders() {
123
- const kt = await loadKeytar();
124
- if (!kt?.findCredentials)
125
- return [];
126
- try {
127
- const creds = await kt.findCredentials(KEYCHAIN_SERVICE);
128
- const validAccounts = new Set(Object.values(ACCOUNT_BY_PROVIDER));
129
- return creds.filter((c) => validAccounts.has(c.account)).map((c) => c.account);
130
- }
131
- catch {
132
- return [];
133
- }
87
+ return KEYCHAIN_PROVIDER_IDS.filter((p) => {
88
+ const v = process.env[ENV_BY_PROVIDER[p]];
89
+ return !!v && v.length >= 20;
90
+ });
134
91
  }
135
92
  /**
136
- * Load the API key for a given provider.
137
- * Priority: OS keychain (keytar) > environment variable.
93
+ * Load the API key for a given provider from the environment.
138
94
  * Ollama returns '' when no key is found (keyless).
139
95
  *
140
96
  * @throws {ProviderKeyMissingError} when no key found for non-ollama providers.
141
97
  */
142
98
  export async function loadKeyForProvider(provider) {
143
- const kt = await loadKeytar();
144
- if (kt) {
145
- try {
146
- const k = await kt.getPassword(KEYCHAIN_SERVICE, ACCOUNT_BY_PROVIDER[provider]);
147
- if (k && k.length >= 20) {
148
- redactor.enrollSecret(k);
149
- return k;
150
- }
151
- }
152
- catch {
153
- /* ignore keytar backend failures */
154
- }
155
- }
156
99
  const envKey = process.env[ENV_BY_PROVIDER[provider]];
157
100
  if (envKey && envKey.length >= 20) {
158
101
  redactor.enrollSecret(envKey);
159
102
  return envKey;
160
103
  }
161
- // Fallback: check user-settings.json providers config (lazy import to avoid circular deps)
162
- const settingsField = SETTINGS_KEY_MAP[provider];
163
- if (settingsField) {
164
- try {
165
- const { loadUserSettings } = await import("../utils/settings.js");
166
- const providers = loadUserSettings().providers;
167
- const settingsKey = providers?.[settingsField]?.apiKey;
168
- if (settingsKey && settingsKey.length >= 20) {
169
- redactor.enrollSecret(settingsKey);
170
- return settingsKey;
171
- }
172
- }
173
- catch {
174
- /* settings load failed — continue to error */
175
- }
176
- }
177
104
  // Ollama may be keyless
178
105
  if (provider === "ollama")
179
106
  return "";
180
107
  throw new ProviderKeyMissingError(provider);
181
108
  }
182
109
  /**
183
- * Return the list of providers that currently have credentials available — checked across
184
- * OS keychain, environment variables, and user-settings.json. `ollama` is always included
110
+ * Return the list of providers that currently have credentials available —
111
+ * either an env API key or a stored OAuth token. `ollama` is always included
185
112
  * because it is keyless. Order is stable for UI rendering.
186
113
  */
187
114
  export async function getConfiguredProviders() {
188
115
  const order = ALL_PROVIDER_IDS;
189
116
  const stored = new Set(await listStoredProviders());
190
- let settingsProviders = {};
191
- try {
192
- const { loadUserSettings } = await import("../utils/settings.js");
193
- settingsProviders = (loadUserSettings().providers ?? {});
194
- }
195
- catch {
196
- /* settings unreadable — keychain + env still work */
197
- }
198
117
  // OAuth-authenticated providers (no API key, but tokens stored via `keys
199
118
  // login`) need to count as configured — otherwise the model picker silently
200
119
  // hides them. Source the eligible provider list from the OAuth registry so
@@ -214,25 +133,8 @@ export async function getConfiguredProviders() {
214
133
  }
215
134
  const configured = [];
216
135
  for (const p of order) {
217
- if (p === "ollama") {
136
+ if (p === "ollama" || stored.has(p) || oauthAuthenticated.has(p)) {
218
137
  configured.push(p);
219
- continue;
220
- }
221
- if (stored.has(p) || oauthAuthenticated.has(p)) {
222
- configured.push(p);
223
- continue;
224
- }
225
- const envKey = process.env[ENV_BY_PROVIDER[p]];
226
- if (envKey && envKey.length >= 20) {
227
- configured.push(p);
228
- continue;
229
- }
230
- const settingsField = SETTINGS_KEY_MAP[p];
231
- if (settingsField) {
232
- const k = settingsProviders[settingsField]?.apiKey;
233
- if (k && k.length >= 20) {
234
- configured.push(p);
235
- }
236
138
  }
237
139
  }
238
140
  return configured;
@@ -275,4 +177,91 @@ export async function firstAvailableProvider() {
275
177
  }
276
178
  return null;
277
179
  }
180
+ /**
181
+ * One-time migration: move any legacy API keys — OS keychain (keytar) or
182
+ * `settings.json` (`providers.<p>.apiKey`) — into the env-store, then remove
183
+ * the legacy copies. Best-effort, idempotent, and never throws. Guarded by
184
+ * `settings.keysMigratedToEnv`. Runs once at startup before key resolution.
185
+ */
186
+ export async function migrateLegacyKeysToEnv() {
187
+ let settingsMod;
188
+ try {
189
+ settingsMod = await import("../utils/settings.js");
190
+ }
191
+ catch (err) {
192
+ if (process.env.MUONROI_DEBUG_ENVSTORE) {
193
+ console.error(`[keychain] migration: settings unavailable: ${err instanceof Error ? err.message : String(err)}`);
194
+ }
195
+ return;
196
+ }
197
+ let settings;
198
+ try {
199
+ settings = settingsMod.loadUserSettings();
200
+ }
201
+ catch {
202
+ return;
203
+ }
204
+ if (settings.keysMigratedToEnv)
205
+ return;
206
+ // Legacy keytar reader — the dependency has been removed, so absence is the
207
+ // normal case. The specifier is assembled at runtime so the bundler
208
+ // (`bun build --compile`) does not try to statically resolve the missing
209
+ // module; when keytar isn't installed this import simply throws → null.
210
+ let keytar = null;
211
+ try {
212
+ const legacyKeytarSpecifier = ["key", "tar"].join("");
213
+ keytar = (await import(legacyKeytarSpecifier));
214
+ }
215
+ catch {
216
+ keytar = null;
217
+ }
218
+ const providersPatch = { ...(settings.providers ?? {}) };
219
+ let providersDirty = false;
220
+ for (const p of KEYCHAIN_PROVIDER_IDS) {
221
+ const envName = ENV_BY_PROVIDER[p];
222
+ if (process.env[envName])
223
+ continue; // already in env
224
+ let legacy = null;
225
+ if (keytar?.getPassword) {
226
+ try {
227
+ legacy = await keytar.getPassword("muonroi-cli", p);
228
+ }
229
+ catch {
230
+ legacy = null;
231
+ }
232
+ }
233
+ if (!legacy)
234
+ legacy = providersPatch[p]?.apiKey ?? null;
235
+ if (legacy && legacy.length >= 20) {
236
+ persistEnvVar(envName, legacy);
237
+ if (keytar?.deletePassword) {
238
+ try {
239
+ await keytar.deletePassword("muonroi-cli", p);
240
+ }
241
+ catch {
242
+ /* best-effort keychain cleanup */
243
+ }
244
+ }
245
+ if (providersPatch[p]?.apiKey) {
246
+ delete providersPatch[p].apiKey;
247
+ providersDirty = true;
248
+ }
249
+ }
250
+ }
251
+ const patch = { keysMigratedToEnv: true };
252
+ // Strip the legacy plaintext main key — it is no longer read (getApiKey is
253
+ // env-only). Passing undefined drops it from the persisted JSON.
254
+ if (settings.apiKey)
255
+ patch.apiKey = undefined;
256
+ if (providersDirty)
257
+ patch.providers = providersPatch;
258
+ try {
259
+ settingsMod.saveUserSettings(patch);
260
+ }
261
+ catch (err) {
262
+ if (process.env.MUONROI_DEBUG_ENVSTORE) {
263
+ console.error(`[keychain] migration: save failed: ${err instanceof Error ? err.message : String(err)}`);
264
+ }
265
+ }
266
+ }
278
267
  //# sourceMappingURL=keychain.js.map