@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
@@ -0,0 +1,125 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { cspeachRoot } from '../config/paths.js';
5
+ let pasteDir = null;
6
+ let nextNumber = 1;
7
+ const pasted = new Map();
8
+ function dir() {
9
+ if (!pasteDir)
10
+ pasteDir = path.join(cspeachRoot(), 'pasted', `${Date.now()}-${process.pid}`);
11
+ return pasteDir;
12
+ }
13
+ /** Test seam: point the paste folder at a temp dir and reset numbering. */
14
+ export function __resetPastedImagesForTests(testDir) {
15
+ pasteDir = testDir;
16
+ nextNumber = 1;
17
+ pasted.clear();
18
+ }
19
+ /**
20
+ * Save the clipboard image and return its chip, or null when the clipboard
21
+ * holds no image (or the platform tool is missing / fails). Never throws —
22
+ * a paste key must never break the prompt.
23
+ */
24
+ export async function pasteClipboardImage(reader = readClipboardImage) {
25
+ const n = nextNumber;
26
+ const out = path.join(dir(), `image-${n}.png`);
27
+ try {
28
+ await fs.mkdir(dir(), { recursive: true });
29
+ if (!(await reader(out)))
30
+ return null;
31
+ const st = await fs.stat(out);
32
+ if (st.size === 0)
33
+ return null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ nextNumber = n + 1;
39
+ pasted.set(n, out);
40
+ return `[Image #${n}]`;
41
+ }
42
+ /** Swap each known `[Image #N]` chip for its quoted file path. */
43
+ export function expandImageChips(text) {
44
+ if (pasted.size === 0)
45
+ return text;
46
+ return text.replace(/\[Image #(\d+)\]/g, (chip, num) => {
47
+ const file = pasted.get(Number(num));
48
+ return file ? `"${file}"` : chip;
49
+ });
50
+ }
51
+ /**
52
+ * The platform command that writes the clipboard image to `outPath`.
53
+ * Windows and macOS write the file themselves and print "ok"; Linux tools
54
+ * print the PNG bytes to stdout (`stdoutToFile`). null = unsupported.
55
+ */
56
+ export function clipboardImageCommand(platform, outPath, env) {
57
+ if (platform === 'win32') {
58
+ const p = outPath.replace(/'/g, "''");
59
+ const script = 'Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; ' +
60
+ '$i = [System.Windows.Forms.Clipboard]::GetImage(); ' +
61
+ `if ($i -ne $null) { $i.Save('${p}', [System.Drawing.Imaging.ImageFormat]::Png); 'ok' }`;
62
+ return { cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', script] };
63
+ }
64
+ if (platform === 'darwin') {
65
+ const p = outPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
66
+ return {
67
+ cmd: 'osascript',
68
+ args: [
69
+ '-e', 'try',
70
+ '-e', 'set d to the clipboard as «class PNGf»',
71
+ '-e', `set f to open for access POSIX file "${p}" with write permission`,
72
+ '-e', 'write d to f',
73
+ '-e', 'close access f',
74
+ '-e', 'return "ok"',
75
+ '-e', 'on error',
76
+ '-e', 'return "none"',
77
+ '-e', 'end try',
78
+ ],
79
+ };
80
+ }
81
+ if (platform === 'linux') {
82
+ return env.WAYLAND_DISPLAY
83
+ ? { cmd: 'wl-paste', args: ['--no-newline', '--type', 'image/png'], stdoutToFile: true }
84
+ : { cmd: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], stdoutToFile: true };
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Default reader: runs the platform command. stdin is CLOSED — Windows
90
+ * PowerShell 5.1 waits on an open stdin pipe and never exits (seen live
91
+ * 2026-09-23). 10 s ceiling covers a cold PowerShell start (~3 s; warm
92
+ * ~0.35 s).
93
+ */
94
+ export async function readClipboardImage(outPath) {
95
+ const c = clipboardImageCommand(process.platform, outPath, process.env);
96
+ if (!c)
97
+ return false;
98
+ const stdout = await new Promise((resolve, reject) => {
99
+ const child = spawn(c.cmd, c.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
100
+ const chunks = [];
101
+ const timer = setTimeout(() => {
102
+ child.kill();
103
+ reject(new Error('clipboard read timed out'));
104
+ }, 10_000);
105
+ child.stdout.on('data', (d) => chunks.push(d));
106
+ child.on('error', (err) => {
107
+ clearTimeout(timer);
108
+ reject(err);
109
+ });
110
+ child.on('close', (code) => {
111
+ clearTimeout(timer);
112
+ if (code === 0)
113
+ resolve(Buffer.concat(chunks));
114
+ else
115
+ reject(new Error(`${c.cmd} exited ${code}`));
116
+ });
117
+ });
118
+ if (c.stdoutToFile) {
119
+ if (stdout.length === 0)
120
+ return false;
121
+ await fs.writeFile(outPath, stdout);
122
+ return true;
123
+ }
124
+ return stdout.toString('utf8').trim() === 'ok';
125
+ }
package/dist/ui/footer.js CHANGED
@@ -5,6 +5,7 @@ import { Box, Text, useInput, useStdout } from 'ink';
5
5
  // ink-text-input — multi-chunk pastes were losing every chunk but the
6
6
  // last (stale-prop closure in the upstream component). See text-input.tsx.
7
7
  import TextInput from './text-input.js';
8
+ import { pasteClipboardImage } from './clipboard-image.js';
8
9
  import Fuse from 'fuse.js';
9
10
  // Task 8 (ux-wave1) — persistence lives in the shared writer (one file,
10
11
  // one dedup guard for the Footer, Ink handleSubmit, and classic readline).
@@ -194,6 +195,15 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
194
195
  }
195
196
  onSubmit(line.trim());
196
197
  }, [onSubmit, onSlashTrigger, onHistoryTrigger, history, updateBuffer]);
198
+ // Claude Code-style image paste (2026-09-23): Ctrl+V / Alt+V saves the
199
+ // clipboard image and returns an [Image #N] chip; the turn loop swaps the
200
+ // chip for the file path before the image attachment step.
201
+ const handlePasteImage = useCallback(async () => {
202
+ const chip = await pasteClipboardImage();
203
+ if (!chip)
204
+ onNotice?.('no image on the clipboard');
205
+ return chip;
206
+ }, [onNotice]);
197
207
  if (fuzzyMode) {
198
208
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search...", maxDisplayWidth: Math.max(8, columns - 5) }), fuzzyResults.map((r, i) => (_jsx(Text, { dimColor: i > 0, wrap: "truncate-end", children: flattenHistoryEntry(r) }, i))), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
199
209
  }
@@ -205,5 +215,5 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
205
215
  if (disabled) {
206
216
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
207
217
  }
208
- return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [buffer.length > HELD_LINES_SHOWN && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length - HELD_LINES_SHOWN, " earlier line", buffer.length - HELD_LINES_SHOWN === 1 ? '' : 's'] })), buffer.slice(-HELD_LINES_SHOWN).map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['│ ', line] }, `held-${buffer.length}-${i}`))), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [buffer.length, " line", buffer.length === 1 ? '' : 's', " pending \u2014 Enter sends all, Esc discards, \\ continues"] })] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, maxDisplayWidth: inputBudget })] })] }));
218
+ return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [buffer.length > HELD_LINES_SHOWN && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length - HELD_LINES_SHOWN, " earlier line", buffer.length - HELD_LINES_SHOWN === 1 ? '' : 's'] })), buffer.slice(-HELD_LINES_SHOWN).map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['│ ', line] }, `held-${buffer.length}-${i}`))), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [buffer.length, " line", buffer.length === 1 ? '' : 's', " pending \u2014 Enter sends all, Esc discards, \\ continues"] })] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, onPasteImage: handlePasteImage, maxDisplayWidth: inputBudget })] })] }));
209
219
  }
