From 5eeeacb9e15ee37e3cdb5a72432986ea0c4eedd1 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 15 May 2026 17:18:26 +0100 Subject: [PATCH 1/2] feat(cli): 5-port ready panel, console install, global-install prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot/ready surface now reflects what's actually running and what's on the menu instead of eliding three services and offering one passive hint. 1. Five-port ready panel. printReadyHint switches from a single-line "Memory ready on :3111 · viewer on ..." to a clack p.note panel that lists REST API, Viewer, Streams, Engine bridge, and iii console. Each port reads from its env var (III_REST_PORT, III_VIEWER_PORT, III_STREAM_PORT / III_STREAMS_PORT, III_ENGINE_PORT / III_ENGINE_URL) with sane defaults; nothing is hard-coded. 2. iii console install probe + auto-install. New ensureIiiConsole detects the binary via whichBinary("iii-console") or ~/.local/bin/iii-console. If absent on a TTY (not CI), prompt the user; on yes shell out to "curl | sh"; on no persist skipConsoleInstall: true. We do NOT probe the console's HTTP port: its default 3113 collides with our viewer, so binary presence is the right signal at boot time. 3. Global-install prompt replaces maybeEmitNpxHint. Users kept ignoring the passive npx tip and then typing "agentmemory stop" in a new shell to a "command not found". Now on first npx run (TTY, not CI, not already skipped), we ask once and run "npm install -g @agentmemory/agentmemory@" on yes. On no, we persist skipGlobalInstall: true so we never re-prompt. 4. Version-string fix. The engine-version-mismatch warning quoted "agentmemory v0.9.14+ pins ..." forever. Switched to the live VERSION constant so the message stays honest across releases. Preferences schema gains skipGlobalInstall and skipConsoleInstall (both default false); schemaVersion stays at 1 because the reader already merges over defaults. --- src/cli.ts | 223 +++++++++++++++++++++++++++++++++++------ src/cli/preferences.ts | 10 ++ 2 files changed, 203 insertions(+), 30 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 83baedba..59e4fab5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -199,11 +199,45 @@ function getViewerUrl(): string { if (envUrl) return envUrl.replace(/\/+$/, ""); try { const u = new URL(getBaseUrl()); - const vPort = (parseInt(u.port || "3111", 10) || 3111) + 2; + const vPort = + parseInt(process.env["III_VIEWER_PORT"] || "", 10) || + (parseInt(u.port || "3111", 10) || 3111) + 2; return `${u.protocol}//${u.hostname}:${vPort}`; } catch { - return `http://localhost:${getRestPort() + 2}`; + const vPort = + parseInt(process.env["III_VIEWER_PORT"] || "", 10) || + getRestPort() + 2; + return `http://localhost:${vPort}`; + } +} + +// WebSocket streams port. Engine writes here; the SDK and viewer +// subscribe. Honors both `III_STREAM_PORT` (the singular name the +// engine docs use post-0.11) and `III_STREAMS_PORT` (the name our +// own config.ts has used since 0.7) so a single source of truth in +// either form lights up the ready panel. +function getStreamPort(): number { + return ( + parseInt(process.env["III_STREAM_PORT"] || "", 10) || + parseInt(process.env["III_STREAMS_PORT"] || "", 10) || + 3112 + ); +} + +// Bridge WebSocket port — the iii engine's internal worker bus. +// Defaults to 49134 (engine convention) and is overridable via +// `III_ENGINE_PORT` or the legacy `III_ENGINE_URL=ws://host:port`. +function getEnginePort(): number { + const explicit = parseInt(process.env["III_ENGINE_PORT"] || "", 10); + if (explicit) return explicit; + const url = process.env["III_ENGINE_URL"]; + if (url) { + try { + const parsed = new URL(url).port; + if (parsed) return parseInt(parsed, 10); + } catch {} } + return 49134; } async function isEngineRunning(): Promise { @@ -296,7 +330,7 @@ function warnIfEngineVersionMismatch(iiiBinPath: string | null | undefined): voi ? `curl -fsSL https://github.com/iii-hq/iii/releases/download/iii/v${IIPINNED_VERSION}/${asset} | tar -xz -C ~/.local/bin` : `download v${IIPINNED_VERSION} from https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}`; p.log.warn( - `iii-engine on PATH is v${detected} but agentmemory v0.9.14+ pins v${IIPINNED_VERSION}. Set AGENTMEMORY_III_VERSION=${detected} to silence, or downgrade with: \`${downloadHint}\``, + `iii-engine on PATH is v${detected} but agentmemory v${VERSION} pins v${IIPINNED_VERSION}. Set AGENTMEMORY_III_VERSION=${detected} to silence, or downgrade with: \`${downloadHint}\``, ); } @@ -385,24 +419,125 @@ function isInvokedViaNpx(): boolean { return false; } -function shouldSkipNpxHint(): boolean { - try { - const prefsPath = join(homedir(), ".agentmemory", "preferences.json"); - if (!existsSync(prefsPath)) return false; - const raw = readFileSync(prefsPath, "utf-8"); - const prefs = JSON.parse(raw) as { skipNpxHint?: boolean }; - return prefs?.skipNpxHint === true; - } catch { - return false; +// First-run global-install prompt. Replaces the previous passive +// `p.log.info` hint that users ignored — typing `agentmemory stop` +// in a new shell would then 404 with `command not found`. We now +// ask once, persist the answer in preferences, and never ask again. +async function maybeOfferGlobalInstall(): Promise { + if (!isInvokedViaNpx()) return; + if (!process.stdin.isTTY) return; + if (process.env["CI"]) return; + const prefs = readPrefs(); + if (prefs.skipGlobalInstall || prefs.skipNpxHint) return; + + const answer = await p.confirm({ + message: + "Install agentmemory globally so the bare `agentmemory` command works in any shell? [Y/n]", + initialValue: true, + }); + if (p.isCancel(answer)) { + // Treat Ctrl+C as "not now" rather than "never". Don't persist. + return; + } + if (answer === false) { + writePrefs({ skipGlobalInstall: true }); + p.log.info( + "Skipped. Re-run via `npx @agentmemory/agentmemory` or install later with: npm install -g @agentmemory/agentmemory", + ); + return; } -} -function maybeEmitNpxHint(): void { - if (!isInvokedViaNpx()) return; - if (shouldSkipNpxHint()) return; - p.log.info( - "Tip: install globally for the bare `agentmemory` command:\n npm install -g @agentmemory/agentmemory", + const npmBin = whichBinary("npm"); + if (!npmBin) { + p.log.warn( + "npm not found on PATH. Install manually: npm install -g @agentmemory/agentmemory", + ); + return; + } + const ok = runCommand( + npmBin, + ["install", "-g", `@agentmemory/agentmemory@${VERSION}`], + { label: `Installing @agentmemory/agentmemory@${VERSION} globally` }, ); + if (ok) { + p.log.success( + "Installed globally. `agentmemory stop` etc. will now work in new shells.", + ); + // Persist so we never re-prompt even if the user happens to npx + // again from a CI-less TTY. + writePrefs({ skipGlobalInstall: true }); + } else { + p.log.warn( + "Global install failed. Try manually: npm install -g @agentmemory/agentmemory", + ); + } +} + +// iii-console install state. +// "installed" — `iii-console` is on PATH or at `~/.local/bin/iii-console` +// "missing" — binary not found anywhere we look +// We deliberately do NOT probe the console's HTTP port: the binary +// being on disk is the signal we care about (it's not auto-started by +// agentmemory and its default port 3113 collides with our viewer, so +// "is it listening?" is the wrong question at boot time). +type IiiConsoleState = + | { kind: "installed"; binPath: string } + | { kind: "missing" }; + +function detectIiiConsole(): IiiConsoleState { + const onPath = whichBinary("iii-console"); + if (onPath) return { kind: "installed", binPath: onPath }; + const fallback = IS_WINDOWS + ? join(process.env["USERPROFILE"] ?? "", ".local", "bin", "iii-console.exe") + : join(homedir(), ".local", "bin", "iii-console"); + if (fallback && existsSync(fallback)) { + return { kind: "installed", binPath: fallback }; + } + return { kind: "missing" }; +} + +const III_CONSOLE_INSTALL_CMD = + "curl -fsSL https://install.iii.dev/console/main/install.sh | sh"; + +async function ensureIiiConsole(): Promise { + const state = detectIiiConsole(); + if (state.kind === "installed") return state; + + // Non-interactive contexts get the panel hint but no prompt. + if (!process.stdin.isTTY || process.env["CI"]) return state; + const prefs = readPrefs(); + if (prefs.skipConsoleInstall) return state; + + const answer = await p.confirm({ + message: + "iii console gives engine-level visibility (workers, functions, queues, traces). Install now?", + initialValue: true, + }); + if (p.isCancel(answer)) return state; + if (answer === false) { + writePrefs({ skipConsoleInstall: true }); + return state; + } + + const shBin = whichBinary("sh"); + const curlBin = whichBinary("curl"); + if (!shBin || !curlBin) { + p.log.warn( + `curl or sh not found. Install manually:\n ${III_CONSOLE_INSTALL_CMD}`, + ); + return state; + } + const ok = runCommand(shBin, ["-c", III_CONSOLE_INSTALL_CMD], { + label: "Installing iii console", + }); + if (!ok) { + p.log.warn( + `iii console install failed. Re-run manually:\n ${III_CONSOLE_INSTALL_CMD}`, + ); + return state; + } + // Re-detect rather than trust install-script output paths. + return detectIiiConsole(); } function adoptRunningEngine(): void { @@ -737,13 +872,31 @@ async function waitForAgentmemoryReady(timeoutMs: number): Promise { return false; } -function printReadyHint(): void { - const port = getRestPort(); - const viewer = getViewerUrl(); - const hint = `Memory ready on :${port} · viewer on ${viewer} · try: agentmemory demo`; - // Use plain stdout (not p.outro) so the hint isn't decorated with - // clack's closing line — it reads as a status, not an end-of-flow. - process.stdout.write("\n" + hint + "\n"); +function printReadyHint(consoleState: IiiConsoleState): void { + const restUrl = `http://localhost:${getRestPort()}`; + const viewerUrl = getViewerUrl(); + const streamUrl = `ws://localhost:${getStreamPort()}`; + const engineUrl = `ws://localhost:${getEnginePort()}`; + const consoleLine = + consoleState.kind === "installed" + ? // We can't safely probe iii-console's port (default 3113 + // collides with our viewer) so we surface the binary location + // and let the user start it on a port of their choice. + `iii console ${consoleState.binPath} (run: iii-console -p )` + : `iii console (run: ${III_CONSOLE_INSTALL_CMD})`; + + const lines = [ + `REST API ${restUrl}`, + `Viewer ${viewerUrl}`, + `Streams ${streamUrl}`, + `Engine ${engineUrl}`, + consoleLine, + ]; + // p.note renders a bordered panel with a title — same affordance + // used elsewhere in this CLI for "Troubleshooting" / "Setup + // required" blocks, so the visual language stays consistent. + p.note(lines.join("\n"), `agentmemory v${VERSION}`); + process.stdout.write("\nTry: agentmemory demo\n"); } async function main() { @@ -770,7 +923,11 @@ async function main() { if (skipEngine) { if (IS_VERBOSE) p.log.info("Skipping engine check (--no-engine)"); await import("./index.js"); - if (await waitForAgentmemoryReady(15000)) printReadyHint(); + if (await waitForAgentmemoryReady(15000)) { + const consoleState = await ensureIiiConsole(); + await maybeOfferGlobalInstall(); + printReadyHint(consoleState); + } return; } @@ -780,9 +937,12 @@ async function main() { whichBinary("iii") ?? fallbackIiiPaths().find((p) => existsSync(p)) ?? null; warnIfEngineVersionMismatch(attachedBin); adoptRunningEngine(); - maybeEmitNpxHint(); await import("./index.js"); - if (await waitForAgentmemoryReady(15000)) printReadyHint(); + if (await waitForAgentmemoryReady(15000)) { + const consoleState = await ensureIiiConsole(); + await maybeOfferGlobalInstall(); + printReadyHint(consoleState); + } return; } @@ -850,9 +1010,12 @@ async function main() { } s.stop("iii-engine is ready"); - maybeEmitNpxHint(); await import("./index.js"); - if (await waitForAgentmemoryReady(15000)) printReadyHint(); + if (await waitForAgentmemoryReady(15000)) { + const consoleState = await ensureIiiConsole(); + await maybeOfferGlobalInstall(); + printReadyHint(consoleState); + } // Mark splash as something to skip on subsequent runs. This is a // no-op if onboarding already flipped the flag (idempotent merge). writePrefs({ skipSplash: true }); diff --git a/src/cli/preferences.ts b/src/cli/preferences.ts index 903d28b6..ccebd1a3 100644 --- a/src/cli/preferences.ts +++ b/src/cli/preferences.ts @@ -45,6 +45,14 @@ export interface Prefs { // tradeoff" toggle. Kept on the schema so we don't have to bump // schemaVersion when we ship the flag. skipNpxHint: boolean; + // Set to true when the user declines the "install agentmemory + // globally?" prompt on first npx run. We never ask again on this + // machine so the prompt stays a one-time DX nudge, not a nag. + skipGlobalInstall: boolean; + // Set to true when the user declines the "install iii console?" + // prompt. iii console is first-class engine UI but optional at the + // install step — once the user says no, we stop asking. + skipConsoleInstall: boolean; // ISO timestamp of the first time onboarding completed. Set once, // never updated, so we can show "you joined agentmemory N days ago" // copy in /status later without keeping a separate file. @@ -58,6 +66,8 @@ const DEFAULTS: Prefs = { lastProvider: null, skipSplash: false, skipNpxHint: false, + skipGlobalInstall: false, + skipConsoleInstall: false, firstRunAt: null, }; From e4e9e8cf4363563852f7b1196c5cd1903378afb6 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 15 May 2026 18:01:38 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(cli):=20coderabbit=20PR=20#410=20?= =?UTF-8?q?=E2=80=94=20ready=20panel=20host=20detection=20+=20runnable=20h?= =?UTF-8?q?ints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both valid against the just-shipped feat/cli-boot-panel code: 1. The console line printed `(run: iii-console -p )` regardless of where the binary actually lived. We just detected the binary path two lines earlier — print that as the executable hint (`consoleState.binPath -p `) so the suggestion is runnable without assuming `iii-console` is on PATH. 2. REST / Streams / Engine URLs hardcoded `localhost`, so a deploy that binds `III_ENGINE_URL=ws://remote-host:49134` would print misleading addresses. Added `getEngineHost()` which reads III_ENGINE_URL or AGENTMEMORY_URL and falls back to localhost. REST now uses getBaseUrl() (already honors AGENTMEMORY_URL), Streams + Engine read the new host helper. Viewer line untouched — getViewerUrl() already handles this correctly. 3. Bonus runnability fix: the "Try: agentmemory demo" suggestion assumed the bare command was on PATH. Now picks `npx @agentmemory/agentmemory demo` when invoked via npx, falls back to the bare form for global installs. Build clean, 954/954 tests pass. --- src/cli.ts | 48 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 59e4fab5..f17301b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -872,18 +872,43 @@ async function waitForAgentmemoryReady(timeoutMs: number): Promise { return false; } +// Derive a host string for the streams/engine WebSocket lines from +// the configured engine URL (`III_ENGINE_URL`) or REST base +// (`AGENTMEMORY_URL`) so a remote-bind setup like +// `III_ENGINE_URL=ws://my-host:49134` doesn't print misleading +// localhost addresses. Falls back to localhost. +function getEngineHost(): string { + for (const envKey of ["III_ENGINE_URL", "AGENTMEMORY_URL"]) { + const raw = process.env[envKey]; + if (!raw) continue; + try { + const parsed = new URL(raw); + if (parsed.hostname) return parsed.hostname; + } catch {} + } + return "localhost"; +} + function printReadyHint(consoleState: IiiConsoleState): void { - const restUrl = `http://localhost:${getRestPort()}`; + // REST goes through getBaseUrl which already honors AGENTMEMORY_URL + // for full host+protocol overrides. Streams/Engine are derived from + // III_ENGINE_URL so a remote bind reads correctly in the panel. + const restUrl = getBaseUrl(); const viewerUrl = getViewerUrl(); - const streamUrl = `ws://localhost:${getStreamPort()}`; - const engineUrl = `ws://localhost:${getEnginePort()}`; + const engineHost = getEngineHost(); + const streamUrl = `ws://${engineHost}:${getStreamPort()}`; + const engineUrl = `ws://${engineHost}:${getEnginePort()}`; + const consoleLine = consoleState.kind === "installed" ? // We can't safely probe iii-console's port (default 3113 // collides with our viewer) so we surface the binary location - // and let the user start it on a port of their choice. - `iii console ${consoleState.binPath} (run: iii-console -p )` - : `iii console (run: ${III_CONSOLE_INSTALL_CMD})`; + // and let the user start it on a port of their choice. Use + // the detected binary path so `(run: ...)` is executable as- + // is, even when the binary isn't on PATH under the bare + // name `iii-console`. + `iii console ${consoleState.binPath} (run: ${consoleState.binPath} -p )` + : `iii console (install: ${III_CONSOLE_INSTALL_CMD})`; const lines = [ `REST API ${restUrl}`, @@ -896,7 +921,16 @@ function printReadyHint(consoleState: IiiConsoleState): void { // used elsewhere in this CLI for "Troubleshooting" / "Setup // required" blocks, so the visual language stays consistent. p.note(lines.join("\n"), `agentmemory v${VERSION}`); - process.stdout.write("\nTry: agentmemory demo\n"); + + // Pick a runnable form for the suggested next-step. Users invoked + // via `npx` don't have the bare `agentmemory` command on PATH yet + // (unless they accepted the global-install prompt and the npm bin + // dir was already on PATH in this shell), so we suggest the npx + // form for them; everyone else gets the global form. + const demoCommand = isInvokedViaNpx() + ? "npx @agentmemory/agentmemory demo" + : "agentmemory demo"; + process.stdout.write(`\nTry: ${demoCommand}\n`); } async function main() {