From d30c3743fddc1d35b1256d51a632df23ff6a9bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Mon, 13 Apr 2026 16:21:57 +0200 Subject: [PATCH 01/14] show offline banner during argent init Detect offline state via a DNS lookup of the npm registry at the start of `argent init` and render a red "Offline mode" note listing the steps that require network access (global install/update, `npx skills`). Skip the update-check spinner entirely when offline and default the skills prompt to the manual option so the user is not dropped into a failing npx run. Also pipe stderr on the `npm view` version lookup so its 404 output no longer leaks to the terminal. --- packages/mcp/src/cli/init.ts | 28 ++++++++++++++++++++++++---- packages/mcp/src/cli/utils.ts | 13 +++++++++++++ packages/mcp/test/cli/utils.test.ts | 13 +++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 5f52516b..d0cb2b40 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -16,6 +16,7 @@ import { AGENTS_DIR, getInstalledVersion, getLatestVersion, + isOnline, detectPackageManager, globalInstallCommand, formatShellCommand, @@ -75,6 +76,20 @@ export async function init(args: string[]): Promise { let version = getInstalledVersion() ?? "unknown"; p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); + const online = await isOnline(); + if (!online) { + p.note( + [ + pc.red("You appear to be offline."), + ``, + `You can continue, but the following will be skipped:`, + ` • Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`, + ` • Installing skills via ${pc.cyan("npx skills")} (use the manual option instead)`, + ].join("\n"), + pc.red("Offline mode") + ); + } + // ── Step 0: Install / Update Check ────────────────────────────────────────── const globallyInstalled = isGloballyInstalled(); @@ -135,7 +150,7 @@ export async function init(args: string[]): Promise { p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); process.exit(1); } - } else { + } else if (online) { let latest: string | null = null; const spinner = p.spinner(); spinner.start("Checking for updates..."); @@ -388,22 +403,27 @@ export async function init(args: string[]): Promise { let skillsMethod: SkillsMethod; if (nonInteractive) { - skillsMethod = "default"; + skillsMethod = online ? "default" : "manual"; } else { p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); const choice = await p.select({ message: "How would you like to install skills?", + initialValue: online ? "default" : "manual", options: [ { value: "default" as const, label: "Automatic", - hint: "Installs all skills automatically with npx skills", + hint: online + ? "Installs all skills automatically with npx skills" + : "Requires network - unavailable offline", }, { value: "interactive" as const, label: "Interactive", - hint: "Full npx skills TUI - choose skills, agents, and method", + hint: online + ? "Full npx skills TUI - choose skills, agents, and method" + : "Requires network - unavailable offline", }, { value: "manual" as const, diff --git a/packages/mcp/src/cli/utils.ts b/packages/mcp/src/cli/utils.ts index 29931a78..b2411b1b 100644 --- a/packages/mcp/src/cli/utils.ts +++ b/packages/mcp/src/cli/utils.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import * as dns from "node:dns"; import { execSync } from "node:child_process"; import { PACKAGE_NAME, NPM_REGISTRY } from "./constants.js"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; @@ -89,10 +90,22 @@ export function getInstalledVersion(): string | null { export function getLatestVersion(): string { const result = execSync(`npm view ${PACKAGE_NAME} version --registry ${NPM_REGISTRY}`, { encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], }); return result.trim(); } +export async function isOnline(timeoutMs = 1500): Promise { + const host = new URL(NPM_REGISTRY).hostname; + const lookup = new Promise((resolve) => { + dns.lookup(host, (err) => resolve(!err)); + }); + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(false), timeoutMs); + }); + return Promise.race([lookup, timeout]); +} + // ── Package manager detection ───────────────────────────────────────────────── export type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; diff --git a/packages/mcp/test/cli/utils.test.ts b/packages/mcp/test/cli/utils.test.ts index 3b6206dd..b97313a4 100644 --- a/packages/mcp/test/cli/utils.test.ts +++ b/packages/mcp/test/cli/utils.test.ts @@ -11,6 +11,7 @@ import { globalInstallCommand, globalUninstallCommand, formatShellCommand, + isOnline, SKILLS_DIR, RULES_DIR, AGENTS_DIR, @@ -228,3 +229,15 @@ describe("bundled paths", () => { expect(AGENTS_DIR).toMatch(/agents$/); }); }); + +// ── isOnline ────────────────────────────────────────────────────────────────── + +describe("isOnline", () => { + it("resolves to false within the timeout when DNS does not respond", async () => { + const start = Date.now(); + const result = await isOnline(50); + const elapsed = Date.now() - start; + expect(typeof result).toBe("boolean"); + expect(elapsed).toBeLessThan(500); + }); +}); From ccd312335f846ea4baece76d4247bb475b20fd9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Mon, 13 Apr 2026 16:23:29 +0200 Subject: [PATCH 02/14] soften offline skills wording to account for npx cache --- packages/mcp/src/cli/init.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index d0cb2b40..047c0637 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -84,7 +84,7 @@ export async function init(args: string[]): Promise { ``, `You can continue, but the following will be skipped:`, ` • Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`, - ` • Installing skills via ${pc.cyan("npx skills")} (use the manual option instead)`, + ` • Installing skills via ${pc.cyan("npx skills")} (unless already cached; manual install always works)`, ].join("\n"), pc.red("Offline mode") ); @@ -416,14 +416,14 @@ export async function init(args: string[]): Promise { label: "Automatic", hint: online ? "Installs all skills automatically with npx skills" - : "Requires network - unavailable offline", + : "May require network if not already cached", }, { value: "interactive" as const, label: "Interactive", hint: online ? "Full npx skills TUI - choose skills, agents, and method" - : "Requires network - unavailable offline", + : "May require network if not already cached", }, { value: "manual" as const, From 0139551f4010f59b87cf8fb7b8066715d4654613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Mon, 13 Apr 2026 16:24:26 +0200 Subject: [PATCH 03/14] hide offline skills warning when npx skills is already cached --- packages/mcp/src/cli/init.ts | 36 +++++++++++++++++++---------------- packages/mcp/src/cli/utils.ts | 11 +++++++++++ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 047c0637..5803bd23 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -17,6 +17,7 @@ import { getInstalledVersion, getLatestVersion, isOnline, + isSkillsCliAvailable, detectPackageManager, globalInstallCommand, formatShellCommand, @@ -77,17 +78,18 @@ export async function init(args: string[]): Promise { p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); const online = await isOnline(); + const skillsCached = !online && isSkillsCliAvailable(); if (!online) { - p.note( - [ - pc.red("You appear to be offline."), - ``, - `You can continue, but the following will be skipped:`, - ` • Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`, - ` • Installing skills via ${pc.cyan("npx skills")} (unless already cached; manual install always works)`, - ].join("\n"), - pc.red("Offline mode") - ); + const lines = [ + pc.red("You appear to be offline."), + ``, + `You can continue, but the following will be skipped:`, + ` • Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`, + ]; + if (!skillsCached) { + lines.push(` • Installing skills via ${pc.cyan("npx skills")} (use the manual option instead)`); + } + p.note(lines.join("\n"), pc.red("Offline mode")); } // ── Step 0: Install / Update Check ────────────────────────────────────────── @@ -402,28 +404,30 @@ export async function init(args: string[]): Promise { type SkillsMethod = "default" | "interactive" | "manual"; let skillsMethod: SkillsMethod; + const skillsCliReady = online || skillsCached; + if (nonInteractive) { - skillsMethod = online ? "default" : "manual"; + skillsMethod = skillsCliReady ? "default" : "manual"; } else { p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); const choice = await p.select({ message: "How would you like to install skills?", - initialValue: online ? "default" : "manual", + initialValue: skillsCliReady ? "default" : "manual", options: [ { value: "default" as const, label: "Automatic", - hint: online + hint: skillsCliReady ? "Installs all skills automatically with npx skills" - : "May require network if not already cached", + : "Requires network - unavailable offline", }, { value: "interactive" as const, label: "Interactive", - hint: online + hint: skillsCliReady ? "Full npx skills TUI - choose skills, agents, and method" - : "May require network if not already cached", + : "Requires network - unavailable offline", }, { value: "manual" as const, diff --git a/packages/mcp/src/cli/utils.ts b/packages/mcp/src/cli/utils.ts index b2411b1b..786cecc0 100644 --- a/packages/mcp/src/cli/utils.ts +++ b/packages/mcp/src/cli/utils.ts @@ -95,6 +95,17 @@ export function getLatestVersion(): string { return result.trim(); } +export function isSkillsCliAvailable(): boolean { + try { + execSync("npx --no-install skills --version", { + stdio: ["ignore", "ignore", "ignore"], + }); + return true; + } catch { + return false; + } +} + export async function isOnline(timeoutMs = 1500): Promise { const host = new URL(NPM_REGISTRY).hostname; const lookup = new Promise((resolve) => { From d0a7acb9ca2701208d55541800380f5e0ae6aa71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Mon, 13 Apr 2026 16:25:15 +0200 Subject: [PATCH 04/14] extract offline banner into its own helper --- packages/mcp/src/cli/init.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 5803bd23..93c3b74b 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -80,16 +80,7 @@ export async function init(args: string[]): Promise { const online = await isOnline(); const skillsCached = !online && isSkillsCliAvailable(); if (!online) { - const lines = [ - pc.red("You appear to be offline."), - ``, - `You can continue, but the following will be skipped:`, - ` • Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`, - ]; - if (!skillsCached) { - lines.push(` • Installing skills via ${pc.cyan("npx skills")} (use the manual option instead)`); - } - p.note(lines.join("\n"), pc.red("Offline mode")); + printOfflineBanner({ skillsCached }); } // ── Step 0: Install / Update Check ────────────────────────────────────────── @@ -528,6 +519,22 @@ export async function init(args: string[]): Promise { p.outro(pc.green("Argent is ready!")); } +function printOfflineBanner({ skillsCached }: { skillsCached: boolean }): void { + const bullets = [`Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`]; + if (!skillsCached) { + bullets.push(`Installing skills via ${pc.cyan("npx skills")} (use the manual option instead)`); + } + + const body = [ + pc.red("You appear to be offline."), + "", + "You can continue, but the following will be skipped:", + ...bullets.map((b) => ` • ${b}`), + ].join("\n"); + + p.note(body, pc.red("Offline mode")); +} + export function printBanner(): void { const lines = [ " █████╗ ██████╗ ██████╗ ███████╗███╗ ██╗████████╗", From 8360e4c32f4a26056dfb54cb0a3496733a334202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Mon, 13 Apr 2026 16:26:26 +0200 Subject: [PATCH 05/14] drop redundant manual-option hint from offline bullet --- packages/mcp/src/cli/init.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 93c3b74b..ecf25879 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -522,7 +522,7 @@ export async function init(args: string[]): Promise { function printOfflineBanner({ skillsCached }: { skillsCached: boolean }): void { const bullets = [`Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`]; if (!skillsCached) { - bullets.push(`Installing skills via ${pc.cyan("npx skills")} (use the manual option instead)`); + bullets.push(`Installing skills via ${pc.cyan("npx skills")}`); } const body = [ From 55ba8c5064d5d0d4919c96e3358d2743e9c06ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Mon, 13 Apr 2026 19:00:21 +0200 Subject: [PATCH 06/14] harden isOnline: unref timer, clear on resolve, guard URL parse --- packages/mcp/src/cli/utils.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/mcp/src/cli/utils.ts b/packages/mcp/src/cli/utils.ts index f67e96ab..e384e34c 100644 --- a/packages/mcp/src/cli/utils.ts +++ b/packages/mcp/src/cli/utils.ts @@ -149,14 +149,21 @@ export function isSkillsCliAvailable(): boolean { } export async function isOnline(timeoutMs = 1500): Promise { - const host = new URL(NPM_REGISTRY).hostname; - const lookup = new Promise((resolve) => { - dns.lookup(host, (err) => resolve(!err)); - }); - const timeout = new Promise((resolve) => { - setTimeout(() => resolve(false), timeoutMs); + let host: string; + try { + host = new URL(NPM_REGISTRY).hostname; + } catch { + return false; + } + + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + timer.unref(); + dns.lookup(host, (err) => { + clearTimeout(timer); + resolve(!err); + }); }); - return Promise.race([lookup, timeout]); } // ── Package manager detection ───────────────────────────────────────────────── From aa17bd1b32ccf8926a01b767baafc8e8ec001e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Tue, 14 Apr 2026 16:33:10 +0200 Subject: [PATCH 07/14] test(mcp/cli): mock network probes; harden offline skills install Replace the `isOnline` test that hit real DNS with deterministic mocks for `node:dns.lookup` and `node:child_process.execSync`, and add coverage for `isSkillsCliAvailable` and `getLatestVersion` (trim, stderr pipe regression guard, error propagation). Fixes found during verification: - Offline skills install now passes `--no-install` to npx so it uses the cache that `isSkillsCliAvailable()` already confirmed, instead of hitting the registry for a fresh copy and failing offline. - Strip `--no-install` from the manual-recovery hint so users can retry with network access. - `isSkillsCliAvailable` and `getLatestVersion` now pass execSync timeouts (2s and 10s) so a wedged `npx` or captive portal cannot hang `argent init`. --- packages/mcp/src/cli/init.ts | 14 +- packages/mcp/src/cli/utils.ts | 2 + packages/mcp/test/cli/utils.test.ts | 199 +++++++++++++++++++++++++++- 3 files changed, 209 insertions(+), 6 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index ecf25879..f26a450e 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -456,7 +456,13 @@ export async function init(args: string[]): Promise { "Manual Skills Installation" ); } else { - const skillsArgs = ["skills", "add", SKILLS_DIR]; + // When offline we rely on npx's own cache — pass --no-install so npx + // does not attempt to fetch a newer `skills` package from the registry, + // which would fail and mask the cached copy that `isSkillsCliAvailable` + // already confirmed is present. + const skillsArgs = skillsCached + ? ["--no-install", "skills", "add", SKILLS_DIR] + : ["skills", "add", SKILLS_DIR]; if (scope === "global") { skillsArgs.push("-g"); @@ -484,7 +490,11 @@ export async function init(args: string[]): Promise { spinner.stop(pc.red("Skills installation failed.")); } p.log.error(`Failed to run npx skills: ${err}`); - p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); + // Strip `--no-install` from the manual-recovery hint: if the cached + // copy just failed, the user's actual recovery path is almost always + // re-running with network access, not forcing the same offline mode. + const manualArgs = skillsArgs[0] === "--no-install" ? skillsArgs.slice(1) : skillsArgs; + p.log.info(`You can install skills manually:\n npx ${manualArgs.join(" ")}`); } } diff --git a/packages/mcp/src/cli/utils.ts b/packages/mcp/src/cli/utils.ts index e384e34c..a362db1f 100644 --- a/packages/mcp/src/cli/utils.ts +++ b/packages/mcp/src/cli/utils.ts @@ -133,6 +133,7 @@ export function getLatestVersion(): string { const result = execSync(`npm view ${PACKAGE_NAME} version --registry ${NPM_REGISTRY}`, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + timeout: 10_000, }); return result.trim(); } @@ -141,6 +142,7 @@ export function isSkillsCliAvailable(): boolean { try { execSync("npx --no-install skills --version", { stdio: ["ignore", "ignore", "ignore"], + timeout: 2_000, }); return true; } catch { diff --git a/packages/mcp/test/cli/utils.test.ts b/packages/mcp/test/cli/utils.test.ts index 04c674fd..7581e7ec 100644 --- a/packages/mcp/test/cli/utils.test.ts +++ b/packages/mcp/test/cli/utils.test.ts @@ -1,7 +1,36 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; + +// ── Module mocks ───────────────────────────────────────────────────────────── +// These are hoisted so `vi.mock` can reference them. They let the network- +// dependent helpers (`isOnline`, `isSkillsCliAvailable`, `getLatestVersion`) +// be tested deterministically without touching DNS or spawning `npm`/`npx`. + +const { dnsLookupMock, execSyncMock } = vi.hoisted(() => ({ + dnsLookupMock: vi.fn(), + execSyncMock: vi.fn(), +})); + +vi.mock("node:dns", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, lookup: dnsLookupMock }, + lookup: dnsLookupMock, + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, execSync: execSyncMock }, + execSync: execSyncMock, + }; +}); + import { readJson, writeJson, @@ -11,12 +40,15 @@ import { globalInstallCommand, globalUninstallCommand, formatShellCommand, + getLatestVersion, isOnline, + isSkillsCliAvailable, resolveProjectRoot, SKILLS_DIR, RULES_DIR, AGENTS_DIR, } from "../../src/cli/utils.js"; +import { NPM_REGISTRY, PACKAGE_NAME } from "../../src/cli/constants.js"; let tmpDir: string; @@ -272,13 +304,172 @@ describe("bundled paths", () => { }); // ── isOnline ────────────────────────────────────────────────────────────────── +// `isOnline` wraps `dns.lookup` with a timeout. All tests below run against +// the mocked `dns.lookup` set up at the top of this file — they never touch +// real DNS, which keeps them deterministic on offline runners and CI machines +// that deny outbound network access. describe("isOnline", () => { - it("resolves to false within the timeout when DNS does not respond", async () => { + beforeEach(() => { + dnsLookupMock.mockReset(); + }); + + it("returns true when DNS resolution succeeds", async () => { + dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => { + setImmediate(() => callback(null)); + }); + + await expect(isOnline()).resolves.toBe(true); + expect(dnsLookupMock).toHaveBeenCalledTimes(1); + }); + + it("returns false when DNS resolution errors", async () => { + dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => { + setImmediate(() => callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" }))); + }); + + await expect(isOnline()).resolves.toBe(false); + }); + + it("returns false when DNS never responds before the timeout", async () => { + dnsLookupMock.mockImplementation(() => { + // Never invoke the callback — simulate a hanging DNS query. + }); + const start = Date.now(); - const result = await isOnline(50); + const result = await isOnline(30); const elapsed = Date.now() - start; - expect(typeof result).toBe("boolean"); + + expect(result).toBe(false); + expect(elapsed).toBeGreaterThanOrEqual(25); expect(elapsed).toBeLessThan(500); }); + + it("looks up the hostname from NPM_REGISTRY", async () => { + const expectedHost = new URL(NPM_REGISTRY).hostname; + dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => { + setImmediate(() => callback(null)); + }); + + await isOnline(); + + expect(dnsLookupMock).toHaveBeenCalledWith(expectedHost, expect.any(Function)); + }); + + it("settles once even if DNS responds after the timeout has already fired", async () => { + let dnsCallback: ((err: Error | null) => void) | null = null; + dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => { + dnsCallback = callback; + }); + + const result = await isOnline(10); + expect(result).toBe(false); + + // Late DNS callback must not throw, log, or re-resolve the already- + // settled promise. This mirrors what happens when DNS responds after + // we have already given up waiting. + expect(() => dnsCallback?.(null)).not.toThrow(); + }); +}); + +// ── isSkillsCliAvailable ───────────────────────────────────────────────────── + +describe("isSkillsCliAvailable", () => { + beforeEach(() => { + execSyncMock.mockReset(); + }); + + it("returns true when `npx --no-install skills --version` exits successfully", () => { + execSyncMock.mockReturnValue(Buffer.from("0.1.0\n")); + + expect(isSkillsCliAvailable()).toBe(true); + expect(execSyncMock).toHaveBeenCalledTimes(1); + const [cmd] = execSyncMock.mock.calls[0]!; + expect(cmd).toBe("npx --no-install skills --version"); + }); + + it("returns false when the probe throws (skills CLI not in npx cache)", () => { + execSyncMock.mockImplementation(() => { + throw new Error("command failed"); + }); + + expect(isSkillsCliAvailable()).toBe(false); + }); + + it("fully silences stdio so nothing leaks to the terminal", () => { + execSyncMock.mockReturnValue(Buffer.from("")); + + isSkillsCliAvailable(); + + const opts = execSyncMock.mock.calls[0]![1] as + | { stdio?: [unknown, unknown, unknown] } + | undefined; + expect(opts?.stdio).toEqual(["ignore", "ignore", "ignore"]); + }); + + it("passes a timeout so a wedged npx cannot hang init forever", () => { + execSyncMock.mockReturnValue(Buffer.from("")); + + isSkillsCliAvailable(); + + const opts = execSyncMock.mock.calls[0]![1] as { timeout?: number } | undefined; + expect(typeof opts?.timeout).toBe("number"); + expect(opts!.timeout!).toBeGreaterThan(0); + }); +}); + +// ── getLatestVersion ───────────────────────────────────────────────────────── + +describe("getLatestVersion", () => { + beforeEach(() => { + execSyncMock.mockReset(); + }); + + it("returns the trimmed version reported by `npm view`", () => { + execSyncMock.mockReturnValue("9.9.9\n"); + + expect(getLatestVersion()).toBe("9.9.9"); + }); + + it("queries the package name and registry from constants", () => { + execSyncMock.mockReturnValue("1.0.0\n"); + + getLatestVersion(); + + const [cmd] = execSyncMock.mock.calls[0]!; + expect(cmd).toContain(`npm view ${PACKAGE_NAME} version`); + expect(cmd).toContain(`--registry ${NPM_REGISTRY}`); + }); + + it("pipes stderr so 404s do not leak to the terminal", () => { + // Regression guard for the previous behavior where `stdio: 'inherit'` let + // `npm view`'s 404 output bleed into the init UI on fresh/private + // packages. stderr must be captured (piped), not inherited. + execSyncMock.mockReturnValue("1.0.0\n"); + + getLatestVersion(); + + const opts = execSyncMock.mock.calls[0]![1] as + | { stdio?: [unknown, unknown, unknown] } + | undefined; + expect(opts?.stdio).toEqual(["ignore", "pipe", "pipe"]); + }); + + it("passes a timeout to bound the registry probe", () => { + execSyncMock.mockReturnValue("1.0.0\n"); + + getLatestVersion(); + + const opts = execSyncMock.mock.calls[0]![1] as { timeout?: number } | undefined; + expect(typeof opts?.timeout).toBe("number"); + expect(opts!.timeout!).toBeGreaterThan(0); + }); + + it("propagates errors from `npm view` to the caller", () => { + execSyncMock.mockImplementation(() => { + throw new Error("E404"); + }); + + expect(() => getLatestVersion()).toThrow("E404"); + }); }); From 5930fbe12a86e5ae316975cce8ee755236175721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 16 Apr 2026 15:34:25 +0200 Subject: [PATCH 08/14] refactor: replace top-level offline banner with contextual skills warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of showing a large offline banner at the start of init that lists everything that will be skipped, only check online status at the skills step — the one place where offline truly matters. The update check already handles registry failures gracefully via try/catch. When offline and skills CLI is not cached, a single-line warning appears right before the skills prompt, and the default shifts to Manual. --- packages/mcp/src/cli/init.ts | 43 ++++++++++++------------------------ 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index f26a450e..9ed33332 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -77,12 +77,6 @@ export async function init(args: string[]): Promise { let version = getInstalledVersion() ?? "unknown"; p.log.info(`${pc.dim("Package:")} ${PACKAGE_NAME}@${version}`); - const online = await isOnline(); - const skillsCached = !online && isSkillsCliAvailable(); - if (!online) { - printOfflineBanner({ skillsCached }); - } - // ── Step 0: Install / Update Check ────────────────────────────────────────── const globallyInstalled = isGloballyInstalled(); @@ -143,7 +137,7 @@ export async function init(args: string[]): Promise { p.log.info(`Install manually with: ${pc.cyan(cmdStr)}`); process.exit(1); } - } else if (online) { + } else { let latest: string | null = null; const spinner = p.spinner(); spinner.start("Checking for updates..."); @@ -395,7 +389,16 @@ export async function init(args: string[]): Promise { type SkillsMethod = "default" | "interactive" | "manual"; let skillsMethod: SkillsMethod; - const skillsCliReady = online || skillsCached; + const online = await isOnline(); + const offlineWithCache = !online && isSkillsCliAvailable(); + const skillsCliReady = online || offlineWithCache; + + if (!skillsCliReady) { + p.log.warn( + pc.yellow("You appear to be offline. ") + + "Automatic skills installation requires a network connection." + ); + } if (nonInteractive) { skillsMethod = skillsCliReady ? "default" : "manual"; @@ -456,11 +459,9 @@ export async function init(args: string[]): Promise { "Manual Skills Installation" ); } else { - // When offline we rely on npx's own cache — pass --no-install so npx - // does not attempt to fetch a newer `skills` package from the registry, - // which would fail and mask the cached copy that `isSkillsCliAvailable` - // already confirmed is present. - const skillsArgs = skillsCached + // Offline with a cached skills CLI: --no-install prevents npx from + // hitting the registry for a newer version that it can't reach. + const skillsArgs = offlineWithCache ? ["--no-install", "skills", "add", SKILLS_DIR] : ["skills", "add", SKILLS_DIR]; @@ -529,22 +530,6 @@ export async function init(args: string[]): Promise { p.outro(pc.green("Argent is ready!")); } -function printOfflineBanner({ skillsCached }: { skillsCached: boolean }): void { - const bullets = [`Installing or updating ${pc.cyan(PACKAGE_NAME)} from npm`]; - if (!skillsCached) { - bullets.push(`Installing skills via ${pc.cyan("npx skills")}`); - } - - const body = [ - pc.red("You appear to be offline."), - "", - "You can continue, but the following will be skipped:", - ...bullets.map((b) => ` • ${b}`), - ].join("\n"); - - p.note(body, pc.red("Offline mode")); -} - export function printBanner(): void { const lines = [ " █████╗ ██████╗ ██████╗ ███████╗███╗ ██╗████████╗", From 90b747af601ad0d43f78eab13aeaa4a01e9102b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 16 Apr 2026 15:38:00 +0200 Subject: [PATCH 09/14] revert: remove unrelated getLatestVersion stdio/timeout change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stdio piping and timeout addition to getLatestVersion belongs in a separate PR — it fixes stderr leakage from npm view, not offline handling. --- packages/mcp/src/cli/utils.ts | 2 - packages/mcp/test/cli/utils.test.ts | 58 +---------------------------- 2 files changed, 1 insertion(+), 59 deletions(-) diff --git a/packages/mcp/src/cli/utils.ts b/packages/mcp/src/cli/utils.ts index a362db1f..e8e340b9 100644 --- a/packages/mcp/src/cli/utils.ts +++ b/packages/mcp/src/cli/utils.ts @@ -132,8 +132,6 @@ export function getInstalledVersion(): string | null { export function getLatestVersion(): string { const result = execSync(`npm view ${PACKAGE_NAME} version --registry ${NPM_REGISTRY}`, { encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 10_000, }); return result.trim(); } diff --git a/packages/mcp/test/cli/utils.test.ts b/packages/mcp/test/cli/utils.test.ts index 7581e7ec..95b6c4e8 100644 --- a/packages/mcp/test/cli/utils.test.ts +++ b/packages/mcp/test/cli/utils.test.ts @@ -40,7 +40,6 @@ import { globalInstallCommand, globalUninstallCommand, formatShellCommand, - getLatestVersion, isOnline, isSkillsCliAvailable, resolveProjectRoot, @@ -48,7 +47,7 @@ import { RULES_DIR, AGENTS_DIR, } from "../../src/cli/utils.js"; -import { NPM_REGISTRY, PACKAGE_NAME } from "../../src/cli/constants.js"; +import { NPM_REGISTRY } from "../../src/cli/constants.js"; let tmpDir: string; @@ -418,58 +417,3 @@ describe("isSkillsCliAvailable", () => { }); }); -// ── getLatestVersion ───────────────────────────────────────────────────────── - -describe("getLatestVersion", () => { - beforeEach(() => { - execSyncMock.mockReset(); - }); - - it("returns the trimmed version reported by `npm view`", () => { - execSyncMock.mockReturnValue("9.9.9\n"); - - expect(getLatestVersion()).toBe("9.9.9"); - }); - - it("queries the package name and registry from constants", () => { - execSyncMock.mockReturnValue("1.0.0\n"); - - getLatestVersion(); - - const [cmd] = execSyncMock.mock.calls[0]!; - expect(cmd).toContain(`npm view ${PACKAGE_NAME} version`); - expect(cmd).toContain(`--registry ${NPM_REGISTRY}`); - }); - - it("pipes stderr so 404s do not leak to the terminal", () => { - // Regression guard for the previous behavior where `stdio: 'inherit'` let - // `npm view`'s 404 output bleed into the init UI on fresh/private - // packages. stderr must be captured (piped), not inherited. - execSyncMock.mockReturnValue("1.0.0\n"); - - getLatestVersion(); - - const opts = execSyncMock.mock.calls[0]![1] as - | { stdio?: [unknown, unknown, unknown] } - | undefined; - expect(opts?.stdio).toEqual(["ignore", "pipe", "pipe"]); - }); - - it("passes a timeout to bound the registry probe", () => { - execSyncMock.mockReturnValue("1.0.0\n"); - - getLatestVersion(); - - const opts = execSyncMock.mock.calls[0]![1] as { timeout?: number } | undefined; - expect(typeof opts?.timeout).toBe("number"); - expect(opts!.timeout!).toBeGreaterThan(0); - }); - - it("propagates errors from `npm view` to the caller", () => { - execSyncMock.mockImplementation(() => { - throw new Error("E404"); - }); - - expect(() => getLatestVersion()).toThrow("E404"); - }); -}); From 2252a8a147fa2ce56ac59c332f745530edb26d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 16 Apr 2026 15:43:29 +0200 Subject: [PATCH 10/14] refactor: build skills args then prepend --no-install when offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the base skillsArgs list once, then derive npxArgs by prepending --no-install when using the offline cache. The error recovery hint uses the base list directly — no stripping needed. --- packages/mcp/src/cli/init.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 9ed33332..15328e32 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -459,11 +459,7 @@ export async function init(args: string[]): Promise { "Manual Skills Installation" ); } else { - // Offline with a cached skills CLI: --no-install prevents npx from - // hitting the registry for a newer version that it can't reach. - const skillsArgs = offlineWithCache - ? ["--no-install", "skills", "add", SKILLS_DIR] - : ["skills", "add", SKILLS_DIR]; + const skillsArgs = ["skills", "add", SKILLS_DIR]; if (scope === "global") { skillsArgs.push("-g"); @@ -473,7 +469,11 @@ export async function init(args: string[]): Promise { skillsArgs.push("--skill", "*", "-y"); } - p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(skillsArgs.join(" "))}`); + const npxArgs = offlineWithCache + ? ["--no-install", ...skillsArgs] + : skillsArgs; + + p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); const spinner = p.spinner(); if (skillsMethod === "default") { @@ -482,7 +482,7 @@ export async function init(args: string[]): Promise { try { const skillsCwd = scope === "custom" ? customRoot : undefined; - await runNpxSkills(skillsArgs, skillsMethod === "interactive", skillsCwd); + await runNpxSkills(npxArgs, skillsMethod === "interactive", skillsCwd); if (skillsMethod === "default") { spinner.stop("Skills installed."); } @@ -491,11 +491,7 @@ export async function init(args: string[]): Promise { spinner.stop(pc.red("Skills installation failed.")); } p.log.error(`Failed to run npx skills: ${err}`); - // Strip `--no-install` from the manual-recovery hint: if the cached - // copy just failed, the user's actual recovery path is almost always - // re-running with network access, not forcing the same offline mode. - const manualArgs = skillsArgs[0] === "--no-install" ? skillsArgs.slice(1) : skillsArgs; - p.log.info(`You can install skills manually:\n npx ${manualArgs.join(" ")}`); + p.log.info(`You can install skills manually:\n npx ${skillsArgs.join(" ")}`); } } From 3f03ddd1ef30fede5500fe900a25460c57a4b133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 16 Apr 2026 15:53:43 +0200 Subject: [PATCH 11/14] cleanup: remove initialValue override and fix stale test comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the initialValue on the skills prompt — it changed the default selection which is unrelated to offline handling. Fix the test file comment that still referenced getLatestVersion after its tests were removed. --- packages/mcp/src/cli/init.ts | 1 - packages/mcp/test/cli/utils.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 15328e32..7a8f1e54 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -407,7 +407,6 @@ export async function init(args: string[]): Promise { const choice = await p.select({ message: "How would you like to install skills?", - initialValue: skillsCliReady ? "default" : "manual", options: [ { value: "default" as const, diff --git a/packages/mcp/test/cli/utils.test.ts b/packages/mcp/test/cli/utils.test.ts index 95b6c4e8..48c4a05f 100644 --- a/packages/mcp/test/cli/utils.test.ts +++ b/packages/mcp/test/cli/utils.test.ts @@ -5,8 +5,8 @@ import * as os from "node:os"; // ── Module mocks ───────────────────────────────────────────────────────────── // These are hoisted so `vi.mock` can reference them. They let the network- -// dependent helpers (`isOnline`, `isSkillsCliAvailable`, `getLatestVersion`) -// be tested deterministically without touching DNS or spawning `npm`/`npx`. +// dependent helpers (`isOnline`, `isSkillsCliAvailable`) be tested +// deterministically without touching DNS or spawning `npx`. const { dnsLookupMock, execSyncMock } = vi.hoisted(() => ({ dnsLookupMock: vi.fn(), From 2640d63627d4d599c973438e7a9d8298838f0c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 16 Apr 2026 15:59:59 +0200 Subject: [PATCH 12/14] chore: unify probe timeout to 8s and fix prettier --- packages/mcp/src/cli/init.ts | 4 +--- packages/mcp/src/cli/utils.ts | 6 ++++-- packages/mcp/test/cli/utils.test.ts | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 7a8f1e54..74b2ba3d 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -468,9 +468,7 @@ export async function init(args: string[]): Promise { skillsArgs.push("--skill", "*", "-y"); } - const npxArgs = offlineWithCache - ? ["--no-install", ...skillsArgs] - : skillsArgs; + const npxArgs = offlineWithCache ? ["--no-install", ...skillsArgs] : skillsArgs; p.log.info(`Running: ${pc.dim("npx")} ${pc.cyan(npxArgs.join(" "))}`); diff --git a/packages/mcp/src/cli/utils.ts b/packages/mcp/src/cli/utils.ts index e8e340b9..f3c8643d 100644 --- a/packages/mcp/src/cli/utils.ts +++ b/packages/mcp/src/cli/utils.ts @@ -136,11 +136,13 @@ export function getLatestVersion(): string { return result.trim(); } +const PROBE_TIMEOUT_MS = 8_000; + export function isSkillsCliAvailable(): boolean { try { execSync("npx --no-install skills --version", { stdio: ["ignore", "ignore", "ignore"], - timeout: 2_000, + timeout: PROBE_TIMEOUT_MS, }); return true; } catch { @@ -148,7 +150,7 @@ export function isSkillsCliAvailable(): boolean { } } -export async function isOnline(timeoutMs = 1500): Promise { +export async function isOnline(timeoutMs = PROBE_TIMEOUT_MS): Promise { let host: string; try { host = new URL(NPM_REGISTRY).hostname; diff --git a/packages/mcp/test/cli/utils.test.ts b/packages/mcp/test/cli/utils.test.ts index 48c4a05f..c1bd928e 100644 --- a/packages/mcp/test/cli/utils.test.ts +++ b/packages/mcp/test/cli/utils.test.ts @@ -416,4 +416,3 @@ describe("isSkillsCliAvailable", () => { expect(opts!.timeout!).toBeGreaterThan(0); }); }); - From 68f18ee2122150da0af8b8bd8687259819e22910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 16 Apr 2026 16:17:08 +0200 Subject: [PATCH 13/14] add timeout to getLatestVersion and lower probe timeout to 3s getLatestVersion (npm view) hangs indefinitely offline without a timeout, blocking users from reaching the skills step. Use the shared PROBE_TIMEOUT_MS constant, lowered to 3s for snappier offline detection. --- packages/mcp/src/cli/utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/cli/utils.ts b/packages/mcp/src/cli/utils.ts index f3c8643d..d1706e3a 100644 --- a/packages/mcp/src/cli/utils.ts +++ b/packages/mcp/src/cli/utils.ts @@ -129,15 +129,16 @@ export function getInstalledVersion(): string | null { } } +const PROBE_TIMEOUT_MS = 3_000; + export function getLatestVersion(): string { const result = execSync(`npm view ${PACKAGE_NAME} version --registry ${NPM_REGISTRY}`, { encoding: "utf8", + timeout: PROBE_TIMEOUT_MS, }); return result.trim(); } -const PROBE_TIMEOUT_MS = 8_000; - export function isSkillsCliAvailable(): boolean { try { execSync("npx --no-install skills --version", { From 67287b062b3ad56b132483aba2bf5cad44bed4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacy=20=C5=81=C4=85tka?= Date: Thu, 16 Apr 2026 16:24:25 +0200 Subject: [PATCH 14/14] skip skills prompt when offline, add timeout to getLatestVersion When offline without cached skills CLI, go straight to manual instead of showing unselectable options. Add PROBE_TIMEOUT_MS to getLatestVersion so the update check doesn't hang indefinitely offline. --- packages/mcp/src/cli/init.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/mcp/src/cli/init.ts b/packages/mcp/src/cli/init.ts index 74b2ba3d..31eea6b8 100644 --- a/packages/mcp/src/cli/init.ts +++ b/packages/mcp/src/cli/init.ts @@ -400,8 +400,10 @@ export async function init(args: string[]): Promise { ); } - if (nonInteractive) { - skillsMethod = skillsCliReady ? "default" : "manual"; + if (!skillsCliReady) { + skillsMethod = "manual"; + } else if (nonInteractive) { + skillsMethod = "default"; } else { p.log.message(pc.dim(" Use arrow keys to move, enter to confirm.")); @@ -411,16 +413,12 @@ export async function init(args: string[]): Promise { { value: "default" as const, label: "Automatic", - hint: skillsCliReady - ? "Installs all skills automatically with npx skills" - : "Requires network - unavailable offline", + hint: "Installs all skills automatically with npx skills", }, { value: "interactive" as const, label: "Interactive", - hint: skillsCliReady - ? "Full npx skills TUI - choose skills, agents, and method" - : "Requires network - unavailable offline", + hint: "Full npx skills TUI - choose skills, agents, and method", }, { value: "manual" as const,