@cspeach/cli 1.1.14 → 1.1.16

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 (38) hide show
  1. package/README.md +63 -0
  2. package/dist/agent/loop.js +32 -7
  3. package/dist/agent/tool-dispatch.js +13 -9
  4. package/dist/cli.js +81 -27
  5. package/dist/commands/compact.js +6 -2
  6. package/dist/commands/project-context-impact.js +2 -2
  7. package/dist/commands/project.js +301 -0
  8. package/dist/commands/team.js +1166 -0
  9. package/dist/one-shot.js +2 -0
  10. package/dist/projects/image-attachments.js +155 -0
  11. package/dist/projects/save-command.js +20 -0
  12. package/dist/register/atc-file.js +802 -0
  13. package/dist/register/baseline.js +106 -0
  14. package/dist/register/measure.js +380 -0
  15. package/dist/register/records.js +159 -0
  16. package/dist/register/store.js +227 -0
  17. package/dist/repl/post-turn-status.js +2 -1
  18. package/dist/repl.js +16 -2
  19. package/dist/rewind/candidates.js +14 -1
  20. package/dist/rewind/restore.js +53 -19
  21. package/dist/router/classifier.js +24 -4
  22. package/dist/session/recap.js +8 -5
  23. package/dist/session/resume.js +37 -17
  24. package/dist/session/store.js +5 -3
  25. package/dist/session/user-prompt.js +23 -0
  26. package/dist/skill-catalog.js +33 -0
  27. package/dist/skills/bundled-skills.js +1 -1
  28. package/dist/tools/filesystem/extract-document.js +6 -0
  29. package/dist/tools/filesystem/extract-xlsx.js +139 -0
  30. package/dist/tools/filesystem/file-write.js +20 -0
  31. package/dist/tools/filesystem/read-document.js +15 -6
  32. package/dist/tools/include-snapshot.js +24 -0
  33. package/dist/tools/sap-write.js +19 -9
  34. package/dist/tools/snapshot.js +27 -0
  35. package/dist/ui/clipboard-image.js +125 -0
  36. package/dist/ui/footer.js +11 -1
  37. package/dist/ui/text-input.js +30 -2
  38. package/package.json +7 -4
package/README.md CHANGED
@@ -128,6 +128,69 @@ If `cspeach doctor` fails:
128
128
 
129
129
  If the REPL hangs on first prompt, check `https://api.cspeach.dev/health`.
130
130
 
131
+ ## Working on team projects (contributors only)
132
+
133
+ `cspeach team …` and the `/team/` page are built on two sibling packages in this
134
+ repo:
135
+
136
+ - `register-core/` — the merge: records in, one picture out
137
+ - `sap-client/` — the SAP calls, including `atcRunSet`
138
+
139
+ The CLI depends on them as **`file:` links**, and pnpm **copies their `dist/`**
140
+ into `node_modules` at install time. Neither `dist/` is in git. So a fresh
141
+ checkout, or any checkout where somebody changed one of them and did not
142
+ rebuild, runs the CLI against whatever was copied last — usually a
143
+ `TypeError: … is not a function` in the middle of a `cspeach team refresh`.
144
+
145
+ `src/register/__tests__/linked-packages.test.ts` is the tripwire. When it fails
146
+ with *"The linked packages are out of date"*, run the recipe below.
147
+
148
+ ### The recipe
149
+
150
+ Git Bash:
151
+
152
+ ```bash
153
+ cd sap-client && npm run build
154
+ cd ../register-core && ../cspeach-cli/node_modules/.bin/tsc
155
+ cd ../cspeach-cli && pnpm install
156
+ ```
157
+
158
+ Windows (PowerShell or cmd):
159
+
160
+ ```text
161
+ cd sap-client
162
+ npm run build
163
+ cd ..\register-core
164
+ ..\cspeach-cli\node_modules\.bin\tsc.CMD
165
+ cd ..\cspeach-cli
166
+ pnpm install
167
+ ```
168
+
169
+ Then check the link really took:
170
+
171
+ ```bash
172
+ grep -c ruleIdFromAtc cspeach-cli/node_modules/@cspeach/register-core/dist/rules.js # ≥ 1
173
+ grep -c atcRunSet cspeach-cli/node_modules/@cspeach/sap-client/dist/adt-client.js # ≥ 1
174
+ ```
175
+
176
+ If either prints `0`, run `pnpm install --force` in `cspeach-cli/`.
177
+
178
+ Notes:
179
+
180
+ - **`npm run build` does not work in `register-core/`** in this checkout — its
181
+ devDependencies are not installed. Build it with the CLI's own compiler, as
182
+ above. `sap-client/` builds normally.
183
+ - **`dist/index.js` is only a re-export barrel**, so grep a real module
184
+ (`dist/rules.js`, `dist/register.js`) when you check a new name landed.
185
+ - **`pnpm install` rewrites one path separator in `cspeach-cli/pnpm-lock.yaml`.**
186
+ That is noise. Restore it before you commit:
187
+ `git checkout -- cspeach-cli/pnpm-lock.yaml`.
188
+ - The **viewer** reads `register-core` from SOURCE (a Vite alias) and needs none
189
+ of this.
190
+ - Both packages are **unpublished**. `prepublishOnly` blocks `npm publish` of
191
+ the CLI while either dependency is still a `file:` link; they have to be
192
+ published and the versions pinned before a CLI release.
193
+
131
194
  ## Links
