@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.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- 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
|
+
}
|