@matterailab/orbcode 0.1.4 → 0.1.5
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 +2 -0
- package/dist/index.js +37 -1
- package/dist/ui/App.js +85 -7
- package/dist/ui/components/InputBox.js +12 -8
- package/dist/utils/updateCheck.js +162 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ models, the same native tool schemas, the same MatterAI auth backend — rebuilt
|
|
|
7
7
|
scratch as an interactive TUI with streaming chat, live thinking display, tool
|
|
8
8
|
activity rows, edit/command approvals, and todo tracking.
|
|
9
9
|
|
|
10
|
+

|
|
11
|
+
|
|
10
12
|
---
|
|
11
13
|
|
|
12
14
|
## Table of contents
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { PRODUCT_NAME, VERSION } from "./branding.js";
|
|
|
4
4
|
import { App } from "./ui/App.js";
|
|
5
5
|
import { runHeadless } from "./headless.js";
|
|
6
6
|
import { loadSessionById } from "./core/sessions.js";
|
|
7
|
+
import { clearUpdateCache, compareVersions, fetchLatestNpmVersion, getGlobalInstallRoot, getUpdateInfo, isGlobalInstall, runNpmUpdate, } from "./utils/updateCheck.js";
|
|
8
|
+
const PACKAGE_NAME = "@matterailab/orbcode";
|
|
7
9
|
function printHelp() {
|
|
8
10
|
console.log(`${PRODUCT_NAME} v${VERSION}
|
|
9
11
|
|
|
@@ -11,6 +13,7 @@ Usage:
|
|
|
11
13
|
orbcode start an interactive session
|
|
12
14
|
orbcode "<prompt>" start an interactive session with an initial prompt
|
|
13
15
|
orbcode login sign in to MatterAI
|
|
16
|
+
orbcode update install the latest version from npm
|
|
14
17
|
orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
|
|
15
18
|
orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
|
|
16
19
|
orbcode --model <id> use a specific model for this run
|
|
@@ -19,6 +22,32 @@ Usage:
|
|
|
19
22
|
orbcode --help show this help
|
|
20
23
|
`);
|
|
21
24
|
}
|
|
25
|
+
async function runUpdate() {
|
|
26
|
+
const latest = await fetchLatestNpmVersion(PACKAGE_NAME);
|
|
27
|
+
if (latest === null) {
|
|
28
|
+
console.error("Could not reach the npm registry. Check your network connection and try again.");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
if (compareVersions(VERSION, latest) >= 0) {
|
|
32
|
+
console.log(`${PRODUCT_NAME} v${VERSION} is already up to date (latest: v${latest}).`);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
console.log(`Updating ${PRODUCT_NAME} v${VERSION} → v${latest}…`);
|
|
36
|
+
const global = isGlobalInstall();
|
|
37
|
+
if (!global) {
|
|
38
|
+
const root = await getGlobalInstallRoot();
|
|
39
|
+
console.error(`This CLI was not installed globally (argv[1] = ${process.argv[1] || "<unknown>"}).\n` +
|
|
40
|
+
`To update a local/dev install, run \`npm install -g ${PACKAGE_NAME}@latest\` manually, or \`npm install\` inside the source checkout.` +
|
|
41
|
+
(root ? `\nGlobal install root detected at: ${root}` : ""));
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
clearUpdateCache();
|
|
45
|
+
const code = await runNpmUpdate(PACKAGE_NAME);
|
|
46
|
+
if (code === 0) {
|
|
47
|
+
console.log(`Updated to v${latest}. Run \`${PRODUCT_NAME.split(" ")[0].toLowerCase()}\` again to use it.`);
|
|
48
|
+
}
|
|
49
|
+
return code;
|
|
50
|
+
}
|
|
22
51
|
/** Pop `flag <value>` (accepting -flag and --flag) out of args; returns the value. */
|
|
23
52
|
function takeFlagValue(args, name) {
|
|
24
53
|
const index = args.findIndex((a) => a === `--${name}` || a === `-${name}`);
|
|
@@ -42,6 +71,10 @@ async function main() {
|
|
|
42
71
|
printHelp();
|
|
43
72
|
return;
|
|
44
73
|
}
|
|
74
|
+
if (args[0] === "update") {
|
|
75
|
+
const code = await runUpdate();
|
|
76
|
+
process.exit(code);
|
|
77
|
+
}
|
|
45
78
|
const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m");
|
|
46
79
|
if (model) {
|
|
47
80
|
// loadSettings() treats ORBCODE_MODEL as the highest-precedence override,
|
|
@@ -76,7 +109,10 @@ async function main() {
|
|
|
76
109
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
77
110
|
process.stdout.write("\x1b]0;orbcode\x07");
|
|
78
111
|
}
|
|
79
|
-
|
|
112
|
+
// Fire-and-forget: a stale or no-network state just means the header shows
|
|
113
|
+
// the "current version" line instead of an upgrade prompt.
|
|
114
|
+
const updateCheckPromise = getUpdateInfo(PACKAGE_NAME, VERSION);
|
|
115
|
+
render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession, updateCheck: updateCheckPromise }));
|
|
80
116
|
}
|
|
81
117
|
main().catch((error) => {
|
|
82
118
|
console.error(error);
|
package/dist/ui/App.js
CHANGED
|
@@ -142,10 +142,11 @@ let rowCounter = 0;
|
|
|
142
142
|
function rowId() {
|
|
143
143
|
return `row-${rowCounter++}`;
|
|
144
144
|
}
|
|
145
|
-
export function App({ initialView, initialPrompt, initialSession, }) {
|
|
145
|
+
export function App({ initialView, initialPrompt, initialSession, updateCheck, }) {
|
|
146
146
|
const { exit } = useApp();
|
|
147
147
|
const [settings, setSettings] = useState(() => loadSettings());
|
|
148
148
|
const [view, setView] = useState(initialView ?? (getAuthToken(settings) ? "chat" : "login"));
|
|
149
|
+
const [updateInfo, setUpdateInfo] = useState(null);
|
|
149
150
|
const [rows, setRows] = useState(() => [
|
|
150
151
|
{ kind: "header", id: "header", cwd: process.cwd(), modelName: getModel(loadSettings().model).name },
|
|
151
152
|
]);
|
|
@@ -156,6 +157,10 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
156
157
|
const [runningTool, setRunningTool] = useState(null);
|
|
157
158
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
158
159
|
const [pendingFollowup, setPendingFollowup] = useState(null);
|
|
160
|
+
// FIFO queue of messages the user typed while the LLM was still streaming.
|
|
161
|
+
// Drained one-per-turn on each `turn-end` event so multi-step work can
|
|
162
|
+
// keep flowing without making the user wait for the previous response.
|
|
163
|
+
const [queuedMessages, setQueuedMessages] = useState([]);
|
|
159
164
|
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
|
160
165
|
const [resumableSessions, setResumableSessions] = useState(null);
|
|
161
166
|
const [staticKey, setStaticKey] = useState(0);
|
|
@@ -184,6 +189,26 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
184
189
|
const textBufferRef = useRef("");
|
|
185
190
|
// taskId for which a title fetch has already been started (once per task).
|
|
186
191
|
const titleTaskRef = useRef(null);
|
|
192
|
+
// Mirror of `queuedMessages` for the agent event handler (kept on a ref so
|
|
193
|
+
// we can drain it inside `handleEvent` without re-creating that callback
|
|
194
|
+
// on every keystroke).
|
|
195
|
+
const queueRef = useRef([]);
|
|
196
|
+
const enqueueMessage = useCallback((text) => {
|
|
197
|
+
queueRef.current = [...queueRef.current, text];
|
|
198
|
+
setQueuedMessages(queueRef.current);
|
|
199
|
+
}, []);
|
|
200
|
+
const drainQueue = useCallback(() => {
|
|
201
|
+
if (queueRef.current.length === 0)
|
|
202
|
+
return null;
|
|
203
|
+
const [next, ...rest] = queueRef.current;
|
|
204
|
+
queueRef.current = rest;
|
|
205
|
+
setQueuedMessages(rest);
|
|
206
|
+
return next;
|
|
207
|
+
}, []);
|
|
208
|
+
const clearQueue = useCallback(() => {
|
|
209
|
+
queueRef.current = [];
|
|
210
|
+
setQueuedMessages([]);
|
|
211
|
+
}, []);
|
|
187
212
|
const maybeFetchTitle = useCallback(() => {
|
|
188
213
|
const agent = agentRef.current;
|
|
189
214
|
if (!agent || titleTaskRef.current === agent.taskId)
|
|
@@ -289,9 +314,25 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
289
314
|
setBusy(false);
|
|
290
315
|
maybeFetchTitle();
|
|
291
316
|
refreshUsage();
|
|
317
|
+
// Pop the next queued message and immediately start a new
|
|
318
|
+
// turn on top of the one that just ended. The agent's
|
|
319
|
+
// `runTurn` is fully resolved at this point (its
|
|
320
|
+
// `finally` emitted this event), so the new turn picks
|
|
321
|
+
// up the up-to-date conversation history. We use
|
|
322
|
+
// `agentRef` directly here to avoid a circular
|
|
323
|
+
// `handleEvent` ↔ `getAgent` reference; the ref is
|
|
324
|
+
// guaranteed populated because `runTurn` set it
|
|
325
|
+
// synchronously before emitting this event.
|
|
326
|
+
const nextQueued = drainQueue();
|
|
327
|
+
if (nextQueued !== null && agentRef.current) {
|
|
328
|
+
pushRow({ kind: "user", text: nextQueued });
|
|
329
|
+
setBusy(true);
|
|
330
|
+
setBusyLabel("Thinking");
|
|
331
|
+
void agentRef.current.runTurn(nextQueued);
|
|
332
|
+
}
|
|
292
333
|
break;
|
|
293
334
|
}
|
|
294
|
-
}, [pushRow, maybeFetchTitle, refreshUsage]);
|
|
335
|
+
}, [pushRow, maybeFetchTitle, refreshUsage, drainQueue]);
|
|
295
336
|
const createAgent = useCallback((resume) => {
|
|
296
337
|
const current = loadSettings();
|
|
297
338
|
return new Agent({
|
|
@@ -391,6 +432,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
391
432
|
break;
|
|
392
433
|
case "/new":
|
|
393
434
|
// Drop the agent entirely so the next message starts a fresh session.
|
|
435
|
+
clearQueue();
|
|
394
436
|
agentRef.current = null;
|
|
395
437
|
titleTaskRef.current = null;
|
|
396
438
|
setSessionTitle("");
|
|
@@ -514,6 +556,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
514
556
|
setView("login");
|
|
515
557
|
break;
|
|
516
558
|
case "/logout": {
|
|
559
|
+
clearQueue();
|
|
517
560
|
const updated = { ...settings, token: undefined };
|
|
518
561
|
setSettings(updated);
|
|
519
562
|
saveSettings(updated);
|
|
@@ -527,7 +570,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
527
570
|
default:
|
|
528
571
|
pushRow({ kind: "error", text: `Unknown command: ${name}. Try /help.` });
|
|
529
572
|
}
|
|
530
|
-
}, [settings, tasks, contextTokens, totalCost, sessionTitle, exit, pushRow, getAgent, switchModel, resetTranscript]);
|
|
573
|
+
}, [settings, tasks, contextTokens, totalCost, sessionTitle, exit, pushRow, getAgent, switchModel, resetTranscript, clearQueue]);
|
|
531
574
|
const handleSubmit = useCallback((value) => {
|
|
532
575
|
if (value.startsWith("/")) {
|
|
533
576
|
handleCommand(value);
|
|
@@ -537,11 +580,18 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
537
580
|
setView("login");
|
|
538
581
|
return;
|
|
539
582
|
}
|
|
583
|
+
// While the LLM is still streaming, hold the message in a FIFO
|
|
584
|
+
// queue instead of dropping it on the floor. `handleEvent`'s
|
|
585
|
+
// `turn-end` case drains the queue and starts the next turn.
|
|
586
|
+
if (busy) {
|
|
587
|
+
enqueueMessage(value);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
540
590
|
pushRow({ kind: "user", text: value });
|
|
541
591
|
setBusy(true);
|
|
542
592
|
setBusyLabel("Thinking");
|
|
543
593
|
void getAgent().runTurn(value);
|
|
544
|
-
}, [settings
|
|
594
|
+
}, [settings, busy, handleCommand, enqueueMessage, getAgent, pushRow]);
|
|
545
595
|
// Apply --resume and an initial prompt (`orbcode "do something"`) on startup.
|
|
546
596
|
const bootedRef = useRef(false);
|
|
547
597
|
useEffect(() => {
|
|
@@ -554,7 +604,29 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
554
604
|
handleSubmit(initialPrompt);
|
|
555
605
|
refreshUsage();
|
|
556
606
|
}, [initialSession, initialPrompt, handleResume, handleSubmit, refreshUsage]);
|
|
607
|
+
// Resolve the npm version check after first paint so the TUI shows up
|
|
608
|
+
// immediately and the upgrade banner fades in once we know the answer.
|
|
609
|
+
useEffect(() => {
|
|
610
|
+
if (!updateCheck)
|
|
611
|
+
return;
|
|
612
|
+
let cancelled = false;
|
|
613
|
+
updateCheck.then((info) => {
|
|
614
|
+
if (!cancelled)
|
|
615
|
+
setUpdateInfo(info);
|
|
616
|
+
});
|
|
617
|
+
return () => {
|
|
618
|
+
cancelled = true;
|
|
619
|
+
};
|
|
620
|
+
}, [updateCheck]);
|
|
557
621
|
useInput((input, key) => {
|
|
622
|
+
// Ctrl+D quits from any view (chat, login, busy, picker, approval,
|
|
623
|
+
// followup). InputBox swallows other Ctrl-combos, but this hook is a
|
|
624
|
+
// sibling of InputBox's hook, so it still sees the key.
|
|
625
|
+
if (key.ctrl && input === "d") {
|
|
626
|
+
agentRef.current?.abort();
|
|
627
|
+
exit();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
558
630
|
if (key.escape && busy && !pendingApproval && !pendingFollowup) {
|
|
559
631
|
agentRef.current?.abort();
|
|
560
632
|
}
|
|
@@ -593,8 +665,8 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
593
665
|
pushRow({ kind: "info", text: `Signed in${who ? ` as ${who}` : ""}. Ready when you are.` });
|
|
594
666
|
}, [pushRow]);
|
|
595
667
|
const taskLines = useMemo(() => tasks.split("\n").map((l) => l.trim()).filter(Boolean), [tasks]);
|
|
596
|
-
const inputActive = view === "chat" && !
|
|
597
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
|
|
668
|
+
const inputActive = view === "chat" && !pendingApproval && !pendingFollowup && !modelPickerOpen && !resumableSessions;
|
|
669
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [updateInfo?.updateAvailable && updateInfo.latest && (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: COLORS.warning, paddingX: 2, alignSelf: "flex-start", children: [_jsxs(Text, { color: COLORS.warning, bold: true, children: ["\u2191 Update available: v", updateInfo.current, " \u2192 v", updateInfo.latest] }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: COLORS.accent, children: "orbcode update" }), " to install the latest version, then relaunch."] })] })), streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
|
|
598
670
|
.replace(/^[-*]\s*\[x\]/i, " ■")
|
|
599
671
|
.replace(/^[-*]\s*\[-\]/, " ◧")
|
|
600
672
|
.replace(/^[-*]\s*\[ \]/, " □") }, i))), taskLines.length > 10 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", taskLines.length - 10, " more"] })] })), modelPickerOpen && (_jsx(Box, { marginTop: 1, children: _jsx(ModelPicker, { currentId: settings.model, onSelect: (modelId) => {
|
|
@@ -607,12 +679,18 @@ export function App({ initialView, initialPrompt, initialSession, }) {
|
|
|
607
679
|
pushRow({ kind: "user", text: answer });
|
|
608
680
|
pendingFollowup.resolve(answer);
|
|
609
681
|
setPendingFollowup(null);
|
|
610
|
-
} }) })), busy && !pendingApproval && !pendingFollowup && !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage, tieredUsage: usage?.tieredUsage })] })] }))] }));
|
|
682
|
+
} }) })), busy && !pendingApproval && !pendingFollowup && !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [queuedMessages.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, marginBottom: 1, children: [_jsxs(Text, { dimColor: true, bold: true, children: ["Queue (", queuedMessages.length, ")"] }), queuedMessages.slice(0, 5).map((msg, i) => (_jsxs(Text, { dimColor: true, children: [i + 1, ". ", truncateForQueue(msg).replace(/\n/g, "↵")] }, i))), queuedMessages.length > 5 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", queuedMessages.length - 5, " more"] }))] })), _jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage, tieredUsage: usage?.tieredUsage })] })] }))] }));
|
|
611
683
|
}
|
|
612
684
|
function tail(text, lines) {
|
|
613
685
|
const all = text.split("\n").filter((l) => l.trim());
|
|
614
686
|
return all.slice(-lines).join("\n");
|
|
615
687
|
}
|
|
688
|
+
const QUEUE_PREVIEW_LIMIT = 80;
|
|
689
|
+
function truncateForQueue(text) {
|
|
690
|
+
if (text.length <= QUEUE_PREVIEW_LIMIT)
|
|
691
|
+
return text;
|
|
692
|
+
return text.slice(0, QUEUE_PREVIEW_LIMIT - 1) + "…";
|
|
693
|
+
}
|
|
616
694
|
function LoginSection({ onLogin }) {
|
|
617
695
|
return _jsx(LoginView, { onLogin: onLogin });
|
|
618
696
|
}
|
|
@@ -147,6 +147,14 @@ export function InputBox({ active, slashCommands, onSubmit }) {
|
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
+
if (key.shift && key.return) {
|
|
151
|
+
// Shift+Enter inserts a literal newline without submitting.
|
|
152
|
+
setHistoryIndex(-1);
|
|
153
|
+
const next = value.slice(0, cursor) + "\n" + value.slice(cursor);
|
|
154
|
+
setValue(next);
|
|
155
|
+
setCursor((c) => c + 1);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
150
158
|
if (key.return) {
|
|
151
159
|
submit(value);
|
|
152
160
|
return;
|
|
@@ -198,15 +206,11 @@ export function InputBox({ active, slashCommands, onSubmit }) {
|
|
|
198
206
|
return;
|
|
199
207
|
}
|
|
200
208
|
if (input) {
|
|
201
|
-
// Multi-char chunks (paste)
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
const clean = input.replace(
|
|
209
|
+
// Multi-char chunks (paste) are inserted as-is. Newlines inside
|
|
210
|
+
// the chunk are literal newlines, not a submit signal — press
|
|
211
|
+
// Enter when you're ready to send.
|
|
212
|
+
const clean = input.replace(/\r\n?/g, "\n");
|
|
205
213
|
const next = value.slice(0, cursor) + clean + value.slice(cursor);
|
|
206
|
-
if (endsWithNewline) {
|
|
207
|
-
submit(next);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
214
|
setHistoryIndex(-1);
|
|
211
215
|
setValue(next);
|
|
212
216
|
setCursor((c) => c + clean.length);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { getConfigDir } from "../config/settings.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/** Network timeout when hitting the npm registry. Kept short so it never blocks startup. */
|
|
8
|
+
const REGISTRY_TIMEOUT_MS = 3_000;
|
|
9
|
+
/** Don't re-hit npm more than once per TTL window. */
|
|
10
|
+
const CACHE_TTL_MS = 60 * 60 * 1_000; // 1 hour
|
|
11
|
+
/** Hit the npm registry to learn what the latest published version is. */
|
|
12
|
+
export async function fetchLatestNpmVersion(pkg, timeoutMs = REGISTRY_TIMEOUT_MS) {
|
|
13
|
+
// encodeURIComponent turns "@matterailab/orbcode" into "@matterailab%2Forbcode",
|
|
14
|
+
// which is the form the registry URL expects for scoped packages.
|
|
15
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`;
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
signal: controller.signal,
|
|
21
|
+
headers: { accept: "application/json" },
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return null;
|
|
25
|
+
const data = (await res.json());
|
|
26
|
+
return typeof data.version === "string" ? data.version : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Compare two semver strings (X.Y.Z, optionally with a pre-release suffix).
|
|
37
|
+
* Returns -1 if a < b, 0 if equal, 1 if a > b. Non-numeric segments fall back
|
|
38
|
+
* to lexicographic compare so "0.1.4-beta" sorts before "0.1.4".
|
|
39
|
+
*/
|
|
40
|
+
export function compareVersions(a, b) {
|
|
41
|
+
const [aMain, aPre] = splitVersion(a);
|
|
42
|
+
const [bMain, bPre] = splitVersion(b);
|
|
43
|
+
for (let i = 0; i < 3; i++) {
|
|
44
|
+
const ai = aMain[i] ?? 0;
|
|
45
|
+
const bi = bMain[i] ?? 0;
|
|
46
|
+
if (ai !== bi)
|
|
47
|
+
return ai < bi ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
// A version with no pre-release is greater than one with one.
|
|
50
|
+
if (aPre === bPre)
|
|
51
|
+
return 0;
|
|
52
|
+
if (aPre === "")
|
|
53
|
+
return 1;
|
|
54
|
+
if (bPre === "")
|
|
55
|
+
return -1;
|
|
56
|
+
return aPre < bPre ? -1 : 1;
|
|
57
|
+
}
|
|
58
|
+
function splitVersion(v) {
|
|
59
|
+
const [main, pre = ""] = v.split("-", 2);
|
|
60
|
+
const parts = main.split(".").map((p) => {
|
|
61
|
+
const n = Number.parseInt(p, 10);
|
|
62
|
+
return Number.isFinite(n) ? n : 0;
|
|
63
|
+
});
|
|
64
|
+
while (parts.length < 3)
|
|
65
|
+
parts.push(0);
|
|
66
|
+
return [parts, pre];
|
|
67
|
+
}
|
|
68
|
+
function getCachePath() {
|
|
69
|
+
return path.join(getConfigDir(), "update-check.json");
|
|
70
|
+
}
|
|
71
|
+
function readCache() {
|
|
72
|
+
try {
|
|
73
|
+
const raw = fs.readFileSync(getCachePath(), "utf8");
|
|
74
|
+
const parsed = JSON.parse(raw);
|
|
75
|
+
if (typeof parsed.checkedAt !== "number")
|
|
76
|
+
return null;
|
|
77
|
+
return { checkedAt: parsed.checkedAt, latest: parsed.latest ?? null };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function writeCache(latest) {
|
|
84
|
+
try {
|
|
85
|
+
const dir = getConfigDir();
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(getCachePath(), JSON.stringify({ checkedAt: Date.now(), latest }), { mode: 0o600 });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// best-effort; a missing cache just means we'll hit the registry again next launch
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve whether a newer version is available, using a short-lived cache so
|
|
95
|
+
* we don't hammer the npm registry on every launch.
|
|
96
|
+
*/
|
|
97
|
+
export async function getUpdateInfo(pkg, current) {
|
|
98
|
+
const cached = readCache();
|
|
99
|
+
if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
|
|
100
|
+
return makeResult(current, cached.latest);
|
|
101
|
+
}
|
|
102
|
+
const latest = await fetchLatestNpmVersion(pkg);
|
|
103
|
+
if (latest !== null)
|
|
104
|
+
writeCache(latest);
|
|
105
|
+
return makeResult(current, latest);
|
|
106
|
+
}
|
|
107
|
+
function makeResult(current, latest) {
|
|
108
|
+
if (latest === null) {
|
|
109
|
+
return { current, latest: null, updateAvailable: false, unknown: true };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
current,
|
|
113
|
+
latest,
|
|
114
|
+
updateAvailable: compareVersions(current, latest) < 0,
|
|
115
|
+
unknown: false,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Invalidate the cached "latest" so the next launch re-checks. */
|
|
119
|
+
export function clearUpdateCache() {
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(getCachePath());
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Run `npm install -g <pkg>@latest` and return the exit code (0 = success). */
|
|
128
|
+
export function runNpmUpdate(pkg) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
// On Windows, npm is a .cmd shim; spawning it directly fails without shell:true.
|
|
131
|
+
const isWin = process.platform === "win32";
|
|
132
|
+
const child = spawn("npm", ["install", "-g", `${pkg}@latest`], {
|
|
133
|
+
stdio: "inherit",
|
|
134
|
+
shell: isWin,
|
|
135
|
+
});
|
|
136
|
+
child.on("error", () => resolve(1));
|
|
137
|
+
child.on("close", (code) => resolve(typeof code === "number" ? code : 1));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Detect whether the running CLI is inside a global npm install, so the
|
|
142
|
+
* update command can surface a friendlier message for local/dev installs.
|
|
143
|
+
*/
|
|
144
|
+
export function isGlobalInstall() {
|
|
145
|
+
// When installed via `npm i -g @matterailab/orbcode`, the entrypoint lives
|
|
146
|
+
// somewhere like <global root>/node_modules/@matterailab/orbcode/...
|
|
147
|
+
const here = process.argv[1] || "";
|
|
148
|
+
return /node_modules[\\/]@matterailab[\\/]orbcode/.test(here);
|
|
149
|
+
}
|
|
150
|
+
/** Best-effort: run `npm root -g` so we can show the install location. */
|
|
151
|
+
export async function getGlobalInstallRoot() {
|
|
152
|
+
try {
|
|
153
|
+
const { stdout } = await execFileAsync("npm", ["root", "-g"], {
|
|
154
|
+
shell: process.platform === "win32",
|
|
155
|
+
});
|
|
156
|
+
const trimmed = stdout.trim();
|
|
157
|
+
return trimmed ? trimmed : null;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matterailab/orbcode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"ink": "^5.2.1",
|
|
41
41
|
"open": "^10.1.0",
|
|
42
42
|
"openai": "^4.78.0",
|
|
43
|
-
"picomatch": "^4.0.
|
|
43
|
+
"picomatch": "^4.0.4",
|
|
44
44
|
"react": "^18.3.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|