@@ -27,6 +27,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
27
27
  import { useState, useEffect, useRef } from 'react';
28
28
  import { Text, useInput } from 'ink';
29
29
  import chalk from 'chalk';
30
+ /** An image chip ending exactly at the cursor — Backspace removes it whole. */
31
+ const CHIP_BEFORE_CURSOR = /\[Image #\d+\]$/;
30
32
  /**
31
33
  * Pure windowing core (exported for tests). Given the value length, the
32
34
  * cursor position, the display budget (character cells) and whether the
@@ -48,7 +50,7 @@ export function computeDisplayWindow(valueLength, cursorOffset, budget, cursorVi
48
50
  const end = Math.min(valueLength, start + budget);
49
51
  return { start, end };
50
52
  }
51
- export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, }) {
53
+ export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, }) {
52
54
  const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
53
55
  // The paste-safety core: synchronous mirrors of value + cursor. The
54
56
  // input handler reads/writes THESE, never the (possibly stale) props.
@@ -129,6 +131,23 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
129
131
  (key.shift && key.tab)) {
130
132
  return;
131
133
  }
134
+ if (onPasteImage && input === 'v' && (key.ctrl || key.meta)) {
135
+ void onPasteImage().then((chip) => {
136
+ if (!chip)
137
+ return;
138
+ // Resolves after the clipboard read — insert at the cursor as it
139
+ // is NOW, through the same synchronous mirrors as a keystroke.
140
+ const cur = valueRef.current;
141
+ const at = cursorRef.current;
142
+ const next = cur.slice(0, at) + chip + cur.slice(at);
143
+ valueRef.current = next;
144
+ cursorRef.current = at + chip.length;
145
+ setCursorOffset(at + chip.length);
146
+ pendingEchoes.current.push(next);
147
+ onChange(next);
148
+ });
149
+ return;
150
+ }
132
151
  if (key.return) {
133
152
  // valueRef (not the prop) — so an Enter in the same stdin drain as
134
153
  // the paste tail submits the complete line.
@@ -151,7 +170,16 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
151
170
  nextCursorOffset++;
152
171
  }
153
172
  else if (key.backspace || key.delete) {
154
- if (cursorRef.current > 0) {
173
+ const chip = onPasteImage
174
+ ? CHIP_BEFORE_CURSOR.exec(currentValue.slice(0, cursorRef.current))
175
+ : null;
176
+ if (chip) {
177
+ nextValue =
178
+ currentValue.slice(0, cursorRef.current - chip[0].length) +
179
+ currentValue.slice(cursorRef.current);
180
+ nextCursorOffset -= chip[0].length;
181
+ }
182
+ else if (cursorRef.current > 0) {
155
183
  nextValue =
156
184
  currentValue.slice(0, cursorRef.current - 1) +
157
185
  currentValue.slice(cursorRef.current, currentValue.length);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -44,22 +44,25 @@
44
44
  "bench:audit": "tsx scripts/bench-audit-task.ts",
45
45
  "fiori": "tsx src/tools/fiori/bin.ts",
46
46
  "prepare": "husky",
47
- "prepublishOnly": "node -e \"const d=(require('./package.json').dependencies||{})['@cspeach/sap-client']||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: @cspeach/sap-client is a local link ('+d+') — point it at the published version before npm publish'); process.exit(1)}\""
47
+ "prepublishOnly": "node -e \"const deps=require('./package.json').dependencies||{}; for (const n of ['@cspeach/sap-client','@cspeach/register-core']) { const d=deps[n]||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: '+n+' is a local link ('+d+') — point it at the published version before npm publish'); process.exit(1)} }\""
48
48
  },
49
49
  "engines": {
50
50
  "node": ">=20.0.0"
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/sdk": "^0.90.0",
54
- "@cspeach/sap-client": "^0.1.11",
54
+ "@cspeach/register-core": "0.1.0",
55
+ "@cspeach/sap-client": "0.1.12",
55
56
  "@iarna/toml": "^2.2.5",
56
57
  "@inkjs/ui": "^2.0.0",
57
58
  "@inquirer/prompts": "^7.0.0",
59
+ "@napi-rs/keyring": "2.1.0",
58
60
  "@noble/ed25519": "2.1.0",
59
61
  "@noble/hashes": "1.4.0",
60
62
  "@sap-ux/fe-fpm-writer": "0.43.7",
61
63
  "@sap-ux/fiori-elements-writer": "^3.0.39",
62
64
  "@sap-ux/fiori-freestyle-writer": "3.0.12",
65
+ "adm-zip": "0.5.17",
63
66
  "chalk": "^5.3.0",
64
67
  "cli-highlight": "^2.1.11",
65
68
  "cli-table3": "^0.6.5",
@@ -71,7 +74,6 @@
71
74
  "ink": "^5.0.0",
72
75
  "ink-text-input": "^6.0.0",
73
76
  "jose": "^5.9.0",
74
- "@napi-rs/keyring": "2.1.0",
75
77
  "mammoth": "^1.8.0",
76
78
  "marked": "^11.2.0",
77
79
  "marked-terminal": "^6.2.0",
@@ -86,6 +88,7 @@
86
88
  "zod": "^3.23.0"
87
89
  },
88
90
  "devDependencies": {
91
+ "@types/adm-zip": "0.5.8",
89
92
  "@types/cross-spawn": "^6.0.6",
90
93
  "@types/diff": "^7.0.2",
91
94
  "@types/ejs": "^3.1.5",