@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,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSPeach workspace folder — the user's "primary folder" for .cspeach.json
|
|
3
|
+
* artefacts.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: before v0.5.x, CLI saves landed in process.cwd() and
|
|
6
|
+
* @<file> tokens needed full absolute paths. Both pushed real friction onto
|
|
7
|
+
* the user — the path-copy circus. The workspace folder is one configurable
|
|
8
|
+
* directory that the CLI saves into and resolves bare @<name> tokens against.
|
|
9
|
+
*
|
|
10
|
+
* Default resolution (2026-05-08, "project-rooted workspace"):
|
|
11
|
+
* 1. `cfg.workspace_folder` in `~/.cspeach/config.toml` — explicit override,
|
|
12
|
+
* highest priority.
|
|
13
|
+
* 2. `<git-root>/.cspeach/` — when the cwd is inside a git repository.
|
|
14
|
+
* Each customer / project gets its own workspace; multi-project
|
|
15
|
+
* consultants don't have artefacts piling up in one folder.
|
|
16
|
+
* 3. `<cwd>/.cspeach/` — fallback when cwd isn't inside a git repo.
|
|
17
|
+
*
|
|
18
|
+
* Earlier (pre-2026-05-08) default was `~/CSPeach/`. A first-run migration
|
|
19
|
+
* prompt (see src/projects/migration.ts) offers to move existing artefacts
|
|
20
|
+
* into the new project-rooted workspace, or pin workspace_folder back to
|
|
21
|
+
* `~/CSPeach/` for users who prefer the old behaviour.
|
|
22
|
+
*
|
|
23
|
+
* The folder is created on first use — no `cspeach init` ceremony.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, join, isAbsolute } from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import toml from '@iarna/toml';
|
|
29
|
+
import { loadConfig } from '../config/loader.js';
|
|
30
|
+
/**
|
|
31
|
+
* Walk up the directory tree from `startDir` looking for a `.git` entry
|
|
32
|
+
* (file or directory — submodules use a file). Returns the directory
|
|
33
|
+
* containing `.git`, or null when the search hits the filesystem root
|
|
34
|
+
* without finding one.
|
|
35
|
+
*
|
|
36
|
+
* Bounded by parent === dir, which terminates at "/" on POSIX and at
|
|
37
|
+
* each drive root on Windows.
|
|
38
|
+
*/
|
|
39
|
+
export function findProjectRoot(startDir) {
|
|
40
|
+
let dir = startDir;
|
|
41
|
+
for (let i = 0; i < 200; i++) {
|
|
42
|
+
if (existsSync(join(dir, '.git')))
|
|
43
|
+
return dir;
|
|
44
|
+
const parent = dirname(dir);
|
|
45
|
+
if (parent === dir)
|
|
46
|
+
return null;
|
|
47
|
+
dir = parent;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the project-rooted default workspace path for a given cwd.
|
|
53
|
+
* Pure: does NOT touch the filesystem (other than the .git existence
|
|
54
|
+
* check via findProjectRoot). Caller decides whether to mkdir.
|
|
55
|
+
*/
|
|
56
|
+
export function defaultWorkspaceFor(cwd) {
|
|
57
|
+
const root = findProjectRoot(cwd);
|
|
58
|
+
return join(root ?? cwd, '.cspeach');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* If the workspace lives directly under a project root that has a
|
|
62
|
+
* .gitignore, append a `.cspeach/` line so per-developer state doesn't
|
|
63
|
+
* get committed by accident. Idempotent — skips if .cspeach/ is already
|
|
64
|
+
* listed under any of the common patterns. No-op if .gitignore is
|
|
65
|
+
* missing (we don't create one — that's the user's call).
|
|
66
|
+
*/
|
|
67
|
+
function maybeAddToGitignore(workspaceDir) {
|
|
68
|
+
const projectRoot = dirname(workspaceDir);
|
|
69
|
+
const gitignorePath = join(projectRoot, '.gitignore');
|
|
70
|
+
if (!existsSync(gitignorePath))
|
|
71
|
+
return;
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = readFileSync(gitignorePath, 'utf-8');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const linesNorm = content.split('\n').map((l) => l.trim());
|
|
80
|
+
const already = linesNorm.some((l) => l === '.cspeach/' || l === '.cspeach' || l === '/.cspeach/' || l === '/.cspeach');
|
|
81
|
+
if (already)
|
|
82
|
+
return;
|
|
83
|
+
const sep = content.length === 0 || content.endsWith('\n') ? '' : '\n';
|
|
84
|
+
const block = `${sep}\n# CSPeach project workspace — per-developer state, not for commit.\n.cspeach/\n`;
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(gitignorePath, content + block, 'utf-8');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Read-only filesystem / permission denied — silently skip; the workspace
|
|
90
|
+
// still works, the user just has to ignore it manually.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the workspace folder path, creating it if missing.
|
|
95
|
+
* Returns the absolute path. Idempotent.
|
|
96
|
+
*/
|
|
97
|
+
export async function ensureWorkspace() {
|
|
98
|
+
const cfg = await loadConfig();
|
|
99
|
+
const configured = cfg.workspace_folder?.trim();
|
|
100
|
+
const target = configured && configured.length > 0
|
|
101
|
+
? expandHome(configured)
|
|
102
|
+
: defaultWorkspaceFor(process.cwd());
|
|
103
|
+
const justCreated = !existsSync(target);
|
|
104
|
+
if (justCreated) {
|
|
105
|
+
mkdirSync(target, { recursive: true });
|
|
106
|
+
// Only add to .gitignore for default project-rooted workspaces.
|
|
107
|
+
// Explicit overrides (e.g. ~/CSPeach/, /shared/team/cspeach/) are
|
|
108
|
+
// outside any project tree by design — adding to a parent .gitignore
|
|
109
|
+
// would be overreach.
|
|
110
|
+
if (!configured)
|
|
111
|
+
maybeAddToGitignore(target);
|
|
112
|
+
}
|
|
113
|
+
return target;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Synchronous variant for callers that cannot await — most importantly
|
|
117
|
+
* Node's readline `completer` interface, which is sync. Reads the TOML
|
|
118
|
+
* config directly via fs.readFileSync. Falls back to project-rooted
|
|
119
|
+
* default on any error (missing config, parse failure, etc.) — the
|
|
120
|
+
* completer should never throw.
|
|
121
|
+
*
|
|
122
|
+
* Earlier revision used `require('@iarna/toml')` here, which threw in
|
|
123
|
+
* ESM context (cspeach-cli has `"type": "module"`) and silently broke
|
|
124
|
+
* Tab completion. Top-level import is correct for ESM modules.
|
|
125
|
+
*/
|
|
126
|
+
export function ensureWorkspaceSync() {
|
|
127
|
+
let target = defaultWorkspaceFor(process.cwd());
|
|
128
|
+
try {
|
|
129
|
+
const cfgPath = join(os.homedir(), '.cspeach', 'config.toml');
|
|
130
|
+
if (existsSync(cfgPath)) {
|
|
131
|
+
const raw = readFileSync(cfgPath, 'utf-8');
|
|
132
|
+
const parsed = toml.parse(raw);
|
|
133
|
+
const configured = parsed.workspace_folder?.trim();
|
|
134
|
+
if (configured && configured.length > 0) {
|
|
135
|
+
target = expandHome(configured);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Fall through to default — completer must never throw on a borked config.
|
|
141
|
+
}
|
|
142
|
+
if (!existsSync(target)) {
|
|
143
|
+
mkdirSync(target, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
return target;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Synchronous list of supported files in `workspace` — both
|
|
149
|
+
* `.cspeach.json` envelopes and `.txt` / `.md` text inputs. Mirrors
|
|
150
|
+
* listProjectFiles but skips the metadata parse (filename + kind +
|
|
151
|
+
* mtime only) since the readline completer only needs filenames.
|
|
152
|
+
* Lighter and faster for the per-keystroke hot path. Errors swallowed
|
|
153
|
+
* — see ensureWorkspaceSync.
|
|
154
|
+
*/
|
|
155
|
+
export function listProjectFilesSync(workspace) {
|
|
156
|
+
try {
|
|
157
|
+
const entries = readdirSync(workspace);
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const name of entries) {
|
|
160
|
+
const isEnvelope = name.endsWith('.cspeach.json');
|
|
161
|
+
const isText = isTextFilename(name);
|
|
162
|
+
if (!isEnvelope && !isText)
|
|
163
|
+
continue;
|
|
164
|
+
const full = join(workspace, name);
|
|
165
|
+
const stat = statSync(full);
|
|
166
|
+
if (!stat.isFile())
|
|
167
|
+
continue;
|
|
168
|
+
out.push({
|
|
169
|
+
filename: name,
|
|
170
|
+
kind: isEnvelope ? 'envelope' : 'text',
|
|
171
|
+
mtimeMs: stat.mtimeMs,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Expand a leading `~/` to the user's home directory. Other paths are
|
|
183
|
+
* returned unchanged.
|
|
184
|
+
*/
|
|
185
|
+
function expandHome(p) {
|
|
186
|
+
if (p.startsWith('~/') || p === '~') {
|
|
187
|
+
return join(os.homedir(), p.slice(p === '~' ? 1 : 2));
|
|
188
|
+
}
|
|
189
|
+
return p;
|
|
190
|
+
}
|
|
191
|
+
/** File suffixes recognised as text-input candidates for skills. */
|
|
192
|
+
const TEXT_SUFFIXES = ['.txt', '.md'];
|
|
193
|
+
function isTextFilename(name) {
|
|
194
|
+
const lower = name.toLowerCase();
|
|
195
|
+
return TEXT_SUFFIXES.some((s) => lower.endsWith(s));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* List supported workspace files (.cspeach.json envelopes AND .txt/.md
|
|
199
|
+
* text inputs), parsed for a tiny metadata summary. Sorted by mtime
|
|
200
|
+
* descending (most-recent first).
|
|
201
|
+
*
|
|
202
|
+
* Errors on individual envelope files (corrupt JSON, missing fields)
|
|
203
|
+
* yield an `unknown` summary rather than throwing — callers see the
|
|
204
|
+
* file name with no metadata so the user can investigate.
|
|
205
|
+
*/
|
|
206
|
+
export async function listProjectFiles() {
|
|
207
|
+
const ws = await ensureWorkspace();
|
|
208
|
+
const entries = readdirSync(ws);
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const name of entries) {
|
|
211
|
+
const isEnvelope = name.endsWith('.cspeach.json');
|
|
212
|
+
const isText = isTextFilename(name);
|
|
213
|
+
if (!isEnvelope && !isText)
|
|
214
|
+
continue;
|
|
215
|
+
const path = join(ws, name);
|
|
216
|
+
const stat = statSync(path);
|
|
217
|
+
if (!stat.isFile())
|
|
218
|
+
continue;
|
|
219
|
+
if (isEnvelope) {
|
|
220
|
+
let artefactType = 'unknown';
|
|
221
|
+
let version = null;
|
|
222
|
+
let title = null;
|
|
223
|
+
try {
|
|
224
|
+
const env = JSON.parse(readFileSync(path, 'utf8'));
|
|
225
|
+
if (env.artefactType)
|
|
226
|
+
artefactType = env.artefactType;
|
|
227
|
+
if (typeof env.version === 'number')
|
|
228
|
+
version = env.version;
|
|
229
|
+
if (typeof env.title === 'string')
|
|
230
|
+
title = env.title;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// leave defaults; the caller still sees the file
|
|
234
|
+
}
|
|
235
|
+
out.push({
|
|
236
|
+
path,
|
|
237
|
+
filename: name,
|
|
238
|
+
kind: 'envelope',
|
|
239
|
+
artefactType,
|
|
240
|
+
version,
|
|
241
|
+
title,
|
|
242
|
+
sizeBytes: stat.size,
|
|
243
|
+
mtimeMs: stat.mtimeMs,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// text — no envelope metadata, just the file presence
|
|
248
|
+
out.push({
|
|
249
|
+
path,
|
|
250
|
+
filename: name,
|
|
251
|
+
kind: 'text',
|
|
252
|
+
artefactType: 'unknown',
|
|
253
|
+
version: null,
|
|
254
|
+
title: null,
|
|
255
|
+
sizeBytes: stat.size,
|
|
256
|
+
mtimeMs: stat.mtimeMs,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Inspect a `design` envelope and decide whether the design describes a
|
|
265
|
+
* RAP stack (Behavior Definition / Behavior Pool / Service Binding) or a
|
|
266
|
+
* classic build (report, class, function module). Used by the /files
|
|
267
|
+
* action menu and the /abap-design chain prompt to recommend the right
|
|
268
|
+
* BUILD skill — /abap-rap for RAP-shaped designs, /abap-generate for
|
|
269
|
+
* classic.
|
|
270
|
+
*
|
|
271
|
+
* Detection is conservative: a single RAP marker in any object's `type`
|
|
272
|
+
* field flags the whole design. The match is case-insensitive substring
|
|
273
|
+
* against the labels /abap-design produces (e.g. "BDEF", "Behavior
|
|
274
|
+
* Definition", "Service Binding"). Returns 'unknown' on any read/parse
|
|
275
|
+
* error so callers fall back to a neutral default ordering.
|
|
276
|
+
*/
|
|
277
|
+
export function detectDesignShape(envelopePath) {
|
|
278
|
+
let objects;
|
|
279
|
+
try {
|
|
280
|
+
const env = JSON.parse(readFileSync(envelopePath, 'utf8'));
|
|
281
|
+
objects = env.content?.objects;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return 'unknown';
|
|
285
|
+
}
|
|
286
|
+
if (!Array.isArray(objects) || objects.length === 0)
|
|
287
|
+
return 'unknown';
|
|
288
|
+
const RAP_MARKERS = [
|
|
289
|
+
'behavior definition', 'bdef',
|
|
290
|
+
'behavior implementation', 'behavior pool',
|
|
291
|
+
'service binding', 'srvb',
|
|
292
|
+
'service definition', 'srvd',
|
|
293
|
+
];
|
|
294
|
+
const hasRap = objects.some((o) => {
|
|
295
|
+
const type = String(o?.type ?? '').toLowerCase();
|
|
296
|
+
return RAP_MARKERS.some((m) => type.includes(m));
|
|
297
|
+
});
|
|
298
|
+
return hasRap ? 'rap' : 'classic';
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Resolve a CLI `@<token>` argument:
|
|
302
|
+
* - absolute path → returned verbatim (no workspace lookup)
|
|
303
|
+
* - relative path (starts with ./, ../, or contains a path separator)
|
|
304
|
+
* → resolved against `cwd`
|
|
305
|
+
* - bare name → searched in the workspace folder. Substring match
|
|
306
|
+
* on filename. Single match wins; multiple → ambiguous;
|
|
307
|
+
* none → notFound.
|
|
308
|
+
*
|
|
309
|
+
* Substring rather than prefix so the user can type any unique fragment of
|
|
310
|
+
* a long auto-generated filename (e.g. `@b9b0` finds
|
|
311
|
+
* `sales-order-status-dashboard-spec-gap-b9b0-v2.cspeach.json`).
|
|
312
|
+
*
|
|
313
|
+
* Pure function except for the workspace-listing read; all other I/O lives
|
|
314
|
+
* in the caller. Easy to unit-test by injecting a fake `files` list.
|
|
315
|
+
*/
|
|
316
|
+
export function resolveAtToken(args) {
|
|
317
|
+
const { token, cwd, workspace, files } = args;
|
|
318
|
+
if (isAbsolute(token)) {
|
|
319
|
+
return { kind: 'path', path: token };
|
|
320
|
+
}
|
|
321
|
+
if (token.startsWith('./') || token.startsWith('../') || token.startsWith('.\\') || token.startsWith('..\\')) {
|
|
322
|
+
return { kind: 'path', path: join(cwd, token) };
|
|
323
|
+
}
|
|
324
|
+
if (token.includes('/') || token.includes('\\')) {
|
|
325
|
+
// Other relative paths (no leading ./) — resolve against cwd, like a shell.
|
|
326
|
+
return { kind: 'path', path: join(cwd, token) };
|
|
327
|
+
}
|
|
328
|
+
// Bare name — workspace lookup.
|
|
329
|
+
const lower = token.toLowerCase();
|
|
330
|
+
const matches = files.filter((f) => f.filename.toLowerCase().includes(lower));
|
|
331
|
+
if (matches.length === 1) {
|
|
332
|
+
return { kind: 'path', path: matches[0].path };
|
|
333
|
+
}
|
|
334
|
+
if (matches.length === 0) {
|
|
335
|
+
return { kind: 'notFound', token, workspace };
|
|
336
|
+
}
|
|
337
|
+
return { kind: 'ambiguous', matches };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* High-level resolver — combines listProjectFiles + resolveAtToken into a
|
|
341
|
+
* single async call. Callers in promote-command, status, etc. use this.
|
|
342
|
+
*/
|
|
343
|
+
export async function resolveAtTokenAsync(token, cwd) {
|
|
344
|
+
const workspace = await ensureWorkspace();
|
|
345
|
+
const files = await listProjectFiles();
|
|
346
|
+
return resolveAtToken({ token, cwd, workspace, files });
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Format `listProjectFiles` output as a human-readable block for the
|
|
350
|
+
* `/files` REPL command and the ambiguous-match error path.
|
|
351
|
+
*
|
|
352
|
+
* Columns: filename, artefactType, version, last-modified relative time.
|
|
353
|
+
* No headers — keeps the output dense and copy-pasteable.
|
|
354
|
+
*/
|
|
355
|
+
export function formatProjectFileList(files, workspace) {
|
|
356
|
+
if (files.length === 0) {
|
|
357
|
+
return `Workspace: ${workspace}\n (empty — drop .cspeach.json envelopes or .txt/.md spec docs here)`;
|
|
358
|
+
}
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
const lines = [`Workspace: ${workspace}`, ''];
|
|
361
|
+
for (const f of files) {
|
|
362
|
+
const ageStr = relativeTime(now - f.mtimeMs);
|
|
363
|
+
const meta = f.kind === 'envelope'
|
|
364
|
+
? `${f.artefactType.padEnd(9)} v${f.version ?? '?'}`
|
|
365
|
+
: `text ${formatBytes(f.sizeBytes).padStart(7)}`;
|
|
366
|
+
lines.push(` ${f.filename} — ${meta} ${ageStr}`);
|
|
367
|
+
}
|
|
368
|
+
lines.push('');
|
|
369
|
+
lines.push(`${files.length} file(s). Use @<partial-name> to load — e.g. @${shortHint(files[0].filename)}`);
|
|
370
|
+
return lines.join('\n');
|
|
371
|
+
}
|
|
372
|
+
function formatBytes(n) {
|
|
373
|
+
if (n < 1024)
|
|
374
|
+
return `${n} B`;
|
|
375
|
+
if (n < 1024 * 1024)
|
|
376
|
+
return `${Math.round(n / 1024)} KB`;
|
|
377
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
378
|
+
}
|
|
379
|
+
function relativeTime(deltaMs) {
|
|
380
|
+
const m = Math.round(deltaMs / 60_000);
|
|
381
|
+
if (m < 1)
|
|
382
|
+
return 'just now';
|
|
383
|
+
if (m < 60)
|
|
384
|
+
return `${m} minute${m === 1 ? '' : 's'} ago`;
|
|
385
|
+
const h = Math.round(m / 60);
|
|
386
|
+
if (h < 24)
|
|
387
|
+
return `${h} hour${h === 1 ? '' : 's'} ago`;
|
|
388
|
+
const d = Math.round(h / 24);
|
|
389
|
+
return `${d} day${d === 1 ? '' : 's'} ago`;
|
|
390
|
+
}
|
|
391
|
+
function shortHint(filename) {
|
|
392
|
+
// First word of the filename (everything before the first hyphen) is
|
|
393
|
+
// usually a strong unique fragment.
|
|
394
|
+
const m = /^([^-]+)/.exec(filename);
|
|
395
|
+
return m ? m[1] : filename.slice(0, 8);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Cap on text-file content the model receives. Long text files would
|
|
399
|
+
* blow the model's context budget and bury the user's actual prompt.
|
|
400
|
+
* 64 KB is generous for typical requirement docs (a 20-page Word
|
|
401
|
+
* conversion clocks in around 30 KB), and chops cleanly on a UTF-8
|
|
402
|
+
* char boundary because we slice the decoded string, not the bytes.
|
|
403
|
+
*/
|
|
404
|
+
const MAX_TEXT_FILE_CHARS = 64 * 1024;
|
|
405
|
+
/**
|
|
406
|
+
* Scan `userMessage` for `@<token>` references that resolve to .txt /
|
|
407
|
+
* .md files in the workspace, read their content, and substitute the
|
|
408
|
+
* token with an `<attached file="...">` block containing the file
|
|
409
|
+
* body. Returns the expanded message.
|
|
410
|
+
*
|
|
411
|
+
* Tokens that resolve to .cspeach.json envelopes are LEFT ALONE —
|
|
412
|
+
* those are handled by the --from / --status code paths and substitute
|
|
413
|
+
* here would double-handle them. Tokens that don't resolve to any
|
|
414
|
+
* file in the workspace are also left alone (treated as literal text)
|
|
415
|
+
* with a warning emitted via the optional `log` callback.
|
|
416
|
+
*
|
|
417
|
+
* Used by runTurn before the user message hits the LLM, so any text
|
|
418
|
+
* file the user references gets ingested as input context.
|
|
419
|
+
*/
|
|
420
|
+
export async function expandTextFileAttachments(userMessage, cwd, log) {
|
|
421
|
+
// Match @<word> tokens. Same regex as parseFromFlag's anchor but
|
|
422
|
+
// we keep it global to find every occurrence.
|
|
423
|
+
const tokenRe = /@(?:"([^"]+)"|(\S+))/g;
|
|
424
|
+
const matches = [];
|
|
425
|
+
let m;
|
|
426
|
+
while ((m = tokenRe.exec(userMessage)) !== null) {
|
|
427
|
+
const token = m[1] ?? m[2] ?? '';
|
|
428
|
+
if (token.length === 0)
|
|
429
|
+
continue;
|
|
430
|
+
matches.push({ match: m[0], token, index: m.index });
|
|
431
|
+
}
|
|
432
|
+
if (matches.length === 0)
|
|
433
|
+
return userMessage;
|
|
434
|
+
// Resolve each token; only act on those pointing to .txt / .md files
|
|
435
|
+
// that exist on disk. Build a list of substitutions to apply.
|
|
436
|
+
const substitutions = [];
|
|
437
|
+
for (const { match, token, index } of matches) {
|
|
438
|
+
const resolved = await resolveAtTokenAsync(token, cwd);
|
|
439
|
+
if (resolved.kind !== 'path')
|
|
440
|
+
continue;
|
|
441
|
+
const path = resolved.path;
|
|
442
|
+
if (!isTextFilename(path))
|
|
443
|
+
continue;
|
|
444
|
+
// Only ingest if the file exists. Missing files are just left as
|
|
445
|
+
// literal @<token> in the message (the LLM gets to see them too).
|
|
446
|
+
if (!existsSync(path)) {
|
|
447
|
+
if (log)
|
|
448
|
+
log(`(attached @${token} not found in workspace — left as literal)`);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
let content;
|
|
452
|
+
try {
|
|
453
|
+
content = readFileSync(path, 'utf8');
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
if (log)
|
|
457
|
+
log(`(could not read @${token}: ${err.message})`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (content.length > MAX_TEXT_FILE_CHARS) {
|
|
461
|
+
content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n... [truncated, file exceeds 64 KB]';
|
|
462
|
+
}
|
|
463
|
+
const filename = path.split(/[\\\/]/).pop() ?? token;
|
|
464
|
+
const replacement = `<attached file="${filename}">\n${content}\n</attached>`;
|
|
465
|
+
substitutions.push({ index, matchLen: match.length, replacement });
|
|
466
|
+
if (log)
|
|
467
|
+
log(`(attached ${filename} — ${formatBytes(Buffer.byteLength(content, 'utf8'))})`);
|
|
468
|
+
}
|
|
469
|
+
if (substitutions.length === 0)
|
|
470
|
+
return userMessage;
|
|
471
|
+
// Apply substitutions back-to-front so earlier indices stay valid.
|
|
472
|
+
substitutions.sort((a, b) => b.index - a.index);
|
|
473
|
+
let out = userMessage;
|
|
474
|
+
for (const s of substitutions) {
|
|
475
|
+
out = out.slice(0, s.index) + s.replacement + out.slice(s.index + s.matchLen);
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\abap-inline.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whole-text syntax-highlight pass for fenced code blocks.
|
|
4
|
+
*
|
|
5
|
+
* v0.1.1 shipped this file as a regex-based ABAP-only highlighter
|
|
6
|
+
* (function `applyAbapHighlight`). v0.2 extends it to dispatch by language:
|
|
7
|
+
* ```abap → existing ABAP regex logic (unchanged)
|
|
8
|
+
* ```cds → cli-highlight CDS grammar (via syntax.ts)
|
|
9
|
+
* ```bdef → cli-highlight BDEF grammar (via syntax.ts)
|
|
10
|
+
* ```<any other or empty> → pass through (no highlight)
|
|
11
|
+
*
|
|
12
|
+
* The function is renamed `applyCodeHighlight`. A deprecation re-export
|
|
13
|
+
* `applyAbapHighlight` is kept because `pipeline.ts` currently imports that
|
|
14
|
+
* name; removing the re-export would require an edit to pipeline.ts, which
|
|
15
|
+
* is out of scope for this task.
|
|
16
|
+
*/
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { highlightCodeBlock } from './syntax.js';
|
|
19
|
+
const ABAP_KEYWORDS = new Set([
|
|
20
|
+
'DATA', 'TYPES', 'CONSTANTS', 'CLASS', 'INTERFACE', 'METHOD', 'ENDMETHOD',
|
|
21
|
+
'ENDCLASS', 'ENDINTERFACE', 'IF', 'ELSE', 'ELSEIF', 'ENDIF', 'LOOP',
|
|
22
|
+
'ENDLOOP', 'DO', 'ENDDO', 'WHILE', 'ENDWHILE', 'TRY', 'CATCH', 'CLEANUP',
|
|
23
|
+
'ENDTRY', 'RAISE', 'SELECT', 'FROM', 'WHERE', 'INTO', 'TABLE', 'FOR',
|
|
24
|
+
'ALL', 'ENTRIES', 'IN', 'CALL', 'FUNCTION', 'METHOD', 'PERFORMING',
|
|
25
|
+
'PERFORM', 'WRITE', 'MOVE', 'APPEND', 'READ', 'DELETE', 'INSERT',
|
|
26
|
+
'MODIFY', 'ASSIGN', 'UNASSIGN', 'CHECK', 'RETURN', 'EXIT', 'CONTINUE',
|
|
27
|
+
'CASE', 'WHEN', 'ENDCASE', 'NEW', 'CREATE', 'OBJECT', 'FIELD-SYMBOLS',
|
|
28
|
+
'TYPE', 'REF', 'TO', 'IMPORTING', 'EXPORTING', 'CHANGING', 'RETURNING',
|
|
29
|
+
'EXCEPTIONS', 'PUBLIC', 'PROTECTED', 'PRIVATE', 'SECTION', 'DEFINITION',
|
|
30
|
+
'IMPLEMENTATION', 'INHERITING', 'INTERFACES', 'ALIASES', 'ABSTRACT',
|
|
31
|
+
'FINAL', 'STATIC', 'REDEFINITION', 'AND', 'OR', 'NOT', 'IS', 'INITIAL',
|
|
32
|
+
'BOUND', 'INSTANCE', 'LOCAL', 'FRIENDS', 'DEFERRED',
|
|
33
|
+
]);
|
|
34
|
+
// Capture the language tag on the opening fence (was: /^```(abap)?(\s*)$/i).
|
|
35
|
+
// Now accepts any alphanumeric/_/- lang slug and makes it available as m[1].
|
|
36
|
+
const OPEN_FENCE_RE = /^```([a-z0-9_-]*)\s*$/i;
|
|
37
|
+
const CLOSE_FENCE_RE = /^```\s*$/;
|
|
38
|
+
/** Whole-text pass. Splits by fenced blocks; routes each block by lang. */
|
|
39
|
+
export function applyCodeHighlight(text) {
|
|
40
|
+
const lines = text.split('\n');
|
|
41
|
+
const out = [];
|
|
42
|
+
let inFence = false;
|
|
43
|
+
let lang = '';
|
|
44
|
+
let blockLines = [];
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
if (!inFence) {
|
|
47
|
+
const m = OPEN_FENCE_RE.exec(line);
|
|
48
|
+
if (m) {
|
|
49
|
+
inFence = true;
|
|
50
|
+
lang = (m[1] ?? '').toLowerCase();
|
|
51
|
+
blockLines = [];
|
|
52
|
+
out.push(line);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
out.push(line);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// inFence === true
|
|
59
|
+
if (CLOSE_FENCE_RE.test(line)) {
|
|
60
|
+
const body = blockLines.join('\n');
|
|
61
|
+
if (lang === 'abap') {
|
|
62
|
+
// Preserve v0.1.1 regex-based highlighter for ABAP — it is stable,
|
|
63
|
+
// has been tested in production, and avoids a second runtime
|
|
64
|
+
// dependency for the hottest fenced-block type.
|
|
65
|
+
out.push(highlightAbapBlock(body));
|
|
66
|
+
}
|
|
67
|
+
else if (lang === 'cds' || lang === 'bdef') {
|
|
68
|
+
// cli-highlight grammars via the Task 5 dispatcher.
|
|
69
|
+
out.push(highlightCodeBlock(body, lang));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Unknown / empty lang → pass through unchanged.
|
|
73
|
+
out.push(body);
|
|
74
|
+
}
|
|
75
|
+
out.push(line);
|
|
76
|
+
inFence = false;
|
|
77
|
+
lang = '';
|
|
78
|
+
blockLines = [];
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
blockLines.push(line);
|
|
82
|
+
}
|
|
83
|
+
// Unterminated fence at EOF — emit what we buffered without highlighting.
|
|
84
|
+
if (inFence && blockLines.length > 0) {
|
|
85
|
+
out.push(blockLines.join('\n'));
|
|
86
|
+
}
|
|
87
|
+
return out.join('\n');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Deprecated name kept for pipeline.ts compatibility. New call sites should
|
|
91
|
+
* import `applyCodeHighlight` directly.
|
|
92
|
+
*/
|
|
93
|
+
export { applyCodeHighlight as applyAbapHighlight };
|
|
94
|
+
function highlightAbapBlock(block) {
|
|
95
|
+
return block.split('\n').map(highlightLine).join('\n');
|
|
96
|
+
}
|
|
97
|
+
function highlightLine(line) {
|
|
98
|
+
// Comment: full line starting with * or " (ABAP inline comment)
|
|
99
|
+
const trimmed = line.trimStart();
|
|
100
|
+
if (trimmed.startsWith('*') || trimmed.startsWith('"')) {
|
|
101
|
+
return chalk.gray(line);
|
|
102
|
+
}
|
|
103
|
+
// Tokenise: strings first (to avoid coloring contents), then the rest.
|
|
104
|
+
// Z/Y prefix heuristic: matches any word starting with Z or Y — case-insensitive
|
|
105
|
+
// on the prefix letter because modern ABAP (Eclipse ADT / Clean ABAP) is typically
|
|
106
|
+
// lowercase (`zcl_my_class`) while legacy tooling uses UPPERCASE (`ZCL_MY_CLASS`).
|
|
107
|
+
// The `\w+` tail accepts either case. May false-positive on English words like
|
|
108
|
+
// "Yes", "Zebra" if they appear as bare identifiers — acceptable trade-off for
|
|
109
|
+
// zero-config highlighting.
|
|
110
|
+
return line.replace(/('(?:[^']|'')*')|(\b[ZYzy]\w+)|(\b(?:0x[\da-fA-F]+|\d+(?:\.\d+)?)\b)|(\b[A-Z_][A-Z0-9_-]*\b)/g, (match, str, zy, num, word) => {
|
|
111
|
+
if (str !== undefined)
|
|
112
|
+
return chalk.green(str);
|
|
113
|
+
if (zy !== undefined)
|
|
114
|
+
return chalk.yellow(match);
|
|
115
|
+
if (num !== undefined)
|
|
116
|
+
return chalk.magenta(match);
|
|
117
|
+
if (word !== undefined && ABAP_KEYWORDS.has(word.toUpperCase()))
|
|
118
|
+
return chalk.cyan(match);
|
|
119
|
+
return match;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
/** CSPeach brand colour — used for the brand-name text in the startup line,
|
|
3
|
+
* the tool-dispatch bullet (⏺), and the result continuation glyph (⎿).
|
|
4
|
+
* Single source of truth so a future rebrand is one find-replace.
|
|
5
|
+
* The startup banner uses the 🍑 emoji directly (no tint) for identity. */
|
|
6
|
+
export const PEACH = '#FFCBA4';
|
|
7
|
+
/**
|
|
8
|
+
* Minimal startup line — terse, single bullet, three lines max. Modeled on
|
|
9
|
+
* Claude Code's `✻ Welcome to Claude Code v1.0.x` pattern but with the
|
|
10
|
+
* CSPeach brand glyph (Georgia peach emoji) replacing the generic star.
|
|
11
|
+
* No box, no fanfare — get out of the way.
|
|
12
|
+
*
|
|
13
|
+
* Renders:
|
|
14
|
+
* 🍑 CSPeach v0.5.0
|
|
15
|
+
* ABAP + AI + safety gates · by Cremencing Solutions
|
|
16
|
+
*
|
|
17
|
+
* Note on indent — the 🍑 emoji renders as 2 cells in modern terminals
|
|
18
|
+
* (Windows Terminal, iTerm2, Git Bash). Line 2 uses 3 leading spaces so
|
|
19
|
+
* the tagline column-aligns with the brand-name letters above. The emoji
|
|
20
|
+
* is intentionally NOT chalk-colored — terminals render emoji with their
|
|
21
|
+
* native color glyph and applying a tint usually corrupts that.
|
|
22
|
+
*
|
|
23
|
+
* The fallback literal is the published version — bumped here whenever
|
|
24
|
+
* the CLI is released. npm_package_version is set when the binary runs
|
|
25
|
+
* via `npm exec` / `pnpm`, but unset for direct `cspeach` shim launches,
|
|
26
|
+
* which is why the literal exists at all.
|
|
27
|
+
*/
|
|
28
|
+
export function printStartupBanner() {
|
|
29
|
+
const version = process.env.npm_package_version ?? '0.5.0';
|
|
30
|
+
console.log('');
|
|
31
|
+
console.log(`🍑 ${chalk.hex(PEACH).bold('CSPeach')} ${chalk.white.bold('v' + version)}`);
|
|
32
|
+
console.log(` ${chalk.gray('ABAP + AI + safety gates')} ${chalk.dim('·')} ${chalk.gray('by Cremencing Solutions')}`);
|
|
33
|
+
console.log('');
|
|
34
|
+
}
|
|
35
|
+
export function printKeytarFallbackBanner() {
|
|
36
|
+
console.log(chalk.yellow('⚠ OS keychain unavailable on this system.'));
|
|
37
|
+
console.log(chalk.yellow(' API key + SAP passwords will be prompted per session.'));
|
|
38
|
+
console.log(chalk.yellow(' Run `cspeach doctor` for remediation hints.\n'));
|
|
39
|
+
}
|