From d0fe921fb14a8183666a30fe06eb7df9e67f0bf1 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Fri, 7 Aug 2026 10:08:49 -0400
Subject: [PATCH 1/2] Install a missing provider CLI with one click
A provider whose CLI is absent now carries an install capability when npm
resolves on the server: `npm install -g @latest`, the same command
family updates use, run through the same maintenance runner (shared
npm-global lock, output capture, updateState progress, post-command
re-probe). Verification differs by intent: an install must leave the
provider detectable, an update must leave it current; either shortfall
reports as unchanged with a plain explanation.
The settings card shows Install in its header action group with running
and failure states; the first-run setup card upgrades its Install guide
action to Install when the capability exists. Both render from streamed
snapshots. A configured NPM_CONFIG_PREFIX is honored end to end (baked
into the command's environment patch and display string), and with npm
absent everything falls back to the linkified guide.
Contracts: versionAdvisory gains installCommand/canInstall with decoding
defaults for older snapshot caches; server.updateProvider gains an
action field instead of a new RPC.
---
.../src/provider/Layers/ProviderRegistry.ts | 34 +--
.../src/provider/Services/ProviderRegistry.ts | 10 +-
.../makeManagedServerProvider.test.ts | 1 +
.../src/provider/providerMaintenance.test.ts | 128 +++++++++--
.../src/provider/providerMaintenance.ts | 96 +++++++--
.../providerMaintenanceRunner.test.ts | 198 +++++++++++++++++-
.../src/provider/providerMaintenanceRunner.ts | 110 ++++++++--
...iderUpdateLaunchNotification.logic.test.ts | 27 ++-
.../ProviderUpdateLaunchNotification.logic.ts | 20 +-
.../chat/FirstRunSetupCard.browser.tsx | 81 ++++++-
.../src/components/chat/FirstRunSetupCard.tsx | 17 ++
.../src/components/chat/firstRunSetup.test.ts | 49 +++++
apps/web/src/components/chat/firstRunSetup.ts | 27 +--
.../settings/ProviderInstallAction.tsx | 119 +++++++++++
.../settings/ProviderInstanceCard.tsx | 24 ++-
.../settings/SettingsPanels.browser.tsx | 151 +++++++++++++
.../components/settings/providerInstall.ts | 101 +++++++++
.../src/components/settings/providerStatus.ts | 16 ++
packages/contracts/src/server.ts | 21 ++
19 files changed, 1138 insertions(+), 92 deletions(-)
create mode 100644 apps/web/src/components/settings/ProviderInstallAction.tsx
create mode 100644 apps/web/src/components/settings/providerInstall.ts
diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts
index 26e53648e..ef8bb1a3f 100644
--- a/apps/server/src/provider/Layers/ProviderRegistry.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.ts
@@ -44,7 +44,11 @@ import * as Semaphore from "effect/Semaphore";
import { ServerConfig } from "../../config.ts";
import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
-import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
+import {
+ ProviderRegistry,
+ type ProviderMaintenanceActionKind,
+ type ProviderRegistryShape,
+} from "../Services/ProviderRegistry.ts";
import {
hydrateCachedProvider,
isCachedProviderCorrelated,
@@ -424,7 +428,13 @@ export const ProviderRegistryLive = Layer.effect(
);
const providersRef = yield* Ref.make>(cachedProviders);
const maintenanceActionStatesRef = yield* Ref.make<
- ReadonlyMap
+ ReadonlyMap<
+ ProviderInstanceId,
+ {
+ readonly action: ProviderMaintenanceActionKind;
+ readonly state: ServerProviderUpdateState;
+ }
+ >
>(new Map());
// Live-source registry — the dynamic counterpart to the boot-time
@@ -467,7 +477,7 @@ export const ProviderRegistryLive = Layer.effect(
provider: ServerProvider,
) {
const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef);
- const updateState = maintenanceActionStates.get(provider.instanceId)?.update;
+ const updateState = maintenanceActionStates.get(provider.instanceId)?.state;
if (!updateState) {
const { updateState: _updateState, ...providerWithoutUpdateState } = provider;
return providerWithoutUpdateState;
@@ -547,23 +557,23 @@ export const ProviderRegistryLive = Layer.effect(
const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")(
function* (input: {
readonly instanceId: ProviderInstanceId;
- readonly action: "update";
+ readonly action: ProviderMaintenanceActionKind;
readonly state: ServerProviderUpdateState | null;
}) {
yield* Ref.update(maintenanceActionStatesRef, (previous) => {
- const previousActions = previous.get(input.instanceId);
- const nextActions = { ...previousActions };
- if (input.state === null || input.state.status === "idle") {
- delete nextActions[input.action];
- } else {
- nextActions[input.action] = input.state;
+ const isCleared = input.state === null || input.state.status === "idle";
+ const current = previous.get(input.instanceId);
+ // Clearing only retires this action's own state; a different action
+ // that has since taken over the instance keeps reporting.
+ if (isCleared && current?.action !== input.action) {
+ return previous;
}
const next = new Map(previous);
- if (Object.keys(nextActions).length === 0) {
+ if (isCleared || input.state === null) {
next.delete(input.instanceId);
} else {
- next.set(input.instanceId, nextActions);
+ next.set(input.instanceId, { action: input.action, state: input.state });
}
return next;
});
diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts
index b9f52069b..3fe2fa522 100644
--- a/apps/server/src/provider/Services/ProviderRegistry.ts
+++ b/apps/server/src/provider/Services/ProviderRegistry.ts
@@ -20,7 +20,7 @@ import type * as Effect from "effect/Effect";
import type * as Stream from "effect/Stream";
import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
-export type ProviderMaintenanceActionKind = "update";
+export type ProviderMaintenanceActionKind = "update" | "install";
export interface ProviderRegistryShape {
/**
@@ -73,9 +73,11 @@ export interface ProviderRegistryShape {
/**
* Apply volatile maintenance-action state to one configured instance.
- * This state is never persisted to disk. Today only update actions are
- * projected onto `ServerProvider.updateState`; install/auth actions can
- * extend this action map without adding driver-scoped APIs.
+ * This state is never persisted to disk. An instance runs at most one
+ * maintenance action at a time (they share the runner's per-instance lock),
+ * so the latest action's state is the one projected onto
+ * `ServerProvider.updateState`; a later action replaces an earlier one's
+ * state rather than competing with it.
*/
readonly setProviderMaintenanceActionState: (input: {
readonly instanceId: ProviderInstanceId;
diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts
index cabdbbb5b..a9403c882 100644
--- a/apps/server/src/provider/makeManagedServerProvider.test.ts
+++ b/apps/server/src/provider/makeManagedServerProvider.test.ts
@@ -41,6 +41,7 @@ const maintenanceCapabilities = {
lockKey: "npm-global",
},
+ install: null,
manualUpdateCommand: null,
advisoryMessage: null,
} as const;
diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts
index 78285ca43..f517c5955 100644
--- a/apps/server/src/provider/providerMaintenance.test.ts
+++ b/apps/server/src/provider/providerMaintenance.test.ts
@@ -18,7 +18,8 @@ import {
} from "./providerMaintenance.ts";
const driver = (value: string) => ProviderDriverKind.make(value);
-const noManualUpdate = {
+const noInstallOrManualUpdate = {
+ install: null,
manualUpdateCommand: null,
advisoryMessage: null,
} as const;
@@ -31,6 +32,25 @@ const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) {
return path.join(os.tmpdir(), `${name}-${id}`);
});
+const WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD";
+
+/**
+ * Put an executable named `name` in `dir` for the platform the test is
+ * actually running on, so PATH lookups behave the way they would in
+ * production. Returns the directory, for use as a PATH entry.
+ */
+function writeCommandShim(dir: string, name: string): string {
+ mkdirSync(dir, { recursive: true });
+ if (process.platform === "win32") {
+ writeFileSync(path.join(dir, `${name}.cmd`), "@echo off\r\n");
+ return dir;
+ }
+ const commandPath = path.join(dir, name);
+ writeFileSync(commandPath, "#!/bin/sh\n");
+ chmodSync(commandPath, 0o755);
+ return dir;
+}
+
function linkPackageCommand(input: {
readonly packageBinDir: string;
readonly packageBinPath: string;
@@ -177,7 +197,7 @@ describe("providerMaintenance", () => {
lockKey: "static-tool",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
});
@@ -212,7 +232,7 @@ describe("providerMaintenance", () => {
lockKey: "vite-plus-global",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
}),
);
@@ -247,7 +267,7 @@ describe("providerMaintenance", () => {
lockKey: "bun-global",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
}),
);
@@ -283,7 +303,7 @@ describe("providerMaintenance", () => {
lockKey: "pnpm-global",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
}),
);
@@ -316,7 +336,7 @@ describe("providerMaintenance", () => {
environmentPatch: { NPM_CONFIG_PREFIX: npmPrefix },
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
});
@@ -363,7 +383,7 @@ describe("providerMaintenance", () => {
lockKey: "homebrew",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
});
@@ -398,7 +418,7 @@ describe("providerMaintenance", () => {
lockKey: "native-package-tool-native",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
}),
);
@@ -431,6 +451,7 @@ describe("providerMaintenance", () => {
lockKey: "native-package-tool-installer-win32",
},
+ install: null,
manualUpdateCommand: null,
advisoryMessage:
"Run the native-package-tool Windows installer instead of native-package-tool update.",
@@ -456,7 +477,7 @@ describe("providerMaintenance", () => {
lockKey: "native-package-tool-native",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
});
@@ -517,11 +538,88 @@ describe("providerMaintenance", () => {
lockKey: "scoped-package-tool-native",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
}),
);
+ it.effect("derives an npm global install for a provider CLI that resolves nowhere on PATH", () =>
+ Effect.gen(function* () {
+ const tempDir = yield* makeTempDir("t3-npm-install-capabilities");
+ const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm");
+
+ expect(
+ packageToolUpdate.resolve({
+ binaryPath: "package-tool",
+ platform: process.platform,
+ env: { PATH: npmBinDir, PATHEXT: WINDOWS_PATHEXT },
+ }).install,
+ ).toEqual({
+ command: "npm install -g @example/package-tool@latest",
+ executable: "npm",
+ args: ["install", "-g", "@example/package-tool@latest"],
+ lockKey: "npm-global",
+ });
+ }),
+ );
+
+ it.effect("offers no install capability when npm itself is missing", () =>
+ Effect.gen(function* () {
+ const tempDir = yield* makeTempDir("t3-npm-install-capabilities-missing");
+ const emptyBinDir = writeCommandShim(path.join(tempDir, "empty-bin"), "unrelated-tool");
+
+ expect(
+ packageToolUpdate.resolve({
+ binaryPath: "package-tool",
+ platform: process.platform,
+ env: { PATH: emptyBinDir, PATHEXT: WINDOWS_PATHEXT },
+ }).install,
+ ).toBeNull();
+ }),
+ );
+
+ it.effect("scopes the derived install to the configured npm prefix", () =>
+ Effect.gen(function* () {
+ const tempDir = yield* makeTempDir("t3-npm-install-capabilities-prefix");
+ const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm");
+ const npmPrefix = path.join(tempDir, "npm prefix");
+
+ expect(
+ packageToolUpdate.resolve({
+ binaryPath: "package-tool",
+ platform: process.platform,
+ env: {
+ PATH: npmBinDir,
+ PATHEXT: WINDOWS_PATHEXT,
+ NPM_CONFIG_PREFIX: npmPrefix,
+ },
+ }).install,
+ ).toEqual({
+ command: `npm --prefix "${npmPrefix}" install -g @example/package-tool@latest`,
+ executable: "npm",
+ args: ["install", "-g", "@example/package-tool@latest"],
+ lockKey: "npm-global",
+ environmentPatch: { NPM_CONFIG_PREFIX: npmPrefix },
+ });
+ }),
+ );
+
+ it.effect("keeps an installed provider free of an install capability", () =>
+ Effect.gen(function* () {
+ const tempDir = yield* makeTempDir("t3-npm-install-capabilities-installed");
+ const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm");
+ writeCommandShim(npmBinDir, "package-tool");
+
+ expect(
+ packageToolUpdate.resolve({
+ binaryPath: "package-tool",
+ platform: process.platform,
+ env: { PATH: npmBinDir, PATHEXT: WINDOWS_PATHEXT },
+ }).install,
+ ).toBeNull();
+ }),
+ );
+
it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
expect(
nativePackageToolUpdate.resolve({
@@ -543,7 +641,7 @@ describe("providerMaintenance", () => {
lockKey: "homebrew",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
});
@@ -568,7 +666,7 @@ describe("providerMaintenance", () => {
lockKey: "homebrew",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
});
@@ -612,7 +710,7 @@ describe("providerMaintenance", () => {
lockKey: "npm-global",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
}),
);
@@ -661,7 +759,7 @@ describe("providerMaintenance", () => {
lockKey: "pnpm-global",
},
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
}),
);
@@ -680,7 +778,7 @@ describe("providerMaintenance", () => {
provider: driver("packageTool"),
packageName: "@example/package-tool",
update: null,
- ...noManualUpdate,
+ ...noInstallOrManualUpdate,
});
});
});
diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts
index f021d1ee9..ad44774c6 100644
--- a/apps/server/src/provider/providerMaintenance.ts
+++ b/apps/server/src/provider/providerMaintenance.ts
@@ -22,6 +22,13 @@ export interface ProviderMaintenanceCapabilities {
readonly provider: ProviderDriverKind;
readonly packageName: string | null;
readonly update: ProviderMaintenanceCommandAction | null;
+ /**
+ * How Threadlines would put this provider's CLI on the machine when it is
+ * missing. There is no installed binary to inspect in that state, so this
+ * is only ever the default manager (npm global) and only when `npm` itself
+ * resolves. `null` means the UI falls back to the provider's install guide.
+ */
+ readonly install: ProviderMaintenanceCommandAction | null;
readonly manualUpdateCommand: string | null;
readonly advisoryMessage: string | null;
}
@@ -98,6 +105,7 @@ export function makeProviderMaintenanceCapabilities(input: {
readonly updateLockKey: string | null;
readonly updateDisplayCommand?: string | null | undefined;
readonly updateEnvironmentPatch?: Readonly> | undefined;
+ readonly install?: ProviderMaintenanceCommandAction | null | undefined;
readonly manualUpdateCommand?: string | null | undefined;
readonly advisoryMessage?: string | null | undefined;
}): ProviderMaintenanceCapabilities {
@@ -118,6 +126,7 @@ export function makeProviderMaintenanceCapabilities(input: {
provider: input.provider,
packageName: input.packageName,
update,
+ install: input.install ?? null,
manualUpdateCommand: input.manualUpdateCommand ?? null,
advisoryMessage: input.advisoryMessage ?? null,
};
@@ -140,22 +149,70 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {
});
}
+/**
+ * `npm install -g @latest`. Installing and updating an npm-managed CLI
+ * are the same command, so both capabilities are built from here and share
+ * the `npm-global` lock key that serializes them against each other.
+ */
+function makeNpmGlobalCommandAction(input: {
+ readonly packageName: string;
+ readonly prefix?: string | undefined;
+}): ProviderMaintenanceCommandAction {
+ const args = ["install", "-g", `${input.packageName}@latest`];
+ return {
+ command: input.prefix
+ ? `npm --prefix "${input.prefix}" install -g ${input.packageName}@latest`
+ : ["npm", ...args].join(" "),
+ executable: "npm",
+ args,
+ lockKey: "npm-global",
+ ...(input.prefix ? { environmentPatch: { NPM_CONFIG_PREFIX: input.prefix } } : {}),
+ };
+}
+
+/**
+ * The install command for a provider whose CLI could not be located. There is
+ * no binary whose origin we could inspect, so the manager is the default one
+ * (npm global) and the only question is whether `npm` is on the server's PATH.
+ * A configured `NPM_CONFIG_PREFIX` is carried into the command's environment
+ * patch so the install lands in the same prefix the rest of the process uses.
+ */
+function resolveNpmGlobalInstallAction(
+ definition: PackageManagedProviderMaintenanceDefinition,
+ options?: ProviderMaintenanceCapabilityResolutionOptions,
+): ProviderMaintenanceCommandAction | null {
+ const env = options?.env ?? process.env;
+ const platform = options?.platform ?? process.platform;
+ if (!resolveCommandPath("npm", { platform, env })) {
+ return null;
+ }
+ const prefix = nonEmptyString(env.NPM_CONFIG_PREFIX);
+ return makeNpmGlobalCommandAction({
+ packageName: definition.npmPackageName,
+ ...(prefix ? { prefix } : {}),
+ });
+}
+
function makeNpmGlobalProviderMaintenanceCapabilities(
definition: PackageManagedProviderMaintenanceDefinition,
- prefix?: string,
+ options?: {
+ readonly prefix?: string | undefined;
+ readonly install?: ProviderMaintenanceCommandAction | null | undefined;
+ },
): ProviderMaintenanceCapabilities {
+ const update = makeNpmGlobalCommandAction({
+ packageName: definition.npmPackageName,
+ ...(options?.prefix ? { prefix: options.prefix } : {}),
+ });
return makeProviderMaintenanceCapabilities({
provider: definition.provider,
packageName: definition.npmPackageName,
- updateExecutable: "npm",
- updateArgs: ["install", "-g", `${definition.npmPackageName}@latest`],
- updateLockKey: "npm-global",
- ...(prefix
- ? {
- updateDisplayCommand: `npm --prefix "${prefix}" install -g ${definition.npmPackageName}@latest`,
- updateEnvironmentPatch: { NPM_CONFIG_PREFIX: prefix },
- }
- : {}),
+ updateExecutable: update.executable,
+ updateArgs: update.args,
+ updateLockKey: update.lockKey,
+ updateDisplayCommand: update.command,
+ ...(update.environmentPatch ? { updateEnvironmentPatch: update.environmentPatch } : {}),
+ ...(options?.install ? { install: options.install } : {}),
});
}
@@ -345,7 +402,9 @@ export function resolvePackageManagedProviderMaintenance(
const binaryPath = nonEmptyString(options?.binaryPath);
const platform = options?.platform ?? process.platform;
if (!binaryPath) {
- return makeNpmGlobalProviderMaintenanceCapabilities(definition);
+ return makeNpmGlobalProviderMaintenanceCapabilities(definition, {
+ install: resolveNpmGlobalInstallAction(definition, options),
+ });
}
const resolvedCommandPath =
@@ -395,7 +454,9 @@ export function resolvePackageManagedProviderMaintenance(
platform,
});
if (windowsNpmPrefix) {
- return makeNpmGlobalProviderMaintenanceCapabilities(definition, windowsNpmPrefix);
+ return makeNpmGlobalProviderMaintenanceCapabilities(definition, {
+ prefix: windowsNpmPrefix,
+ });
}
if (commandPaths.some(isNpmGlobalCommandPath)) {
return makeNpmGlobalProviderMaintenanceCapabilities(definition);
@@ -405,8 +466,15 @@ export function resolvePackageManagedProviderMaintenance(
}
}
+ // A bare command name that resolved nowhere: the CLI is not installed, so
+ // this is the one place an install capability is derived. A bare name that
+ // did resolve but matched no known manager falls through here too, and
+ // there is nothing to install for it.
if (!hasPathSeparator(binaryPath)) {
- return makeNpmGlobalProviderMaintenanceCapabilities(definition);
+ return makeNpmGlobalProviderMaintenanceCapabilities(definition, {
+ install:
+ resolvedCommandPath === null ? resolveNpmGlobalInstallAction(definition, options) : null,
+ });
}
return makeManualOnlyProviderMaintenanceCapabilities({
@@ -514,6 +582,8 @@ export function createProviderVersionAdvisory(input: {
latestVersion,
updateCommand: capabilities.update?.command ?? capabilities.manualUpdateCommand,
canUpdate: capabilities.update !== null,
+ installCommand: capabilities.install?.command ?? null,
+ canInstall: capabilities.install !== null,
checkedAt: input.checkedAt ?? null,
message: advisoryMessage,
};
diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts
index b6aaf3128..23010b772 100644
--- a/apps/server/src/provider/providerMaintenanceRunner.test.ts
+++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts
@@ -18,7 +18,11 @@ import * as Stream from "effect/Stream";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
-import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
+import {
+ ProviderRegistry,
+ type ProviderMaintenanceActionKind,
+ type ProviderRegistryShape,
+} from "./Services/ProviderRegistry.ts";
import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
import {
clearLatestProviderVersionCacheForTests,
@@ -176,6 +180,16 @@ function withProcessPlatform(
function makeRegistry(
initialProviders: ServerProvider | ReadonlyArray = baseProvider,
+ options?: {
+ /**
+ * Stands in for a real re-probe: the runner refreshes the instance after
+ * its command finishes, and what that probe now reports is what decides
+ * whether the run succeeded.
+ */
+ readonly onRefreshInstance?: (
+ providers: ReadonlyArray,
+ ) => ReadonlyArray;
+ },
) {
return Effect.gen(function* () {
const providersRef = yield* Ref.make>(
@@ -187,7 +201,7 @@ function makeRegistry(
"providerMaintenanceRunner.test.setProviderMaintenanceActionState",
)(function* (input: {
readonly instanceId: ProviderInstanceId;
- readonly action: "update";
+ readonly action: ProviderMaintenanceActionKind;
readonly state: ServerProviderUpdateState | null;
}) {
const updateState = input.state;
@@ -214,7 +228,10 @@ function makeRegistry(
const registry: ProviderRegistryShape = {
getProviders: Ref.get(providersRef),
refresh: () => Ref.get(providersRef),
- refreshInstance: () => Ref.get(providersRef),
+ refreshInstance: () =>
+ options?.onRefreshInstance
+ ? Ref.updateAndGet(providersRef, options.onRefreshInstance)
+ : Ref.get(providersRef),
consumeRateLimitResetCredit: () =>
Ref.get(providersRef).pipe(
Effect.map((providers) => ({ outcome: "nothingToReset" as const, providers })),
@@ -232,6 +249,35 @@ function makeRegistry(
});
}
+const NPM_PREFIX = "C:\\Users\\Alice Smith\\AppData\\Roaming\\npm";
+const missingCodexProvider: ServerProvider = {
+ ...baseProvider,
+ installed: false,
+ version: null,
+ status: "error",
+};
+
+/**
+ * What the resolver hands back for a provider whose CLI is missing: the same
+ * npm command for both actions, differing only in why it runs.
+ */
+function codexInstallCapabilities(): ProviderMaintenanceCapabilities {
+ return makeProviderMaintenanceCapabilities({
+ provider: CODEX_DRIVER,
+ packageName: "@openai/codex",
+ updateExecutable: "npm",
+ updateArgs: ["install", "-g", "@openai/codex@latest"],
+ updateLockKey: "npm-global",
+ install: {
+ command: `npm --prefix "${NPM_PREFIX}" install -g @openai/codex@latest`,
+ executable: "npm",
+ args: ["install", "-g", "@openai/codex@latest"],
+ lockKey: "npm-global",
+ environmentPatch: { NPM_CONFIG_PREFIX: NPM_PREFIX },
+ },
+ });
+}
+
function claudeWindowsUpdateCapabilities(): ProviderMaintenanceCapabilities {
return makeProviderMaintenanceCapabilities({
provider: CLAUDE_DRIVER,
@@ -298,6 +344,8 @@ describe("providerMaintenanceRunner", () => {
latestVersion: "2.1.123",
updateCommand: "bun i -g @anthropic-ai/claude-code@latest",
canUpdate: true,
+ installCommand: null,
+ canInstall: false,
checkedAt: "2026-04-30T12:00:00.000Z",
message: "Update available.",
},
@@ -753,6 +801,150 @@ describe("providerMaintenanceRunner", () => {
),
);
+ it.effect("installs a missing provider in the npm prefix and records success", () => {
+ const calls: Array<{
+ environment: ChildProcess.CommandOptions["env"];
+ extendEnv: ChildProcess.CommandOptions["extendEnv"];
+ }> = [];
+ return Effect.gen(function* () {
+ const { registry, updateStatesRef } = yield* makeRegistry(missingCodexProvider, {
+ onRefreshInstance: (providers) =>
+ providers.map((provider) =>
+ provider.instanceId === CODEX_INSTANCE_ID
+ ? { ...provider, installed: true, version: "1.0.0", status: "ready" as const }
+ : provider,
+ ),
+ });
+ const runner = yield* makeTestRunner({
+ ...registry,
+ getProviderMaintenanceCapabilitiesForInstance: () =>
+ Effect.succeed(codexInstallCapabilities()),
+ });
+
+ const result = yield* runner.updateProvider({
+ provider: CODEX_DRIVER,
+ action: "install",
+ });
+
+ assert.deepStrictEqual(calls, [
+ {
+ environment: { NPM_CONFIG_PREFIX: NPM_PREFIX },
+ extendEnv: true,
+ },
+ ]);
+ assert.deepStrictEqual(
+ (yield* Ref.get(updateStatesRef)).map((state) => state.status),
+ ["queued", "running", "succeeded"],
+ );
+ assert.strictEqual(result.providers[0]?.installed, true);
+ assert.strictEqual(result.providers[0]?.updateState?.message, "Provider installed.");
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ latestVersionHttpClient("1.0.0"),
+ mockSpawnerLayer((_command, _args, options) => {
+ calls.push({ environment: options.env, extendEnv: options.extendEnv });
+ return { stdout: "added 1 package" };
+ }),
+ ),
+ ),
+ );
+ });
+
+ it.effect("reports an install that left the provider CLI missing as unchanged", () =>
+ Effect.gen(function* () {
+ const { registry } = yield* makeRegistry(missingCodexProvider);
+ const runner = yield* makeTestRunner({
+ ...registry,
+ getProviderMaintenanceCapabilitiesForInstance: () =>
+ Effect.succeed(codexInstallCapabilities()),
+ });
+
+ const result = yield* runner.updateProvider({
+ provider: CODEX_DRIVER,
+ action: "install",
+ });
+
+ assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged");
+ assert.include(result.providers[0]?.updateState?.message ?? "", "still cannot find");
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ latestVersionHttpClient("1.0.0"),
+ mockSpawnerLayer(() => ({ stdout: "added 1 package" })),
+ ),
+ ),
+ ),
+ );
+
+ it.effect("queues an install behind an update holding the same package-manager lock", () => {
+ const firstStartedLatch: { resolve: () => void } = { resolve: () => {} };
+ const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} };
+ const firstStarted = new Promise((resolve) => {
+ firstStartedLatch.resolve = resolve;
+ });
+ const releaseFirst = new Promise((resolve) => {
+ releaseFirstLatch.resolve = resolve;
+ });
+ let commandCount = 0;
+ return Effect.gen(function* () {
+ const { registry } = yield* makeRegistry([missingCodexProvider, baseOpenCodeProvider]);
+ const runner = yield* makeTestRunner({
+ ...registry,
+ getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
+ Effect.succeed(
+ provider === OPENCODE_DRIVER ? lifecycleFor(provider) : codexInstallCapabilities(),
+ ),
+ });
+
+ const update = yield* runner.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped);
+ yield* Effect.promise(() => firstStarted);
+
+ const install = yield* runner
+ .updateProvider({ provider: CODEX_DRIVER, action: "install" })
+ .pipe(Effect.forkScoped);
+ let codexUpdateStatus: string | undefined;
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ codexUpdateStatus = (yield* registry.getProviders).find(
+ (provider) => provider.instanceId === CODEX_INSTANCE_ID,
+ )?.updateState?.status;
+ if (codexUpdateStatus === "queued") {
+ break;
+ }
+ yield* Effect.yieldNow;
+ }
+
+ // The install waits on the update's `npm-global` lock instead of racing
+ // it: one package-manager command runs at a time.
+ assert.strictEqual(codexUpdateStatus, "queued");
+ assert.strictEqual(commandCount, 1);
+
+ releaseFirstLatch.resolve();
+ yield* Fiber.join(update);
+ yield* Fiber.join(install);
+ assert.strictEqual(commandCount, 2);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ latestVersionHttpClient("0.0.0"),
+ mockSpawnerLayer(() => {
+ commandCount += 1;
+ if (commandCount === 1) {
+ firstStartedLatch.resolve();
+ return {
+ stdout: "updated",
+ exitCode: Effect.promise(() => releaseFirst).pipe(
+ Effect.as(ChildProcessSpawner.ExitCode(0)),
+ ),
+ };
+ }
+ return { stdout: "added 1 package" };
+ }),
+ ),
+ ),
+ );
+ });
+
it.effect("prevents concurrent updates for the same provider", () => {
const startedLatch: { resolve: () => void } = { resolve: () => {} };
const releaseLatch: { resolve: () => void } = { resolve: () => {} };
diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts
index 1b8f08607..0420084e6 100644
--- a/apps/server/src/provider/providerMaintenanceRunner.ts
+++ b/apps/server/src/provider/providerMaintenanceRunner.ts
@@ -4,6 +4,7 @@ import {
ServerProviderUpdateError,
type ProviderInstanceId,
type ServerProvider,
+ type ServerProviderMaintenanceAction,
type ServerProviderUpdateBlockerResolutionResult,
type ServerProviderUpdatedPayload,
type ServerProviderUpdateState,
@@ -70,12 +71,20 @@ export interface ProviderMaintenanceCommandResult {
}
export interface ProviderMaintenanceRunnerShape {
+ /**
+ * Run one package-manager command for the target instance. `action`
+ * selects which capability supplies the command: `"update"` (default)
+ * refreshes an installed CLI, `"install"` puts a missing one on the
+ * machine. Both share this path so they also share the lock, the captured
+ * output, the progress state, and the post-command re-probe.
+ */
readonly updateProvider: (
target:
| ProviderDriverKind
| {
readonly provider: ProviderDriverKind;
readonly instanceId?: ProviderInstanceId | undefined;
+ readonly action?: ServerProviderMaintenanceAction | undefined;
},
) => Effect.Effect;
readonly resolveUpdateBlockers: (
@@ -151,7 +160,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
Effect.mapError(
(cause) =>
new ProviderMaintenanceCommandError({
- message: `Failed to run update command ${input.command}: ${cause.message}`,
+ message: `Failed to run ${input.command}: ${cause.message}`,
cause,
}),
),
@@ -175,7 +184,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
Effect.mapError(
(cause) =>
new ProviderMaintenanceCommandError({
- message: cause instanceof Error ? cause.message : "Update command failed to run.",
+ message: cause instanceof Error ? cause.message : "Provider command failed to run.",
cause,
}),
),
@@ -366,6 +375,60 @@ function isWindowsExecutableReplaceFailure(result: ProviderMaintenanceCommandRes
);
}
+/**
+ * Install and update run the same command through the same machinery, so the
+ * only thing that varies is what we tell the user we are doing.
+ */
+interface ProviderMaintenanceActionCopy {
+ readonly running: string;
+ readonly succeeded: string;
+ readonly unverified: string;
+ readonly incomplete: string;
+ readonly timedOut: string;
+ readonly failed: string;
+ readonly unsupported: string;
+ readonly exitCode: (exitCode: number) => string;
+}
+
+const PROVIDER_MAINTENANCE_ACTION_COPY: Record<
+ ServerProviderMaintenanceAction,
+ ProviderMaintenanceActionCopy
+> = {
+ update: {
+ running: "Updating provider.",
+ succeeded: "Provider updated.",
+ unverified: "Update command completed, but Threadlines could not verify the provider version.",
+ incomplete:
+ "Update command completed, but Threadlines still detects an outdated provider version.",
+ timedOut: "Update timed out.",
+ failed: "Update command failed.",
+ unsupported: "This provider does not support one-click updates.",
+ exitCode: (exitCode) => `Update command exited with code ${exitCode}.`,
+ },
+ install: {
+ running: "Installing provider.",
+ succeeded: "Provider installed.",
+ unverified: "Install command completed, but Threadlines could not verify the provider.",
+ incomplete: "Install command completed, but Threadlines still cannot find the provider CLI.",
+ timedOut: "Install timed out.",
+ failed: "Install command failed.",
+ unsupported: "Threadlines cannot install this provider for you.",
+ exitCode: (exitCode) => `Install command exited with code ${exitCode}.`,
+ },
+};
+
+/**
+ * Did the command leave the provider in the state the user asked for? An
+ * update has to move off the outdated version; an install has to produce a
+ * CLI the probe can find.
+ */
+function isProviderMaintenanceIncomplete(
+ action: ServerProviderMaintenanceAction,
+ provider: ServerProvider,
+): boolean {
+ return action === "install" ? !provider.installed : isOutdatedProvider(provider);
+}
+
function shouldPrepareSessionsForUpdate(input: {
readonly provider: ProviderDriverKind;
readonly update: ProviderMaintenanceCapabilities["update"];
@@ -380,17 +443,19 @@ function shouldPrepareSessionsForUpdate(input: {
function failureMessage(
provider: ProviderDriverKind,
result: ProviderMaintenanceCommandResult,
+ action: ServerProviderMaintenanceAction = "update",
): string {
+ const copy = PROVIDER_MAINTENANCE_ACTION_COPY[action];
if (result.timedOut) {
- return "Update timed out.";
+ return copy.timedOut;
}
if (isWindowsExecutableReplaceFailure(result)) {
return windowsClaudeProcessLockMessage(provider, 0);
}
if (result.exitCode !== null && result.exitCode !== 0) {
- return `Update command exited with code ${result.exitCode}.`;
+ return copy.exitCode(result.exitCode);
}
- return "Update command failed.";
+ return copy.failed;
}
function isOutdatedProvider(provider: ServerProvider | undefined): boolean {
@@ -432,7 +497,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
makeAlreadyRunningError: () =>
new ServerProviderUpdateError({
provider: ProviderDriverKind.make("unknown"),
- reason: "An update is already running for this provider.",
+ reason: "Threadlines is already running a command for this provider.",
}),
});
@@ -574,22 +639,25 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
? defaultInstanceIdForDriver(provider)
: (target.instanceId ?? defaultInstanceIdForDriver(provider));
const targetKey = `instance:${instanceId}`;
+ const action: ServerProviderMaintenanceAction =
+ typeof target === "string" ? "update" : (target.action ?? "update");
+ const copy = PROVIDER_MAINTENANCE_ACTION_COPY[action];
const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
instanceId,
provider,
);
- const update = capabilities.update;
+ const update = action === "install" ? capabilities.install : capabilities.update;
if (!update) {
return yield* new ServerProviderUpdateError({
provider,
- reason: "This provider does not support one-click updates.",
+ reason: copy.unsupported,
});
}
const setUpdateState = (state: ServerProviderUpdateState | null) =>
providerRegistry.setProviderMaintenanceActionState({
instanceId,
- action: "update",
+ action,
state,
});
const setQueuedState = setUpdateState(
@@ -597,7 +665,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
status: "queued",
startedAt: null,
finishedAt: null,
- message: "Waiting for another provider update to finish.",
+ message: "Waiting for another provider command to finish.",
}),
).pipe(Effect.asVoid);
@@ -616,7 +684,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
status: "running",
startedAt,
finishedAt: null,
- message: "Updating provider.",
+ message: copy.running,
}),
);
@@ -651,7 +719,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
status: "failed",
startedAt,
finishedAt,
- message: failureMessage(provider, result),
+ message: failureMessage(provider, result, action),
output: commandOutput(result),
}),
);
@@ -663,19 +731,21 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
instanceId,
);
const couldNotVerify = verifiedProviders.length === 0;
- const stillOutdated =
+ const isIncomplete =
couldNotVerify ||
- verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider));
+ verifiedProviders.some((verifiedProvider) =>
+ isProviderMaintenanceIncomplete(action, verifiedProvider),
+ );
return yield* finish(
makeUpdateState({
- status: stillOutdated ? "unchanged" : "succeeded",
+ status: isIncomplete ? "unchanged" : "succeeded",
startedAt,
finishedAt,
message: couldNotVerify
- ? "Update command completed, but Threadlines could not verify the provider version."
- : stillOutdated
- ? "Update command completed, but Threadlines still detects an outdated provider version."
- : "Provider updated.",
+ ? copy.unverified
+ : isIncomplete
+ ? copy.incomplete
+ : copy.succeeded,
output: commandOutput(result),
}),
);
@@ -691,7 +761,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
status: "failed",
startedAt,
finishedAt: yield* nowIso,
- message: failure instanceof Error ? failure.message : "Update command failed.",
+ message: failure instanceof Error ? failure.message : copy.failed,
output: null,
}),
);
diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts
index 7eaa27846..61c4317d6 100644
--- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts
+++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts
@@ -38,13 +38,14 @@ function provider(input: {
readonly canUpdate?: boolean;
readonly updateCommand?: string | null;
readonly updateState?: ServerProvider["updateState"];
+ readonly installed?: boolean;
readonly advisoryStatus?: NonNullable["status"];
}): ServerProvider {
const result: ServerProvider = {
instanceId: input.instanceId ?? instanceId(String(input.driver)),
driver: input.driver,
enabled: input.enabled ?? true,
- installed: true,
+ installed: input.installed ?? true,
version: input.version ?? "1.0.0",
status: "ready",
auth: { status: "authenticated" },
@@ -58,6 +59,8 @@ function provider(input: {
latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0",
updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider",
canUpdate: input.canUpdate ?? true,
+ installCommand: null,
+ canInstall: false,
checkedAt,
message: "Update available.",
},
@@ -509,6 +512,28 @@ describe("provider update launch notification logic", () => {
).toEqual([cursor]);
});
+ it("calls a run against a missing CLI an install, not an update", () => {
+ const view = getProviderUpdateSidebarPillView([
+ provider({
+ driver: driver("claudeAgent"),
+ installed: false,
+ version: null,
+ updateState: {
+ status: "running",
+ startedAt: checkedAt,
+ finishedAt: null,
+ message: "Installing provider.",
+ output: null,
+ },
+ }),
+ ]);
+
+ expect(view).toMatchObject({
+ statusChipLabel: "Installing",
+ description: "Claude install in progress.",
+ });
+ });
+
it("summarizes active provider updates for the sidebar pill", () => {
const view = getProviderUpdateSidebarPillView([
provider({
diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts
index 572b1836d..4777472ba 100644
--- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts
+++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts
@@ -464,21 +464,31 @@ function getProviderUpdateSidebarItemTone(
}
}
+/**
+ * A provider with no CLI on the machine has no update to run, so a
+ * maintenance command against it is an install. The runner writes both to the
+ * same state, and this is where the sidebar tells them apart.
+ */
+function isProviderInstallState(provider: Pick): boolean {
+ return !provider.installed;
+}
+
function getProviderUpdateSidebarStatusLabel(
- provider: Pick,
+ provider: Pick,
status: ProviderUpdateSidebarPillItemStatus,
): string {
+ const isInstalling = isProviderInstallState(provider);
switch (status) {
case "queued":
return "Queued";
case "running":
- return "Updating";
+ return isInstalling ? "Installing" : "Updating";
case "succeeded":
return provider.version ? formatVersion(provider.version) : "Updated";
case "failed":
return "Failed";
case "unchanged":
- return "Needs update";
+ return isInstalling ? "Not installed" : "Needs update";
}
}
@@ -582,7 +592,9 @@ export function getProviderUpdateSidebarPillView(
description:
items.length > 1
? formatProviderUpdateSidebarItemDescription(items)
- : `${formatProviderList(activeProviders)} update in progress.`,
+ : `${formatProviderList(activeProviders)} ${
+ activeProviders.every(isProviderInstallState) ? "install" : "update"
+ } in progress.`,
items,
};
}
diff --git a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx
index 9a6f32a77..3d61ac1fa 100644
--- a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx
+++ b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx
@@ -41,12 +41,22 @@ const providerAuthHarness = vi.hoisted(() => {
readonly listener: (event: AuthEvent) => void;
}>();
const startCalls: Array<{ instanceId: string; flow: string }> = [];
+ const installCalls: Array<{ provider: string; instanceId?: string; action?: string }> = [];
return {
startCalls,
+ installCalls,
+ // The install row calls the same server RPC the Update button uses.
+ server: {
+ updateProvider: (input: { provider: string; instanceId?: string; action?: string }) => {
+ installCalls.push(input);
+ return Promise.resolve({ providers: [] });
+ },
+ },
reset() {
listeners.clear();
startCalls.length = 0;
+ installCalls.length = 0;
},
emit(event: AuthEvent) {
for (const entry of listeners) {
@@ -75,7 +85,9 @@ const providerAuthHarness = vi.hoisted(() => {
});
vi.mock("../../environments/runtime", () => {
- const primaryConnection = { client: { providerAuth: providerAuthHarness.client } } as never;
+ const primaryConnection = {
+ client: { providerAuth: providerAuthHarness.client, server: providerAuthHarness.server },
+ } as never;
const notUsed = () => undefined as never;
return {
environmentUsesRelayTransport: () => false,
@@ -165,6 +177,31 @@ const MISSING_CLAUDE = buildProvider({
auth: { status: "unknown" },
});
+/** Same missing CLI, but the server derived an install command for it. */
+function withInstallCapability(
+ provider: FirstRunSetupProvider,
+ updateState?: ServerProvider["updateState"],
+): FirstRunSetupProvider {
+ return {
+ ...provider,
+ snapshot: {
+ ...provider.snapshot,
+ versionAdvisory: {
+ status: "unknown",
+ currentVersion: null,
+ latestVersion: null,
+ updateCommand: null,
+ canUpdate: false,
+ installCommand: "npm install -g @anthropic-ai/claude-code@latest",
+ canInstall: true,
+ checkedAt: null,
+ message: null,
+ },
+ ...(updateState ? { updateState } : {}),
+ },
+ };
+}
+
const SIGNED_IN_CLAUDE = buildProvider({
instanceId: "claudeAgent",
driver: "claudeAgent",
@@ -327,6 +364,48 @@ describe("FirstRunSetupCard", () => {
await screen.unmount();
});
+ it("installs a missing agent from the row instead of pointing at a guide", async () => {
+ const screen = await renderCard({
+ providers: [withInstallCapability(MISSING_CLAUDE)],
+ projectName: "B-git-project",
+ });
+
+ // Never both: an install the row can run replaces the guide link.
+ expect(document.querySelector('a[href="/settings/providers"]')).toBeNull();
+
+ await page.getByRole("button", { name: "Install Claude" }).click();
+
+ await vi.waitFor(() => {
+ expect(providerAuthHarness.installCalls).toEqual([
+ { provider: "claudeAgent", instanceId: "claudeAgent", action: "install" },
+ ]);
+ });
+
+ await screen.unmount();
+ });
+
+ it("reports a live install on the row it belongs to", async () => {
+ const screen = await renderCard({
+ providers: [
+ withInstallCapability(MISSING_CLAUDE, {
+ status: "running",
+ startedAt: "2026-08-06T00:00:00.000Z",
+ finishedAt: null,
+ message: "Installing provider.",
+ output: "added 1 package",
+ }),
+ ],
+ projectName: "B-git-project",
+ });
+
+ await expect.element(page.getByText("Installing… added 1 package")).toBeVisible();
+ await expect
+ .element(page.getByRole("button", { name: "Install Claude" }))
+ .not.toBeInTheDocument();
+
+ await screen.unmount();
+ });
+
it("enables the start action once one agent is signed in and a folder exists", async () => {
const onStart = vi.fn();
const onSkip = vi.fn();
diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx
index 6b60fd277..198f43d65 100644
--- a/apps/web/src/components/chat/FirstRunSetupCard.tsx
+++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx
@@ -12,6 +12,11 @@
* session the Providers settings panel runs and reports it in place; only a
* run that stalls long enough to need a real terminal hands off to settings.
*
+ * A missing CLI works the same way when the server can install it: the row
+ * runs the install and reports progress in place, then re-derives from the
+ * snapshot into the sign-in state. Rows fall back to the install guide only
+ * when there is no install to run.
+ *
* No container: typography, spacing, and hairline dividers on the empty
* canvas, matching the rest of the app.
*
@@ -23,6 +28,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { cn } from "../../lib/utils";
import { ProjectFavicon } from "../ProjectFavicon";
+import { ProviderInstallAction } from "../settings/ProviderInstallAction";
import { useProviderConnectFlow } from "../settings/useProviderConnectFlow";
import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure";
import { Button } from "../ui/button";
@@ -146,6 +152,17 @@ function providerRowAction(row: FirstRunProviderRow): ReactNode {
if (row.state === "needsSignIn" && row.signInCommand) {
return ;
}
+ if (row.install) {
+ return (
+
+ );
+ }
return (
{
expect(new Set(rows.map((row) => row.dotClassName)).size).toBe(3);
});
+ it("offers a one-click install only when the server derived one", () => {
+ const installableClaude = provider({
+ instanceId: "claudeAgent",
+ driver: "claudeAgent",
+ displayName: "Claude",
+ installed: false,
+ auth: { status: "unknown" },
+ installCommand: "npm install -g @anthropic-ai/claude-code@latest",
+ updateState: {
+ status: "running",
+ startedAt: "2026-01-01T00:00:00.000Z",
+ finishedAt: null,
+ message: "Installing provider.",
+ output: null,
+ },
+ });
+
+ const [installableRow] = deriveFirstRunProviderRows([installableClaude]);
+ expect(installableRow?.install).toEqual({
+ command: "npm install -g @anthropic-ai/claude-code@latest",
+ status: "running",
+ message: "Installing provider.",
+ lastOutputLine: null,
+ });
+
+ // No derived command means no button: the row keeps the install guide.
+ expect(deriveFirstRunProviderRows([missingClaude])[0]?.install).toBeNull();
+ // An installed provider never offers an install, whatever else is true.
+ expect(deriveFirstRunProviderRows([signedOutCodex])[0]?.install).toBeNull();
+ });
+
it("leaves out instances the user disabled", () => {
const rows = deriveFirstRunProviderRows([{ ...signedInCodex, enabled: false }, missingClaude]);
diff --git a/apps/web/src/components/chat/firstRunSetup.ts b/apps/web/src/components/chat/firstRunSetup.ts
index a7418e677..d9858d23e 100644
--- a/apps/web/src/components/chat/firstRunSetup.ts
+++ b/apps/web/src/components/chat/firstRunSetup.ts
@@ -28,7 +28,9 @@ import {
getLocalStorageItemWithLegacyKeys,
setLocalStorageItem,
} from "../../hooks/useLocalStorage";
+import { deriveProviderInstallView, type ProviderInstallView } from "../settings/providerInstall";
import {
+ firstSentenceOf,
getProviderSummary,
getProviderVersionLabel,
PROVIDER_STATUS_STYLES,
@@ -156,6 +158,13 @@ export interface FirstRunProviderRow {
* the install guide instead of offering a sign-in that cannot run.
*/
readonly signInCommand: string | null;
+ /**
+ * The one-click install Threadlines can run for a missing CLI, or null when
+ * it cannot (the CLI is already there, or the server found no package
+ * manager to install it with). Null is what sends the row back to the
+ * install guide, so the two are never offered together.
+ */
+ readonly install: ProviderInstallView | null;
}
const PROVIDER_ROW_DOT_CLASS_NAMES: Record = {
@@ -192,7 +201,7 @@ function providerRowDescription(
}
const summary = getProviderSummary(provider.snapshot);
- const detail = firstSentence(summary.detail);
+ const detail = firstSentenceOf(summary.detail);
if (detail) {
return `${summary.headline}. ${detail}`;
}
@@ -201,21 +210,6 @@ function providerRowDescription(
: `${summary.headline}. Sign in to use ${provider.displayName} here.`;
}
-/**
- * Row descriptions stay at roughly two rendered lines (design system), but
- * provider status details are written for the settings page and can run to a
- * paragraph with install URLs. The card keeps the diagnosis sentence; the
- * full recipe is one click away behind the row's action.
- */
-function firstSentence(value: string | null | undefined): string | null {
- const trimmed = value?.trim();
- if (!trimmed) {
- return null;
- }
- const match = /^.*?\.(?=\s|$)/.exec(trimmed);
- return match ? match[0] : trimmed;
-}
-
/**
* The card is a checklist, so rows are ordered by how close the provider is
* to working: sign-in is one browser window away, an install is a bigger
@@ -278,6 +272,7 @@ export function deriveFirstRunProviderRows(
state === "needsSignIn"
? (providerAuthReconnectCommand(provider.driverKind) ?? null)
: null,
+ install: state === "notInstalled" ? deriveProviderInstallView(provider.snapshot) : null,
} satisfies FirstRunProviderRow;
})
.toSorted(
diff --git a/apps/web/src/components/settings/ProviderInstallAction.tsx b/apps/web/src/components/settings/ProviderInstallAction.tsx
new file mode 100644
index 000000000..ceebc7235
--- /dev/null
+++ b/apps/web/src/components/settings/ProviderInstallAction.tsx
@@ -0,0 +1,119 @@
+/**
+ * The "Install" control, shared by the settings provider card and the
+ * first-run setup card.
+ *
+ * Starting an install is one RPC: the same `server.updateProvider` the Update
+ * button calls, with `action: "install"`. Everything after that arrives on the
+ * provider snapshot, so both surfaces render progress from
+ * `deriveProviderInstallView` and neither has to poll or hold its own copy of
+ * the run. While the command is running the button gives way to the status
+ * line, because the row already says what is happening and a second click
+ * would only be refused.
+ *
+ * @module ProviderInstallAction
+ */
+import {
+ PROVIDER_DISPLAY_NAMES,
+ type ProviderDriverKind,
+ type ProviderInstanceId,
+} from "@threadlines/contracts";
+import { LoaderIcon } from "lucide-react";
+import { useCallback, useState, type ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+import { ensureLocalApi } from "../../localApi";
+import { Button } from "../ui/button";
+import { stackedThreadToast, toastManager } from "../ui/toast";
+import {
+ isProviderInstallInFlight,
+ providerInstallStatusText,
+ type ProviderInstallView,
+} from "./providerInstall";
+
+function useProviderInstallStart(input: {
+ readonly instanceId: ProviderInstanceId;
+ readonly driverKind: ProviderDriverKind;
+ readonly displayName: string | undefined;
+}): { readonly isStarting: boolean; readonly start: () => void } {
+ const { instanceId, driverKind, displayName } = input;
+ const [isStarting, setIsStarting] = useState(false);
+
+ const start = useCallback(() => {
+ if (isStarting) {
+ return;
+ }
+ setIsStarting(true);
+ void ensureLocalApi()
+ .server.updateProvider({ provider: driverKind, instanceId, action: "install" })
+ .catch((error: unknown) => {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: `Could not install ${displayName ?? PROVIDER_DISPLAY_NAMES[driverKind] ?? driverKind}`,
+ description:
+ error instanceof Error
+ ? error.message
+ : "The provider install command could not be started.",
+ }),
+ );
+ })
+ .finally(() => {
+ setIsStarting(false);
+ });
+ }, [displayName, driverKind, instanceId, isStarting]);
+
+ return { isStarting, start };
+}
+
+export function ProviderInstallAction({
+ instanceId,
+ driverKind,
+ displayName,
+ view,
+ statusClassName,
+ buttonVariant,
+}: {
+ readonly instanceId: ProviderInstanceId;
+ readonly driverKind: ProviderDriverKind;
+ readonly displayName?: string | undefined;
+ readonly view: ProviderInstallView;
+ readonly statusClassName?: string | undefined;
+ readonly buttonVariant?: "default" | "outline" | undefined;
+}): ReactNode {
+ const { isStarting, start } = useProviderInstallStart({
+ instanceId,
+ driverKind,
+ displayName,
+ });
+ const name = displayName ?? PROVIDER_DISPLAY_NAMES[driverKind] ?? String(driverKind);
+ const statusText = providerInstallStatusText({ view, isStarting });
+ const inFlight = isProviderInstallInFlight({ view, isStarting });
+
+ return (
+ <>
+ {statusText === null ? null : (
+
+ {inFlight ? : null}
+ {statusText}
+
+ )}
+ {inFlight ? null : (
+
+ {view.status === "failed" ? "Retry" : "Install"}
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx
index 9329adf97..b5931c1d9 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.tsx
+++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx
@@ -68,12 +68,15 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import { ProviderUsageDashboard } from "../ProviderUsageDashboard";
import { RedactedSensitiveText } from "./RedactedSensitiveText";
import {
+ firstSentenceOf,
getProviderVersionAdvisoryPresentation,
PROVIDER_STATUS_STYLES,
getProviderSummary,
getProviderVersionLabel,
type ProviderStatusKey,
} from "./providerStatus";
+import { deriveProviderInstallView } from "./providerInstall";
+import { ProviderInstallAction } from "./ProviderInstallAction";
const PROVIDER_ACCENT_SWATCHES = ["#00347D", "#16a34a", "#ea580c", "#dc2626", "#7c3aed"] as const;
const PROVIDER_UPDATE_OUTPUT_PREVIEW_CHARS = 700;
@@ -1216,6 +1219,12 @@ export function ProviderInstanceCard({
: null;
const summary = rawSummary;
const versionLabel = getProviderVersionLabel(liveProvider?.version);
+ const providerInstallView = deriveProviderInstallView(liveProvider);
+ // With an Install button on the row, the detail keeps its diagnosis and
+ // drops the manual install recipe: never both at once.
+ const summaryDetail = providerInstallView
+ ? firstSentenceOf(summary.detail)
+ : (summary.detail ?? null);
const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
const updateCommand = versionAdvisory?.updateCommand ?? null;
const providerUpdateState = liveProvider?.updateState ?? null;
@@ -1478,9 +1487,9 @@ export function ProviderInstanceCard({
>
)}
- {summary.detail ? (
+ {summaryDetail ? (
- -
+ -
) : null}
@@ -1660,7 +1669,16 @@ export function ProviderInstanceCard({
/>
) : null}
-
+
+ {providerInstallView && driverKind ? (
+
+ ) : null}
{
});
});
+ it("installs a missing provider CLI from the provider card", async () => {
+ const updateProvider = vi.fn().mockResolvedValue({
+ providers: [createMissingClaudeProvider({ canInstall: true })],
+ });
+ window.nativeApi = {
+ persistence: {
+ getClientSettings: vi.fn().mockResolvedValue(null),
+ setClientSettings: vi.fn().mockResolvedValue(undefined),
+ },
+ server: {
+ updateProvider,
+ },
+ } as unknown as LocalApi;
+
+ setServerConfigSnapshot({
+ ...createBaseServerConfig(),
+ providers: [createMissingClaudeProvider({ canInstall: true })],
+ });
+
+ mounted = await render(
+
+
+ ,
+ );
+
+ // The button replaces the manual recipe; the diagnosis sentence stays.
+ await expect
+ .element(page.getByText("Claude Agent CLI (`claude`) is not installed or not on PATH."))
+ .toBeVisible();
+ await expect
+ .element(page.getByRole("link", { name: "https://claude.com/product/claude-code" }))
+ .not.toBeInTheDocument();
+ await page.getByRole("button", { name: "Install Claude" }).click();
+
+ expect(updateProvider).toHaveBeenCalledWith({
+ provider: ProviderDriverKind.make("claudeAgent"),
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ action: "install",
+ });
+ });
+
+ it("reports a running provider install in place of the install button", async () => {
+ window.nativeApi = {
+ persistence: {
+ getClientSettings: vi.fn().mockResolvedValue(null),
+ setClientSettings: vi.fn().mockResolvedValue(undefined),
+ },
+ server: {
+ updateProvider: vi.fn().mockResolvedValue({ providers: [] }),
+ },
+ } as unknown as LocalApi;
+
+ setServerConfigSnapshot({
+ ...createBaseServerConfig(),
+ providers: [
+ createMissingClaudeProvider({
+ canInstall: true,
+ updateState: {
+ status: "running",
+ startedAt: "2026-05-04T10:00:00.000Z",
+ finishedAt: null,
+ message: "Installing provider.",
+ output: "added 1 package",
+ },
+ }),
+ ],
+ });
+
+ mounted = await render(
+
+
+ ,
+ );
+
+ await expect.element(page.getByText("Installing… added 1 package")).toBeVisible();
+ await expect
+ .element(page.getByRole("button", { name: "Install Claude" }))
+ .not.toBeInTheDocument();
+ });
+
+ it("keeps the install guide when the server derived no install command", async () => {
+ window.nativeApi = {
+ persistence: {
+ getClientSettings: vi.fn().mockResolvedValue(null),
+ setClientSettings: vi.fn().mockResolvedValue(undefined),
+ },
+ server: {
+ updateProvider: vi.fn().mockResolvedValue({ providers: [] }),
+ },
+ } as unknown as LocalApi;
+
+ setServerConfigSnapshot({
+ ...createBaseServerConfig(),
+ providers: [createMissingClaudeProvider()],
+ });
+
+ mounted = await render(
+
+
+ ,
+ );
+
+ // No derived install command: the full guide sentence and its link stay.
+ await expect
+ .element(page.getByRole("button", { name: "Install Claude" }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(page.getByRole("link", { name: "https://claude.com/product/claude-code" }))
+ .toBeVisible();
+ });
+
it("runs verified native one-click updates for Windows Claude advisories", async () => {
const updateProvider = vi.fn().mockResolvedValue({
providers: [createVerifiedNativeOutdatedClaudeProvider()],
diff --git a/apps/web/src/components/settings/providerInstall.ts b/apps/web/src/components/settings/providerInstall.ts
new file mode 100644
index 000000000..89e33be87
--- /dev/null
+++ b/apps/web/src/components/settings/providerInstall.ts
@@ -0,0 +1,101 @@
+/**
+ * One-click provider install: what the button knows.
+ *
+ * A provider whose CLI is missing used to offer nothing but a link to the
+ * vendor's install page. The server now derives an install command for it
+ * (npm global, when npm is on the server's PATH) and runs it through the same
+ * runner, lock, and progress state as an update, so the snapshot the browser
+ * already streams carries everything a surface needs: whether an install is
+ * offered at all, whether one is running, and why the last one failed.
+ *
+ * Both surfaces that show the action (the settings provider card and the
+ * first-run setup card) read it from here so they never disagree about
+ * whether a provider can be installed.
+ *
+ * @module providerInstall
+ */
+import type { ServerProvider } from "@threadlines/contracts";
+
+export type ProviderInstallStatus = "idle" | "running" | "failed";
+
+export interface ProviderInstallView {
+ /** The command the server will run, for display next to the action. */
+ readonly command: string;
+ readonly status: ProviderInstallStatus;
+ /** The server's plain-language message about the last attempt. */
+ readonly message: string | null;
+ /** Last visible line of the command's output, when it failed. */
+ readonly lastOutputLine: string | null;
+}
+
+function lastNonEmptyLine(value: string | null | undefined): string | null {
+ const lines = (value ?? "")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ return lines.length > 0 ? lines[lines.length - 1]! : null;
+}
+
+/**
+ * The install action for one provider, or null when there is nothing to
+ * offer: the provider is disabled, its CLI is already installed, or the
+ * server could not derive an install command for this machine (no npm), in
+ * which case the surface falls back to the provider's install guide.
+ *
+ * Install progress rides on `updateState`, which the server writes for
+ * whichever maintenance command is running. A provider that is not installed
+ * has no update to run, so an active state here is always the install.
+ */
+export function deriveProviderInstallView(
+ provider: ServerProvider | undefined,
+): ProviderInstallView | null {
+ const command = provider?.versionAdvisory?.installCommand ?? null;
+ if (
+ !provider ||
+ !provider.enabled ||
+ provider.installed ||
+ provider.versionAdvisory?.canInstall !== true ||
+ command === null
+ ) {
+ return null;
+ }
+
+ const status = provider.updateState?.status;
+ return {
+ command,
+ status:
+ status === "queued" || status === "running"
+ ? "running"
+ : status === "failed" || status === "unchanged"
+ ? "failed"
+ : "idle",
+ message: provider.updateState?.message?.trim() || null,
+ lastOutputLine: lastNonEmptyLine(provider.updateState?.output),
+ };
+}
+
+/**
+ * One line describing the run in progress or the one that failed, or null
+ * when there is nothing to say. Every surface that shows it truncates.
+ */
+export function providerInstallStatusText(input: {
+ readonly view: ProviderInstallView;
+ readonly isStarting: boolean;
+}): string | null {
+ if (input.view.status === "running" || input.isStarting) {
+ return input.view.lastOutputLine ? `Installing… ${input.view.lastOutputLine}` : "Installing…";
+ }
+ if (input.view.status === "failed") {
+ const reason = input.view.lastOutputLine ?? input.view.message;
+ return reason ? `Install failed. ${reason}` : "Install failed.";
+ }
+ return null;
+}
+
+/** True while the surface should describe the install instead of offering it. */
+export function isProviderInstallInFlight(input: {
+ readonly view: ProviderInstallView;
+ readonly isStarting: boolean;
+}): boolean {
+ return input.view.status === "running" || input.isStarting;
+}
diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts
index 8876cd0dd..a8ed150a8 100644
--- a/apps/web/src/components/settings/providerStatus.ts
+++ b/apps/web/src/components/settings/providerStatus.ts
@@ -100,6 +100,22 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
};
}
+/**
+ * The diagnosis sentence out of a provider status detail. Details are written
+ * for the settings page and can run to a paragraph with install URLs; a
+ * surface that offers the fix as a button keeps the diagnosis and drops the
+ * recipe, so the user is never told to go read an install guide next to a
+ * button that would do it for them.
+ */
+export function firstSentenceOf(value: string | null | undefined): string | null {
+ const trimmed = value?.trim();
+ if (!trimmed) {
+ return null;
+ }
+ const match = /^.*?\.(?=\s|$)/.exec(trimmed);
+ return match ? match[0] : trimmed;
+}
+
/**
* Normalize a version string for display. Adds the `v` prefix when the
* driver reported a bare version (e.g. `1.2.3`) so cards render
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index 3c22b7484..444327615 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -290,6 +290,17 @@ export const ServerProviderVersionAdvisory = Schema.Struct({
latestVersion: Schema.NullOr(TrimmedNonEmptyString),
updateCommand: Schema.NullOr(TrimmedNonEmptyString),
canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ /**
+ * The command Threadlines would run to install a provider whose CLI is
+ * missing, and whether it can run it without help. Derived from the same
+ * maintenance capabilities as `updateCommand` / `canUpdate`, so the two
+ * halves of "get this provider onto the machine" travel together. Both are
+ * defaulted on decode: snapshots cached by older builds omit them.
+ */
+ installCommand: Schema.NullOr(TrimmedNonEmptyString).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
+ canInstall: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
checkedAt: Schema.NullOr(IsoDateTime),
message: Schema.NullOr(TrimmedNonEmptyString),
});
@@ -781,9 +792,19 @@ export const ServerProviderRateLimitResetCreditConsumeResult = Schema.Struct({
export type ServerProviderRateLimitResetCreditConsumeResult =
typeof ServerProviderRateLimitResetCreditConsumeResult.Type;
+/**
+ * Which maintenance command the runner should run for the target instance.
+ * Install and update share one request, one lock, and one progress state:
+ * they are the same package-manager command with a different reason for
+ * running it. Absent means `"update"` so older clients keep working.
+ */
+export const ServerProviderMaintenanceAction = Schema.Literals(["update", "install"]);
+export type ServerProviderMaintenanceAction = typeof ServerProviderMaintenanceAction.Type;
+
export const ServerProviderUpdateInput = Schema.Struct({
provider: ProviderDriverKind,
instanceId: Schema.optionalKey(ProviderInstanceId),
+ action: Schema.optionalKey(ServerProviderMaintenanceAction),
});
export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type;
From a476cb2230f8d47602acf1423ce7de9c2df05f2d Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Fri, 7 Aug 2026 10:41:57 -0400
Subject: [PATCH 2/2] Click the live palette row, not an exit-animating ghost
The create-folder palette test (issue #117) flaked because the click
helper fired a synthetic click on the first DOM node matching the row
label. During a palette view transition an exit-animating copy of the
row can still be in the DOM with its React handlers gone, and a
synthetic click on it is silently lost; a real pointer cannot make that
mistake. The helper now emulates hit-testing and clicks the candidate
that owns the pixels at its own center.
---
apps/web/src/components/ChatView.browser.tsx | 28 ++++++++++++++++----
1 file changed, 23 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 2321e83ce..14e5d409d 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -1936,12 +1936,30 @@ async function clickCommandPaletteAction(label: string): Promise {
await waitForElement(() => {
const palette = document.querySelector('[data-testid="command-palette"]');
if (!palette) return null;
+ const candidates = Array.from(
+ palette.querySelectorAll('[data-slot="command-item"]'),
+ ).filter((item) =>
+ Array.from(item.querySelectorAll("span")).some(
+ (content) => content.textContent?.trim() === label,
+ ),
+ );
+ // A view transition can briefly leave an exit-animating copy of the row
+ // in the DOM with its React handlers already gone; a synthetic click on
+ // that ghost is silently lost (the source of the #117 flake). A real
+ // pointer can only hit the live copy, so emulate hit-testing: click the
+ // candidate that owns the pixels at its own center.
const action =
- Array.from(palette.querySelectorAll('[data-slot="command-item"]')).find((item) =>
- Array.from(item.querySelectorAll("span")).some(
- (content) => content.textContent?.trim() === label,
- ),
- ) ?? null;
+ candidates.find((item) => {
+ const rect = item.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return false;
+ const hit = document.elementFromPoint(
+ rect.left + rect.width / 2,
+ rect.top + rect.height / 2,
+ );
+ return hit !== null && item.contains(hit);
+ }) ??
+ candidates.at(-1) ??
+ null;
if (!action) return null;
action.click();
return action;