@cspeach/cli 1.1.29 → 1.1.31

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 (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -1,6 +1,6 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
5
5
  import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.js';
6
6
  // BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
@@ -31,16 +31,75 @@ function diffLineColor(line, panels) {
31
31
  return roleProps(line.startsWith('+') ? 'ok' : line.startsWith('-') ? 'bad' : 'dim').color;
32
32
  return legacyInkColor(line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray');
33
33
  }
34
+ /** After the review view closes, an Esc this soon is ignored (a double press), like CONFIRM_INPUT_GUARD_MS. */
35
+ export const BACK_GUARD_MS = 300;
36
+ /** The review view's key line, always at its bottom: how to go back and how to answer from here. */
37
+ export const viewKeysLine = (reviewKey) => `Esc or ${reviewKey}: back · a: approve · s: skip · c: cancel turn`;
34
38
  export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn }) {
35
39
  // BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
36
40
  // when an inquirer gate ran earlier in the turn (see import block).
37
41
  useReclaimRawStdin();
38
42
  const [viewing, setViewing] = useState(false);
43
+ const [top, setTop] = useState(0);
39
44
  const aff = viewAffordance(change);
45
+ const { stdout } = useStdout();
46
+ // Recording fix 2 (2026-10-07): a long diff ran off the top of the screen
47
+ // with its "Esc to return" line, and the person could not get back. The
48
+ // review view shows at most this many lines and scrolls; its key line stays
49
+ // at the bottom. ink-testing-library reports no rows: 24, as App does.
50
+ // The whole live frame must stay strictly below the terminal's rows, or Ink
51
+ // clears the screen on every paint and leaves stale rows behind (Ink 5,
52
+ // outputHeight >= rows). Budget: this view's chrome 6 + the input footer 3 +
53
+ // the footer line 1 + the checklist 1 + 1 spare + the diff lines.
54
+ const viewRows = Math.max(3, (stdout?.rows ?? 24) - 14);
55
+ // Back from the view: an Esc in the next 300 ms is the second of a double
56
+ // press, never a cancel of the turn.
57
+ const backAt = useRef(0);
58
+ const back = () => { backAt.current = Date.now(); setViewing(false); };
59
+ const viewLines = viewing && aff && change.diff
60
+ ? (aff.key === 'd' ? change.diff.split('\n') : toSourceLines(change.diff))
61
+ : [];
62
+ const lastTop = Math.max(0, viewLines.length - viewRows);
40
63
  useInput((input, key) => {
41
64
  if (viewing) {
42
- if (key.escape) {
43
- setViewing(false);
65
+ // The review view answers the card too: Esc or the review key go back;
66
+ // a / s / c (and y / n, as on the card) answer it from here.
67
+ if (key.escape || (aff && input === aff.key)) {
68
+ back();
69
+ return;
70
+ }
71
+ if (key.ctrl && input === 'c') {
72
+ onCancel();
73
+ onInterruptTurn?.();
74
+ return;
75
+ }
76
+ if (key.upArrow || input === 'k') {
77
+ setTop((t) => Math.max(0, t - 1));
78
+ return;
79
+ }
80
+ if (key.downArrow || input === 'j') {
81
+ setTop((t) => Math.min(lastTop, t + 1));
82
+ return;
83
+ }
84
+ if (key.pageUp) {
85
+ setTop((t) => Math.max(0, t - viewRows));
86
+ return;
87
+ }
88
+ if (key.pageDown || input === ' ') {
89
+ setTop((t) => Math.min(lastTop, t + viewRows));
90
+ return;
91
+ }
92
+ if (input === 'a' || input === 'y') {
93
+ onApply();
94
+ return;
95
+ }
96
+ if (input === 's' || input === 'n') {
97
+ onSkip();
98
+ return;
99
+ }
100
+ if (input === 'c') {
101
+ onCancel();
102
+ return;
44
103
  }
45
104
  return;
46
105
  }
@@ -55,6 +114,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
55
114
  // The review key is op-aware ([d] for modify, [v] for create/delete);
56
115
  // display-only — it never changes the resolve contract.
57
116
  if (aff && input === aff.key) {
117
+ setTop(0);
58
118
  setViewing(true);
59
119
  onDiff();
60
120
  return;
@@ -68,6 +128,8 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
68
128
  onInterruptTurn?.();
69
129
  return;
70
130
  }
131
+ if (key.escape && Date.now() - backAt.current < BACK_GUARD_MS)
132
+ return;
71
133
  if (input === 'c' || key.escape) {
72
134
  onCancel();
73
135
  return;
@@ -78,7 +140,13 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
78
140
  const isDiff = aff.key === 'd';
79
141
  const panels = getLook() === 'panels';
80
142
  const quiet = panels ? roleProps('dim').color : legacyInkColor('gray');
81
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, children: [isDiff ? 'Diff' : aff.label, " \u2014 Esc to return"] }), change.diff ? (isDiff ? (change.diff.split('\n').map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, i)))) : (toSourceLines(change.diff).map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(i + 1).padStart(4), " "] }), line] }, i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" }))] }));
143
+ const from = Math.min(top, lastTop);
144
+ const shownLines = viewLines.slice(from, from + viewRows);
145
+ const scrolls = viewLines.length > viewRows;
146
+ const where = scrolls
147
+ ? ` lines ${from + 1}–${from + shownLines.length} of ${viewLines.length} · ↑↓ scroll`
148
+ : '';
149
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [isDiff ? 'Diff' : aff.label, _jsx(Text, { color: quiet, children: where })] }), change.diff ? (isDiff ? (shownLines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, from + i)))) : (shownLines.map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(from + i + 1).padStart(4), " "] }), line] }, from + i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: viewKeysLine(aff.key) }) })] }));
82
150
  }
