@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,77 @@
1
+ import { select } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import { listSessions, loadSession } from './store.js';
4
+ import { formatTimeAgo } from './time-ago.js';
5
+ import { runRepl } from '../repl.js';
6
+ export async function runResume(id) {
7
+ let targetId = id;
8
+ if (!targetId) {
9
+ const sessions = await listSessions(20);
10
+ if (sessions.length === 0) {
11
+ console.log('No recent sessions.');
12
+ return 0;
13
+ }
14
+ // Promote interrupted sessions to the top of the list — they're the
15
+ // ones the user is most likely looking for after a crash.
16
+ const sorted = [...sessions].sort((a, b) => {
17
+ if (a.interrupted !== b.interrupted)
18
+ return a.interrupted ? -1 : 1;
19
+ return b.lastTurnAt.localeCompare(a.lastTurnAt);
20
+ });
21
+ targetId = await select({
22
+ message: 'Pick a session to resume:',
23
+ choices: sorted.map((s) => {
24
+ const flag = s.interrupted ? chalk.yellow('⚠ ') : ' ';
25
+ const idCol = chalk.cyan(s.id.padEnd(8));
26
+ const timeCol = chalk.gray(formatTimeAgo(s.lastTurnAt).padEnd(10));
27
+ const sapCol = chalk.gray((s.sapSystem ?? '-').padEnd(8));
28
+ const skillCol = chalk.gray(`/${s.skill ?? '-'}`.padEnd(22));
29
+ const turns = chalk.gray(`${s.userTurnCount}t`.padStart(3));
30
+ const snippet = s.promptSnippet ?? chalk.dim('(no prompt)');
31
+ return {
32
+ value: s.id,
33
+ name: `${flag}${idCol} ${timeCol} ${sapCol} ${skillCol} ${turns} ${snippet}`,
34
+ description: s.interrupted
35
+ ? `Interrupted (${s.interruptedReason ?? 'unknown'}) — type "continue" after resume`
36
+ : `${s.userTurnCount} prior turn(s) — pick up from where you left off`,
37
+ };
38
+ }),
39
+ });
40
+ }
41
+ let session;
42
+ try {
43
+ session = await loadSession(targetId);
44
+ }
45
+ catch (err) {
46
+ console.error(chalk.red(`\n[error] ${err.message}\n`));
47
+ return 1;
48
+ }
49
+ const unfinished = session.toolCalls.filter((c) => !c.completed);
50
+ if (unfinished.length > 0) {
51
+ console.log(chalk.yellow(`\n⚠ ${unfinished.length} pending mutation(s) from the previous session:\n`));
52
+ for (const u of unfinished) {
53
+ console.log(` ${u.tool} ${JSON.stringify(u.args)}`);
54
+ }
55
+ const action = await select({
56
+ message: 'Reconcile how?',
57
+ choices: [
58
+ { value: 'reconcile', name: 'Query SAP to find out what actually happened (requires SAP reachable)' },
59
+ { value: 'continue', name: 'Leave as unresolved, continue (risky)' },
60
+ { value: 'abandon', name: 'Abandon this session (file is kept on disk)' },
61
+ ],
62
+ });
63
+ if (action === 'abandon')
64
+ return 0;
65
+ if (action === 'reconcile') {
66
+ console.log(chalk.gray('Reconciliation queries will run on the first turn of the resumed session.'));
67
+ console.log(chalk.gray('If SAP is unreachable, the pending entries remain and you can retry later.'));
68
+ }
69
+ }
70
+ // v0.6 — actually feed the loaded SessionState into the REPL so the agent
71
+ // loop sees prior messages, usage counters, and the turnInterrupted flag.
72
+ // Previously this dropped into a fresh REPL and only the on-disk file was
73
+ // kept (a stub from v0.1). Now resume genuinely picks up where the prior
74
+ // turn left off — including mid-turn-interrupted ones recoverable thanks
75
+ // to the persistence work in agent/loop.ts.
76
+ return runRepl({ resumedSession: session });
77
+ }
@@ -0,0 +1,20 @@
1
+ export const SCHEMA_VERSION = 1;
2
+ export function newSession(id, sapSystem, skill, model) {
3
+ const now = new Date().toISOString();
4
+ return {
5
+ schema_version: SCHEMA_VERSION,
6
+ id,
7
+ started_at: now,
8
+ last_turn_at: now,
9
+ sap_system: sapSystem,
10
+ skill,
11
+ model,
12
+ messages: [],
13
+ toolCalls: [],
14
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 },
15
+ last_skill: null,
16
+ last_object: null,
17
+ lastUserPrompt: null,
18
+ awaitingSkillAnswer: false,
19
+ };
20
+ }
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
5
+ import { SCHEMA_VERSION } from './schema.js';
6
+ export function makeSessionId() {
7
+ return crypto.randomBytes(4).toString('hex');
8
+ }
9
+ function sessionPath(id, startedAt) {
10
+ const dir = sessionsDir();
11
+ const stamp = startedAt.replace(/[:.]/g, '-');
12
+ return path.join(dir, `${stamp}-${id}.json`);
13
+ }
14
+ export async function saveSession(session) {
15
+ const dir = sessionsDir();
16
+ await fs.mkdir(dir, { recursive: true });
17
+ const filePath = sessionPath(session.id, session.started_at);
18
+ const tmp = filePath + '.tmp';
19
+ await fs.writeFile(tmp, JSON.stringify(session, null, 2), 'utf-8');
20
+ await fs.rename(tmp, filePath);
21
+ }
22
+ export async function loadSession(id) {
23
+ const dir = sessionsDir();
24
+ const files = await fs.readdir(dir).catch(() => []);
25
+ const match = files.find((f) => f.endsWith(`-${id}.json`));
26
+ if (!match) {
27
+ // Try pinned dir too
28
+ const pinnedDir = pinnedSessionsDir();
29
+ const pinned = await fs.readdir(pinnedDir).catch(() => []);
30
+ const pinnedMatch = pinned.find((f) => f.endsWith(`-${id}.json`));
31
+ if (!pinnedMatch)
32
+ throw new Error(`Session not found: ${id}`);
33
+ const raw = await fs.readFile(path.join(pinnedDir, pinnedMatch), 'utf-8');
34
+ return validateSchema(JSON.parse(raw));
35
+ }
36
+ const raw = await fs.readFile(path.join(dir, match), 'utf-8');
37
+ return validateSchema(JSON.parse(raw));
38
+ }
39
+ function validateSchema(parsed) {
40
+ if (parsed.schema_version > SCHEMA_VERSION) {
41
+ throw new Error(`SCHEMA_VERSION_MISMATCH: session v${parsed.schema_version} is newer than this CLI (v${SCHEMA_VERSION}). Run: cspeach --upgrade`);
42
+ }
43
+ if (parsed.schema_version < SCHEMA_VERSION) {
44
+ throw new Error(`Session schema is older (v${parsed.schema_version}). Auto-migration not yet implemented. Run: cspeach session export ${parsed.id} | wipe`);
45
+ }
46
+ // v0.3.1: `lastUserPrompt` added as a required field but older session
47
+ // files saved at this same SCHEMA_VERSION don't have it. Default
48
+ // undefined → null so the type contract (string | null) holds.
49
+ parsed.lastUserPrompt ??= null;
50
+ // 2026-05-01: heal sessions corrupted by interrupted streaming. A
51
+ // tool_use block that still has `partial_json` was mid-stream when the
52
+ // turn ended; replaying it to the API fails with HTTP 400
53
+ // "messages.N.content.M.tool_use.partial_json: Extra inputs are not
54
+ // permitted". Strip the field on load and parse it into `input` so the
55
+ // session becomes usable again instead of erroring on every next turn.
56
+ for (const msg of parsed.messages ?? []) {
57
+ if (!Array.isArray(msg.content))
58
+ continue;
59
+ for (const block of msg.content) {
60
+ if (block?.type === 'tool_use' && typeof block.partial_json === 'string') {
61
+ if (block.input === undefined) {
62
+ try {
63
+ block.input = JSON.parse(block.partial_json);
64
+ }
65
+ catch {
66
+ block.input = {};
67
+ }
68
+ }
69
+ delete block.partial_json;
70
+ }
71
+ }
72
+ }
73
+ return parsed;
74
+ }
75
+ export async function listSessions(limit = 10) {
76
+ const dir = sessionsDir();
77
+ const files = await fs.readdir(dir).catch(() => []);
78
+ const results = [];
79
+ for (const f of files) {
80
+ if (!f.endsWith('.json'))
81
+ continue;
82
+ try {
83
+ const raw = await fs.readFile(path.join(dir, f), 'utf-8');
84
+ const parsed = JSON.parse(raw);
85
+ const messages = parsed.messages ?? [];
86
+ // First user message that's a plain string (not a tool_result array) — the
87
+ // most recent prompt text that initiated a turn. We pick the LAST such
88
+ // message because the picker wants the freshest turn's prompt, not the
89
+ // first one of the session.
90
+ let promptSnippet = null;
91
+ for (let i = messages.length - 1; i >= 0; i--) {
92
+ const m = messages[i];
93
+ if (m && m.role === 'user' && typeof m.content === 'string') {
94
+ // Strip <session_context>...</session_context> wrapper that the
95
+ // first turn injects, leaving only the user-typed prompt visible
96
+ // in the picker.
97
+ const stripped = m.content.replace(/<session_context>[\s\S]*?<\/session_context>\s*/g, '').trim();
98
+ promptSnippet = (stripped || '<empty>').slice(0, 80);
99
+ break;
100
+ }
101
+ }
102
+ const userTurnCount = messages.filter((m) => m?.role === 'user' && typeof m.content === 'string').length;
103
+ results.push({
104
+ id: parsed.id,
105
+ startedAt: parsed.started_at,
106
+ lastTurnAt: parsed.last_turn_at,
107
+ skill: parsed.skill,
108
+ sapSystem: parsed.sap_system,
109
+ filename: f,
110
+ interrupted: parsed.turnInterrupted === true,
111
+ interruptedReason: parsed.turnInterruptedReason ?? null,
112
+ promptSnippet,
113
+ userTurnCount,
114
+ });
115
+ }
116
+ catch { /* skip corrupt */ }
117
+ }
118
+ results.sort((a, b) => b.lastTurnAt.localeCompare(a.lastTurnAt));
119
+ return results.slice(0, limit);
120
+ }
121
+ /**
122
+ * Count interrupted sessions for the startup banner. Reads only enough of each
123
+ * file to find the turnInterrupted flag, so it scales to many sessions without
124
+ * a full parse + sort pass. Returns 0 on any IO failure (best-effort discovery).
125
+ */
126
+ export async function countInterruptedSessions() {
127
+ try {
128
+ const dir = sessionsDir();
129
+ const files = await fs.readdir(dir).catch(() => []);
130
+ let count = 0;
131
+ for (const f of files) {
132
+ if (!f.endsWith('.json'))
133
+ continue;
134
+ try {
135
+ const raw = await fs.readFile(path.join(dir, f), 'utf-8');
136
+ const parsed = JSON.parse(raw);
137
+ if (parsed.turnInterrupted === true)
138
+ count++;
139
+ }
140
+ catch { /* skip corrupt */ }
141
+ }
142
+ return count;
143
+ }
144
+ catch {
145
+ return 0;
146
+ }
147
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Compact "time ago" formatter for session-picker rows.
3
+ *
4
+ * Produces strings like:
5
+ * "12s ago"
6
+ * "5m ago"
7
+ * "3h ago"
8
+ * "yesterday"
9
+ * "3d ago"
10
+ * "2w ago"
11
+ *
12
+ * Output stays under 12 characters so it fits cleanly in a fixed-width column.
13
+ */
14
+ export function formatTimeAgo(then, now = new Date()) {
15
+ const t = typeof then === 'string' ? new Date(then) : then;
16
+ if (isNaN(t.getTime()))
17
+ return '?';
18
+ const diffMs = Math.max(0, now.getTime() - t.getTime());
19
+ const sec = Math.floor(diffMs / 1000);
20
+ if (sec < 60)
21
+ return `${sec}s ago`;
22
+ const min = Math.floor(sec / 60);
23
+ if (min < 60)
24
+ return `${min}m ago`;
25
+ const hr = Math.floor(min / 60);
26
+ if (hr < 24)
27
+ return `${hr}h ago`;
28
+ // 24h–47h59m ago = "yesterday"; 2-6 days = "3d ago"; 7+ = "2w ago"
29
+ const day = Math.floor(hr / 24);
30
+ if (day === 1)
31
+ return 'yesterday';
32
+ if (day < 7)
33
+ return `${day}d ago`;
34
+ const wk = Math.floor(day / 7);
35
+ if (wk < 5)
36
+ return `${wk}w ago`;
37
+ const mo = Math.floor(day / 30);
38
+ if (mo < 12)
39
+ return `${mo}mo ago`;
40
+ return `${Math.floor(day / 365)}y ago`;
41
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Static skill catalog for CSPeach CLI v0.2.
3
+ *
4
+ * STALENESS RISK (spec §3.3): This constant is hardcoded against the 33 skills
5
+ * in .claude/skills/ as of 2026-04-19. It will drift if skills are added or
6
+ * removed. At Plan 4b kickoff decision the build-time generation option
7
+ * (scripts/generate-skill-catalog.ts reads SKILL.md frontmatter) or runtime
8
+ * fetch (/v1/skills endpoint) will be chosen to replace this file. Until then,
9
+ * update this file manually when the skill roster changes.
10
+ */
11
+ export const SKILL_CATALOG = [
12
+ // ── Read / Analyze ───────────────────────────────────────────────────────
13
+ {
14
+ name: 'abap-radar',
15
+ category: 'Read / Analyze',
16
+ description: 'Understand an ABAP object quickly — dependencies, tables, risk areas, modernisation summary',
17
+ whenToUse: 'pick this for a 3-min cold read of an object (not /abap-review — no quality grade)',
18
+ },
19
+ {
20
+ name: 'abap-impact',
21
+ category: 'Read / Analyze',
22
+ description: 'Impact analysis before changes — traces usage, callers, transports',
23
+ whenToUse: 'pick this for blast radius before a change (not /abap-radar — radar = inside the object)',
24
+ },
25
+ {
26
+ name: 'abap-explain',
27
+ category: 'Read / Analyze',
28
+ description: 'Plain-language explanation of ABAP code for juniors, non-ABAP developers, or architects',
29
+ whenToUse: `pick this to teach code to a human (not /abap-document — that's a dev reference doc)`,
30
+ },
31
+ {
32
+ name: 'abap-review',
33
+ category: 'Read / Analyze',
34
+ description: 'Code review against 7 quality criteria: conventions, performance, security, error handling, tests, cloud-readiness, comments',
35
+ whenToUse: `pick this to grade SOURCE against 7 criteria (not a release gate — see /abap-preflight)`,
36
+ },
37
+ {
38
+ name: 'abap-performance',
39
+ category: 'Read / Analyze',
40
+ description: 'Identify N+1 queries, missing indexes, SELECT *, FOR ALL ENTRIES pitfalls',
41
+ whenToUse: 'pick this for deep SQL/loop bottleneck hunt (not /abap-review — review only skims perf)',
42
+ },
43
+ {
44
+ name: 'abap-cloud-check',
45
+ category: 'Read / Analyze',
46
+ description: 'ABAP Cloud / Clean Core readiness — restricted APIs, tier violations, released object alternatives',
47
+ whenToUse: `pick this for ABAP Cloud verdict on one object (not /abap-cca — that's whole-system)`,
48
+ },
49
+ {
50
+ name: 'abap-handover',
51
+ category: 'Read / Analyze',
52
+ description: 'Generate technical documentation for ABAP objects or a complete development package',
53
+ whenToUse: `pick this for a whole-package handover dossier (not /abap-document — that's one object)`,
54
+ },
55
+ {
56
+ name: 'abap-document',
57
+ category: 'Read / Analyze',
58
+ description: 'Reference doc for ONE ABAP object — purpose, interface, logic flow, dependencies, where-used',
59
+ whenToUse: `pick this for a reference doc on ONE object (not /abap-handover — that's whole-package)`,
60
+ },
61
+ {
62
+ name: 'abap-dump-analyze',
63
+ category: 'Read / Analyze',
64
+ description: 'Analyze ABAP short dumps (ST22) — root cause, call stack, fix suggestions',
65
+ whenToUse: `pick this to diagnose an ST22 dump AND apply the fix (not /abap-dump-triage — read-only)`,
66
+ },
67
+ {
68
+ name: 'abap-dump-triage',
69
+ category: 'Read / Analyze',
70
+ description: 'Structured triage of a runtime dump — ranked hypotheses, what to inspect next, read-only',
71
+ whenToUse: `pick this for ranked dump hypotheses, read-only (not /abap-dump-analyze — that writes a fix)`,
72
+ },
73
+ // ── Modify / Fix ─────────────────────────────────────────────────────────
74
+ {
75
+ name: 'abap-atc-fix',
76
+ category: 'Modify / Fix',
77
+ description: 'Run ATC checks against an object and auto-fix findings with safety gates',
78
+ whenToUse: `pick this for ad-hoc ATC fix on one object (not /abap-upgrade-fix — that's worklist-driven)`,
79
+ },
80
+ {
81
+ name: 'abap-refactor',
82
+ category: 'Modify / Fix',
83
+ description: 'Refactor a Z object in place — FORM→method, SELECT *→specific fields, guard clauses, inline DATA. Same architecture, cleaner code.',
84
+ whenToUse: `pick this to clean up YOUR Z code, same architecture (not /abap-modernize — that rebuilds as RAP/Fiori)`,
85
+ },
86
+ {
87
+ name: 'abap-enhance',
88
+ category: 'Modify / Fix',
89
+ description: 'Extend SAP standard via BAdI implementations, enhancement spots, user exits',
90
+ whenToUse: `pick this to hook into SAP-STANDARD code via BAdI/exit (not /abap-refactor — that's for YOUR Z code)`,
91
+ },
92
+ {
93
+ name: 'abap-incident',
94
+ category: 'Modify / Fix',
95
+ description: 'End-to-end incident pipeline — dump to fix deployed to transport in one skill',
96
+ whenToUse: `pick this for full ST22-to-transport pipeline with formal report (not /abap-dump-analyze — single fix only)`,
97
+ },
98
+ {
99
+ name: 'abap-test',
100
+ category: 'Modify / Fix',
101
+ description: 'Generate ABAP Unit test classes for existing code with dependency injection',
102
+ whenToUse: `pick this to generate + run ABAP Unit tests (not /abap-eml — EML body only)`,
103
+ },
104
+ {
105
+ name: 'abap-eml',
106
+ category: 'Modify / Fix',
107
+ description: 'Write and test EML (Entity Manipulation Language) for RAP business objects',
108
+ whenToUse: `pick this for EML inside a RAP context (not /abap-test — that's the test class)`,
109
+ },
110
+ {
111
+ name: 'abap-segw',
112
+ category: 'Modify / Fix',
113
+ description: 'Implement SEGW OData DPC_EXT method bodies — reads generated MPC/DPC base classes. SEGW project itself created in transaction SEGW. For S/4 1909+ RAP services use /abap-rap.',
114
+ whenToUse: `pick this for DPC_EXT method bodies on an EXISTING SEGW project (ECC / older S/4; not /abap-rap — that's S/4 1909+ RAP; SEGW project + MPC/DPC must already exist)`,
115
+ },
116
+ // ── Create / Generate ────────────────────────────────────────────────────
117
+ {
118
+ name: 'abap-generate',
119
+ category: 'Create / Generate',
120
+ description: 'Generate ONE ABAP class / program / include from a clear scope — asks clarifying questions first per Forge Rule 1. For a full RAP stack use /abap-rap; for DDIC only use /abap-data-model.',
121
+ whenToUse: `pick this for ONE class/program/include from clear scope (not /abap-rap — that's a full RAP stack; not /abap-data-model — that's DDIC only)`,
122
+ },
123
+ {
124
+ name: 'abap-rap',
125
+ category: 'Create / Generate',
126
+ description: 'Scaffold a complete RAP stack on S/4HANA 1909+ — table, CDS interface, projection, BDEF, BP class, SRVD, SRVB, DDLX, DCL. ECC/older S/4 → use /abap-segw.',
127
+ whenToUse: `pick this for a full RAP stack on S/4 1909+ (10 objects, table → SRVB; not /abap-segw — that's ECC/older S/4 OData; not /abap-generate — that's one object)`,
128
+ },
129
+ {
130
+ name: 'abap-data-model',
131
+ category: 'Create / Generate',
132
+ description: 'Design and create DDIC objects — domains, data elements, tables, structures',
133
+ whenToUse: `pick this for DDIC ONLY — domain/data element/table/structure (step after /abap-design; embedded inside /abap-rap step 1 — call standalone when you don't need the rest of the stack)`,
134
+ },
135
+ {
136
+ name: 'abap-modernize',
137
+ category: 'Create / Generate',
138
+ description: 'Transform classic ABAP to the best modern architecture for the target release (ECC→SEGW, S/4→RAP)',
139
+ whenToUse: `pick this to rebuild a WRITE/dynpro/FM as Fiori/RAP (not /abap-upgrade-fix — that's syntax patches)`,
140
+ },
141
+ // ── Upgrade / Migrate ────────────────────────────────────────────────────
142
+ {
143
+ name: 'abap-cca',
144
+ category: 'Upgrade / Migrate',
145
+ description: 'Custom code analysis pipeline for S/4 upgrade — whole-estate inventory + tier classification + ATC findings + report. Multi-session project with state file.',
146
+ whenToUse: `pick this to discover + classify the whole custom estate (not /abap-migrate — that's per object)`,
147
+ },
148
+ {
149
+ name: 'abap-upgrade-scan',
150
+ category: 'Upgrade / Migrate',
151
+ description: 'Scan custom ABAP code for S/4HANA upgrade findings via ATC readiness variant',
152
+ whenToUse: 'pick this to baseline ATC for one package + S/4 release (step 1 of 3 — feeds /abap-upgrade-fix)',
153
+ },
154
+ {
155
+ name: 'abap-upgrade-fix',
156
+ category: 'Upgrade / Migrate',
157
+ description: 'AI-guided upgrade remediation — fixes ATC findings one object at a time with approval gates',
158
+ whenToUse: 'pick this to fix the upgrade-scan baseline object-by-object (step 2 of 3 — needs /abap-upgrade-scan first)',
159
+ },
160
+ {
161
+ name: 'abap-upgrade-verify',
162
+ category: 'Upgrade / Migrate',
163
+ description: 'Verify remediation results — re-runs ATC with same variant, compares before/after finding counts',
164
+ whenToUse: 'pick this for the customer sign-off report after fixing (step 3 of 3 — re-runs ATC vs baseline)',
165
+ },
166
+ {
167
+ name: 'abap-upgrade-merge',
168
+ category: 'Upgrade / Migrate',
169
+ description: 'Reconcile multiple parallel /abap-upgrade-fix progress files into one consolidated upgrade-progress',
170
+ whenToUse: 'pick this when a multi-dev team ran scoped fix slices in parallel — merges N progress files for /abap-upgrade-verify',
171
+ },
172
+ {
173
+ name: 'abap-cca-merge',
174
+ category: 'Upgrade / Migrate',
175
+ description: 'Reconcile multiple parallel /abap-cca assessment files into one consolidated cca-assessment',
176
+ whenToUse: 'pick this when multiple consultants ran scoped /abap-cca slices — merges N assessments into one team view',
177
+ },
178
+ {
179
+ name: 'abap-migrate',
180
+ category: 'Upgrade / Migrate',
181
+ description: 'ECC to S/4HANA migration assessment — API analysis, clean-core alignment, migration path',
182
+ whenToUse: `pick this to pick retire/replace/refactor per object (not /abap-cca — cca discovers them)`,
183
+ },
184
+ // ── Ship / Release ───────────────────────────────────────────────────────
185
+ {
186
+ name: 'abap-transport',
187
+ category: 'Ship / Release',
188
+ description: 'Transport management — create, list, validate, release transport requests',
189
+ whenToUse: `pick this to CREATE/LIST/RELEASE a TR (inspect-only? use /abap-transport-analysis)`,
190
+ },
191
+ {
192
+ name: 'abap-transport-analysis',
193
+ category: 'Ship / Release',
194
+ description: 'Pre-release transport analysis — objects, ATC scan, activation status, conflict check',
195
+ whenToUse: `pick this if you have a TR NUMBER and want a read-only safety scan`,
196
+ },
197
+ {
198
+ name: 'abap-preflight',
199
+ category: 'Ship / Release',
200
+ description: 'Pre-release checklist — ATC, syntax, inactive objects, transport conflicts',
201
+ whenToUse: `pick this for a full ship-readiness package on a CHANGE SET (no TR# needed)`,
202
+ },
203
+ // ── Pre-Coding ───────────────────────────────────────────────────────────
204
+ {
205
+ name: 'abap-spec-gap',
206
+ category: 'Pre-Coding',
207
+ description: 'Find missing requirements before coding — business questions, technical gaps, assumptions',
208
+ whenToUse: `pick this for missing business + technical questions on a vague spec (step 1 of 3 — feeds /abap-design)`,
209
+ },
210
+ {
211
+ name: 'abap-design',
212
+ category: 'Pre-Coding',
213
+ description: 'Solution architecture — object list, dependency sequence, patterns, transport strategy. Plans the build, does not generate code.',
214
+ whenToUse: `pick this for the object list + dependency sequence + pattern choices (step 2 of 3 — needs /abap-spec-gap done, feeds /abap-estimate or /abap-generate)`,
215
+ },
216
+ {
217
+ name: 'abap-estimate',
218
+ category: 'Pre-Coding',
219
+ description: 'Effort estimation for ABAP tickets — component breakdown with hour ranges (optimistic / realistic / pessimistic) + risk adjustments',
220
+ whenToUse: `pick this for hour ranges + risk adjustments on a confirmed object list (step 3 of 3 — needs /abap-design first; high uncertainty without it)`,
221
+ },
222
+ ];
@@ -0,0 +1 @@
1
+ export const BUNDLED_SKILLS = {};
@@ -0,0 +1,12 @@
1
+ // MIRROR of cspeach-proxy/src/skills/signatures.ts canonicalise().
2
+ // If you edit this file, edit the proxy copy identically and update the
3
+ // golden vector in ./__tests__/canonical.test.ts. Drift = "manifest signature
4
+ // invalid" for every non-managed user.
5
+ export function canonicalise(v) {
6
+ if (v === null || typeof v !== 'object')
7
+ return JSON.stringify(v);
8
+ if (Array.isArray(v))
9
+ return '[' + v.map(canonicalise).join(',') + ']';
10
+ const keys = Object.keys(v).sort();
11
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalise(v[k])).join(',') + '}';
12
+ }
@@ -0,0 +1,93 @@
1
+ import * as ed from '@noble/ed25519';
2
+ import { sha256 } from '@noble/hashes/sha256';
3
+ import { sha512 } from '@noble/hashes/sha512';
4
+ import { bytesToHex, concatBytes } from '@noble/hashes/utils';
5
+ import { MANIFEST_PUBLIC_KEYS } from './signing-public-key.js';
6
+ import { canonicalise } from './canonical.js';
7
+ // @noble/ed25519 v2 requires an ed25519 sha512 hook.
8
+ ed.etc.sha512Sync = (...m) => sha512(concatBytes(...m));
9
+ export class ManifestClient {
10
+ manifestUrl;
11
+ constructor(manifestUrl) {
12
+ this.manifestUrl = manifestUrl;
13
+ }
14
+ async fetchManifest() {
15
+ let r;
16
+ try {
17
+ r = await fetch(this.manifestUrl, { signal: AbortSignal.timeout(10_000) });
18
+ }
19
+ catch (e) {
20
+ throw new Error(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
21
+ }
22
+ if (r.status === 503) {
23
+ const body = await r.json().catch(() => ({}));
24
+ const raw = String(body.error ?? 'server says not ready');
25
+ // Cap length — an untrusted server could flood the error message.
26
+ const safe = raw.length > 200 ? raw.slice(0, 200) + '…' : raw;
27
+ throw new Error(`manifest not yet published (HTTP 503): ${safe}`);
28
+ }
29
+ if (!r.ok)
30
+ throw new Error(`manifest fetch: HTTP ${r.status} from ${this.manifestUrl}`);
31
+ return await r.json();
32
+ }
33
+ async fetchAndVerifyManifest() {
34
+ const m = await this.fetchManifest();
35
+ const { signature, ...payload } = m;
36
+ const canonical = canonicalise(payload);
37
+ const canonicalBytes = new TextEncoder().encode(canonical);
38
+ if (MANIFEST_PUBLIC_KEYS.length === 0) {
39
+ throw new Error('manifest signature invalid: no manifest public keys available — run `npm run prebuild` or set CSPEACH_MANIFEST_PUBKEY_CURRENT');
40
+ }
41
+ let sigBytes;
42
+ try {
43
+ sigBytes = base64ToBytes(signature);
44
+ }
45
+ catch (e) {
46
+ throw new Error(`manifest signature invalid: signature is not valid base64 (${e.message})`);
47
+ }
48
+ // ed.verifyAsync with zip215=false (strict). Mirrors the proxy side at
49
+ // cspeach-proxy/src/skills/signatures.ts so signer and verifier share the
50
+ // same verification primitive. Strict mode rejects malleable signatures
51
+ // and identity-point public keys — the conservative choice for a
52
+ // user-controlled verifier facing a potentially hostile distribution path.
53
+ for (const keyB64 of MANIFEST_PUBLIC_KEYS) {
54
+ try {
55
+ const pubKey = base64ToBytes(keyB64);
56
+ const valid = await ed.verifyAsync(sigBytes, canonicalBytes, pubKey, { zip215: false });
57
+ if (valid)
58
+ return m;
59
+ }
60
+ catch {
61
+ // fall through to next key or the final throw
62
+ }
63
+ }
64
+ throw new Error(`manifest signature invalid (tried ${MANIFEST_PUBLIC_KEYS.length} key(s); rotation window may have ended — upgrade CLI)`);
65
+ }
66
+ async fetchSkillBody(name, bodyUrl, opts) {
67
+ let r;
68
+ try {
69
+ r = await fetch(bodyUrl, { signal: AbortSignal.timeout(10_000) });
70
+ }
71
+ catch (e) {
72
+ throw new Error(`skill body fetch failed (${bodyUrl}): ${e.message}`);
73
+ }
74
+ if (!r.ok)
75
+ throw new Error(`skill body fetch: HTTP ${r.status} for ${name}`);
76
+ const j = await r.json();
77
+ if (!opts?.expectedSha256) {
78
+ throw new Error(`skill ${name}: fetchSkillBody called without expectedSha256 — refusing (no signed anchor)`);
79
+ }
80
+ // The server-claimed sha256 field is untrusted and not consulted. Only
81
+ // the recomputed hash over the delivered body bytes, compared against
82
+ // the manifest-anchored expected value, constitutes an integrity check.
83
+ const actualSha = bytesToHex(sha256(new TextEncoder().encode(j.body)));
84
+ if (opts.expectedSha256 !== actualSha) {
85
+ throw new Error(`skill ${name}: sha256 mismatch with manifest (manifest=${opts.expectedSha256}, computed=${actualSha}) — body may have been tampered in transit`);
86
+ }
87
+ return { name, body: j.body, sha256: actualSha, signature: '', signedAt: j.updated_at };
88
+ }
89
+ }
90
+ function base64ToBytes(b64) {
91
+ return new Uint8Array(Buffer.from(b64, 'base64'));
92
+ }
93
+ export const DEFAULT_MANIFEST_URL = process.env.CSPEACH_MANIFEST_URL ?? 'https://manifest.cspeach.dev/v1.json';
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Parse `--from @<path>` from a slash-command argument string. The leading `@`
3
+ * is the file-attach convention used elsewhere in the CLI; supports
4
+ * double-quoted paths for filenames containing spaces.
5
+ *
6
+ * Returns { fromPath, rest } where rest is the original args with the matched
7
+ * `--from @<path>` token removed. Internal spacing between other tokens is
8
+ * preserved (so the caller's own parser still sees its expected word
9
+ * boundaries); only leading and trailing whitespace is trimmed.
10
+ *
11
+ * For the unmatched-and-no-flag case we trim consistently too — same contract
12
+ * as the matched case so consumers do not have to special-case whitespace.
13
+ */
14
+ export function parseFromFlag(args) {
15
+ const re = /--from\s+@(?:"([^"]+)"|(\S+))/;
16
+ const m = re.exec(args);
17
+ if (!m)
18
+ return { fromPath: null, rest: args.replace(/^\s+|\s+$/g, '') };
19
+ const fromPath = m[1] ?? m[2];
20
+ const before = args.slice(0, m.index);
21
+ const after = args.slice(m.index + m[0].length);
22
+ const rest = (before + after).replace(/^\s+|\s+$/g, '');
23
+ return { fromPath, rest };
24
+ }
@@ -0,0 +1,4 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Build artifact: ed25519 public key for SKILL.md body verification.
3
+ // Regenerated by `pnpm prebuild` on each release.
4
+ export const MANIFEST_PUBLIC_KEYS = ['a7Qj8ibfLTj49kQRTn1DOE/UAExuoBgjhaXxf33+JPY='];