132
195
 
133
196
  - **Landing**: https://cspeach.dev
@@ -30,6 +30,8 @@ import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotio
30
30
  import { input } from '@inquirer/prompts';
31
31
  import { withInquirer } from '../repl/inquirer-guard.js';
32
32
  import { expandTextFileAttachments } from '../projects/workspace.js';
33
+ import { expandImageAttachments, buildUserContent, trimHistoryImages } from '../projects/image-attachments.js';
34
+ import { expandImageChips } from '../ui/clipboard-image.js';
33
35
  import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
34
36
  import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
35
37
  import { renderStandardsBlock } from '../standards/standards-file.js';
@@ -45,6 +47,7 @@ import { buildSapConnectionContext } from './sap-connection-adapter.js';
45
47
  import { getCurrentTransport } from '../repl/current-transport.js';
46
48
  import { collectTurnAssistantText } from './turn-assistant-text.js';
47
49
  import { checkAndMark, newGuardState } from './parallel-write-guard.js';
50
+ import { userPromptText } from '../session/user-prompt.js';
48
51
  /**
49
52
  * Author identity for project-file metadata. Reads CSPEACH_AUTHOR_NAME first,
50
53
  * then platform USER/USERNAME, then a generic fallback. Role is fixed to
@@ -103,17 +106,15 @@ const SAVE_HOOK_SKILLS = new Set([
103
106
  * per-process counter would re-inject; the content scan correctly
104
107
  * short-circuits.
105
108
  *
106
- * - tool_result entries push `role: 'user'` with array-shaped content.
107
- * The `typeof === 'string'` check skips those without iterating
108
- * multimodal blocks.
109
+ * - tool_result entries push `role: 'user'` with array-shaped content;
110
+ * userPromptText skips those. An image-attachment prompt is also an
111
+ * array ([image…, text]) and IS checked (2026-09-23).
109
112
  *
110
113
  * Exported for unit testing only — the call site is one floor up in runTurn.
111
114
  */