83
151
  const raisedLabel = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
84
152
  // Meta line: Package · Transport — each segment only when genuinely known.
@@ -28,10 +28,70 @@ export const CONFIRM_INPUT_GUARD_MS = 150;
28
28
  * The card's full rendered height for the live-area budget (F3 liveBudget
29
29
  * `cardRows`): top edge + title + one row per fact + the gap above the
30
30
  * choices + one row per choice + the hint + bottom edge. Every row is
31
- * truncate-end, so nothing wraps into extra rows.
31
+ * truncate-end, so nothing wraps into extra rows — except the facts of a
32
+ * card that asks for `wrapFacts`: with `width` given, each counts the rows
33
+ * it is drawn on.
32
34
  */
33
- export function estimateConfirmCardRows(req) {
34
- return 5 + (req.facts?.length ?? 0) + req.choices.length;
35
+ export function estimateConfirmCardRows(req, width, maxRows) {
36
+ const facts = req.facts ?? [];
37
+ const wrap = width === undefined ? 1 : fittedWrap(req, width, maxRows);
38
+ const factRowCount = width !== undefined && wrap > 1
39
+ ? facts.reduce((n, f) => n + factRows(f, Math.max(1, width - 4), wrap).length, 0)
40
+ : facts.length;
41
+ return 5 + factRowCount + req.choices.length;
42
+ }
43
+ /**
44
+ * The rows a long fact may wrap onto so the whole card stays within
45
+ * `maxRows` (App: rows − 6, so Ink never reaches its full-clear path): the
46
+ * card's own `wrapFacts`, one less each time it would not fit, down to one
47
+ * row (every fact cut with …, as on any card).
48
+ */
49
+ export function fittedWrap(req, width, maxRows) {
50
+ let wrap = Math.max(1, req.wrapFacts ?? 1);
51
+ if (maxRows === undefined)
52
+ return wrap;
53
+ const cells = Math.max(1, width - 4);
54
+ const rowsAt = (w) => 5 + req.choices.length + (req.facts ?? []).reduce((n, f) => n + factRows(f, cells, w).length, 0);
55
+ while (wrap > 1 && rowsAt(wrap) > maxRows)
56
+ wrap -= 1;
57
+ return wrap;
58
+ }
59
+ const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
60
+ /**
61
+ * One fact as the rows it is drawn on. `rows` 1 (the default): one row, cut
62
+ * with … — how every card looks. More: wrapped at spaces (a word wider than
63
+ * the row is cut where it must be) onto at most `rows` rows, the last one cut
64
+ * with … when there is still more. Control characters are neutralised first.
65
+ */
66
+ export function factRows(text, cells, rows = 1) {
67
+ const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
68
+ if (rows <= 1 || stringWidth(flat) <= cells)
69
+ return [truncateToWidth(flat, cells)];
70
+ const out = [];
71
+ let rest = flat;
72
+ while (out.length < rows - 1 && stringWidth(rest) > cells) {
73
+ let used = 0;
74
+ let space = -1;
75
+ let end = rest.length;
76
+ for (const { segment, index } of graphemes.segment(rest)) {
77
+ const w = stringWidth(segment);
78
+ if (used + w > cells) {
79
+ end = index;
80
+ if (segment === ' ')
81
+ space = index;
82
+ break;
83
+ }
84
+ used += w;
85
+ if (segment === ' ')
86
+ space = index;
87
+ }
88
+ const at = space > 0 ? space : Math.max(1, end);
89
+ out.push(rest.slice(0, at).trimEnd());
90
+ rest = rest.slice(at).trimStart();
91
+ }
92
+ if (rest)
93
+ out.push(truncateToWidth(rest, cells));
94
+ return out;
35
95
  }
36
96
  /**
37
97
  * Owner decision (fix-round-3 deferred M1): a digit PICKS only on ordinary
@@ -107,7 +167,7 @@ export function CardFrame({ tag, footnote, width, children }) {
107
167
  const bottom = edgeSegments([box.bl, box.br], box.h, footnote, width);
108
168
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: top.label }), _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
109
169
  }
110
- export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
170
+ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTurn }) {
111
171
  // Re-assert raw stdin on mount: an earlier inquirer prompt can leave Ink's
112
172
  // raw-mode bookkeeping stale, and the card would then get no keys.
113
173
  useReclaimRawStdin();
@@ -175,6 +235,7 @@ export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
175
235
  finish({ value: hit });
176
236
  });
177
237
  const inner = Math.max(1, width - 4);
238
+ const wrap = fittedWrap(request, width, maxRows);
178
239
  const dim = roleProps('dim');
179
240
  const titleProps = request.tag === 'CANNOT BE UNDONE' ? roleProps('bad', { bold: true }) : { bold: true };
180
241
  const letters = Object.keys(request.aliases ?? {}).filter((k) => k.length === 1);
@@ -183,7 +244,9 @@ export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
183
244
  ? `1-${digits} picks · ↑↓ move · Enter chooses · Esc cancels`
184
245
  : `Enter chooses · Esc cancels · ↑↓ or 1-${digits} move`)
185
246
  + (letters.length > 0 ? ` · keys ${letters.join('/')}` : '');
186
- return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).map((f, i) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`))), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
247
+ return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).flatMap((f, i) => (wrap > 1
248
+ ? factRows(f, inner, wrap).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: row }, `fact-${i}-${j}`)))
249
+ : [_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`)])), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
187
250
  const on = i === cursor;
188
251
  const main = fit(`${on ? g.cursor : ' '} ${i + 1}. ${c.label}`, inner);
189
252
  const room = inner - stringWidth(main);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Open a local file or an https link in the default browser — the guided
3
+ * clean-core journey's status page (Task 6) and the `cspeach login` link.
4
+ *
5
+ * SECURITY: nothing here goes through a shell. `cmd /c start "" <path>` re-parses
6
+ * its argument, so a folder or project name with & ^ | % " ( ) could run
7
+ * commands. Instead:
8
+ * Windows local file → rundll32.exe url.dll,FileProtocolHandler <file URL>
9
+ * https link → rundll32.exe url.dll,FileProtocolHandler <url>
10
+ * macOS open <target>
11
+ * Linux xdg-open <target>
12
+ * always spawned with shell:false, the target as ONE argument, detached, stdio
13
+ * ignored. Local files must be absolute paths with no control characters; links
14
+ * must be https with no spaces, quotes or control characters (the login path
15
+ * also allows http://localhost and http://127.0.0.1). On Windows a local file
16
+ * must end in .html (defence in depth: FileProtocolHandler hands any other file
17
+ * to its own program, and a .bat or .exe runs). Nothing throws.
18
+ *
19
+ * Why not explorer.exe <path> for files: Explorer splits its command line on
20
+ * commas, and Node quotes only arguments with a space, tab or quote, so
21
+ * `C:\proj\tool.bat,x\…` would reach it unquoted and split. The encoded file
22
+ * URL has only [A-Za-z0-9%/._~-] after the drive, so nothing can split it.
23
+ * Fallback, NOT active: if a live check shows unicode paths fail through
24
+ * FileProtocolHandler, use explorer.exe with windowsVerbatimArguments:true and
25
+ * args [`"${p}"`], refusing any path containing ','.
26
+ */
27
+ import nodePath from 'node:path';
28
+ import { spawn as nodeSpawn } from 'node:child_process';
29
+ export const BROWSER_FAILED_LINE = 'Could not open the browser — open the link above.';
30
+ // eslint-disable-next-line no-control-regex
31
+ const CONTROL = /[\u0000-\u001f\u007f]/;
32
+ /** https only; a host; no spaces, quotes, backticks, angle brackets, backslashes or control characters. */
33
+ export function isSafeHttpsUrl(url) {
34
+ if (typeof url !== 'string' || CONTROL.test(url))
35
+ return false;
36
+ if (!/^https:\/\/[A-Za-z0-9.-]+(:\d{1,5})?([/?#][^\s"'`<>\\]*)?$/.test(url))
37
+ return false;
38
+ try {
39
+ const u = new URL(url);
40
+ return u.protocol === 'https:' && u.hostname.length > 0;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** http://localhost or http://127.0.0.1, any port; same character rules as https. */
47
+ export function isLocalHttpUrl(url) {
48
+ if (typeof url !== 'string' || CONTROL.test(url))
49
+ return false;
50
+ if (!/^http:\/\/(localhost|127\.0\.0\.1)(:\d{1,5})?([/?#][^\s"'`<>\\]*)?$/.test(url))
51
+ return false;
52
+ try {
53
+ const u = new URL(url);
54
+ return u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1') && !u.username && !u.password;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ function isAbsoluteFile(p, platform) {
61
+ if (typeof p !== 'string' || p.length === 0 || CONTROL.test(p) || p.startsWith('-'))
62
+ return false;
63
+ return platform === 'win32' ? nodePath.win32.isAbsolute(p) && /^([A-Za-z]:\\|\\\\)/.test(p) : nodePath.posix.isAbsolute(p);
64
+ }
65
+ /** Percent-encode one path segment: everything but A–Z a–z 0–9 - . _ ~ */
66
+ function encodeSegment(s) {
67
+ return encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
68
+ }
69
+ /** `file:///…` for a local absolute path: spaces as %20, other reserved characters percent-encoded. */
70
+ export function fileUrlOf(p, platform = process.platform) {
71
+ if (platform === 'win32') {
72
+ const unc = /^\\\\([^\\]+)\\(.*)$/.exec(p);
73
+ if (unc)
74
+ return `file://${encodeSegment(unc[1])}/${unc[2].split(/[\\/]/).map(encodeSegment).join('/')}`;
75
+ const [drive, ...rest] = p.split(/[\\/]/);
76
+ return `file:///${drive.toUpperCase()}/${rest.map(encodeSegment).join('/')}`;
77
+ }
78
+ return `file://${p.split('/').map(encodeSegment).join('/')}`;
79
+ }
80
+ function commandFor(target, platform, allowLocalHttp) {
81
+ const isLink = /^[A-Za-z][A-Za-z0-9+.-]+:/.test(target); // a one-letter "scheme" is a drive letter
82
+ if (isLink) {
83
+ if (!isSafeHttpsUrl(target) && !(allowLocalHttp && isLocalHttpUrl(target)))
84
+ return null;
85
+ if (platform === 'win32')
86
+ return { cmd: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', target] };
87
+ }
88
+ else if (!isAbsoluteFile(target, platform)) {
89
+ return null;
90
+ }
91
+ else if (platform === 'win32') {
92
+ // Only a page: FileProtocolHandler would run a .bat, .cmd or .exe.
93
+ if (!/\.html$/i.test(target))
94
+ return null;
95
+ return { cmd: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', fileUrlOf(target, 'win32')] };
96
+ }
97
+ return { cmd: platform === 'darwin' ? 'open' : 'xdg-open', args: [target] };
98
+ }
99
+ /**
100
+ * Open `target` (an absolute local file path or an https link). Resolves true
101
+ * when the opener started, false when the target was refused or nothing could
102
+ * start. Never throws.
103
+ */
104
+ export async function openInBrowser(target, deps = {}) {
105
+ const platform = deps.platform ?? process.platform;
106
+ const spawn = deps.spawn ?? nodeSpawn;
107
+ const c = commandFor(target, platform, deps.allowLocalHttp === true);
108
+ if (!c)
109
+ return false;
110
+ try {
111
+ const child = spawn(c.cmd, c.args, { shell: false, detached: true, stdio: 'ignore', windowsHide: true });
112
+ const started = await new Promise((resolve) => {
113
+ const timer = setTimeout(() => resolve(true), deps.settleMs ?? 1500);
114
+ child.once('spawn', () => { clearTimeout(timer); resolve(true); });
115
+ child.once('error', () => { clearTimeout(timer); resolve(false); });
116
+ });
117
+ // A late error must never crash the session.
118
+ child.on?.('error', () => { });
119
+ child.unref();
120
+ return started;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
126
+ /**
127
+ * The journey's REPORT stage, after the person chose Yes: the page's file link
128
+ * (to paste when no browser opens), then open it. Only this one path is shown.
129
+ */
130
+ export async function openStatusPage(file, deps = {}) {
131
+ const platform = deps.platform ?? process.platform;
132
+ const lines = [` ${fileUrlOf(file, platform)}`];
133
+ if (!(await openInBrowser(file, deps)))
134
+ lines.push(` ${BROWSER_FAILED_LINE}`);
135
+ return lines;
136
+ }
137
+ /** `cspeach login`'s opener (https, or http on localhost/127.0.0.1): rejects when nothing opened, so login prints its own "open it yourself" line. */
138
+ export function loginOpener(deps = {}) {
139
+ return async (url) => {
140
+ if (!(await openInBrowser(url, { ...deps, allowLocalHttp: true })))
141
+ throw new Error('The browser did not open.');
142
+ };
143
+ }
package/package.json CHANGED
@@ -1,120 +1,120 @@
1
- {
2
- "name": "@cspeach/cli",
3
- "version": "1.1.29",
4
- "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
- "keywords": [
6
- "abap",
7
- "sap",
8
- "ai",
9
- "cli",
10
- "ai-assisted-development",
11
- "s4hana",
12
- "abap-development",
13
- "claude",
14
- "anthropic"
15
- ],
16
- "homepage": "https://cspeach.dev",
17
- "bugs": {
18
- "email": "laeeq.siddique@cremencing.com"
19
- },
20
- "author": "Laeeq Siddique (Cremencing Solutions)",
21
- "license": "SEE LICENSE IN LICENSE",
22
- "private": false,
23
- "publishConfig": {
24
- "access": "public"
25
- },
26
- "type": "module",
27
- "bin": {
28
- "cspeach": "dist/index.js"
29
- },
30
- "scripts": {
31
- "dev": "tsx src/index.ts",
32
- "prebuild": "tsx scripts/prebuild.ts",
33
- "build": "tsc && node scripts/make-bin-executable.js",
34
- "install:global": "tsx scripts/install-global.ts",
35
- "pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
36
- "start": "node dist/index.js",
37
- "typecheck": "tsc --noEmit",
38
- "test": "vitest run",
39
- "test:watch": "vitest",
40
- "test:smoke-e2e": "vitest run --config vitest.smoke-e2e.config.ts",
41
- "adt:smoke": "tsx scripts/adt-smoke.ts",
42
- "smoke:all": "pnpm adt:smoke",
43
- "bench:models": "tsx scripts/bench-models.ts",
44
- "bench:audit": "tsx scripts/bench-audit-task.ts",
45
- "bench:replay": "tsx bench/replay/run.ts",
46
- "fiori": "tsx src/tools/fiori/bin.ts",
47
- "prepare": "husky",
48
- "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)} }\""
49
- },
50
- "engines": {
51
- "node": ">=20.0.0"
52
- },
53
- "dependencies": {
54
- "@anthropic-ai/sdk": "^0.90.0",
55
- "@cspeach/register-core": "0.1.0",
56
- "@cspeach/sap-client": "0.1.16",
57
- "@iarna/toml": "^2.2.5",
58
- "@inkjs/ui": "^2.0.0",
59
- "@inquirer/prompts": "^7.0.0",
60
- "@napi-rs/keyring": "2.1.0",
61
- "@noble/ed25519": "2.1.0",
62
- "@noble/hashes": "1.4.0",
63
- "@sap-ux/annotation-converter": "0.10.21",
64
- "@sap-ux/edmx-parser": "0.10.0",
65
- "@sap-ux/fe-fpm-writer": "0.43.7",
66
- "@sap-ux/fiori-elements-writer": "^3.0.39",
67
- "@sap-ux/fiori-freestyle-writer": "3.0.12",
68
- "@sap-ux/vocabularies-types": "0.15.0",
69
- "adm-zip": "0.5.17",
70
- "chalk": "^5.3.0",
71
- "cli-highlight": "^2.1.11",
72
- "cli-table3": "^0.6.5",
73
- "clipboardy": "^5.3.1",
74
- "cross-spawn": "^7.0.6",
75
- "diff": "^9.0.0",
76
- "ejs": "3.1.10",
77
- "fuse.js": "^7.0.0",
78
- "ink": "^5.0.0",
79
- "ink-text-input": "^6.0.0",
80
- "jose": "^5.9.0",
81
- "mammoth": "^1.8.0",
82
- "marked": "^11.2.0",
83
- "marked-terminal": "^6.2.0",
84
- "mem-fs": "2.1.0",
85
- "mem-fs-editor": "9.4.0",
86
- "node-machine-id": "^1.1.12",
87
- "openai": "^6.49.0",
88
- "puppeteer-core": "24.10.2",
89
- "react": "^18.3.0",
90
- "string-width": "^7.2.0",
91
- "turndown": "^7.2.0",
92
- "unpdf": "1.7.0",
93
- "zod": "^3.23.0"
94
- },
95
- "devDependencies": {
96
- "@sap-ux/ui5-middleware-fe-mockserver": "2.4.17",
97
- "@types/adm-zip": "0.5.8",
98
- "@types/cross-spawn": "^6.0.6",
99
- "@types/diff": "^7.0.2",
100
- "@types/ejs": "^3.1.5",
101
- "@types/node": "^20.17.0",
102
- "@types/react": "^18.3.0",
103
- "@types/turndown": "^5.0.5",
104
- "@ui5/builder": "4.3.2",
105
- "@ui5/fs": "4.0.6",
106
- "docx": "^9.0.2",
107
- "husky": "^9.1.7",
108
- "ink-testing-library": "^4.0.0",
109
- "pdf-lib": "^1.17.1",
110
- "strip-ansi": "6.0.1",
111
- "tsx": "^4.19.0",
112
- "typescript": "^5.7.0",
113
- "vitest": "^2.1.0"
114
- },
115
- "pnpm": {
116
- "onlyBuiltDependencies": [
117
- "esbuild"
118
- ]
119
- }
120
- }
1
+ {
2
+ "name": "@cspeach/cli",
3
+ "version": "1.1.31",
4
+ "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
+ "keywords": [
6
+ "abap",
7
+ "sap",
8
+ "ai",
9
+ "cli",
10
+ "ai-assisted-development",
11
+ "s4hana",
12
+ "abap-development",
13
+ "claude",
14
+ "anthropic"
15
+ ],
16
+ "homepage": "https://cspeach.dev",
17
+ "bugs": {
18
+ "email": "laeeq.siddique@cremencing.com"
19
+ },
20
+ "author": "Laeeq Siddique (Cremencing Solutions)",
21
+ "license": "SEE LICENSE IN LICENSE",
22
+ "private": false,
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "bin": {
28
+ "cspeach": "dist/index.js"
29
+ },
30
+ "scripts": {
31
+ "dev": "tsx src/index.ts",
32
+ "prebuild": "tsx scripts/prebuild.ts",
33
+ "build": "tsc && node scripts/make-bin-executable.js",
34
+ "install:global": "tsx scripts/install-global.ts",
35
+ "pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
36
+ "start": "node dist/index.js",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "test:smoke-e2e": "vitest run --config vitest.smoke-e2e.config.ts",
41
+ "adt:smoke": "tsx scripts/adt-smoke.ts",
42
+ "smoke:all": "pnpm adt:smoke",
43
+ "bench:models": "tsx scripts/bench-models.ts",
44
+ "bench:audit": "tsx scripts/bench-audit-task.ts",
45
+ "bench:replay": "tsx bench/replay/run.ts",
46
+ "fiori": "tsx src/tools/fiori/bin.ts",
47
+ "prepare": "husky",
48
+ "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)} }\""
49
+ },
50
+ "engines": {
51
+ "node": ">=20.0.0"
52
+ },
53
+ "dependencies": {
54
+ "@anthropic-ai/sdk": "^0.90.0",
55
+ "@cspeach/register-core": "0.3.0",
56
+ "@cspeach/sap-client": "0.1.17",
57
+ "@iarna/toml": "^2.2.5",
58
+ "@inkjs/ui": "^2.0.0",
59
+ "@inquirer/prompts": "^7.0.0",
60
+ "@napi-rs/keyring": "2.1.0",
61
+ "@noble/ed25519": "2.1.0",
62
+ "@noble/hashes": "1.4.0",
63
+ "@sap-ux/annotation-converter": "0.10.21",
64
+ "@sap-ux/edmx-parser": "0.10.0",
65
+ "@sap-ux/fe-fpm-writer": "0.43.7",
66
+ "@sap-ux/fiori-elements-writer": "^3.0.39",
67
+ "@sap-ux/fiori-freestyle-writer": "3.0.12",
68
+ "@sap-ux/vocabularies-types": "0.15.0",
69
+ "adm-zip": "0.5.17",
70
+ "chalk": "^5.3.0",
71
+ "cli-highlight": "^2.1.11",
72
+ "cli-table3": "^0.6.5",
73
+ "clipboardy": "^5.3.1",
74
+ "cross-spawn": "^7.0.6",
75
+ "diff": "^9.0.0",
76
+ "ejs": "3.1.10",
77
+ "fuse.js": "^7.0.0",
78
+ "ink": "^5.0.0",
79
+ "ink-text-input": "^6.0.0",
80
+ "jose": "^5.9.0",
81
+ "mammoth": "^1.8.0",
82
+ "marked": "^11.2.0",
83
+ "marked-terminal": "^6.2.0",
84
+ "mem-fs": "2.1.0",
85
+ "mem-fs-editor": "9.4.0",
86
+ "node-machine-id": "^1.1.12",
87
+ "openai": "^6.49.0",
88
+ "puppeteer-core": "24.10.2",
89
+ "react": "^18.3.0",
90
+ "string-width": "^7.2.0",
91
+ "turndown": "^7.2.0",
92
+ "unpdf": "1.7.0",
93
+ "zod": "^3.23.0"
94
+ },
95
+ "devDependencies": {
96
+ "@sap-ux/ui5-middleware-fe-mockserver": "2.4.17",
97
+ "@types/adm-zip": "0.5.8",
98
+ "@types/cross-spawn": "^6.0.6",
99
+ "@types/diff": "^7.0.2",
100
+ "@types/ejs": "^3.1.5",
101
+ "@types/node": "^20.17.0",
102
+ "@types/react": "^18.3.0",
103
+ "@types/turndown": "^5.0.5",
104
+ "@ui5/builder": "4.3.2",
105
+ "@ui5/fs": "4.0.6",
106
+ "docx": "^9.0.2",
107
+ "husky": "^9.1.7",
108
+ "ink-testing-library": "^4.0.0",
109
+ "pdf-lib": "^1.17.1",
110
+ "strip-ansi": "6.0.1",
111
+ "tsx": "^4.19.0",
112
+ "typescript": "^5.7.0",
113
+ "vitest": "^2.1.0"
114
+ },
115
+ "pnpm": {
116
+ "onlyBuiltDependencies": [
117
+ "esbuild"
118
+ ]
119
+ }
120
+ }