@matterailab/orbcode 0.1.4 → 0.1.6

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 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
+ ![OrbCode CLI screenshot](assets/orbcode-screenshot.webp)
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
- render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession }));
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.token, handleCommand, getAgent, pushRow]);
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" && !busy && !pendingApproval && !pendingFollowup && !modelPickerOpen && !resumableSessions;
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) can carry an embedded trailing newline,
202
- // which should submit like pressing Enter.
203
- const endsWithNewline = /[\r\n]$/.test(input);
204
- const clean = input.replace(/[\r\n]+$/, "").replace(/\r/g, "\n");
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.4",
3
+ "version": "0.1.6",
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.2",
43
+ "picomatch": "^4.0.4",
44
44
  "react": "^18.3.1"
45
45
  },
46
46
  "devDependencies": {
@@ -58,5 +58,8 @@
58
58
  "ai",
59
59
  "coding-agent"
60
60
  ],
61
- "license": "MIT"
61
+ "license": "MIT",
62
+ "overrides": {
63
+ "formdata-node": "^6.0.3"
64
+ }
62
65
  }