@cspeach/cli 1.1.30 → 1.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/agent/loop.js +132 -9
- package/dist/cli.js +49 -18
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +17 -1
- package/dist/commands/plan-resume.js +28 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +971 -95
- package/dist/journey/chain.js +978 -0
- package/dist/journey/clean-core.js +235 -0
- package/dist/journey/flow.js +216 -0
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +628 -0
- package/dist/journey/session-deps.js +81 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +21 -9
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +28 -2
- package/dist/projects/clean-core-confirm.js +192 -3
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +3 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +100 -12
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -121
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +931 -0
- package/dist/register/export-files.js +58 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/measure.js +191 -9
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +328 -0
- package/dist/register/records.js +40 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +11 -3
- package/dist/register/status-file/register-model.js +40 -6
- package/dist/register/status-file/register-parts.js +57 -11
- package/dist/register/status-file/register-status-file.js +106 -90
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +4 -0
- package/dist/register/status-html.js +27 -6
- package/dist/register/store.js +44 -5
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +28 -42
- package/dist/register/write-whole.js +30 -2
- package/dist/register/xlsx.js +13 -3
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +27 -0
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +236 -59
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/_filesystem-shared.js +174 -12
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +89 -5
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +200 -10
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/upgrade-check.js +49 -4
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? []).
|
|
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);
|
package/dist/upgrade-check.js
CHANGED
|
@@ -3,7 +3,9 @@ import { loadConfig } from './config/loader.js';
|
|
|
3
3
|
import { CLI_VERSION } from './lib/version.js';
|
|
4
4
|
import { getStore } from './auth/api-key.js';
|
|
5
5
|
import { brandSettled } from './renderer/brand-settled.js';
|
|
6
|
-
|
|
6
|
+
import { ManifestClient, DEFAULT_MANIFEST_URL } from './skills/manifest-client.js';
|
|
7
|
+
import { asRunEntries, runsFloor } from './runs/manifest-runs.js';
|
|
8
|
+
export function compareVersions(a, b) {
|
|
7
9
|
const pa = a.split('.').map(Number);
|
|
8
10
|
const pb = b.split('.').map(Number);
|
|
9
11
|
for (let i = 0; i < 3; i++) {
|
|
@@ -24,19 +26,20 @@ function compareVersions(a, b) {
|
|
|
24
26
|
* GET with the bearer and read the body field (the header is still honoured
|
|
25
27
|
* should the proxy add it); not signed in → skip (a 401 tells us nothing).
|
|
26
28
|
* The warning goes to stderr so one-shot stdout stays the answer only.
|
|
29
|
+
* Returns true when it printed.
|
|
27
30
|
*/
|
|
28
|
-
|
|
31
|
+
async function checkSkillFloor() {
|
|
29
32
|
try {
|
|
30
33
|
const cfg = await loadConfig();
|
|
31
34
|
const key = await (await getStore()).get();
|
|
32
35
|
if (!key)
|
|
33
|
-
return;
|
|
36
|
+
return false;
|
|
34
37
|
const r = await fetch(`${cfg.proxy_url}/v1/skills/abap-radar`, {
|
|
35
38
|
headers: { Authorization: `Bearer ${key}` },
|
|
36
39
|
signal: AbortSignal.timeout(3000),
|
|
37
40
|
});
|
|
38
41
|
if (!r.ok)
|
|
39
|
-
return;
|
|
42
|
+
return false;
|
|
40
43
|
let minCli = r.headers.get('x-skill-min-cli-version');
|
|
41
44
|
if (!minCli) {
|
|
42
45
|
const body = (await r.json().catch(() => null));
|
|
@@ -52,7 +55,49 @@ export async function checkForUpgrade() {
|
|
|
52
55
|
text: `This CSPeach (${current}) is older than the minimum (${minCli})`,
|
|
53
56
|
next: 'cspeach --upgrade',
|
|
54
57
|
}).trimEnd());
|
|
58
|
+
return true;
|
|
55
59
|
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Offline, keychain unavailable or proxy down — never block startup.
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Guided runs (2026-10-09, spec §3.2): a run's min_cli_version comes from the
|
|
69
|
+
* signed manifest's runs[]; an older CLI gets the same "!" line a skill gives.
|
|
70
|
+
* Returns true when it printed. Any failure (offline, unsigned, malformed) is silent.
|
|
71
|
+
*/
|
|
72
|
+
async function checkRunsFloor(deps) {
|
|
73
|
+
try {
|
|
74
|
+
const read = deps.fetchManifest ?? (() => new ManifestClient(DEFAULT_MANIFEST_URL).fetchAndVerifyManifest());
|
|
75
|
+
// The race caps the WAIT at 3 s; the underlying fetch keeps its own 10 s
|
|
76
|
+
// timeout (manifest-client.ts:32) and may outlive this call — harmless:
|
|
77
|
+
// nothing awaits it, and the process exit path does not wait for it (M-9).
|
|
78
|
+
const m = await Promise.race([read(), new Promise((_, rej) => { const t = setTimeout(() => rej(new Error('manifest timeout')), 3000); t.unref?.(); })]);
|
|
79
|
+
const floor = runsFloor(asRunEntries(m.runs), CLI_VERSION);
|
|
80
|
+
if (!floor)
|
|
81
|
+
return false;
|
|
82
|
+
await brandSettled();
|
|
83
|
+
console.error('\n' + formatAttention({
|
|
84
|
+
text: `This CSPeach (${CLI_VERSION}) is older than the guided run '${floor.name.replace(/^run-/, '')}' needs (${floor.min})`,
|
|
85
|
+
next: 'cspeach --upgrade',
|
|
86
|
+
}).trimEnd());
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function checkForUpgrade(deps = {}) {
|
|
94
|
+
try {
|
|
95
|
+
const key = await (await getStore()).get();
|
|
96
|
+
if (!key)
|
|
97
|
+
return;
|
|
98
|
+
const printed = await checkSkillFloor();
|
|
99
|
+
if (!printed)
|
|
100
|
+
await checkRunsFloor(deps);
|
|
56
101
|
}
|
|
57
102
|
catch {
|
|
58
103
|
// Offline, keychain unavailable or proxy down — never block startup.
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.32",
|
|
4
4
|
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@anthropic-ai/sdk": "^0.90.0",
|
|
55
|
-
"@cspeach/register-core": "0.
|
|
56
|
-
"@cspeach/sap-client": "0.1.
|
|
55
|
+
"@cspeach/register-core": "0.4.0",
|
|
56
|
+
"@cspeach/sap-client": "0.1.18",
|
|
57
57
|
"@iarna/toml": "^2.2.5",
|
|
58
58
|
"@inkjs/ui": "^2.0.0",
|
|
59
59
|
"@inquirer/prompts": "^7.0.0",
|