@cspeach/cli 0.6.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 (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,245 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
3
+ /**
4
+ * Mutable keys exposed via `cspeach config set`.
5
+ * Any key not in this set is rejected with an error listing valid keys.
6
+ */
7
+ const MUTABLE_KEYS = new Set([
8
+ 'proxy_url',
9
+ 'default_model',
10
+ 'effort',
11
+ 'telemetry',
12
+ 'classifier.safe_mode',
13
+ 'write_mode',
14
+ 'llm.mode',
15
+ 'llm.ai_hub_base_url',
16
+ 'llm.ai_hub_model_alias',
17
+ 'llm.local_base_url',
18
+ 'llm.local_model',
19
+ // Virtual keys that route to keychain (not saved to config.toml):
20
+ 'llm.byok_key',
21
+ 'llm.ai_hub_token',
22
+ ]);
23
+ // SAP-scoped key pattern: sap.<alias>.auto_approve
24
+ const SAP_AUTO_APPROVE_RE = /^sap\.([A-Z0-9_-]{2,32})\.auto_approve$/;
25
+ const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
26
+ const TELEMETRY_VALUES = ['minimal', 'full'];
27
+ const AUTO_APPROVE_VALUES = ['never', 'low', 'medium'];
28
+ const LLM_MODE_VALUES = ['managed', 'byok', 'ai-hub', 'local'];
29
+ /**
30
+ * Thrown on validation failure. The outer CLI dispatcher converts to
31
+ * `console.error` + `process.exit(exitCode)`. Throwing instead of calling
32
+ * `process.exit` directly means tests can assert behaviour WITHOUT mocking
33
+ * `process.exit` (which, when mocked to return `undefined`, causes the
34
+ * `never`-typed function to fall through and hit the switch with an
35
+ * undefined `key`). See §3 `runConfigSet` wiring in Task 13 for the wrap.
36
+ */
37
+ export class ConfigSetError extends Error {
38
+ exitCode = 1;
39
+ constructor(msg) {
40
+ super(msg);
41
+ this.name = 'ConfigSetError';
42
+ }
43
+ }
44
+ function err(msg) {
45
+ throw new ConfigSetError(`[config set] ${msg}`);
46
+ }
47
+ /**
48
+ * Executes `cspeach config set <key> <value>`.
49
+ * Exits process with code 1 on validation error.
50
+ */
51
+ export async function runConfigSet(args) {
52
+ if (args.length < 2) {
53
+ err('Usage: cspeach config set <key> <value>\n Valid keys: ' + [...MUTABLE_KEYS].join(', ') + ', sap.<alias>.auto_approve');
54
+ }
55
+ const [key, ...rest] = args;
56
+ const value = rest.join(' ').trim();
57
+ if (!key || !value) {
58
+ err('Both key and value are required.');
59
+ }
60
+ // SAP-scoped key: sap.<alias>.auto_approve
61
+ const sapMatch = SAP_AUTO_APPROVE_RE.exec(key);
62
+ if (sapMatch) {
63
+ const alias = sapMatch[1];
64
+ if (!AUTO_APPROVE_VALUES.includes(value)) {
65
+ err(`Invalid value for ${key}. Allowed: ${AUTO_APPROVE_VALUES.join(', ')}`);
66
+ }
67
+ const cfg = await loadConfig();
68
+ if (!cfg.sap[alias]) {
69
+ err(`SAP system "${alias}" not found in config. Add it first with: cspeach config add-sap`);
70
+ }
71
+ cfg.sap[alias].auto_approve = value;
72
+ await saveConfig(cfg);
73
+ console.log(chalk.green(`Set ${key} = ${value}`));
74
+ return;
75
+ }
76
+ // Virtual keys that store secrets in the OS keychain instead of config.toml.
77
+ if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
78
+ // Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
79
+ let secret = value;
80
+ if (secret === '-') {
81
+ if (process.stdin.isTTY) {
82
+ const { password } = await import('@inquirer/prompts');
83
+ secret = await password({ message: `${key} (input masked):`, mask: '*' });
84
+ }
85
+ else {
86
+ const chunks = [];
87
+ for await (const c of process.stdin)
88
+ chunks.push(c);
89
+ secret = Buffer.concat(chunks).toString('utf8').trim();
90
+ }
91
+ }
92
+ else {
93
+ console.log(chalk.yellow(`WARN: secret passed as shell argument — will land in shell history. Prefer: cat key.txt | cspeach config set ${key} -`));
94
+ }
95
+ const { default: keytar } = await import('keytar');
96
+ if (key === 'llm.byok_key') {
97
+ await keytar.setPassword('cspeach.anthropic', 'api-key', secret);
98
+ }
99
+ else {
100
+ await keytar.setPassword('cspeach.ai-hub', 'token', secret);
101
+ }
102
+ console.log(chalk.green(`✓ Secret stored in OS keychain (service: ${key === 'llm.byok_key' ? 'cspeach.anthropic' : 'cspeach.ai-hub'})`));
103
+ return;
104
+ }
105
+ // Top-level mutable keys
106
+ if (!MUTABLE_KEYS.has(key)) {
107
+ err(`Unknown key "${key}". Valid keys: ${[...MUTABLE_KEYS].join(', ')}, sap.<alias>.auto_approve`);
108
+ }
109
+ const cfg = await loadConfig();
110
+ switch (key) {
111
+ case 'proxy_url':
112
+ // Basic URL validation
113
+ try {
114
+ new URL(value);
115
+ }
116
+ catch {
117
+ err(`"${value}" is not a valid URL.`);
118
+ }
119
+ cfg.proxy_url = value;
120
+ break;
121
+ case 'default_model':
122
+ if (!value.startsWith('claude-')) {
123
+ err(`model must start with "claude-", got "${value}".`);
124
+ }
125
+ cfg.default_model = value;
126
+ break;
127
+ case 'effort':
128
+ if (!EFFORT_VALUES.includes(value)) {
129
+ err(`Invalid effort value "${value}". Allowed: ${EFFORT_VALUES.join(', ')}`);
130
+ }
131
+ cfg.effort = value;
132
+ break;
133
+ case 'telemetry':
134
+ if (!TELEMETRY_VALUES.includes(value)) {
135
+ err(`Invalid telemetry value "${value}". Allowed: ${TELEMETRY_VALUES.join(', ')}`);
136
+ }
137
+ cfg.telemetry = value;
138
+ break;
139
+ case 'classifier.safe_mode':
140
+ if (value !== 'true' && value !== 'false') {
141
+ err(`classifier.safe_mode must be "true" or "false".`);
142
+ }
143
+ cfg.classifier.safe_mode = value === 'true';
144
+ break;
145
+ case 'write_mode':
146
+ if (!isValidWriteMode(value)) {
147
+ err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
148
+ }
149
+ cfg.write_mode = value;
150
+ break;
151
+ case 'llm.mode':
152
+ if (!LLM_MODE_VALUES.includes(value)) {
153
+ err(`Invalid llm.mode "${value}". Allowed: ${LLM_MODE_VALUES.join(', ')}`);
154
+ }
155
+ cfg.llm.mode = value;
156
+ break;
157
+ case 'llm.ai_hub_base_url':
158
+ try {
159
+ new URL(value);
160
+ }
161
+ catch {
162
+ err(`"${value}" is not a valid URL.`);
163
+ }
164
+ cfg.llm.ai_hub_base_url = value;
165
+ break;
166
+ case 'llm.ai_hub_model_alias':
167
+ if (value.length === 0)
168
+ err('ai_hub_model_alias cannot be empty.');
169
+ cfg.llm.ai_hub_model_alias = value;
170
+ break;
171
+ case 'llm.local_base_url':
172
+ try {
173
+ new URL(value);
174
+ }
175
+ catch {
176
+ err(`"${value}" is not a valid URL.`);
177
+ }
178
+ cfg.llm.local_base_url = value;
179
+ break;
180
+ case 'llm.local_model':
181
+ if (value.length === 0)
182
+ err('local_model cannot be empty.');
183
+ cfg.llm.local_model = value;
184
+ break;
185
+ default:
186
+ err(`Unhandled key "${key}".`);
187
+ }
188
+ await saveConfig(cfg);
189
+ console.log(chalk.green(`Set ${key} = ${value}`));
190
+ }
191
+ /**
192
+ * Pure predicate: would `cspeach config set <key> <value>` accept this pair?
193
+ * Returns `true` if the value would pass validation for the given key, `false`
194
+ * otherwise (including for unknown keys or unsupported SAP-scoped patterns).
195
+ *
196
+ * Does not load or mutate any config — safe to call in unit tests.
197
+ * Keep in sync with the switch statement in `runConfigSet`.
198
+ */
199
+ export function validateValue(key, value) {
200
+ // SAP-scoped key: sap.<alias>.auto_approve
201
+ if (SAP_AUTO_APPROVE_RE.test(key)) {
202
+ return AUTO_APPROVE_VALUES.includes(value);
203
+ }
204
+ if (!MUTABLE_KEYS.has(key))
205
+ return false;
206
+ switch (key) {
207
+ case 'proxy_url':
208
+ try {
209
+ new URL(value);
210
+ return true;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ case 'default_model':
216
+ return value.startsWith('claude-');
217
+ case 'effort':
218
+ return EFFORT_VALUES.includes(value);
219
+ case 'telemetry':
220
+ return TELEMETRY_VALUES.includes(value);
221
+ case 'classifier.safe_mode':
222
+ return value === 'true' || value === 'false';
223
+ case 'write_mode':
224
+ return isValidWriteMode(value);
225
+ case 'llm.mode':
226
+ return LLM_MODE_VALUES.includes(value);
227
+ case 'llm.ai_hub_base_url':
228
+ case 'llm.local_base_url':
229
+ try {
230
+ new URL(value);
231
+ return true;
232
+ }
233
+ catch {
234
+ return false;
235
+ }
236
+ case 'llm.ai_hub_model_alias':
237
+ case 'llm.local_model':
238
+ return value.length > 0;
239
+ case 'llm.byok_key':
240
+ case 'llm.ai_hub_token':
241
+ return value.length > 0; // Secrets pass through if non-empty; no format check
242
+ default:
243
+ return false;
244
+ }
245
+ }
@@ -0,0 +1,159 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig } from '../config/loader.js';
3
+ /**
4
+ * Dotted paths of keys that must never appear in plaintext config output.
5
+ * SAP passwords live in the keychain and should never land in config.toml,
6
+ * but we list the LLM mode secrets here defensively so an accidental drift
7
+ * that writes them into the TOML file is redacted on display.
8
+ *
9
+ * Keep synchronised with the keychain services in auth/api-key.ts and
10
+ * sap/onboarding.ts — any secret that is keychain-only should appear here
11
+ * if it has a corresponding virtual config path.
12
+ */
13
+ const REDACTED_KEYS = new Set([
14
+ 'llm.byok_key', // Anthropic API key — keychain "cspeach.anthropic"
15
+ 'llm.ai_hub_token', // SAP AI Hub bearer — keychain "cspeach.ai-hub"
16
+ ]);
17
+ function formatTomlValue(value) {
18
+ if (typeof value === 'string')
19
+ return `"${value}"`;
20
+ if (typeof value === 'boolean')
21
+ return String(value);
22
+ if (typeof value === 'number')
23
+ return String(value);
24
+ return JSON.stringify(value);
25
+ }
26
+ /**
27
+ * Per-mode required keys inside `[llm]`. Listed here so config show can point
28
+ * out what is missing for a given mode without reading docs. Secrets live in
29
+ * the keychain (byok_key, ai_hub_token) and are not surfaced by this helper —
30
+ * doctor reports their presence separately.
31
+ */
32
+ const LLM_REQUIRED_KEYS_BY_MODE = {
33
+ managed: [],
34
+ byok: [],
35
+ 'ai-hub': ['ai_hub_base_url', 'ai_hub_model_alias'],
36
+ local: ['local_base_url', 'local_model'],
37
+ };
38
+ function printLlmMissingHints(llm) {
39
+ if (typeof llm !== 'object' || llm === null)
40
+ return;
41
+ const mode = llm['mode'];
42
+ if (typeof mode !== 'string')
43
+ return;
44
+ const required = LLM_REQUIRED_KEYS_BY_MODE[mode];
45
+ if (!required || required.length === 0)
46
+ return;
47
+ for (const key of required) {
48
+ const value = llm[key];
49
+ if (value === undefined || value === null || value === '') {
50
+ console.log(`${key} = ${chalk.red('<not set>')}`);
51
+ }
52
+ }
53
+ }
54
+ function printConfigAsToml(cfg, prefix = '') {
55
+ // Emit scalars before nested objects — otherwise a top-level scalar that
56
+ // follows a nested table (e.g. `write_mode` declared after `ui` in the
57
+ // config shape) would render underneath `[ui]` in TOML, which reparses as
58
+ // `ui.write_mode` instead of a top-level key.
59
+ const entries = Object.entries(cfg);
60
+ const scalars = entries.filter(([, v]) => !(typeof v === 'object' && v !== null && !Array.isArray(v)));
61
+ const tables = entries.filter(([, v]) => typeof v === 'object' && v !== null && !Array.isArray(v));
62
+ for (const [k, v] of scalars) {
63
+ const fullKey = prefix ? `${prefix}.${k}` : k;
64
+ if (REDACTED_KEYS.has(fullKey)) {
65
+ console.log(`${k} = [REDACTED]`);
66
+ }
67
+ else {
68
+ console.log(`${k} = ${formatTomlValue(v)}`);
69
+ // Surface an explanatory hint for advisory-only mode so users understand
70
+ // the implication (writes proposed, never executed) without having to
71
+ // read the docs.
72
+ if (fullKey === 'write_mode' && v === 'advisory-only') {
73
+ console.log(chalk.yellow(' → Advisory mode: writes will be proposed, never executed.'));
74
+ }
75
+ }
76
+ }
77
+ for (const [k, v] of tables) {
78
+ const fullKey = prefix ? `${prefix}.${k}` : k;
79
+ console.log('');
80
+ console.log(chalk.cyan(`[${fullKey}]`));
81
+ printConfigAsToml(v, fullKey);
82
+ // Mode-aware extras for the `[llm]` section — print required keys that
83
+ // are empty as `<not set>` so a user who ran `cspeach config set llm.mode
84
+ // ai-hub` without also setting the base URL can see what is still
85
+ // missing.
86
+ if (fullKey === 'llm') {
87
+ printLlmMissingHints(v);
88
+ }
89
+ }
90
+ }
91
+ /**
92
+ * Resolve a dotted key path in the config object.
93
+ * e.g. 'sap.S4H-DEV' → cfg.sap['S4H-DEV']
94
+ * 'sap.S4H-DEV.auto_approve' → cfg.sap['S4H-DEV'].auto_approve
95
+ */
96
+ function resolveDottedKey(cfg, keyPath) {
97
+ const parts = keyPath.split('.');
98
+ let current = cfg;
99
+ for (const part of parts) {
100
+ if (current === null || typeof current === 'undefined')
101
+ return undefined;
102
+ if (typeof current !== 'object')
103
+ return undefined;
104
+ // Own-property only — prevents `__proto__` / `constructor` traversal
105
+ // from returning an inherited value (which then gets printed as if it
106
+ // were a user-configured key).
107
+ if (!Object.prototype.hasOwnProperty.call(current, part))
108
+ return undefined;
109
+ current = current[part];
110
+ }
111
+ return current;
112
+ }
113
+ /**
114
+ * Executes `cspeach config show [key]`.
115
+ * Without argument: prints full config as TOML (secrets redacted).
116
+ * With argument: prints single resolved value.
117
+ */
118
+ export async function runConfigShow(args) {
119
+ const cfg = await loadConfig();
120
+ if (args.length === 0) {
121
+ // Full config dump
122
+ console.log('');
123
+ console.log(chalk.gray('# ~/.cspeach/config.toml'));
124
+ printConfigAsToml(cfg);
125
+ console.log('');
126
+ return;
127
+ }
128
+ const keyPath = args[0];
129
+ const resolved = resolveDottedKey(cfg, keyPath);
130
+ if (resolved === undefined) {
131
+ console.error(chalk.red(`[config show] Key "${keyPath}" not found.`));
132
+ process.exit(1);
133
+ }
134
+ if (REDACTED_KEYS.has(keyPath)) {
135
+ console.log('[REDACTED]');
136
+ return;
137
+ }
138
+ if (typeof resolved === 'object' && resolved !== null) {
139
+ // Sub-object (e.g. sap.S4H-DEV) — print as TOML section
140
+ console.log('');
141
+ console.log(chalk.cyan(`[${keyPath}]`));
142
+ for (const [k, v] of Object.entries(resolved)) {
143
+ const subKey = `${keyPath}.${k}`;
144
+ if (REDACTED_KEYS.has(subKey)) {
145
+ console.log(`${k} = [REDACTED]`);
146
+ }
147
+ else {
148
+ console.log(`${k} = ${formatTomlValue(v)}`);
149
+ }
150
+ }
151
+ console.log('');
152
+ }
153
+ else {
154
+ console.log(String(resolved));
155
+ if (keyPath === 'write_mode' && resolved === 'advisory-only') {
156
+ console.log(chalk.yellow(' → Advisory mode: writes will be proposed, never executed.'));
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import { SKILL_CATALOG } from '../skill-catalog.js';
3
+ // Workflow order: plan → understand → modify → build → upgrade → ship.
4
+ const CATEGORY_ORDER = [
5
+ 'Pre-Coding',
6
+ 'Read / Analyze',
7
+ 'Modify / Fix',
8
+ 'Create / Generate',
9
+ 'Upgrade / Migrate',
10
+ 'Ship / Release',
11
+ ];
12
+ const NAME_COL_PAD = 28; // padded width of "/skill-name"
13
+ const NAME_GUTTER = 2; // leading 2-space indent
14
+ const DESC_GUTTER = 1; // 1 space between name col and description
15
+ const NAME_COL_WIDTH = NAME_GUTTER + NAME_COL_PAD + DESC_GUTTER; // 31
16
+ const MAX_DESC_LINES = 2;
17
+ const MIN_DESC_WIDTH = 40;
18
+ const DEFAULT_TERM_WIDTH = 100;
19
+ /**
20
+ * Word-wrap a description into at most `maxLines` lines of `width` chars each.
21
+ * If content overflows, the last line is suffixed with `…`.
22
+ */
23
+ function wrapDescription(desc, width, maxLines) {
24
+ const words = desc.split(/\s+/).filter((w) => w.length > 0);
25
+ const lines = [];
26
+ let current = '';
27
+ let i = 0;
28
+ while (i < words.length && lines.length < maxLines) {
29
+ const word = words[i];
30
+ if (current.length === 0) {
31
+ // Word longer than width — hard-truncate
32
+ current = word.length <= width ? word : word.slice(0, width);
33
+ i++;
34
+ }
35
+ else if (current.length + 1 + word.length <= width) {
36
+ current += ' ' + word;
37
+ i++;
38
+ }
39
+ else {
40
+ lines.push(current);
41
+ current = '';
42
+ }
43
+ }
44
+ if (current.length > 0 && lines.length < maxLines) {
45
+ lines.push(current);
46
+ }
47
+ // Overflow → suffix last line with " …"
48
+ if (i < words.length && lines.length > 0) {
49
+ const last = lines[lines.length - 1];
50
+ if (last.length + 2 <= width) {
51
+ lines[lines.length - 1] = last + ' …';
52
+ }
53
+ else {
54
+ lines[lines.length - 1] = last.slice(0, Math.max(0, width - 2)) + ' …';
55
+ }
56
+ }
57
+ return lines;
58
+ }
59
+ /**
60
+ * Prints the flat-list /help screen grouped by category.
61
+ * Cyan category headings, bold skill names, default-color descriptions.
62
+ * Descriptions wrap to MAX_DESC_LINES lines max, aligned to the name column.
63
+ */
64
+ export function printHelp() {
65
+ const termWidth = process.stdout.columns ?? DEFAULT_TERM_WIDTH;
66
+ const descWidth = Math.max(MIN_DESC_WIDTH, termWidth - NAME_COL_WIDTH);
67
+ const continuationIndent = ' '.repeat(NAME_COL_WIDTH);
68
+ console.log('');
69
+ for (const category of CATEGORY_ORDER) {
70
+ const skills = SKILL_CATALOG.filter((s) => s.category === category);
71
+ if (skills.length === 0)
72
+ continue;
73
+ console.log(chalk.cyan.bold(category));
74
+ for (const skill of skills) {
75
+ const nameCol = ('/' + skill.name).padEnd(NAME_COL_PAD);
76
+ const descLines = wrapDescription(skill.description, descWidth, MAX_DESC_LINES);
77
+ const firstLine = descLines[0] ?? '';
78
+ console.log(` ${chalk.bold(nameCol)} ${firstLine}`);
79
+ for (let j = 1; j < descLines.length; j++) {
80
+ console.log(`${continuationIndent}${descLines[j]}`);
81
+ }
82
+ }
83
+ console.log('');
84
+ }
85
+ console.log(chalk.cyan.bold('Shortcuts'));
86
+ console.log(` ${chalk.bold('/help'.padEnd(NAME_COL_PAD))} This screen`);
87
+ console.log(` ${chalk.bold('/skills'.padEnd(NAME_COL_PAD))} Same as /help`);
88
+ console.log(` ${chalk.bold('/ui [auto|ink|classic]'.padEnd(NAME_COL_PAD))} View or change UI rendering mode`);
89
+ console.log(` ${chalk.bold('/reroute <skill>'.padEnd(NAME_COL_PAD))} Re-run your last prompt with a different skill`);
90
+ console.log(` ${chalk.bold('/new'.padEnd(NAME_COL_PAD))} End the current Q&A chain — next prompt is classified fresh`);
91
+ console.log(` ${chalk.bold('/exit'.padEnd(NAME_COL_PAD))} Quit CSPeach`);
92
+ console.log('');
93
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `cspeach login` — device-code flow driver.
3
+ *
4
+ * Flow:
5
+ * 1) POST /v1/auth/device/start → get device_code, user_code, verification_url
6
+ * 2) Open the browser to verification_url_complete (or print URL if open fails)
7
+ * 3) Print user_code prominently so customer can read it
8
+ * 4) Poll /v1/auth/device/poll every interval until verified or maxAttempts
9
+ * 5) Save api_key + customer_id to ~/.cspeach/auth.json
10
+ * 6) Call GET /v1/me — if trial_mode === 'byok', prompt for Anthropic key
11
+ * 7) Re-save auth file with byokAnthropicKey if provided
12
+ *
13
+ * Dependencies (open, print, prompt) are injected so the command is
14
+ * unit-testable without stubbing platform globals.
15
+ */
16
+ import { writeAuthFile } from '../auth/auth-file.js';
17
+ export async function runLoginCommand(deps) {
18
+ const proxy = deps.proxyUrl.replace(/\/$/, '');
19
+ const pollInterval = deps.pollIntervalMs ?? 5000;
20
+ const maxAttempts = deps.maxAttempts ?? 120; // 120 × 5s = 10 min
21
+ // 1) Start.
22
+ let start;
23
+ try {
24
+ const r = await fetch(`${proxy}/v1/auth/device/start`, { method: 'POST' });
25
+ if (!r.ok) {
26
+ deps.print(`error: device-start HTTP ${r.status}`);
27
+ return 2;
28
+ }
29
+ start = await r.json();
30
+ }
31
+ catch (err) {
32
+ deps.print(`error: device-start network failure — ${err instanceof Error ? err.message : String(err)}`);
33
+ return 2;
34
+ }
35
+ // 2 + 3) Browser + user_code. The next line auto-opens the browser, so
36
+ // there's nothing for the user to do — just print honest "opening" wording.
37
+ deps.print('');
38
+ deps.print(`Your one-time code: ${start.user_code}`);
39
+ deps.print(`Opening browser to: ${start.verification_url_complete}`);
40
+ deps.print('');
41
+ try {
42
+ await deps.open(start.verification_url_complete);
43
+ }
44
+ catch {
45
+ deps.print(`(browser didn't auto-open — paste this URL manually: ${start.verification_url_complete})`);
46
+ }
47
+ // 4) Poll.
48
+ let polled = null;
49
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
50
+ if (attempt > 0)
51
+ await sleep(pollInterval);
52
+ try {
53
+ const r = await fetch(`${proxy}/v1/auth/device/poll`, {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json' },
56
+ body: JSON.stringify({ device_code: start.device_code }),
57
+ });
58
+ if (r.ok) {
59
+ polled = await r.json();
60
+ break;
61
+ }
62
+ const errBody = await r.json().catch(() => ({}));
63
+ if (errBody.error === 'authorization_pending')
64
+ continue;
65
+ if (errBody.error === 'expired_token') {
66
+ deps.print('error: device code expired — run `cspeach login` again');
67
+ return 3;
68
+ }
69
+ deps.print(`error: poll HTTP ${r.status} — ${errBody.error ?? 'unknown'}`);
70
+ return 4;
71
+ }
72
+ catch (err) {
73
+ deps.print(`(network blip — retrying — ${err instanceof Error ? err.message : String(err)})`);
74
+ continue;
75
+ }
76
+ }
77
+ if (!polled) {
78
+ deps.print('error: timed out waiting for browser authorization');
79
+ return 5;
80
+ }
81
+ // 5) Save (non-BYOK part).
82
+ await writeAuthFile({
83
+ apiKey: polled.api_key,
84
+ customerId: polled.customer_id,
85
+ byokAnthropicKey: null,
86
+ });
87
+ deps.print(`✓ Saved API key to ~/.cspeach/auth.json`);
88
+ // 6) /v1/me — figure out if BYOK is required.
89
+ try {
90
+ const r = await fetch(`${proxy}/v1/me`, { headers: { Authorization: `Bearer ${polled.api_key}` } });
91
+ if (r.ok) {
92
+ const me = await r.json();
93
+ deps.print(`✓ Signed in as ${me.email} (${me.plan})`);
94
+ if (me.trial_mode === 'byok' || me.plan === 'byo-key') {
95
+ deps.print('');
96
+ deps.print('This account is BYOK — you provide your own Anthropic API key.');
97
+ deps.print('Get one at https://console.anthropic.com/settings/keys');
98
+ const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-...): ')).trim();
99
+ if (ak.startsWith('sk-ant-') && ak.length > 20) {
100
+ // The non-BYOK save above just persisted these exact values,
101
+ // so reading them back from disk would be redundant.
102
+ await writeAuthFile({
103
+ apiKey: polled.api_key,
104
+ customerId: polled.customer_id,
105
+ byokAnthropicKey: ak,
106
+ });
107
+ deps.print('✓ Anthropic key saved');
108
+ }
109
+ else {
110
+ deps.print('error: Anthropic key looks malformed (must start with sk-ant-). Skipped — run `cspeach login` again later.');
111
+ }
112
+ }
113
+ }
114
+ }
115
+ catch (err) {
116
+ deps.print(`(could not fetch /v1/me — login still succeeded — ${err instanceof Error ? err.message : String(err)})`);
117
+ }
118
+ return 0;
119
+ }
120
+ function sleep(ms) {
121
+ return new Promise((r) => setTimeout(r, ms));
122
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `cspeach logout` — delete the device-code-flow auth file. The REPL
3
+ * will fall back to the keytar-stored API key if one is present.
4
+ */
5
+ import { deleteAuthFile, readAuthFile } from '../auth/auth-file.js';
6
+ export async function runLogoutCommand(deps) {
7
+ const before = await readAuthFile();
8
+ await deleteAuthFile();
9
+ if (before) {
10
+ deps.print('✓ Signed out — removed ~/.cspeach/auth.json');
11
+ deps.print(' (the REPL will use the keytar-stored key if you have one)');
12
+ }
13
+ else {
14
+ deps.print('Already signed out — no auth.json present.');
15
+ }
16
+ return 0;
17
+ }