diff --git a/README.md b/README.md
index 41ac65b..8babe99 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ A native companion to [be-concise](https://github.com/yannelli/be-concise) for C
- Call counts, interventions, sessions, average hook duration, and a 30-minute activity chart.
- User and project configuration with threshold controls, check switches, writing presets, an advanced JSON editor, and test filter settings.
- An isolated playground for file writes, Codex patches, shell commands, and final replies. Previews inspect hook responses without executing the pasted command or writing the target file.
+- A Plugin tab that installs, updates, downgrades, and removes be-concise for Claude Code and Codex from the be-concise GitHub releases.
## Screenshots
@@ -83,13 +84,17 @@ Plugins must be enabled in Paseo Settings → Plugins. Installation runs trusted
The plugin detects installed be-concise versions in the Claude and Codex caches, respecting `CLAUDE_CONFIG_DIR` and `CODEX_HOME`. For a source checkout or custom installation, set `PASEO_CONCISE_ROOT` in the daemon environment to the repository or `plugins/concise` directory, then reload this plugin.
+### Install be-concise from Paseo
+
+Open **Be concise → Plugin**, or use the panel that appears when be-concise is missing. Choose a release and press **Install**, **Update**, or **Downgrade** for Claude Code or Codex. The daemon host needs Git and the `claude` or `codex` CLI on `PATH` or in `~/.local/bin`. The plugin downloads the release from [yannelli/be-concise](https://github.com/yannelli/be-concise/releases) into `~/.local/share/paseo-be-concise/releases` and registers it as the `be-concise` marketplace. Claude Code changes use user scope. Restart Claude Code sessions after a change. For Codex, start a new session and review the hooks with `/hooks`. If the marketplace already uses another source, **Switch to GitHub releases** asks for confirmation before it points the marketplace at the release folder. Changes run in the background, and the tab shows the result when the host commands finish. See [be-concise installer](docs/plugin-installer.md) for the exact commands.
+
## Activity and settings
The view refreshes every two seconds. It follows the project registry and saved records under the daemon’s HOME/XDG directories, alongside any running `concise-web` console. It retains up to 500 events per project within a shared 16 MiB budget. Stats describe that retained window; they are not lifetime totals. New activity requires `monitor.persist` to be enabled and `BEC_MONITOR_DISABLED` to be unset in the agent’s environment.
Configuration controls edit the selected file and preserve its other keys. **Save changes** validates through be-concise and checks the file revision. An intervening edit preserves your draft and requires an explicit reload. The effective view uses the daemon’s environment; agent-specific environment overrides apply separately. The interface labels active and inactive layers because creating a higher-priority file changes which configuration is selected.
-Each preview starts with separate retry state. Preview results do not enter live activity. The plugin does not modify the Claude or Codex installation or start another web console.
+Each preview starts with separate retry state. Preview results do not enter live activity. Only the Plugin tab changes the Claude or Codex installation, and only when you press one of its buttons. The plugin does not start another web console.
## Development
@@ -103,7 +108,7 @@ Tests use Node.js 24 and temporary home/project directories. They cover activity
Paseo API reference: [v0.9 plugins](https://paseo.sh/docs/plugins/v0.9/reference).
-See the [documentation index](docs/INDEX.md) for the current composer button contract and screenshot capture details. Consult it when changing pill labels or updating screenshots.
+See the [documentation index](docs/INDEX.md) for the current composer button contract, screenshot capture details, and the be-concise installer commands. Consult it when you change pill labels, update screenshots, or change install behavior.
## Releases
diff --git a/backend.test.mts b/backend.test.mts
index 90e33b1..f6074cd 100644
--- a/backend.test.mts
+++ b/backend.test.mts
@@ -124,6 +124,29 @@ test("discovery respects configured cache homes and rejects older installations"
assert.equal(found!.version, "0.7.1");
});
+test("the newest managed release wins discovery and reset reloads the runtime", async (t) => {
+ const { root, home } = await fixture(t);
+ const data = join(root, "data");
+ const env = { HOME: home, XDG_DATA_HOME: data, CLAUDE_CONFIG_DIR: join(root, "claude") };
+ await fakeInstallation(join(root, "claude/plugins/cache/be-concise/concise/0.8.2"), "0.8.2");
+ const releases = join(data, "paseo-be-concise/releases");
+ await fakeInstallation(join(releases, "v0.8.1/plugins/concise"), "0.8.1");
+ await fakeInstallation(join(releases, "v0.8.2/plugins/concise"), "0.8.2");
+ const select = (claude: string, codex: string) => writeFile(join(data, "paseo-be-concise/state.json"), JSON.stringify({ hosts: {
+ claude: { tag: claude, updatedAt: "2026-10-05T02:00:00.000Z" }, codex: { tag: codex, updatedAt: "2026-10-05T01:00:00.000Z" },
+ } }));
+ await select("v0.8.2", "v0.8.1");
+ const backend = createBackend(env);
+ t.after(backend.close);
+ assert.equal((await backend.getSnapshot()).version, "0.8.2");
+ await select("v0.8.1", "v0.8.2");
+ assert.equal((await backend.getSnapshot()).version, "0.8.2");
+ await backend.reset();
+ const reloaded = await backend.getSnapshot();
+ assert.equal(reloaded.version, "0.8.1");
+ assert.equal(reloaded.root, await realpath(join(releases, "v0.8.1/plugins/concise")));
+});
+
test("project filtering resolves directory aliases", async (t) => {
const { root, cwd } = await fixture(t);
const alias = join(root, "alias");
diff --git a/client/installer.tsx b/client/installer.tsx
new file mode 100644
index 0000000..1558ddf
--- /dev/null
+++ b/client/installer.tsx
@@ -0,0 +1,93 @@
+import type { PluginTheme } from "@getpaseo/plugin";
+import { useRpc } from "@getpaseo/plugin/client";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import React, { useEffect, useRef, useState } from "react";
+import { View } from "react-native";
+import { type HostId, type HostStatus, installerApply, installerStatus } from "../shared/contracts";
+import { compareVersions } from "../shared/versions";
+import { Button, Card, Chips, Label, Row } from "./ui";
+
+const queryKey = ["concise", "installer"];
+
+function actionLabel(host: HostStatus, version: string) {
+ if (host.source && !host.managed) return `Switch to GitHub releases ${version}`;
+ if (!host.version) return `Install ${version}`;
+ const order = compareVersions(version, host.version);
+ return order > 0 ? `Update to ${version}` : order < 0 ? `Downgrade to ${version}` : `Reinstall ${version}`;
+}
+
+function hostSummary(host: HostStatus) {
+ if (!host.available || host.error) return host.error ?? "Unavailable";
+ if (!host.version) return "Not installed";
+ return `Installed ${host.version}${host.enabled ? "" : " · Disabled"}`;
+}
+
+export function Installer({ theme, compact }: { theme: PluginTheme; compact: boolean }) {
+ const read = useRpc(installerStatus);
+ const apply = useRpc(installerApply);
+ const cache = useQueryClient();
+ const query = useQuery({ queryKey, queryFn: () => read({}), staleTime: 60_000, retry: 1,
+ refetchInterval: (current) => current.state.data?.job?.running ? 1500 : false });
+ const [chosen, setChosen] = useState("");
+ const [confirming, setConfirming] = useState<{ host: HostId; action: "switch" | "remove" } | null>(null);
+ const refresh = useMutation({ mutationFn: () => read({ refresh: true }), onSuccess: (data) => cache.setQueryData(queryKey, data) });
+ const mutation = useMutation({ mutationFn: apply, onSuccess: (data) => { cache.setQueryData(queryKey, data); setConfirming(null); } });
+ const job = query.data?.job ?? null;
+ const running = useRef(false);
+ useEffect(() => {
+ if (running.current && !job?.running) {
+ void cache.invalidateQueries({ queryKey: ["concise", "snapshot"] });
+ void cache.invalidateQueries({ queryKey: ["concise", "configuration"] });
+ }
+ running.current = Boolean(job?.running);
+ }, [cache, job?.running]);
+ if (query.isPending) return ;
+ if (!query.data) return ;
+ const { releases, hosts } = query.data;
+ const latest = releases[0];
+ const version = releases.some((release) => release.version === chosen) ? chosen : latest?.version ?? "";
+ const busy = mutation.isPending || refresh.isPending || Boolean(job?.running);
+ return
+
+
+
+
+
+
+
+
+ {latest?.publishedAt && }
+
+
+ {(refresh.error?.message ?? query.data.releasesError) && }
+ {releases.length > 1 && ({ value: release.version, label: release.version }))} value={version} onChange={setChosen} />}
+
+ {hosts.map((host) => {
+ const external = Boolean(host.source && !host.managed);
+ const confirm = confirming?.host === host.id ? confirming.action : null;
+ const install = () => mutation.mutate({ host: host.id, action: "install", version, switchSource: external });
+ return
+
+
+
+
+ {host.available && !host.error && }
+ {confirm === "switch" && }
+ {host.available && !host.error &&
+ {!!version && !confirm &&
}
+ {mutation.isError && mutation.variables?.host === host.id && }
+ {job?.host === host.id && job.running && }
+ {job?.host === host.id && job.error && !mutation.isError && }
+ {job?.host === host.id && job.message && !mutation.isError && }
+ ;
+ })}
+
+ ;
+}
diff --git a/client/main.tsx b/client/main.tsx
index 9a2675c..2e2d9e5 100644
--- a/client/main.tsx
+++ b/client/main.tsx
@@ -6,6 +6,7 @@ import { snapshot } from "../shared/contracts";
import { Activity } from "./activity";
import { BrandIcon } from "./brand";
import { ConfigurationEditor } from "./configuration";
+import { Installer } from "./installer";
import { Playground } from "./playground";
import { Button, Card, Chips, Label, Row } from "./ui";
@@ -51,7 +52,7 @@ function Dashboard({ theme, layout, cwd }: Props) {
-
+
{!cwd && setProjectsOpen(!projectsOpen)} />}
{cwd && {name}}
@@ -71,15 +72,17 @@ function Dashboard({ theme, layout, cwd }: Props) {
void query.refetch()} />
}
+ {data && !data.connected && }
{data?.connected && <>
{tab === "activity" && setPaused(paused ? null : data)} />}
- {tab !== "activity" && !selected &&
+ {(tab === "configuration" || tab === "playground") && !selected &&
setProjectsOpen(true)} />
}
{tab === "configuration" && selected && }
{tab === "playground" && selected && }
+ {tab === "plugin" && }
diff --git a/docs/INDEX.md b/docs/INDEX.md
index b8b622f..12bf82e 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -3,3 +3,4 @@
Created: 2026-10-04. Last updated: 2026-10-05.
- [Composer pill and screenshots](composer-pill.md): compact status labels, the Paseo button contract, and screenshot capture details. Read this before changing labels or refreshing images.
+- [be-concise installer](plugin-installer.md): GitHub release handling, the Claude Code and Codex CLI commands, storage, and adapter discovery order. Read this before you change the Plugin tab or install commands.
diff --git a/docs/plugin-installer.md b/docs/plugin-installer.md
new file mode 100644
index 0000000..1321518
--- /dev/null
+++ b/docs/plugin-installer.md
@@ -0,0 +1,45 @@
+# be-concise installer
+
+Created: 2026-10-05. Last updated: 2026-10-05.
+
+The **Plugin** tab installs, updates, downgrades, and removes the `concise@be-concise` plugin for Claude Code and Codex on the daemon host. It uses the [yannelli/be-concise GitHub releases](https://github.com/yannelli/be-concise/releases). The same view appears in the **Connect be-concise** state when no installation is found.
+
+## Releases
+
+- The server reads `GET https://api.github.com/repos/yannelli/be-concise/releases?per_page=30` without a token and caches the list for 10 minutes. **Check for updates** reads it again.
+- The list keeps published releases with a `vX.Y.Z` tag at or above 0.7.0. Drafts and prereleases are excluded.
+- An install accepts only a version from that list. The server clones that tag with `git clone --depth 1 --branch vX.Y.Z` into a staging folder. Then it checks that both marketplace manifests are named `be-concise`, that the plugin version equals the tag, and that the adapter's `web` modules exist. It renames the folder to `releases/vX.Y.Z` last.
+- Releases are stored under `${XDG_DATA_HOME:-~/.local/share}/paseo-be-concise/releases`. `state.json` records the active tag and the tag before it for each host, and the time each other release folder went out of use. Each status check deletes a release folder that no host has used for 7 days. A Claude Code session that started in that period keeps a valid root.
+
+## Host commands
+
+Each host gets its own release folder as a local marketplace. Each change points the marketplace at the new folder. The commands run with the daemon environment, so `CLAUDE_CONFIG_DIR` and `CODEX_HOME` apply. The CLIs are found on `PATH` or in `~/.local/bin`. On Windows the server looks for `claude.exe`, `codex.exe`, and `git.exe`. Status output that is not the expected JSON shows as a host error.
+
+| Step | Claude Code (user scope) | Codex |
+| --- | --- | --- |
+| Status | `claude plugin list --json`, `claude plugin marketplace list --json` | `codex plugin list --json`, `codex plugin marketplace list --json` |
+| Point at a release | `claude plugin marketplace add `, then `claude plugin marketplace update be-concise` | `codex plugin marketplace remove be-concise` if the source differs, then `codex plugin marketplace add ` |
+| Install or change version | `claude plugin install` or `claude plugin update concise@be-concise --scope user` | `codex plugin add concise@be-concise` |
+| Remove | `claude plugin uninstall concise@be-concise --scope user` | `codex plugin remove concise@be-concise` |
+
+Remove deletes the `be-concise` marketplace only when it points at a managed release folder.
+
+Behavior checked on 2026-10-05 with Claude Code 2.1.289 and Codex CLI 0.159.0, using temporary `CLAUDE_CONFIG_DIR` and `CODEX_HOME` folders:
+
+- Claude Code loads a directory marketplace plugin in place from `/plugins/concise`. A changed folder takes effect at the next session start.
+- `claude plugin marketplace add` with a new path points the existing `be-concise` marketplace at that path. Installed plugins then update from it. `claude plugin update` also downgrades when the folder version is lower.
+- `codex plugin marketplace list --json` reports a local source with symlinks resolved. Claude Code reports the path as given. The server resolves both paths before it compares them.
+- `codex plugin marketplace add` refuses a second source with the same name. `codex plugin marketplace remove` keeps the plugin installed and enabled. `codex plugin add` installs the folder version, including a lower one.
+- Codex keeps a `trusted_hash` for each hook in `config.toml`. After a change, start a new session and review the hooks with `/hooks`.
+
+## Existing installations
+
+When the `be-concise` marketplace already uses another source, such as the GitHub repository or a source checkout, the tab shows that source. In that state the tab offers **Switch to GitHub releases** in place of **Install** and asks for confirmation. The result message lists the source it replaced so you can add it back. If a host command fails after the switch starts, the server adds the earlier source again and the error message names it. Remove on such a host uninstalls the plugin and keeps the marketplace.
+
+## Jobs
+
+Paseo plugin RPCs time out after 60 seconds on the client. A change can take longer, so `concise.installer.apply` starts a background job and returns at once. The tab polls `concise.installer.status` every 1.5 seconds while the job runs. During a job, status returns the last host report and does not start CLI calls. Only one change runs at a time. A plugin reload stops the running job and its host command.
+
+## Adapter discovery
+
+`PASEO_CONCISE_ROOT` takes priority. Next comes the managed release that was changed most recently in `state.json`, and after that the highest version in the Claude and Codex caches. After a successful change, the backend closes its runtime and loads the selected release on the next refresh. The status check also updates `state.json` when a host's marketplace no longer points at a managed release.
diff --git a/index.server.ts b/index.server.ts
index cb8b356..508a6da 100644
--- a/index.server.ts
+++ b/index.server.ts
@@ -1,13 +1,20 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
-import { eventDetail, preview, readConfiguration, snapshot, writeConfiguration } from "./shared/contracts.ts";
+import { eventDetail, installerApply, installerStatus, preview, readConfiguration, snapshot, writeConfiguration } from "./shared/contracts.ts";
import { createBackend } from "./server/adapter.ts";
+import { createInstaller } from "./server/installer.ts";
export default function contribute(server: PluginServerContext) {
const backend = createBackend();
+ const installer = createInstaller({ onChange: backend.reset });
server.handle(snapshot, backend.getSnapshot);
server.handle(eventDetail, backend.getEventDetail);
server.handle(readConfiguration, backend.getConfiguration);
server.handle(writeConfiguration, backend.saveConfiguration);
server.handle(preview, backend.runPreview);
- return backend.close;
+ server.handle(installerStatus, installer.getStatus);
+ server.handle(installerApply, installer.apply);
+ return async () => {
+ await installer.close();
+ await backend.close();
+ };
}
diff --git a/server/adapter.ts b/server/adapter.ts
index 63dd5f0..0bb4c0e 100644
--- a/server/adapter.ts
+++ b/server/adapter.ts
@@ -4,6 +4,8 @@ import { isAbsolute, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { ActivityStore, activityStats, RETAINED } from "./activity.ts";
import type { Configuration } from "../shared/contracts.ts";
+import { compareVersions as compare } from "../shared/versions.ts";
+import { managedRoot } from "./installer.ts";
type Environment = Record;
type Project = { key: string; name: string; cwd: string; lastSeen: string };
@@ -36,16 +38,27 @@ export function visibleEnvironment(env: Environment): Record {
}));
}
-const numbers = (version: string) => version.replace(/^v/, "").split(".").map(Number);
-const compare = (a: string, b: string) => {
- const left = numbers(a), right = numbers(b);
- return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
-};
+type Installation = { root: string; version: string };
+
+async function inspect(candidate: string): Promise {
+ for (const manifest of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json", "plugin.json"]) {
+ try {
+ const { name, version } = JSON.parse(await readFile(join(candidate, manifest), "utf8"));
+ if (name !== "concise" || typeof version !== "string" || !/^v?\d+\.\d+\.\d+$/.test(version) || compare(version, "0.7.0") < 0) continue;
+ await Promise.all(["web/configuration.mjs", "web/hub.mjs", "web/testing/runner.mjs"].map((file) => stat(join(candidate, file))));
+ return { root: await realpath(candidate), version };
+ } catch {}
+ }
+ return null;
+}
-export async function discoverInstallation(env: Environment): Promise<{ root: string; version: string } | null> {
+export async function discoverInstallation(env: Environment): Promise {
const candidates: string[] = [];
if (env.PASEO_CONCISE_ROOT) candidates.push(resolve(env.PASEO_CONCISE_ROOT), resolve(env.PASEO_CONCISE_ROOT, "plugins/concise"));
else {
+ const managed = await managedRoot(env);
+ const preferred = managed ? await inspect(managed) : null;
+ if (preferred) return preferred;
const home = env.HOME || env.USERPROFILE || homedir();
const caches = [join(env.CODEX_HOME || join(home, ".codex"), "plugins/cache/be-concise/concise"),
join(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), "plugins/cache/be-concise/concise")];
@@ -53,18 +66,8 @@ export async function discoverInstallation(env: Environment): Promise<{ root: st
try { candidates.push(...(await readdir(cache)).map((name) => join(cache, name))); } catch {}
}));
}
- const found = await Promise.all(candidates.map(async (candidate) => {
- for (const manifest of [".claude-plugin/plugin.json", ".codex-plugin/plugin.json", "plugin.json"]) {
- try {
- const { name, version } = JSON.parse(await readFile(join(candidate, manifest), "utf8"));
- if (name !== "concise" || typeof version !== "string" || !/^v?\d+\.\d+\.\d+$/.test(version) || compare(version, "0.7.0") < 0) continue;
- await Promise.all(["web/configuration.mjs", "web/hub.mjs", "web/testing/runner.mjs"].map((file) => stat(join(candidate, file))));
- return { root: await realpath(candidate), version };
- } catch {}
- }
- return null;
- }));
- return found.filter((value): value is { root: string; version: string } => value !== null).sort((a, b) => compare(b.version, a.version))[0] || null;
+ const found = await Promise.all(candidates.map(inspect));
+ return found.filter((value): value is Installation => value !== null).sort((a, b) => compare(b.version, a.version))[0] || null;
}
async function target(cwd: string): Promise {
@@ -86,9 +89,12 @@ export function createBackend(env: Environment = process.env) {
let runtime: Runtime | null = null;
let loading: Promise | null = null;
let previews: Promise = Promise.resolve();
+ let resets: Promise = Promise.resolve();
let closed = false;
async function connect(): Promise {
+ if (closed) throw new Error("The plugin is stopping. Reopen it after reload.");
+ await resets;
if (closed) throw new Error("The plugin is stopping. Reopen it after reload.");
if (runtime) return runtime;
if (!loading) loading = (async () => {
@@ -167,9 +173,27 @@ export function createBackend(env: Environment = process.env) {
return operation;
}
+ // connect waits for resets, so a new runtime cannot share a runner that is being disposed.
+ function reset() {
+ const operation = resets.then(async () => {
+ if (closed) return;
+ await loading?.catch(() => {});
+ const previous = runtime;
+ runtime = null;
+ if (!previous) return;
+ previous.hub.close();
+ activity.clear();
+ await Promise.allSettled([...saves.values(), previews]);
+ await previous.runner.disposeTests();
+ });
+ resets = operation.catch(() => {});
+ return operation;
+ }
+
async function close() {
if (closed) return;
closed = true;
+ await resets;
await loading?.catch(() => {});
runtime?.hub.close();
await Promise.allSettled([...saves.values(), previews]);
@@ -178,5 +202,5 @@ export function createBackend(env: Environment = process.env) {
runtime = null;
}
- return { getSnapshot, getEventDetail, getConfiguration, saveConfiguration, runPreview, close };
+ return { getSnapshot, getEventDetail, getConfiguration, saveConfiguration, runPreview, reset, close };
}
diff --git a/server/installer.ts b/server/installer.ts
new file mode 100644
index 0000000..52bee69
--- /dev/null
+++ b/server/installer.ts
@@ -0,0 +1,328 @@
+import { execFile } from "node:child_process";
+import { randomUUID } from "node:crypto";
+import { access, constants, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { basename, delimiter, dirname, join, resolve } from "node:path";
+import type { HostId, HostStatus, InstallerStatus } from "../shared/contracts.ts";
+import { compareVersions } from "../shared/versions.ts";
+
+type Environment = Record;
+type JsonObject = Record;
+type Release = InstallerStatus["releases"][number];
+type HostState = { tag: string; previous?: string; updatedAt: string };
+type State = { hosts: Partial>; retired: Record };
+type Host = HostStatus & { binary: string | null; tag: string | null };
+type Job = NonNullable;
+export type RunResult = { code: number; stdout: string; stderr: string };
+export type Run = (command: string, args: string[], options: { env: Environment; signal?: AbortSignal }) => Promise;
+export type FetchJson = (url: string, init: { headers: Record; signal: AbortSignal }) => Promise<{ ok: boolean; status: number; json(): Promise }>;
+export type ApplyInput = { host: HostId; action: "install" | "remove"; version?: string; switchSource?: boolean };
+
+export const REPOSITORY = "yannelli/be-concise";
+const RELEASES_URL = `https://api.github.com/repos/${REPOSITORY}/releases?per_page=30`;
+const CLONE_URL = `https://github.com/${REPOSITORY}.git`;
+const MARKETPLACE = "be-concise";
+const PLUGIN = "concise@be-concise";
+const MINIMUM = "0.7.0";
+const TAG = /^v\d+\.\d+\.\d+$/;
+const CACHE_MS = 10 * 60 * 1000;
+const RETAIN_MS = 7 * 24 * 60 * 60 * 1000;
+const HOSTS: HostId[] = ["claude", "codex"];
+const LABELS: Record = { claude: "Claude Code", codex: "Codex" };
+const NOTICES: Record = {
+ claude: "Restart Claude Code sessions to load it.",
+ codex: "Start a new Codex session and review the concise hooks with /hooks.",
+};
+
+const object = (value: unknown): value is JsonObject => value !== null && typeof value === "object" && !Array.isArray(value);
+const list = (value: unknown): JsonObject[] => Array.isArray(value) ? value.filter(object) : [];
+const text = (value: unknown): string => typeof value === "string" ? value : "";
+const failure = (error: unknown) => error instanceof Error ? error.message : String(error);
+const canonical = (path: string) => realpath(path).catch(() => resolve(path));
+const lastLine = (value: string) => value.trim().split("\n").filter((line) => line.trim() && !line.startsWith("WARNING")).at(-1) ?? "";
+
+export function dataDirectory(env: Environment): string {
+ const home = env.HOME || env.USERPROFILE || homedir();
+ return join(env.XDG_DATA_HOME || join(home, ".local/share"), "paseo-be-concise");
+}
+const releasesDirectory = (env: Environment) => join(dataDirectory(env), "releases");
+const statePath = (env: Environment) => join(dataDirectory(env), "state.json");
+
+export async function readState(env: Environment): Promise {
+ try {
+ const value: unknown = JSON.parse(await readFile(statePath(env), "utf8"));
+ const hosts = object(value) && object(value.hosts) ? value.hosts : {};
+ const retired = object(value) && object(value.retired) ? value.retired : {};
+ return { hosts: Object.fromEntries(HOSTS.flatMap((id) => {
+ const entry = hosts[id];
+ return object(entry) && TAG.test(text(entry.tag)) ? [[id, { tag: text(entry.tag), previous: TAG.test(text(entry.previous)) ? text(entry.previous) : undefined, updatedAt: text(entry.updatedAt) }]] : [];
+ })), retired: Object.fromEntries(Object.entries(retired).flatMap(([tag, since]) => TAG.test(tag) && typeof since === "string" ? [[tag, since]] : [])) };
+ } catch { return { hosts: {}, retired: {} }; }
+}
+
+async function writeState(env: Environment, state: State) {
+ await mkdir(dataDirectory(env), { recursive: true });
+ const temporary = `${statePath(env)}.${randomUUID()}`;
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`);
+ await rename(temporary, statePath(env));
+}
+
+export async function managedRoot(env: Environment): Promise {
+ const latest = Object.values((await readState(env)).hosts).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
+ return latest ? join(releasesDirectory(env), latest.tag, "plugins/concise") : null;
+}
+
+export async function locate(name: string, env: Environment): Promise {
+ const home = env.HOME || env.USERPROFILE || homedir();
+ for (const directory of [...(env.PATH || "").split(delimiter).filter(Boolean), join(home, ".local/bin")]) {
+ const file = join(directory, process.platform === "win32" ? `${name}.exe` : name);
+ try {
+ await access(file, constants.X_OK);
+ if ((await stat(file)).isFile()) return file;
+ } catch {}
+ }
+ return null;
+}
+
+export const runCommand: Run = (command, args, { env, signal }) => new Promise((done, fail) => {
+ execFile(command, args, { env: { ...env, GIT_TERMINAL_PROMPT: "0" }, signal, timeout: 180_000, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
+ if (error && typeof error.code === "string") fail(new Error(`${basename(command)} failed: ${error.message}`));
+ else done({ code: error ? Number(error.code ?? 1) || 1 : 0, stdout, stderr: error?.killed ? `${stderr}\nTimed out after 180 seconds.` : stderr });
+ });
+});
+
+function parseJson(stdout: string): unknown {
+ for (const candidate of [stdout.trim(), lastLine(stdout)]) {
+ try { return JSON.parse(candidate); } catch {}
+ }
+ return null;
+}
+
+async function validate(root: string, version: string) {
+ const read = async (path: string): Promise => {
+ const value: unknown = JSON.parse(await readFile(join(root, path), "utf8"));
+ return object(value) ? value : {};
+ };
+ const [claude, codex, plugin] = await Promise.all([
+ read(".claude-plugin/marketplace.json"), read(".agents/plugins/marketplace.json"), read("plugins/concise/.claude-plugin/plugin.json"),
+ ]);
+ if (claude.name !== MARKETPLACE || codex.name !== MARKETPLACE) throw new Error("The release does not contain the be-concise marketplace.");
+ if (plugin.name !== "concise" || plugin.version !== version) throw new Error(`The release plugin version ${text(plugin.version) || "is missing and"} does not match ${version}.`);
+ await Promise.all(["web/configuration.mjs", "web/hub.mjs", "web/testing/runner.mjs"].map((file) => stat(join(root, "plugins/concise", file))));
+}
+
+export function createInstaller({ env = process.env, run = runCommand, fetchJson = fetch as FetchJson, onChange = async () => {}, now = Date.now }: {
+ env?: Environment; run?: Run; fetchJson?: FetchJson; onChange?: () => Promise | void; now?: () => number;
+} = {}) {
+ const releasesRoot = releasesDirectory(env);
+ const stopping = new AbortController();
+ let cache: { at: number; releases: Release[]; error: string | null } = { at: 0, releases: [], error: null };
+ let queue: Promise = Promise.resolve();
+ let closed = false;
+ let job: Job | null = null;
+ let last: InstallerStatus | null = null;
+
+ function serial(operation: () => Promise): Promise {
+ if (closed) return Promise.reject(new Error("The plugin is stopping. Reopen it after reload."));
+ const next = queue.catch(() => {}).then(operation);
+ queue = next;
+ return next;
+ }
+
+ async function cli(binary: string, args: string[]): Promise {
+ const result = await run(binary, [...args, "--json"], { env, signal: stopping.signal });
+ const value = parseJson(result.stdout);
+ if (result.code === 0 && (!object(value) || value.outcome === undefined || value.outcome === "ok")) return value;
+ const reason = object(value) && typeof value.message === "string" ? value.message : lastLine(result.stderr) || lastLine(result.stdout);
+ throw new Error(`${basename(binary)} ${args.join(" ")} failed: ${reason || `exit ${result.code}`}`);
+ }
+
+ async function releases(refresh = false): Promise {
+ if (!refresh && cache.at && Date.now() - cache.at < CACHE_MS) return cache.releases;
+ try {
+ const response = await fetchJson(RELEASES_URL, { headers: { accept: "application/vnd.github+json", "user-agent": "paseo-be-concise" }, signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(15_000)]) });
+ if (!response.ok) throw new Error(`GitHub releases returned HTTP ${response.status}.`);
+ const body = await response.json();
+ if (!Array.isArray(body)) throw new Error("GitHub releases returned an unexpected response.");
+ const found = list(body).filter((release) => !release.draft && !release.prerelease && TAG.test(text(release.tag_name)) && compareVersions(text(release.tag_name), MINIMUM) >= 0)
+ .map((release) => ({ tag: text(release.tag_name), version: text(release.tag_name).slice(1), name: text(release.name) || text(release.tag_name), publishedAt: text(release.published_at), url: text(release.html_url) }))
+ .sort((a, b) => compareVersions(b.version, a.version));
+ cache = { at: Date.now(), releases: found, error: null };
+ } catch (error) {
+ cache = { ...cache, error: `Unable to read GitHub releases: ${failure(error)}` };
+ }
+ return cache.releases;
+ }
+
+ // Codex reports marketplace paths with symlinks resolved.
+ async function managedTag(source: string | null): Promise {
+ if (!source) return null;
+ const [root, target] = await Promise.all([canonical(releasesRoot), canonical(source)]);
+ return dirname(target) === root && TAG.test(basename(target)) ? basename(target) : null;
+ }
+
+ async function host(id: HostId): Promise {
+ const base: Host = { id, label: LABELS[id], available: false, version: null, enabled: false, source: null, managed: false, error: null, binary: null, tag: null };
+ const binary = await locate(id, env);
+ if (!binary) return { ...base, error: `${id} was not found on the daemon PATH.` };
+ try {
+ const [plugins, marketplaces] = await Promise.all([cli(binary, ["plugin", "list"]), cli(binary, ["plugin", "marketplace", "list"])]);
+ const expected = id === "claude" ? Array.isArray : object;
+ if (!expected(plugins) || !expected(marketplaces)) throw new Error(`${id} plugin list --json returned output that is not the expected JSON.`);
+ const plugin = id === "claude"
+ ? list(plugins).find((entry) => entry.id === PLUGIN && entry.scope === "user")
+ : list(object(plugins) ? plugins.installed : []).find((entry) => entry.pluginId === PLUGIN && entry.installed !== false);
+ const marketplace = list(id === "claude" ? marketplaces : object(marketplaces) ? marketplaces.marketplaces : []).find((entry) => entry.name === MARKETPLACE);
+ const origin = object(marketplace?.marketplaceSource) ? marketplace.marketplaceSource.source : undefined;
+ const source = marketplace ? text(id === "claude" ? marketplace.path || marketplace.repo || marketplace.url || marketplace.installLocation : origin || marketplace.root) || null : null;
+ const tag = await managedTag(source);
+ return { ...base, binary, available: true, version: text(plugin?.version) || null, enabled: plugin?.enabled !== false && Boolean(plugin), source, managed: Boolean(tag), tag };
+ } catch (error) {
+ return { ...base, binary, available: true, error: failure(error) };
+ }
+ }
+
+ async function reconcile(hosts: Host[]) {
+ const state = await readState(env);
+ let changed = false;
+ for (const entry of hosts.filter((item) => item.available && !item.error)) {
+ const { tag } = entry;
+ const current = state.hosts[entry.id];
+ if (tag === (current?.tag ?? null)) continue;
+ changed = true;
+ if (tag) state.hosts[entry.id] = { tag, previous: current?.tag, updatedAt: new Date().toISOString() };
+ else delete state.hosts[entry.id];
+ }
+ if (changed) {
+ await writeState(env, state);
+ await onChange();
+ }
+ }
+
+ async function report(refresh = false): Promise {
+ const [found, hosts] = await Promise.all([releases(refresh), Promise.all(HOSTS.map(host))]);
+ await reconcile(hosts);
+ await prune();
+ last = {
+ repository: REPOSITORY, directory: releasesRoot, releases: found, releasesError: cache.error,
+ checkedAt: new Date().toISOString(), hosts: hosts.map(({ binary: _binary, tag: _tag, ...entry }) => entry), job: null,
+ };
+ return withJob(last);
+ }
+
+ function withJob(status: InstallerStatus | null): InstallerStatus {
+ const base = status ?? { repository: REPOSITORY, directory: releasesRoot, releases: cache.releases, releasesError: cache.error, checkedAt: new Date().toISOString(), hosts: [], job: null };
+ return { ...base, job: job ? { ...job } : null };
+ }
+
+ async function stage(release: Release): Promise {
+ const target = join(releasesRoot, release.tag);
+ try { await validate(target, release.version); return target; } catch {}
+ const git = await locate("git", env);
+ if (!git) throw new Error("Install Git on the daemon host to download be-concise releases.");
+ await mkdir(releasesRoot, { recursive: true });
+ const staging = join(releasesRoot, `.staging-${randomUUID()}`);
+ try {
+ const result = await run(git, ["-c", "advice.detachedHead=false", "clone", "--quiet", "--depth", "1", "--branch", release.tag, "--", CLONE_URL, staging], { env, signal: stopping.signal });
+ if (result.code !== 0) throw new Error(`git clone ${release.tag} failed: ${lastLine(result.stderr) || `exit ${result.code}`}`);
+ await validate(staging, release.version);
+ await rm(join(staging, ".git"), { recursive: true, force: true });
+ await rm(target, { recursive: true, force: true });
+ await rename(staging, target);
+ return target;
+ } finally { await rm(staging, { recursive: true, force: true }); }
+ }
+
+ // Claude Code sessions load the plugin from the release folder, so an unused folder stays for RETAIN_MS.
+ async function prune() {
+ const state = await readState(env);
+ const keep = new Set(Object.values(state.hosts).flatMap((entry) => [entry.tag, entry.previous]));
+ const retired: Record = {};
+ for (const name of (await readdir(releasesRoot).catch(() => [] as string[])).filter((entry) => TAG.test(entry) && !keep.has(entry)).sort()) {
+ const since = state.retired[name] ?? new Date(now()).toISOString();
+ if (now() - Date.parse(since) < RETAIN_MS) retired[name] = since;
+ else await rm(join(releasesRoot, name), { recursive: true, force: true });
+ }
+ if (JSON.stringify(retired) !== JSON.stringify(state.retired)) await writeState(env, { ...state, retired });
+ }
+
+ async function install(id: HostId, version: string | undefined, switchSource: boolean): Promise {
+ const pick = (found: Release[]) => version ? found.find((item) => item.version === version) : found[0];
+ const release = pick(await releases()) ?? pick(await releases(true));
+ if (!release) throw new Error(cache.error ?? `${version ? `be-concise ${version}` : "No be-concise version"} is not a published release at or above ${MINIMUM}.`);
+ const current = await host(id);
+ if (!current.binary || current.error) throw new Error(current.error ?? `${LABELS[id]} is unavailable.`);
+ if (current.source && !current.managed && !switchSource) {
+ throw new Error(`${LABELS[id]} reads be-concise from ${current.source}. Choose Switch to GitHub releases to replace that marketplace source.`);
+ }
+ const directory = await stage(release);
+ const { binary } = current;
+ const external = current.source && !current.managed ? current.source : null;
+ const pointed = current.tag === release.tag;
+ try {
+ if (id === "claude") {
+ await cli(binary, ["plugin", "marketplace", "add", directory]);
+ await cli(binary, ["plugin", "marketplace", "update", MARKETPLACE]);
+ await cli(binary, ["plugin", current.version ? "update" : "install", PLUGIN, "--scope", "user"]);
+ } else {
+ if (current.source && !pointed) await cli(binary, ["plugin", "marketplace", "remove", MARKETPLACE]);
+ if (!pointed) await cli(binary, ["plugin", "marketplace", "add", directory]);
+ await cli(binary, ["plugin", "add", PLUGIN]);
+ }
+ } catch (error) {
+ if (!external) throw error;
+ const restore = async () => {
+ if (id === "codex") await cli(binary, ["plugin", "marketplace", "remove", MARKETPLACE]).catch(() => {});
+ await cli(binary, ["plugin", "marketplace", "add", external]);
+ };
+ const restored = await restore().then(() => true, () => false);
+ throw new Error(`${failure(error)} ${restored ? "The marketplace source was restored to" : "The marketplace source before this change was"} ${external}.`);
+ }
+ const state = await readState(env);
+ const previous = state.hosts[id];
+ state.hosts[id] = { tag: release.tag, previous: previous?.tag === release.tag ? previous.previous : previous?.tag, updatedAt: new Date().toISOString() };
+ await writeState(env, state);
+ await onChange();
+ const verb = !current.version ? "Installed" : compareVersions(release.version, current.version) > 0 ? "Updated to" : compareVersions(release.version, current.version) < 0 ? "Downgraded to" : "Reinstalled";
+ const switched = external ? ` Previous marketplace source: ${external}.` : "";
+ return `${verb} be-concise ${release.version} for ${LABELS[id]}. ${NOTICES[id]}${switched}`;
+ }
+
+ async function remove(id: HostId): Promise {
+ const current = await host(id);
+ if (!current.binary || current.error) throw new Error(current.error ?? `${LABELS[id]} is unavailable.`);
+ if (!current.version && !current.managed) throw new Error(`be-concise is not installed for ${LABELS[id]}.`);
+ if (current.version) await cli(current.binary, id === "claude" ? ["plugin", "uninstall", PLUGIN, "--scope", "user"] : ["plugin", "remove", PLUGIN]);
+ if (current.managed) await cli(current.binary, ["plugin", "marketplace", "remove", MARKETPLACE]);
+ const state = await readState(env);
+ delete state.hosts[id];
+ await writeState(env, state);
+ await onChange();
+ return `Removed be-concise from ${LABELS[id]}. ${id === "claude" ? "Restart Claude Code sessions to unload it." : "Start a new Codex session to unload it."}`;
+ }
+
+ async function start(input: ApplyInput): Promise {
+ if (job?.running) throw new Error("Another be-concise change is running. Wait for it to finish.");
+ const current: Job = { host: input.host, action: input.action, version: input.version ?? null, running: true, message: null, error: null };
+ job = current;
+ void serial(async () => {
+ try {
+ current.message = input.action === "install" ? await install(input.host, input.version, Boolean(input.switchSource)) : await remove(input.host);
+ } catch (error) {
+ current.error = failure(error);
+ } finally { current.running = false; }
+ await report().catch(() => {});
+ }).catch(() => {});
+ return withJob(last);
+ }
+
+ return {
+ getStatus: ({ refresh }: { refresh?: boolean } = {}) => job?.running ? Promise.resolve(withJob(last)) : serial(() => report(refresh)),
+ apply: (input: ApplyInput) => closed ? Promise.reject(new Error("The plugin is stopping. Reopen it after reload.")) : start(input),
+ async close() {
+ closed = true;
+ stopping.abort();
+ await queue.catch(() => {});
+ },
+ };
+}
diff --git a/shared/contracts.ts b/shared/contracts.ts
index c22ce42..33a9f1e 100644
--- a/shared/contracts.ts
+++ b/shared/contracts.ts
@@ -51,3 +51,29 @@ export const preview = defineRpc({
input: TargetSchema.extend({ kind: z.enum(["Write", "apply_patch", "Bash", "Stop"]), text: z.string().max(65536), path: z.string().max(1024) }),
output: z.object({ json: z.string() }),
});
+export const HostIdSchema = z.enum(["claude", "codex"]);
+export type HostId = z.infer;
+export const ReleaseSchema = z.object({ version: z.string(), tag: z.string(), name: z.string(), publishedAt: z.string(), url: z.string() });
+export const HostSchema = z.object({
+ id: HostIdSchema, label: z.string(), available: z.boolean(), version: z.string().nullable(), enabled: z.boolean(),
+ source: z.string().nullable(), managed: z.boolean(), error: z.string().nullable(),
+});
+export type HostStatus = z.infer;
+export const InstallActionSchema = z.enum(["install", "remove"]);
+export const JobSchema = z.object({
+ host: HostIdSchema, action: InstallActionSchema, version: z.string().nullable(), running: z.boolean(),
+ message: z.string().nullable(), error: z.string().nullable(),
+});
+export const InstallerSchema = z.object({
+ repository: z.string(), directory: z.string(), releases: z.array(ReleaseSchema),
+ releasesError: z.string().nullable(), checkedAt: z.string(), hosts: z.array(HostSchema), job: JobSchema.nullable(),
+});
+export type InstallerStatus = z.infer;
+export const installerStatus = defineRpc({
+ name: "concise.installer.status", input: z.object({ refresh: z.boolean().optional() }), output: InstallerSchema,
+});
+export const installerApply = defineRpc({
+ name: "concise.installer.apply",
+ input: z.object({ host: HostIdSchema, action: InstallActionSchema, version: z.string().max(32).optional(), switchSource: z.boolean().optional() }),
+ output: InstallerSchema,
+});
diff --git a/shared/versions.ts b/shared/versions.ts
new file mode 100644
index 0000000..605e621
--- /dev/null
+++ b/shared/versions.ts
@@ -0,0 +1,6 @@
+const numbers = (version: string) => version.replace(/^v/, "").split(".").map(Number);
+
+export function compareVersions(a: string, b: string): number {
+ const left = numbers(a), right = numbers(b);
+ return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
+}
diff --git a/test/installer.test.mts b/test/installer.test.mts
new file mode 100644
index 0000000..8d14b7d
--- /dev/null
+++ b/test/installer.test.mts
@@ -0,0 +1,227 @@
+import assert from "node:assert/strict";
+import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, dirname, join } from "node:path";
+import test from "node:test";
+import { createInstaller, managedRoot, readState, type ApplyInput, type FetchJson, type Run, type RunResult } from "../server/installer.ts";
+
+type FakeHost = { version: string | null; source: string | null };
+const release = (tag: string, extra: Record = {}) => ({ tag_name: tag, name: tag, published_at: "2026-09-29T21:58:29Z", html_url: `https://github.com/yannelli/be-concise/releases/tag/${tag}`, ...extra });
+const mutations = (calls: string[][], host: string) => calls.filter(([name]) => name === host).map((call) => call.slice(1).join(" "))
+ .filter((call) => call !== "plugin list" && call !== "plugin marketplace list");
+async function settle(installer: ReturnType, input: ApplyInput) {
+ await installer.apply(input);
+ for (;;) {
+ const status = await installer.getStatus();
+ if (!status.job?.running) return { message: status.job?.message ?? "", error: status.job?.error ?? null, status };
+ await new Promise((done) => setTimeout(done, 1));
+ }
+}
+const published = [release("v0.8.2"), release("v0.8.2-beta.1"), release("v0.9.0", { prerelease: true }), release("v1.0.0", { draft: true }), release("v0.6.3"), release("nightly"), release("v0.8.1"), release("v0.7.1")];
+
+async function writeRelease(root: string, version: string) {
+ const files: Record = {
+ ".claude-plugin/marketplace.json": JSON.stringify({ name: "be-concise" }),
+ ".agents/plugins/marketplace.json": JSON.stringify({ name: "be-concise" }),
+ "plugins/concise/.claude-plugin/plugin.json": JSON.stringify({ name: "concise", version }),
+ "plugins/concise/web/configuration.mjs": "", "plugins/concise/web/hub.mjs": "", "plugins/concise/web/testing/runner.mjs": "",
+ };
+ for (const [path, content] of Object.entries(files)) {
+ await mkdir(dirname(join(root, path)), { recursive: true });
+ await writeFile(join(root, path), content);
+ }
+}
+
+const DAY = 24 * 60 * 60 * 1000;
+type Options = {
+ binaries?: string[]; hosts?: Partial>; cloned?: (tag: string) => string; linked?: boolean;
+ respond?: (call: string) => RunResult | undefined;
+};
+
+async function fixture(t: test.TestContext, { binaries = ["claude", "codex", "git"], hosts = {}, cloned = (tag) => tag.slice(1), linked = false, respond = () => undefined }: Options = {}) {
+ const root = await mkdtemp(join(tmpdir(), "paseo-concise-installer-"));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const bin = join(root, "bin");
+ await mkdir(bin);
+ for (const name of binaries) {
+ await writeFile(join(bin, name), "#!/bin/sh\nexit 1\n");
+ await chmod(join(bin, name), 0o755);
+ }
+ const env = { HOME: join(root, "home"), PATH: bin, XDG_DATA_HOME: join(root, "data") };
+ if (linked) {
+ await mkdir(join(root, "data-target"));
+ await symlink(join(root, "data-target"), env.XDG_DATA_HOME);
+ }
+ const clock = { now: Date.now() };
+ const signals: AbortSignal[] = [];
+ const state: Record = { claude: { version: null, source: null }, codex: { version: null, source: null }, ...hosts };
+ const calls: string[][] = [];
+ const folderVersion = async (source: string | null) => JSON.parse(await readFile(join(source!, "plugins/concise/.claude-plugin/plugin.json"), "utf8")).version;
+ const ok = (value: unknown) => ({ code: 0, stdout: `${JSON.stringify(value)}\n`, stderr: "" });
+ const run: Run = async (command, args, { signal }) => {
+ const name = basename(command);
+ calls.push([name, ...args.filter((arg) => arg !== "--json")]);
+ if (signal) signals.push(signal);
+ const response = respond(calls.at(-1)!.join(" "));
+ if (response) return response;
+ if (name === "git") {
+ const tag = args[args.indexOf("--branch") + 1];
+ await writeRelease(args.at(-1)!, cloned(tag));
+ await mkdir(join(args.at(-1)!, ".git"));
+ return ok({});
+ }
+ const host = state[name];
+ const action = args.slice(1, 3).join(" ");
+ if (name === "claude") {
+ if (action === "list --json") return ok(host.version ? [{ id: "concise@be-concise", scope: "user", version: host.version, enabled: true }] : []);
+ if (action === "marketplace list") return ok(host.source ? [{ name: "be-concise", source: "directory", path: host.source }] : []);
+ if (action === "marketplace add") host.source = args[3];
+ if (action === "install concise@be-concise" || action === "update concise@be-concise") host.version = await folderVersion(host.source);
+ if (action === "uninstall concise@be-concise") host.version = null;
+ if (action === "marketplace remove") host.source = null;
+ return ok({ outcome: "ok", message: "done" });
+ }
+ if (action === "list --json") return { code: 0, stdout: JSON.stringify({ installed: host.version ? [{ pluginId: "concise@be-concise", version: host.version, installed: true, enabled: true }] : [] }, null, 2), stderr: "WARNING: helper aliases" };
+ if (action === "marketplace list") return ok({ marketplaces: host.source ? [{ name: "be-concise", root: host.source, marketplaceSource: { sourceType: "local", source: host.source } }] : [] });
+ if (action === "marketplace add") {
+ const source = await realpath(args[3]).catch(() => args[3]);
+ if (host.source && host.source !== source) return { code: 1, stdout: "", stderr: "Error: marketplace 'be-concise' is already added from a different source" };
+ host.source = source;
+ }
+ if (action === "marketplace remove") host.source = null;
+ if (action === "add concise@be-concise") host.version = await folderVersion(host.source);
+ if (action === "remove concise@be-concise") host.version = null;
+ return ok({});
+ };
+ const fetchJson: FetchJson = async () => ({ ok: true, status: 200, json: async () => published });
+ let changes = 0;
+ const installer = createInstaller({ env, run, fetchJson, onChange: () => { changes++; }, now: () => clock.now });
+ t.after(installer.close);
+ const releases = join(env.XDG_DATA_HOME, "paseo-be-concise/releases");
+ return { env, state, calls, installer, releases, clock, signals, changes: () => changes };
+}
+
+test("status lists stable releases at or above 0.7.0 and reports missing host CLIs", async (t) => {
+ const { installer } = await fixture(t, { binaries: ["claude", "git"] });
+ const status = await installer.getStatus();
+ assert.deepEqual(status.releases.map(({ version }) => version), ["0.8.2", "0.8.1", "0.7.1"]);
+ assert.equal(status.releasesError, null);
+ assert.deepEqual(status.hosts.map(({ id, available, version }) => [id, available, version]), [["claude", true, null], ["codex", false, null]]);
+ assert.match(status.hosts[1].error!, /codex was not found/);
+});
+
+test("install stages each release once, updates, downgrades, and prunes releases unused for 7 days", async (t) => {
+ const { env, calls, installer, releases, clock, changes } = await fixture(t);
+ const first = await settle(installer, { host: "claude", action: "install", version: "0.8.1" });
+ const v081 = join(releases, "v0.8.1");
+ assert.match(first.message, /Installed be-concise 0\.8\.1 for Claude Code\. Restart/);
+ assert.deepEqual(mutations(calls, "claude"), [`plugin marketplace add ${v081}`, "plugin marketplace update be-concise", "plugin install concise@be-concise --scope user"]);
+ assert.ok(calls.some((call) => call[0] === "git" && call.includes("v0.8.1") && call.includes("https://github.com/yannelli/be-concise.git")));
+ assert.equal(first.status.hosts[0].version, "0.8.1");
+ assert.equal(first.status.hosts[0].managed, true);
+ assert.equal(await managedRoot(env), join(v081, "plugins/concise"));
+ await assert.rejects(readdir(join(v081, ".git")), { code: "ENOENT" });
+
+ const updated = await settle(installer, { host: "claude", action: "install", version: "0.8.2" });
+ assert.match(updated.message, /Updated to be-concise 0\.8\.2/);
+ assert.ok(calls.some((call) => call.join(" ") === "claude plugin update concise@be-concise --scope user"));
+ const clones = calls.filter(([name]) => name === "git").length;
+ const downgraded = await settle(installer, { host: "claude", action: "install", version: "0.8.1" });
+ assert.match(downgraded.message, /Downgraded to be-concise 0\.8\.1/);
+ assert.equal(calls.filter(([name]) => name === "git").length, clones);
+ assert.deepEqual((await readdir(releases)).sort(), ["v0.8.1", "v0.8.2"]);
+ assert.equal(await managedRoot(env), join(v081, "plugins/concise"));
+ await settle(installer, { host: "claude", action: "install", version: "0.7.1" });
+ assert.deepEqual((await readdir(releases)).sort(), ["v0.7.1", "v0.8.1", "v0.8.2"]);
+ assert.deepEqual(Object.keys((await readState(env)).retired), ["v0.8.2"]);
+ clock.now += 6 * DAY;
+ await installer.getStatus();
+ assert.deepEqual((await readdir(releases)).sort(), ["v0.7.1", "v0.8.1", "v0.8.2"]);
+ clock.now += 2 * DAY;
+ await installer.getStatus();
+ assert.deepEqual((await readdir(releases)).sort(), ["v0.7.1", "v0.8.1"]);
+ assert.deepEqual((await readState(env)).retired, {});
+ assert.ok(changes() >= 4);
+});
+
+test("an existing marketplace source requires an explicit switch and reports the previous source", async (t) => {
+ const external = "/home/user/.codex/.tmp/marketplaces/be-concise";
+ const { calls, installer, releases, state } = await fixture(t, { hosts: { codex: { version: "0.8.2", source: external } } });
+ assert.match((await settle(installer, { host: "codex", action: "install", version: "0.8.2" })).error!, /reads be-concise from .*Switch to GitHub releases/);
+ assert.equal(calls.filter(([name]) => name === "git").length, 0);
+ const result = await settle(installer, { host: "codex", action: "install", version: "0.8.2", switchSource: true });
+ assert.match(result.message, /Reinstalled be-concise 0\.8\.2 for Codex\. Start a new Codex session.*\/hooks\. Previous marketplace source: \/home\/user\/\.codex/);
+ assert.deepEqual(mutations(calls, "codex"), ["plugin marketplace remove be-concise", `plugin marketplace add ${join(releases, "v0.8.2")}`, "plugin add concise@be-concise"]);
+ assert.equal(state.codex.source, join(releases, "v0.8.2"));
+});
+
+test("a failed switch restores the earlier marketplace source", async (t) => {
+ const external = "/home/user/.codex/.tmp/marketplaces/be-concise";
+ const { installer, state } = await fixture(t, { hosts: { codex: { version: "0.8.1", source: external } },
+ respond: (call) => call === "codex plugin add concise@be-concise" ? { code: 1, stdout: "", stderr: "Error: plugin add failed" } : undefined });
+ const result = await settle(installer, { host: "codex", action: "install", version: "0.8.2", switchSource: true });
+ assert.match(result.error!, /plugin add failed.*The marketplace source was restored to \/home\/user\/\.codex/);
+ assert.deepEqual(state.codex, { version: "0.8.1", source: external });
+});
+
+test("a symlinked data folder keeps Codex installs managed", async (t) => {
+ const { env, installer, releases, state } = await fixture(t, { linked: true });
+ const first = await settle(installer, { host: "codex", action: "install", version: "0.8.1" });
+ assert.equal(state.codex.source, await realpath(join(releases, "v0.8.1")));
+ assert.notEqual(state.codex.source, join(releases, "v0.8.1"));
+ assert.equal(first.status.hosts[1].managed, true);
+ assert.equal((await readState(env)).hosts.codex?.tag, "v0.8.1");
+ const updated = await settle(installer, { host: "codex", action: "install", version: "0.8.2" });
+ assert.match(updated.message, /Updated to be-concise 0\.8\.2 for Codex/);
+ assert.equal(updated.status.hosts[1].managed, true);
+});
+
+test("status output that is not JSON reports a host error and keeps the saved state", async (t) => {
+ let broken = false;
+ const { env, installer } = await fixture(t, { respond: (call) => broken && call === "claude plugin marketplace list" ? { code: 0, stdout: "Loading…\n", stderr: "" } : undefined });
+ await settle(installer, { host: "claude", action: "install", version: "0.8.2" });
+ broken = true;
+ const status = await installer.getStatus();
+ assert.match(status.hosts[0].error!, /not the expected JSON/);
+ assert.equal((await readState(env)).hosts.claude?.tag, "v0.8.2");
+});
+
+test("unknown versions and mismatched release contents leave hosts unchanged", async (t) => {
+ const { calls, installer, releases, state } = await fixture(t, { cloned: () => "0.8.0" });
+ assert.match((await settle(installer, { host: "claude", action: "install", version: "0.9.0" })).error!, /0\.9\.0 is not a published release/);
+ assert.match((await settle(installer, { host: "claude", action: "install", version: "0.8.2" })).error!, /version 0\.8\.0 does not match 0\.8\.2/);
+ assert.deepEqual(await readdir(releases), []);
+ assert.deepEqual(state.claude, { version: null, source: null });
+ assert.deepEqual(mutations(calls, "claude"), []);
+});
+
+test("one change runs at a time and status answers from cache while it runs", async (t) => {
+ const { installer, signals } = await fixture(t);
+ const before = await installer.getStatus();
+ const started = await installer.apply({ host: "claude", action: "install", version: "0.8.2" });
+ assert.deepEqual(started.job, { host: "claude", action: "install", version: "0.8.2", running: true, message: null, error: null });
+ assert.deepEqual(started.hosts, before.hosts);
+ await assert.rejects(installer.apply({ host: "codex", action: "install", version: "0.8.2" }), /Another be-concise change is running/);
+ const during = await installer.getStatus();
+ assert.equal(during.job?.running, true);
+ assert.equal(during.checkedAt, before.checkedAt);
+ await installer.close();
+ assert.ok(signals.length > 0 && signals.every((signal) => signal.aborted));
+ await assert.rejects(installer.getStatus(), /plugin is stopping/);
+});
+
+test("remove uninstalls and removes only a managed marketplace", async (t) => {
+ const external = "/home/user/.agents/plugins/be-concise";
+ const { env, calls, installer, state } = await fixture(t, { hosts: { codex: { version: "0.8.2", source: external } } });
+ await settle(installer, { host: "claude", action: "install", version: "0.8.2" });
+ const removed = await settle(installer, { host: "claude", action: "remove" });
+ assert.match(removed.message, /Removed be-concise from Claude Code/);
+ assert.deepEqual(state.claude, { version: null, source: null });
+ assert.deepEqual(mutations(calls, "claude").slice(-2), ["plugin uninstall concise@be-concise --scope user", "plugin marketplace remove be-concise"]);
+ assert.equal(await managedRoot(env), null);
+ assert.deepEqual(Object.keys((await readState(env)).retired), ["v0.8.2"]);
+ await settle(installer, { host: "codex", action: "remove" });
+ assert.deepEqual(state.codex, { version: null, source: external });
+ assert.deepEqual(mutations(calls, "codex"), ["plugin remove concise@be-concise"]);
+ assert.match((await settle(installer, { host: "codex", action: "remove" })).error!, /not installed for Codex/);
+});
diff --git a/test/server-entry.test.mts b/test/server-entry.test.mts
index 98cd7e8..7cb9e4b 100644
--- a/test/server-entry.test.mts
+++ b/test/server-entry.test.mts
@@ -16,7 +16,7 @@ test("server entry registers RPCs and releases each backend on cleanup", async (
const first = start();
try {
assert.deepEqual([...first.handlers.keys()].sort(), [
- "concise.config.read", "concise.config.write", "concise.event", "concise.preview", "concise.snapshot",
+ "concise.config.read", "concise.config.write", "concise.event", "concise.installer.apply", "concise.installer.status", "concise.preview", "concise.snapshot",
]);
const read = first.handlers.get(snapshot.name)!;
const before = snapshot.output.parse(await read({}));