112
115
  export function hasProjectContextBlock(messages) {
113
116
  for (const m of messages) {
114
- if (m.role !== 'user')
115
- continue;
116
- if (typeof m.content === 'string' && m.content.includes('<project_context')) {
117
+ if (userPromptText(m)?.includes('<project_context')) {
117
118
  return true;
118
119
  }
119
120
  }
@@ -332,6 +333,20 @@ export async function runTurn(params) {
332
333
  userMessageForSave = rest;
333
334
  }
334
335
  }
336
+ // Tier 1 screenshots (2026-09-23) — image paths in the prompt (typed,
337
+ // @-referenced or dragged in) become image content blocks and the path
338
+ // becomes `[attached <name>]`. Runs BEFORE the text-file expansion so a
339
+ // path written inside an attached document is never pulled in. Too large
340
+ // or too many images → the turn is not sent (same early exit as --from).
341
+ // Pasted clipboard images arrive as [Image #N] chips — swap each for its
342
+ // saved file path first, so the step below attaches it like a typed path.
343
+ const imageExpansion = await expandImageAttachments(expandImageChips(userMessageForLLM), params.ctx.cwd, (line) => emit(chalk.dim(line)));
344
+ if (imageExpansion.error) {
345
+ emit(chalk.yellow(`
346
+ ${imageExpansion.error}`));
347
+ return;
348
+ }
349
+ userMessageForLLM = imageExpansion.text;
335
350
  // Phase D — `@<text-file>` attachment expansion.
336
351
  // After --from has consumed its own @<token>, scan what's left for
337
352
  // `@<filename>` references that resolve to .txt / .md files in the
@@ -416,7 +431,17 @@ export async function runTurn(params) {
416
431
  const finalUserContent = projectContextBlock
417
432
  ? `${projectContextBlock}\n\n${enriched}`
418
433
  : enriched;
419
- session.messages.push({ role: 'user', content: finalUserContent });
434
+ // Images (if any) ride as blocks ahead of the text; with none this is the
435
+ // same plain string as before.
436
+ session.messages.push({ role: 'user', content: buildUserContent(finalUserContent, imageExpansion.images) });
437
+ if (imageExpansion.images.length > 0 && params.provider.name === 'local-openai-compat') {
438
+ // The local (OpenAI-compatible) provider keeps text blocks only.
439
+ emit(chalk.dim('(local model: the image is not sent — the model gets the text only)'));
440
+ }
441
+ const droppedImages = trimHistoryImages(session.messages);
442
+ if (droppedImages > 0) {
443
+ emit(chalk.dim(`(${droppedImages} older image(s) removed from the conversation to keep it under the size limit)`));
444
+ }
420
445
  session.skill = params.skill;
421
446
  /**
422
447
  * key: `${tool_name}:${object}` — consecutive failures per (tool, target).
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import { getTool, isStandalone } from '../tools/index.js';
2
3
  import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
3
4
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
@@ -30,7 +31,7 @@ export async function dispatchTool(name, args, ctx) {
30
31
  // GUIDE.md) — local scratch, not SAP objects. Rule 8 protects SAP batches;
31
32
  // applying it here made the 2nd+ file (GUIDE.md) hit the batch card and,
32
33
  // headless, get refused. Exempt from the gate AND from the batch count.
33
- const scratchWrite = isScratchOutputWrite(tool, args);
34
+ const scratchWrite = isScratchOutputWrite(tool, args, ctx.cwd);
34
35
  if (tool.isMutating && !scratchWrite && shouldGateRule8()) {
35
36
  const r = await presentSafetyConfirmation({
36
37
  rule: 'RULE_8_BATCH_PLAN',
@@ -83,21 +84,24 @@ export async function dispatchTool(name, args, ctx) {
83
84
  }
84
85
  }
85
86
  /**
86
- * True for a mutating filesystem tool whose target sits under ./cspeach-out/
87
- * (relative to cwd). Accepts `cspeach-out/…`, `./cspeach-out/…` and the
88
- * Windows backslash form; rejects anything that climbs out (`..`) or is
89
- * absolute. Deliberately narrow: only this one folder is exempt.
87
+ * True for a mutating filesystem tool whose target sits under the project's
88
+ * own ./cspeach-out/ (cwd). Accepts `cspeach-out/…`, `./cspeach-out/…`, the
89
+ * Windows backslash form, and an ABSOLUTE path that resolves inside
90
+ * <cwd>/cspeach-out (2026-09-23: models write full paths, and a project deep
91
+ * in a synced folder hit the batch card on every file after the first).
92
+ * Rejects anything with a `..` segment and any cspeach-out outside cwd.
93
+ * Deliberately narrow: only this one folder is exempt.
90
94
  */
91
- export function isScratchOutputWrite(tool, args) {
95
+ export function isScratchOutputWrite(tool, args, cwd = process.cwd()) {
92
96
  if (!tool.isMutating || tool.category !== 'filesystem')
93
97
  return false;
94
98
  const raw = args?.path;
95
99
  if (typeof raw !== 'string' || raw.length === 0)
96
100
  return false;
97
101
  const p = raw.replace(/\\/g, '/').replace(/^\.\//, '');
98
- if (p.startsWith('/') || /^[A-Za-z]:\//.test(p))
99
- return false;
100
102
  if (p.split('/').some((seg) => seg === '..'))
101
103
  return false;
102
- return p === 'cspeach-out' || p.startsWith('cspeach-out/');
104
+ const scratchRoot = path.resolve(cwd, 'cspeach-out');
105
+ const rel = path.relative(scratchRoot, path.resolve(cwd, p));
106
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
103
107
  }
package/dist/cli.js CHANGED
@@ -4,6 +4,11 @@ import { runAddSapWizard } from './sap/onboarding.js';
4
4
  import { runGcOnStartup } from './session/gc.js';
5
5
  import { checkForUpgrade } from './upgrade-check.js';
6
6
  import { extractSapAliasFlag, extractNoSapFlag } from './cli-args.js';
7
+ /**
8
+ * The verbs that make `cspeach project …` a mistyped team command rather than
9
+ * a prompt somebody meant to send. See the check in `main`.
10
+ */
11
+ const TEAM_SUBCOMMANDS = new Set(['init', 'join', 'lane', 'refresh', 'status', 'outbox', 'rule', 'leave']);
7
12
  export async function main(argv) {
8
13
  runGcOnStartup().catch(() => { });
9
14
  checkForUpgrade().catch(() => { });
@@ -144,6 +149,47 @@ export async function main(argv) {
144
149
  const cfg = await loadConfig();
145
150
  return runWhoamiCommand({ proxyUrl: cfg.proxy_url, print: (s) => console.log(s) });
146
151
  }
152
+ // A team project — many laptops, one picture (spec 2026-09-18).
153
+ if (args[0] === 'team') {
154
+ const { runTeamCommand } = await import('./commands/team.js');
155
+ const { getAuthorIdentity } = await import('./agent/loop.js');
156
+ return runTeamCommand(args.slice(1), {
157
+ print: (s) => console.log(s),
158
+ identity: () => getAuthorIdentity(),
159
+ noSap,
160
+ // Standalone: no connection is offered at all, so nothing in `team` can reach for one.
161
+ connect: noSap ? undefined : async (alias) => {
162
+ const { loadConfig } = await import('./config/loader.js');
163
+ const { getConnection } = await import('./sap/connection-manager.js');
164
+ const { AdtClient } = await import('@cspeach/sap-client');
165
+ const cfg = await loadConfig();
166
+ const name = alias ?? sapAlias ?? Object.keys(cfg.sap)[0];
167
+ if (!name || !cfg.sap[name])
168
+ throw new Error('No SAP system is configured. Run: cspeach config add-sap');
169
+ const adt = new AdtClient(await getConnection(name, cfg.sap[name]));
170
+ return {
171
+ sql: (q, max) => adt.sqlQuery(q, max),
172
+ system: { sid: name },
173
+ atc: { runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts) },
174
+ };
175
+ },
176
+ ask: async (question) => {
177
+ const { isHeadless } = await import('./renderer/tty.js');
178
+ if (isHeadless())
179
+ return '';
180
+ const { input } = await import('@inquirer/prompts');
181
+ return input({ message: question });
182
+ },
183
+ });
184
+ }
185
+ // Typed from yesterday's notes. Without this the words fall through to the
186
+ // one-shot path at the bottom of this function and are sent to the model as
187
+ // a PAID prompt on a machine that is configured and logged in. A hint, not
188
+ // an alias: the old family does not run, and nothing is renamed back.
189
+ if (args[0] === 'project' && TEAM_SUBCOMMANDS.has(args[1] ?? '')) {
190
+ console.log('Unknown command: cspeach project. Team projects use: cspeach team init|join|lane|refresh|status|outbox|rule|leave');
191
+ return 1;
192
+ }
147
193
  if (args[0] === 'analyze' && args[1] === 'project-context') {
148
194
  const { runProjectContextImpactCommand } = await import('./commands/project-context-impact.js');
149
195
  return runProjectContextImpactCommand(args.slice(2));
@@ -162,32 +208,40 @@ export async function main(argv) {
162
208
  return runRepl({ sapAlias, noSap });
163
209
  }
164
210
  function printHelp() {
165
- console.log(`
166
- CSPeach — AI-assisted ABAP development CLI
167
-
168
- Usage:
169
- cspeach Start interactive REPL
170
- cspeach "<prompt>" One-shot — runs one turn and exits
171
- cspeach --one-shot "<prompt>" Same as above, explicit flag
172
- cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
173
- cspeach --no-sap Start without a SAP system (paste-mode skills only)
174
- cspeach --resume [id] Resume a previous session (list if no id)
175
- cspeach config add-sap Add a SAP system interactively
176
- cspeach config standalone Set the standalone SAP profile (no system)
177
- cspeach standards init Capture your team's ABAP development standards
178
- cspeach configure Alias for \`cspeach config add-sap\`
179
- cspeach config set <key> <value> Update a config key
180
- cspeach config unset <key> Remove a config key (revert to default)
181
- cspeach config show [key] Show config (full or single key)
182
- cspeach doctor Diagnose install + connectivity
183
- cspeach login Sign in via device-code flow
184
- cspeach logout Sign out (keep keychain key, if any)
185
- cspeach whoami Print current account info
186
- cspeach analyze project-context Compare flag-on vs flag-off session metrics
187
- cspeach --upgrade Upgrade to latest via npm
188
- cspeach --version Print version
189
- cspeach --help Show this help
190
-
191
- Docs: https://cspeach.dev
211
+ console.log(`
212
+ CSPeach — AI-assisted ABAP development CLI
213
+
214
+ Usage:
215
+ cspeach Start interactive REPL
216
+ cspeach "<prompt>" One-shot — runs one turn and exits
217
+ cspeach --one-shot "<prompt>" Same as above, explicit flag
218
+ cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
219
+ cspeach --no-sap Start without a SAP system (paste-mode skills only)
220
+ cspeach --resume [id] Resume a previous session (list if no id)
221
+ cspeach config add-sap Add a SAP system interactively
222
+ cspeach config standalone Set the standalone SAP profile (no system)
223
+ cspeach standards init Capture your team's ABAP development standards
224
+ cspeach configure Alias for \`cspeach config add-sap\`
225
+ cspeach config set <key> <value> Update a config key
226
+ cspeach config unset <key> Remove a config key (revert to default)
227
+ cspeach config show [key] Show config (full or single key)
228
+ cspeach doctor Diagnose install + connectivity
229
+ cspeach login Sign in via device-code flow
230
+ cspeach logout Sign out (keep keychain key, if any)
231
+ cspeach whoami Print current account info
232
+ cspeach team init <folder> Lead: pull the object list from SAP, start a team project
233
+ cspeach team join <folder> Record your results against a team project (on this laptop only)
234
+ cspeach team lane add|list Lead: who owns which packages
235
+ cspeach team refresh --lane <id> Measure with ATC: what is verified, what did not hold
236
+ cspeach team status [folder] Where every object stands, who owns it, what is proven
237
+ cspeach team outbox What this laptop has recorded, and where
238
+ cspeach team rule T N <d> Lead: settle a conflicting decision
239
+ cspeach team leave Stop recording
240
+ cspeach analyze project-context Compare flag-on vs flag-off session metrics
241
+ cspeach --upgrade Upgrade to latest via npm
242
+ cspeach --version Print version
243
+ cspeach --help Show this help
244
+
245
+ Docs: https://cspeach.dev
192
246
  `);
193
247
  }
@@ -29,6 +29,7 @@ import path from 'node:path';
29
29
  import chalk from 'chalk';
30
30
  import { sessionsDir } from '../config/paths.js';
31
31
  import { saveSession } from '../session/store.js';
32
+ import { isUserPrompt } from '../session/user-prompt.js';
32
33
  /** Keep this many recent user-prompt anchored turns verbatim. */
33
34
  export const KEEP_RECENT_TURNS = 5;
34
35
  /** Don't bother compacting if there's only a handful of older turns. */
@@ -58,8 +59,9 @@ keepRecentTurns = KEEP_RECENT_TURNS) {
58
59
  let cutAt = 0;
59
60
  for (let i = messages.length - 1; i >= 0; i--) {
60
61
  const msg = messages[i];
61
- // A user PROMPT (not a tool_result block) — content is a string.
62
- if (msg.role === 'user' && typeof msg.content === 'string') {
62
+ // A user PROMPT (not a tool_result round) — string content, or an
63
+ // image-attachment prompt ([image…, text]).
64
+ if (isUserPrompt(msg)) {
63
65
  userPromptsSeen++;
64
66
  if (userPromptsSeen === keepRecentTurns) {
65
67
  cutAt = i;
@@ -134,6 +136,8 @@ export function serialiseForSummariser(messages) {
134
136
  return '';
135
137
  if (b.type === 'text')
136
138
  return b.text ?? '';
139
+ if (b.type === 'image')
140
+ return '[image]';
137
141
  if (b.type === 'thinking')
138
142
  return `[thinking: ${(b.thinking ?? '').slice(0, 200)}]`;
139
143
  if (b.type === 'tool_use')
@@ -24,6 +24,7 @@ import fs from 'node:fs/promises';
24
24
  import path from 'node:path';
25
25
  import chalk from 'chalk';
26
26
  import { sessionsDir } from '../config/paths.js';
27
+ import { userPromptText } from '../session/user-prompt.js';
27
28
  /**
28
29
  * Read all session JSONs in `dir` (defaults to sessionsDir()), classify
29
30
  * each by whether the flag was on, and produce a comparison report.
@@ -134,8 +135,7 @@ async function readAllSessions(dir) {
134
135
  function toMetrics(s) {
135
136
  const firstUser = s.messages.find((m) => m.role === 'user');
136
137
  const flagOn = firstUser !== undefined &&
137
- typeof firstUser.content === 'string' &&
138
- firstUser.content.includes('<project_context');
138
+ (userPromptText(firstUser)?.includes('<project_context') ?? false);
139
139
  // Count "turns" as user messages — each user message kicks off a model turn.
140
140
  const turnCount = s.messages.filter((m) => m.role === 'user').length;
141
141
  const usage = s.usage ?? { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };