From 3ca19abda88b32198689d89b99f315e1cabfd620 Mon Sep 17 00:00:00 2001 From: Simon Mak Date: Sat, 3 Oct 2026 08:36:34 +0800 Subject: [PATCH] refactor(mcp)!: remove provision_host orchestrator (v4.0.0) Drop the one-call provision_host tool (and bootstrap.ts/platform.ts) to eliminate tool-purpose overlap with apply_clone. Tool count 11 -> 10. - tools.ts: remove provision_host; scrub its references from sibling descriptions; add parameter-semantics wording to verify_clone and list_required_credentials - remove test/platform.test.ts + provision_host help test - docs: README, AGENTS, llms.txt, context7.json, glama.json, public/*, capability/architecture docs, CHANGELOG v4.0.0 - vdd: vision amendment + tactics A-013 TDQS (hosted, 1.2): overall 4.8 / coherence 5.0 / DQ 4.7 (was 4.4-4.6). --- AGENTS.md | 2 +- WORKFLOW_STATE.md | 4 +- connector.json | 2 +- context7.json | 4 +- docs/CHANGELOG_WORKBENCH.md | 18 ++ docs/architecture/clone-mcp.md | 2 +- .../architecture/opencode-runtime-snapshot.md | 4 +- docs/recovery/recovery-gap-analysis.md | 3 +- glama.json | 2 +- llms.txt | 10 +- mcp-server/README.md | 1 - mcp-server/src/bootstrap.ts | 155 ------------ mcp-server/src/platform.ts | 235 ------------------ mcp-server/src/tools.ts | 157 +----------- mcp-server/test/platform.test.ts | 136 ---------- mcp-server/test/server.test.ts | 15 -- package.json | 2 +- public/index.html | 11 +- public/llms.txt | 15 +- server.json | 4 +- vdd/tactics.md | 8 +- vdd/vision.md | 5 + 22 files changed, 67 insertions(+), 728 deletions(-) delete mode 100644 mcp-server/src/bootstrap.ts delete mode 100644 mcp-server/src/platform.ts delete mode 100644 mcp-server/test/platform.test.ts diff --git a/AGENTS.md b/AGENTS.md index cab2c5c..119cdd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ offloaded to a compute box (`cs run "pnpm test"`) or CI — do not run them loca - `mcp-server/` — the `opencode-workbench` MCP server + connector (TypeScript). Tools: `describe_workbench`, `inspect_target`, `plan_clone`, `apply_clone`, `verify_clone`, `install_component`, `remove_component`, `update_component`, - `list_required_credentials`, `run_auth_flow`, `provision_host`. + `list_required_credentials`, `run_auth_flow`. Local + SSH transports. See `mcp-server/README.md` and `docs/architecture/clone-mcp.md`. - `connector.json` — connector manifest / registration snippet. diff --git a/WORKFLOW_STATE.md b/WORKFLOW_STATE.md index 778935d..3e47377 100644 --- a/WORKFLOW_STATE.md +++ b/WORKFLOW_STATE.md @@ -10,7 +10,7 @@ | Phase #0 — Verify Docs | **COMPLETED** | | Phase #1 — Research | **COMPLETED** | | Phase #2 — Architecture | **COMPLETED** | -| Phase #3 — Implementation | **COMPLETED** (clone MCP + provision_host shipped) | +| Phase #3 — Implementation | **COMPLETED** (clone MCP shipped) | | VDD chain (Phase 0–8) | **COMPLETED** — `constitution.md` + `vdd/` (gates G1–G7 PASS) | | Distribution | In progress — npm published; Vercel live; registry publish prepared | @@ -29,7 +29,7 @@ Timestamp: 2026-10-03 |------|--------|--------| | Clone engine (inspect/plan/apply/verify) | DONE | `mcp-server/src/clone.ts` | | Consent gate + value-blind credentials | DONE | `apply_clone`/`install_component` `confirm:true`; `list_required_credentials`, `run_auth_flow` | -| One-call provisioning | DONE | `provision_host` (platform scan + upgrade plan + OpenCode pin + verify + `help`) | +| One-call provisioning | REMOVED | `provision_host` removed in v4.0.0; use `apply_clone` (inspect → plan → apply → verify) | | Tests + CI | DONE | 49 tests; `mcp-server.yml` | ### VDD chain — DELIVERED diff --git a/connector.json b/connector.json index 878d25d..50b7cb8 100644 --- a/connector.json +++ b/connector.json @@ -3,7 +3,7 @@ "name": "opencode-workbench", "title": "OpenCode Workbench", "description": "Clones the OpenCode workbench installation onto Linux machines, locally or over SSH.", - "version": "3.1.0", + "version": "4.0.0", "transport": { "type": "stdio", "command": "npx", diff --git a/context7.json b/context7.json index affc6f4..24083b3 100644 --- a/context7.json +++ b/context7.json @@ -1,7 +1,7 @@ { "$schema": "https://context7.com/schema/context7.json", "projectTitle": "OpenCode Workbench (opencode-workbench)", - "description": "Provisions and clones the OpenCode workbench onto Linux machines — locally or over SSH — reproducing the config, MCP servers (35), agent skills (45), plugins and Docker tooling. Exposes 11 tools over stdio, including provision_host for one-call provisioning and a full install/update/remove lifecycle.", + "description": "Provisions and clones the OpenCode workbench onto Linux machines — locally or over SSH — reproducing the config, MCP servers (35), agent skills (45), plugins and Docker tooling. Exposes 10 tools over stdio, including a full install/update/remove lifecycle.", "folders": [ "mcp-server", "docs", @@ -9,7 +9,7 @@ ], "rules": [ "Run locally with `npx -y @simonmak-ascent/opencode-workbench` (Node stdio MCP server; no API key).", - "Drive a clone with the tool order `describe_workbench` → `inspect_target` → `plan_clone` → `apply_clone` → `verify_clone`; use `install_component`/`remove_component`/`update_component` for per-component lifecycle, and `provision_host` for a one-call end-to-end provision (platform scan + OpenCode + apply + verify; `help:true` documents parameters).", + "Drive a clone with the tool order `describe_workbench` → `inspect_target` → `plan_clone` → `apply_clone` → `verify_clone`; use `install_component`/`remove_component`/`update_component` for per-component lifecycle.", "`apply_clone` and `install_component` are consent-gated: without `confirm:true` they return a plan and change nothing.", "Every tool takes a `target` of `{mode: \"local\"}` or `{mode: \"ssh\", host, user, port?, identityFile?}`.", "The connector never reads, transmits or writes secret values; `apply_clone` writes an empty `~/.env.workbench` template (mode 600) listing the `{env:VAR}` names to fill in.", diff --git a/docs/CHANGELOG_WORKBENCH.md b/docs/CHANGELOG_WORKBENCH.md index 9c10067..1d8b692 100644 --- a/docs/CHANGELOG_WORKBENCH.md +++ b/docs/CHANGELOG_WORKBENCH.md @@ -3,6 +3,24 @@ > All notable changes to the workbench configuration. > Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +## [2026-10-03] — v4.0.0 + +### Breaking +- Removed the MCP tool **`provision_host`** (one-call provisioning). It overlapped + `apply_clone` + `install_component` and was the main source of tool-purpose + ambiguity in TDQS disambiguation. Clients that call `provision_host` must switch + to the ordered `inspect_target → plan_clone → apply_clone { confirm: true } → + verify_clone` flow (or `install_component`/`update_component` for single steps). + Tool count 11 → **10**. +- Removed the internal `bootstrap.ts` / `platform.ts` orchestration modules and + their tests (`platform.test.ts`) alongside the tool. + +### Changed +- TDQS (spec 1.2, hosted): **overall 4.8 / coherence 5.0 / description-quality 4.7** + (was 4.4–4.6 / 4.6 / 4.6). Disambiguation 4→5, naming 4→5, tool-count 5, + completeness 5. Parameter-semantics wording added to `verify_clone` and + `list_required_credentials`. + ## [2026-10-03] — v3.1.0 ### Added diff --git a/docs/architecture/clone-mcp.md b/docs/architecture/clone-mcp.md index 510bdb8..c207602 100644 --- a/docs/architecture/clone-mcp.md +++ b/docs/architecture/clone-mcp.md @@ -47,7 +47,7 @@ mcp-server/src/ | `verify_clone` | Re-check config, env template, and every component. | | `list_required_credentials` | Value-blind: which keys are missing and how to acquire each. | | `run_auth_flow` | Emit-and-verify acquisition guidance for one credential. | -| `provision_host` | One-call provisioning: kernel-up platform scan + dry-run upgrade plan, latest-stable OpenCode with recorded version, apply + verify; `help:true` documents every parameter without touching the target. Consent-gated. | +| `verify_clone` | Re-check config, env template, and every component. Consent-gated. | ### Connector model diff --git a/docs/architecture/opencode-runtime-snapshot.md b/docs/architecture/opencode-runtime-snapshot.md index 874b5be..e774ec4 100644 --- a/docs/architecture/opencode-runtime-snapshot.md +++ b/docs/architecture/opencode-runtime-snapshot.md @@ -35,9 +35,9 @@ Notes: ## MCP Tool Surface -The server exposes **11 tools**: `describe_workbench`, `inspect_target`, `plan_clone`, +The server exposes **10 tools**: `describe_workbench`, `inspect_target`, `plan_clone`, `apply_clone`, `verify_clone`, `install_component`, `remove_component`, -`update_component`, `list_required_credentials`, `run_auth_flow`, `provision_host`. +`update_component`, `list_required_credentials`, `run_auth_flow`. ## Agent Configuration diff --git a/docs/recovery/recovery-gap-analysis.md b/docs/recovery/recovery-gap-analysis.md index d173ad1..0044a28 100644 --- a/docs/recovery/recovery-gap-analysis.md +++ b/docs/recovery/recovery-gap-analysis.md @@ -68,8 +68,7 @@ - Agent skills: **45** (was 28). - Clone MCP hardened and extended: consent gate (`confirm:true` on `apply_clone`/`install_component`), value-blind credential tools - (`list_required_credentials`, `run_auth_flow`), and `provision_host` (one-call - provisioning with a kernel-up scan and a recorded OpenCode version pin). + (`list_required_credentials`, `run_auth_flow`). - Distribution: npm `@simonmak-ascent/opencode-workbench@1.1.2` published; hosted connector live at `https://opencode-workbench.simonmak.com/mcp`; `server.json` + GitHub-OIDC registry-publish workflow added. diff --git a/glama.json b/glama.json index 01a4207..d4b7d62 100644 --- a/glama.json +++ b/glama.json @@ -2,7 +2,7 @@ "$schema": "https://glama.ai/mcp/schemas/server.json", "maintainers": ["simonmak-ascent"], "name": "opencode-workbench", - "description": "One-command reproducibility for your AI-agent workstation. Eleven tools let an agent inspect, plan, apply, verify, and manage an idempotent, consent-gated clone of the full OpenCode stack — the OpenCode CLI, 35 MCP servers, agent skills, plugins, and Docker infrastructure — onto any Linux machine, over SSH or locally, with zero secrets ever written. provision_host does it end-to-end, including a kernel-up platform scan and a recorded OpenCode version pin.", + "description": "One-command reproducibility for your AI-agent workstation. Ten tools let an agent inspect, plan, apply, verify, and manage an idempotent, consent-gated clone of the full OpenCode stack — the OpenCode CLI, 35 MCP servers, agent skills, plugins, and Docker infrastructure — onto any Linux machine, over SSH or locally, with zero secrets ever written.", "repository": "https://github.com/simonmak-ascent/opencode-workbench", "license": "MIT", "runtime": "node", diff --git a/llms.txt b/llms.txt index 73840e7..ef47da3 100644 --- a/llms.txt +++ b/llms.txt @@ -1,9 +1,9 @@ # OpenCode Workbench (opencode-workbench) > Open-source (MIT) MCP server + connector that provisions and clones the OpenCode -> workbench onto Linux machines — locally or over SSH. Eleven tools cover a -> kernel-up platform scan, a one-call bootstrap, an idempotent, consent-gated -> clone, the install/update/remove lifecycle, and value-blind credential guidance. +> workbench onto Linux machines — locally or over SSH. Ten tools cover a +> kernel-up platform scan, an idempotent, consent-gated clone, the +> install/update/remove lifecycle, and value-blind credential guidance. > No secret values are ever read, transmitted, or written. ## Connect @@ -14,7 +14,7 @@ - Glama: https://glama.ai/mcp/servers/simonmak-ascent/opencode-workbench - Context7 library ID: `/simonmak-ascent/opencode-workbench` -## MCP tools (11) +## MCP tools (10) - `describe_workbench` — describe this server: repo, components by tier, optional MCP add-ons - `inspect_target` — probe a machine: OS, arch, package manager, Node, Docker, OpenCode, home and npm-global paths @@ -26,7 +26,6 @@ - `update_component` — update/upgrade a single component in place (`confirm`-gated) - `list_required_credentials` — value-blind: which credentials are missing and how to acquire each - `run_auth_flow` — emit-and-verify acquisition guidance for one credential -- `provision_host` — one-call provisioning: kernel-up platform scan + dry-run upgrade plan, latest-stable OpenCode with recorded version, apply + verify; `help:true` documents every parameter without touching the target Every tool takes a `target`: @@ -35,7 +34,6 @@ Every tool takes a `target`: ``` `apply_clone` options: `components[]`, `workspace`, `profileUrl`, `skipRepo`, `dryRun`, `confirm`. -`provision_host` options: `help`, `confirm`, `upgrade`, `assumeYes`, `opencodeVersion`, `dryRun`. ## Docs diff --git a/mcp-server/README.md b/mcp-server/README.md index 6e06e1c..076b175 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -42,7 +42,6 @@ From a checkout, use the local build instead: | `remove_component` | Uninstall a single component by id (bounded subset; consent-gated). | | `update_component` | Update/upgrade a single component in place (consent-gated). | | `verify_clone` | Re-check config, env template, and every component. | -| `provision_host` | One-call provisioning: kernel-up platform scan + dry-run upgrade plan, latest-stable OpenCode + version pin, apply, verify; `help:true` documents parameters without touching the target. | | `list_required_credentials` | Value-blind: which credentials are missing and how to acquire each. | | `run_auth_flow` | Emit-and-verify acquisition guidance for one credential. | diff --git a/mcp-server/src/bootstrap.ts b/mcp-server/src/bootstrap.ts deleted file mode 100644 index 54bcc00..0000000 --- a/mcp-server/src/bootstrap.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * `provision_host` orchestration: take a bare Linux target (local or SSH) to a - * VDD-configured OpenCode workstation in one call. - * - * Order: inspect → platform plan (dry-run unless `upgrade`) → apply the profile - * (installs latest-stable OpenCode) → capture the resolved version → verify. - * Never reads or transmits secret values; the env template is names-only. - */ -import type { Target } from "./target.js"; -import { - apply, - inspect, - verify, - type ApplyResult, - type CloneOptions, - type VerifyResult, -} from "./clone.js"; -import { - buildUpgradePlan, - planPlatform, - parseVersion, - type PackageFamily, - type PackageManagerId, - type PackagePlan, -} from "./platform.js"; - -export interface BootstrapOptions extends CloneOptions { - /** Execute the platform upgrade (default false: plan only). */ - upgrade?: boolean; - /** Use non-interactive upgrade flags (default true). */ - assumeYes?: boolean; - /** Pin a specific OpenCode version instead of latest stable. */ - opencodeVersion?: string; -} - -export interface PlatformReport { - osId: string | null; - osIdLike: string[]; - osName: string | null; - osVersion: string | null; - kernel: string | null; - arch: string | null; - packageManager: PackageManagerId | null; - family: PackageFamily; -} - -export interface OpenCodeInstall { - installed: boolean; - version: string | null; - requested: string | null; -} - -export interface BootstrapResult { - target: string; - platform: PlatformReport; - upgrade: PackagePlan; - opencode: OpenCodeInstall; - apply: ApplyResult; - verify: VerifyResult; - warnings: string[]; -} - -function platformReport(info: { - osId?: string; - osIdLike?: string; - osName?: string; - osVersion?: string; - kernel?: string; - arch?: string; - packageManager?: string; -}): PlatformReport { - const packageManager = planPlatform(info); - const family: PackageFamily = packageManager - ? buildUpgradePlan(packageManager).family - : "unknown"; - return { - osId: info.osId ?? null, - osIdLike: (info.osIdLike ?? "") - .toLowerCase() - .split(/\s+/) - .filter(Boolean), - osName: info.osName ?? null, - osVersion: info.osVersion ?? null, - kernel: info.kernel ?? null, - arch: info.arch ?? null, - packageManager, - family, - }; -} - -async function captureOpencodeVersion(target: Target, requested: string | null): Promise { - const res = await target.run( - `export PATH="$HOME/.opencode/bin:$PATH"\nopencode --version 2>/dev/null | head -n1\n`, - { timeoutMs: 30_000 }, - ); - const parsed = parseVersion(res.stdout); - return parsed ?? requested; -} - -export async function bootstrap( - target: Target, - options: BootstrapOptions = {}, -): Promise { - const warnings: string[] = []; - const info = await inspect(target); - const platform = platformReport(info); - const upgrade = buildUpgradePlan(platform.packageManager, { - assumeYes: options.assumeYes !== false, - }); - - if (!platform.packageManager) { - warnings.push( - `unsupported package manager (osId=${platform.osId ?? "unknown"}); platform upgrade skipped`, - ); - } - - // Optional, explicitly-requested platform upgrade. Dry-run by default. - if (options.upgrade && !options.dryRun && platform.packageManager) { - for (const command of upgrade.commands) { - const res = await target.run(`${command}\n`, { timeoutMs: 1_800_000 }); - upgrade.output.push(`${command} (exit ${res.code})`); - } - upgrade.executed = true; - upgrade.rebootAdvisory = true; - } - - // Pin a specific version first if requested, then let apply fill the rest. - if (options.opencodeVersion && !options.dryRun) { - await target.run( - `curl -fsSL https://opencode.ai/install | bash -s -- --version ${options.opencodeVersion} || true\n`, - { timeoutMs: 300_000 }, - ); - } - - const applyResult = await apply(target, options); - const opencodeInstalled = applyResult.installed.some((i) => i.id === "opencode"); - const version = options.dryRun - ? options.opencodeVersion ?? null - : await captureOpencodeVersion(target, options.opencodeVersion ?? null); - const verifyResult = await verify(target, options); - - return { - target: target.label, - platform, - upgrade, - opencode: { - installed: opencodeInstalled, - version, - requested: options.opencodeVersion ?? null, - }, - apply: applyResult, - verify: verifyResult, - warnings, - }; -} diff --git a/mcp-server/src/platform.ts b/mcp-server/src/platform.ts deleted file mode 100644 index 67c5c0e..0000000 --- a/mcp-server/src/platform.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Pure, side-effect-free platform logic for `provision_host`. - * - * Nothing here touches the network, the filesystem or a target: every function - * takes plain input and returns plain output, so the whole cross-distro mapping - * and upgrade plan is unit-testable without root access. - */ - -export type PackageManagerId = "apt-get" | "dnf" | "yum" | "apk" | "pacman" | "zypper"; - -export type PackageFamily = "debian" | "rhel" | "arch" | "suse" | "alpine" | "unknown"; - -export interface OsRelease { - id: string; - idLike: string[]; - version: string | null; - name: string | null; -} - -export interface ManagerSpec { - id: PackageManagerId; - family: PackageFamily; - /** Refresh package metadata. */ - update: string; - /** Upgrade every installed package (the "kernel up" step). */ - upgrade: string; - /** Non-interactive suffix appended to the upgrade command when assumeYes. */ - yesFlag: string; - /** Command whose stdout lists upgradable packages, when one exists. */ - upgradableQuery: string | null; -} - -export interface PackagePlan { - packageManager: PackageManagerId | null; - family: PackageFamily; - /** Ordered update-then-upgrade commands; empty when the manager is unknown. */ - commands: string[]; - /** Number of upgradable packages when parseable, else null. */ - upgradable: number | null; - /** True only after the commands have actually been run. */ - executed: boolean; - /** Captured per-command results when executed. */ - output: string[]; - /** Kernels/reboots may be required after an upgrade. */ - rebootAdvisory: boolean; -} - -/** Parse the key=value surface of /etc/os-release. */ -export function parseOsRelease(content: string): OsRelease { - const map: Record = {}; - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const eq = line.indexOf("="); - if (eq === -1) continue; - const key = line.slice(0, eq).trim(); - let value = line.slice(eq + 1).trim(); - const quoted = - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")); - if (quoted && value.length >= 2) value = value.slice(1, -1); - map[key] = value; - } - return { - id: (map.ID ?? "").toLowerCase(), - idLike: (map.ID_LIKE ?? "") - .toLowerCase() - .split(/\s+/) - .filter(Boolean), - version: map.VERSION_ID ?? null, - name: map.NAME ?? null, - }; -} - -const MANAGER_SPECS: Record = { - "apt-get": { - id: "apt-get", - family: "debian", - update: "apt-get update", - upgrade: "apt-get dist-upgrade", - yesFlag: "-y", - upgradableQuery: "apt list --upgradable 2>/dev/null", - }, - dnf: { - id: "dnf", - family: "rhel", - update: "dnf makecache", - upgrade: "dnf upgrade --refresh", - yesFlag: "-y", - upgradableQuery: "dnf -q check-update 2>/dev/null", - }, - yum: { - id: "yum", - family: "rhel", - update: "yum makecache", - upgrade: "yum update", - yesFlag: "-y", - upgradableQuery: "yum -q check-update 2>/dev/null", - }, - apk: { - id: "apk", - family: "alpine", - update: "apk update", - upgrade: "apk -U upgrade", - yesFlag: "", - upgradableQuery: "apk version -l '<' 2>/dev/null", - }, - pacman: { - id: "pacman", - family: "arch", - update: "pacman -Sy", - upgrade: "pacman -Syu", - yesFlag: "--noconfirm", - upgradableQuery: "pacman -Qu 2>/dev/null", - }, - zypper: { - id: "zypper", - family: "suse", - update: "zypper refresh", - upgrade: "zypper update", - yesFlag: "-n", - upgradableQuery: "zypper list-updates 2>/dev/null", - }, -}; - -export function managerSpec(id: PackageManagerId): ManagerSpec { - return MANAGER_SPECS[id]; -} - -/** Map a distro (by ID / ID_LIKE) to its package manager, or null if unknown. */ -export function resolvePackageManager(os: Pick): PackageManagerId | null { - const tokens = new Set([os.id, ...os.idLike].filter(Boolean)); - const has = (...names: string[]): boolean => names.some((n) => tokens.has(n)); - - if (has("debian", "ubuntu", "linuxmint", "pop", "raspbian", "devuan", "kali")) return "apt-get"; - if ( - has( - "fedora", - "rhel", - "centos", - "rocky", - "almalinux", - "ol", - "oracle", - "amzn", - "scientific", - ) - ) - return "dnf"; - if (has("arch", "archlinux", "manjaro", "endeavouros", "cachyos", "garuda")) return "pacman"; - if (has("opensuse", "opensuse-leap", "opensuse-tumbleweed", "sles", "suse")) return "zypper"; - if (has("alpine")) return "apk"; - return null; -} - -/** Prefer the manager detected on the host; fall back to the distro mapping. */ -export function planPlatform(info: { - osId?: string; - osIdLike?: string; - packageManager?: string; -}): PackageManagerId | null { - const detected = info.packageManager; - if (detected && detected in MANAGER_SPECS) return detected as PackageManagerId; - const idLike = (info.osIdLike ?? "") - .toLowerCase() - .split(/\s+/) - .filter(Boolean); - return resolvePackageManager({ id: (info.osId ?? "").toLowerCase(), idLike }); -} - -/** Insert a flag right after the command name, e.g. `apt-get dist-upgrade` -> `apt-get -y dist-upgrade`. */ -export function insertFlag(command: string, flag: string): string { - if (!flag) return command; - const [bin = command, ...rest] = command.split(" "); - return [bin, flag, ...rest].join(" "); -} - -/** - * Build the ordered upgrade plan. With `assumeYes` the upgrade command carries - * the manager's non-interactive flag. No command is ever executed here. - */ -export function buildUpgradePlan( - packageManager: PackageManagerId | null, - opts: { assumeYes?: boolean } = {}, -): PackagePlan { - if (!packageManager) { - return { - packageManager: null, - family: "unknown", - commands: [], - upgradable: null, - executed: false, - output: [], - rebootAdvisory: false, - }; - } - const spec = managerSpec(packageManager); - const assumeYes = opts.assumeYes !== false; - const upgrade = assumeYes ? insertFlag(spec.upgrade, spec.yesFlag) : spec.upgrade; - return { - packageManager, - family: spec.family, - commands: [spec.update, upgrade], - upgradable: null, - executed: false, - output: [], - rebootAdvisory: false, - }; -} - -/** Extract the upgradable count from an `apt list --upgradable` style output. */ -export function parseUpgradableCount( - packageManager: PackageManagerId | null, - stdout: string, -): number | null { - if (!packageManager) return null; - const lines = stdout.split(/\r?\n/).map((l) => l.trim()); - if (packageManager === "apt-get") { - // e.g. `nginx/jammy-updates 1.24 amd64 [upgradable from: 1.18]` - return lines.filter((l) => l.includes("[upgradable from:")).length; - } - // Generic fallback: count non-empty, non-header lines. - return lines.filter( - (l) => l && !/^(Listing|Obsoleting|Last metadata|Available Upgrades)/i.test(l), - ).length; -} - -/** - * Parse the first semantic version out of `opencode --version` output. - * Tolerant of prefixes/suffixes; returns null when nothing matches. - */ -export function parseVersion(output: string): string | null { - const m = /(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/.exec(output); - return m?.[1] ?? null; -} diff --git a/mcp-server/src/tools.ts b/mcp-server/src/tools.ts index c2d7638..38302ca 100644 --- a/mcp-server/src/tools.ts +++ b/mcp-server/src/tools.ts @@ -15,7 +15,6 @@ import { COMPONENTS, componentById, defaultComponentIds } from "./components.js" import { OPTIONAL_MCP_IDS } from "./render.js"; import { REPO_URL, REPO_WEB, packageRoot } from "./profile.js"; import { REQUIRED_CREDENTIALS, credentialByVar, credentialStatus } from "./credential-catalog.js"; -import { bootstrap } from "./bootstrap.js"; const targetShape = z .object({ @@ -232,7 +231,7 @@ export function registerTools(server: McpServer): void { { title: "Inspect a Linux target", description: - "Probe one Linux machine — this host (`local`) or a remote host over SSH (`ssh`) — and report its OS, kernel, architecture, package manager, Node/npm, OpenCode, Docker, and per-tool detection flags, plus the names (never values) of credentials present. Read-only: it runs a single shell probe on the target, installs nothing, writes nothing, and contacts no external service; `target.cwd` sets the working directory for relative checks, and `target.port`/`identityFile` are passed to ssh. Use it to see what a clone would touch, then plan_clone to turn that into an ordered plan; for a first-time end-to-end provision use provision_host.", + "Probe one Linux machine — this host (`local`) or a remote host over SSH (`ssh`) — and report its OS, kernel, architecture, package manager, Node/npm, OpenCode, Docker, and per-tool detection flags, plus the names (never values) of credentials present. Read-only: it runs a single shell probe on the target, installs nothing, writes nothing, and contacts no external service; `target.cwd` sets the working directory for relative checks, and `target.port`/`identityFile` are passed to ssh. Use it to see what a clone would touch, then plan_clone to turn that into an ordered plan and apply_clone to perform it.", inputSchema: { target: targetShape }, outputSchema: inspectOutput, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, @@ -251,7 +250,7 @@ export function registerTools(server: McpServer): void { { title: "Plan a Workbench clone", description: - "Return a read-only plan for a target: compare it against the Workbench profile and list each component as `install`, `present`, or `manual`, with the commands that would run. It clones nothing and writes nothing; `components` narrows the plan and `workspace` sets where the profile repo is expected. Use it to preview before apply_clone (which performs the changes) or provision_host (which plans and applies in one call).", + "Return a read-only plan for a target: compare it against the Workbench profile and list each component as `install`, `present`, or `manual`, with the commands that would run. It clones nothing and writes nothing; `components` narrows the plan and `workspace` sets where the profile repo is expected. Use it to preview before apply_clone, which performs the changes.", inputSchema: { target: targetShape, ...optionsShape }, outputSchema: planOutput, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, @@ -270,7 +269,7 @@ export function registerTools(server: McpServer): void { { title: "Apply a Workbench clone", description: - "Apply the Workbench profile to a target: clone the repo and install missing components (OpenCode CLI, Node/pnpm, MCP servers, skills, plugins, optional Docker). It OVERWRITES `/.config/opencode/opencode.json`, copies `AGENTS.md`, resets an existing profile repo to `origin/main`, and runs global installs that need write permission (plus SSH for `mode:ssh`) and can take minutes. Writes a names-only `~/.env.workbench` (mode 600); never secret values. Consent-gated and idempotent: without `confirm:true` it returns the plan and changes nothing. To preview only, use plan_clone; for a first-time provision, use provision_host.", + "Apply the Workbench profile to a target: clone the repo and install missing components (OpenCode CLI, Node/pnpm, MCP servers, skills, plugins, optional Docker). It OVERWRITES `/.config/opencode/opencode.json`, copies `AGENTS.md`, resets an existing profile repo to `origin/main`, and runs global installs that need write permission (plus SSH for `mode:ssh`) and can take minutes. Writes a names-only `~/.env.workbench` (mode 600); never secret values. Consent-gated and idempotent: without `confirm:true` it returns the plan and changes nothing. To preview only, use plan_clone.", inputSchema: { target: targetShape, confirm: z.boolean().optional().describe("Set true to actually install. When absent, the call returns a plan and makes no changes."), @@ -306,7 +305,7 @@ export function registerTools(server: McpServer): void { { title: "Verify a Workbench clone", description: - "Re-check a target after a clone: return a per-component `present`/`missing` list plus whether `opencode.json` and `~/.env.workbench` exist. Read-only: it checks files and re-runs detection, making no changes; `components` narrows the check. Use it after apply_clone; provision_host runs it automatically, so call verify_clone directly only for a targeted re-check. For a pre-clone preview use plan_clone.", + "Re-check a target after a clone: return a per-component `present`/`missing` list plus whether `opencode.json` and `~/.env.workbench` exist. Read-only and idempotent: it checks files and re-runs detection, writing nothing. `target` selects the machine (`local` or SSH) and its `home` is where the `opencode.json` and `~/.env.workbench` checks run; `components` restricts the check to those ids (omit it to check every component). Use it after apply_clone and after component changes; for a pre-clone preview use plan_clone.", inputSchema: { target: targetShape, ...optionsShape }, outputSchema: verifyOutput, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, @@ -325,7 +324,7 @@ export function registerTools(server: McpServer): void { { title: "Install one Workbench component", description: - "Install a single Workbench component by id (e.g. `node`, `opencode`, `npm-mcps`, `skills`) on a target; `component` must be an id from describe_workbench. Overwrites that component's files when present (e.g. `skills`/`plugins` replace the copies under `/.config/opencode`); global installs need write permission and can take minutes. Idempotent and consent-gated: without `confirm:true` it returns the plan. Use it for one targeted component on an existing host; use apply_clone for a subset, or provision_host for a first-time end-to-end provision.", + "Install a single Workbench component by id (e.g. `node`, `opencode`, `npm-mcps`, `skills`) on a target; `component` must be an id from describe_workbench. Overwrites that component's files when present (e.g. `skills`/`plugins` replace the copies under `/.config/opencode`); global installs need write permission and can take minutes. Idempotent and consent-gated: without `confirm:true` it returns the plan. Use it for one targeted component; use apply_clone to install the full default set.", inputSchema: { target: targetShape, component: z.string().describe("Component id from describe_workbench."), @@ -401,7 +400,7 @@ export function registerTools(server: McpServer): void { { title: "Update one Workbench component", description: - "Update one Workbench component in place by id: re-run its install script to fetch the current version (e.g. `opencode` re-runs the official installer; `npm-mcps` reinstalls the latest globals). OVERWRITES the component's files and needs write permission; global installs can take minutes. `workspace` points at the profile repo when needed; `dryRun` previews. Consent-gated: without `confirm:true` it returns a preview. For the whole profile use apply_clone; for the platform plus an OpenCode version pin use provision_host.", + "Update one Workbench component in place by id: re-run its install script to fetch the current version (e.g. `opencode` re-runs the official installer; `npm-mcps` reinstalls the latest globals). OVERWRITES the component's files and needs write permission; global installs can take minutes. `workspace` points at the profile repo when needed; `dryRun` previews. Consent-gated: without `confirm:true` it returns a preview. For the whole profile use apply_clone.", inputSchema: { target: targetShape, component: z.string().describe("Component id from describe_workbench."), @@ -433,7 +432,7 @@ export function registerTools(server: McpServer): void { { title: "List required credentials", description: - "List the credentials the profile references, which the target already provides, and how to acquire each missing one. Value-blind: it reads only whether each env var is set — no values, no network, no writes. Returns `present`/`missing` var-name arrays plus one guidance record per credential (`var`, `purpose`, `url`, `method`, `command`). Use it after plan_clone to see what would degrade; act on one credential with run_auth_flow.", + "List the credentials the profile references, which the target already provides, and how to acquire each missing one. Value-blind: it reads only whether each env var is set — no values, no network, no writes. `target` selects the machine (`local` or SSH); its `home` determines the `template` path returned and the env file the presence check reads. Returns `present`/`missing` var-name arrays plus one guidance record per credential (`var`, `purpose`, `url`, `method`, `command`). Use it after plan_clone to see what would degrade; act on one credential with run_auth_flow.", inputSchema: { target: targetShape }, outputSchema: credentialsOutput, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true }, @@ -501,146 +500,4 @@ export function registerTools(server: McpServer): void { }, ); - const bootstrapHelp = { - name: "provision_host", - description: - "Provision a bare Linux host (local or SSH) into a VDD-configured OpenCode workstation in one call: scan the platform from the kernel up, return a dry-run upgrade plan, install the latest stable OpenCode and record its version, apply the VDD profile, and verify.", - parameters: [ - { name: "target", type: "object", required: false, description: "Machine to provision; required unless help=true. { mode: local|ssh, host?, user?, port?, identityFile?, cwd? }." }, - { name: "help", type: "boolean", required: false, description: "Return this parameter reference and contact no target." }, - { name: "confirm", type: "boolean", required: false, description: "Set true to actually provision. When absent, a plan (platform + upgrade + components) is returned and nothing changes." }, - { name: "workspace", type: "string", required: false, description: "Target directory for the profile repo." }, - { name: "components", type: "string[]", required: false, description: "Component ids to include; defaults to required+core." }, - { name: "upgrade", type: "boolean", required: false, description: "Execute the platform upgrade (default false = plan only)." }, - { name: "assumeYes", type: "boolean", required: false, description: "Use non-interactive upgrade flags (default true)." }, - { name: "opencodeVersion", type: "string", required: false, description: "Pin a specific OpenCode version (default: latest stable)." }, - { name: "dryRun", type: "boolean", required: false, description: "Report only; make no changes." }, - ], - }; - - const bootstrapHelpOutput = z.object({ - name: z.string(), - description: z.string(), - parameters: z.array( - z.object({ - name: z.string(), - type: z.string(), - required: z.boolean(), - description: z.string(), - }), - ), - }); - - const bootstrapPlatformOutput = z.object({ - osId: z.string().nullable(), - osIdLike: z.array(z.string()), - osName: z.string().nullable(), - osVersion: z.string().nullable(), - kernel: z.string().nullable(), - arch: z.string().nullable(), - packageManager: z.string().nullable(), - family: z.string(), - }); - - const bootstrapUpgradeOutput = z.object({ - packageManager: z.string().nullable(), - family: z.string(), - commands: z.array(z.string()), - upgradable: z.number().nullable(), - executed: z.boolean(), - output: z.array(z.string()), - rebootAdvisory: z.boolean(), - }); - - const bootstrapResultOutput = z.object({ - target: z.string(), - platform: bootstrapPlatformOutput.describe("Platform report from the kernel up."), - upgrade: bootstrapUpgradeOutput.describe("Dry-run platform upgrade plan."), - opencode: z.object({ - installed: z.boolean(), - version: z.string().nullable().describe("Resolved OpenCode build — the version pin."), - requested: z.string().nullable(), - }), - apply: applyOutput, - verify: verifyOutput, - warnings: z.array(z.string()), - }); - - const bootstrapOutput = z.object({ - mode: z.enum(["help", "confirm", "result"]), - help: bootstrapHelpOutput.optional(), - confirm: z - .object({ - target: z.string(), - platform: bootstrapPlatformOutput, - upgrade: bootstrapUpgradeOutput, - toInstall: z.array(z.string()), - commands: z.array(z.object({ id: z.string(), command: z.string().optional() })), - }) - .optional(), - result: bootstrapResultOutput.optional(), - }); - - server.registerTool( - "provision_host", - { - title: "Provision a bare Linux host", - description: - "Provision a bare Linux host end-to-end in one call: scan the platform from the kernel up and return a dry-run upgrade plan, install the latest stable OpenCode and record the resolved version, apply the VDD profile, and verify parity. It makes no change until `confirm:true`; `upgrade:true` needs root/sudo, and the installs mean a full run can take many minutes; it requires SSH for `mode:ssh` and OVERWRITES `/.config/opencode/opencode.json`. Pass `help:true` for full parameter documentation without contacting the target. Never reads or transmits secret values. Boundary: use provision_host for a first-time provision of a bare host; use the granular tools (inspect_target, plan_clone, apply_clone, verify_clone, install_component, update_component, remove_component) for step-by-step changes to an existing host.", - inputSchema: { - target: targetShape.optional(), - help: z.boolean().optional().describe("Return parameter documentation and skip all target access."), - confirm: z.boolean().optional().describe("Set true to actually provision. When absent, the call returns a plan and makes no changes."), - ...optionsShape, - upgrade: z.boolean().optional().describe("Execute the platform upgrade (default false: plan only)."), - assumeYes: z.boolean().optional().describe("Use non-interactive upgrade flags (default true)."), - opencodeVersion: z.string().optional().describe("Pin a specific OpenCode version (default: latest stable)."), - }, - outputSchema: bootstrapOutput, - annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true }, - }, - async (args: { - target?: TargetArg; - help?: boolean; - confirm?: boolean; - } & Partial & { upgrade?: boolean; assumeYes?: boolean; opencodeVersion?: string }) => { - try { - if (args.help) { - return structured({ mode: "help" as const, help: bootstrapHelp }); - } - if (!args.target) throw new Error("target is required unless help=true"); - const spec = targetFromSpec(toSpec(args.target)); - const base = { - components: args.components, - workspace: args.workspace, - profileUrl: args.profileUrl, - skipRepo: args.skipRepo, - dryRun: args.dryRun, - upgrade: args.upgrade, - assumeYes: args.assumeYes, - opencodeVersion: args.opencodeVersion, - }; - if (args.confirm !== true && args.dryRun !== true) { - const p = await plan(spec, toOptions(args)); - const preview = await bootstrap(spec, { ...base, upgrade: false, dryRun: true }); - return structured({ - mode: "confirm" as const, - confirm: { - target: preview.target, - platform: preview.platform, - upgrade: preview.upgrade, - toInstall: p.toInstall, - commands: p.steps - .filter((s) => s.action === "install") - .map((s) => ({ id: s.id, command: s.command })), - }, - }); - } - const result = await bootstrap(spec, base); - return structured({ mode: "result" as const, result }); - } catch (e) { - return fail("provision_host failed", e); - } - }, - ); } diff --git a/mcp-server/test/platform.test.ts b/mcp-server/test/platform.test.ts deleted file mode 100644 index 80276ad..0000000 --- a/mcp-server/test/platform.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - buildUpgradePlan, - managerSpec, - parseOsRelease, - parseUpgradableCount, - parseVersion, - planPlatform, - resolvePackageManager, -} from "../src/platform"; - -const UBUNTU = [ - 'PRETTY_NAME="Ubuntu 24.04 LTS"', - 'NAME="Ubuntu"', - 'VERSION_ID="24.04"', - "ID=ubuntu", - "ID_LIKE=debian", - "HOME_URL=https://www.ubuntu.com/", -].join("\n"); - -const FEDORA = ['NAME="Fedora Linux"', "ID=fedora", "VERSION_ID=40"].join("\n"); -const ARCH = ['NAME="Arch Linux"', "ID=arch", "ID_LIKE=archlinux"].join("\n"); -const SUSE = ['NAME="openSUSE Leap"', "ID=opensuse-leap", "ID_LIKE=suse"].join("\n"); -const ALPINE = ['NAME="Alpine Linux"', "ID=alpine", "VERSION_ID=3.20"].join("\n"); -const GENTOO = ['NAME="Gentoo"', "ID=gentoo"].join("\n"); - -describe("parseOsRelease", () => { - it("parses ids, id_like and strips quotes", () => { - const os = parseOsRelease(UBUNTU); - expect(os.id).toBe("ubuntu"); - expect(os.idLike).toEqual(["debian"]); - expect(os.version).toBe("24.04"); - expect(os.name).toBe("Ubuntu"); - }); - - it("tolerates comments, blanks and single quotes", () => { - const os = parseOsRelease("# comment\n\nID='rocky'\nID_LIKE='rhel fedora'\n"); - expect(os.id).toBe("rocky"); - expect(os.idLike).toEqual(["rhel", "fedora"]); - expect(os.version).toBeNull(); - }); - - it("passes through name only when quoted form is used", () => { - const os = parseOsRelease('NAME="Fedora Linux"\nID=fedora\nVERSION_ID=40\n'); - expect(os.name).toBe("Fedora Linux"); - expect(os.version).toBe("40"); - }); -}); - -describe("resolvePackageManager", () => { - it.each([ - [UBUNTU, "apt-get"], - [FEDORA, "dnf"], - [ARCH, "pacman"], - [SUSE, "zypper"], - [ALPINE, "apk"], - ])("maps distro to manager (%s)", (content, expected) => { - const os = parseOsRelease(content); - expect(resolvePackageManager(os)).toBe(expected); - }); - - it("returns null for unsupported distros", () => { - expect(resolvePackageManager(parseOsRelease(GENTOO))).toBeNull(); - }); -}); - -describe("planPlatform", () => { - it("prefers the manager already detected on the host", () => { - expect(planPlatform({ osId: "ubuntu", packageManager: "dnf" })).toBe("dnf"); - }); - - it("falls back to the distro mapping when none was detected", () => { - expect(planPlatform({ osId: "ubuntu", osIdLike: "debian" })).toBe("apt-get"); - expect(planPlatform({ osId: "rocky", osIdLike: "rhel centos" })).toBe("dnf"); - }); - - it("returns null when unknown", () => { - expect(planPlatform({ osId: "gentoo" })).toBeNull(); - }); -}); - -describe("buildUpgradePlan", () => { - it("orders update then upgrade with a non-interactive flag", () => { - const plan = buildUpgradePlan("apt-get", { assumeYes: true }); - expect(plan.commands).toEqual(["apt-get update", "apt-get -y dist-upgrade"]); - expect(plan.family).toBe("debian"); - expect(plan.executed).toBe(false); - }); - - it("omits the flag when assumeYes is false", () => { - const plan = buildUpgradePlan("pacman", { assumeYes: false }); - expect(plan.commands).toEqual(["pacman -Sy", "pacman -Syu"]); - }); - - it("appends the manager-specific flag", () => { - expect(buildUpgradePlan("zypper").commands).toEqual(["zypper refresh", "zypper -n update"]); - expect(buildUpgradePlan("apk").commands).toEqual(["apk update", "apk -U upgrade"]); - }); - - it("returns an empty plan for an unsupported manager", () => { - const plan = buildUpgradePlan(null); - expect(plan.commands).toEqual([]); - expect(plan.packageManager).toBeNull(); - expect(plan.family).toBe("unknown"); - }); -}); - -describe("parseUpgradableCount", () => { - it("counts apt upgradable lines", () => { - const out = [ - "Listing...", - "nginx/jammy-updates 1.24.0-1ubuntu1 amd64 [upgradable from: 1.18.0-6ubuntu14.4]", - "curl/jammy-updates 7.81.0-1ubuntu1.16 amd64 [upgradable from: 7.81.0-1ubuntu1.15]", - ].join("\n"); - expect(parseUpgradableCount("apt-get", out)).toBe(2); - }); - - it("returns null without a manager", () => { - expect(parseUpgradableCount(null, "anything")).toBeNull(); - }); -}); - -describe("managerSpec", () => { - it("exposes the family and upgrade command per manager", () => { - expect(managerSpec("dnf").upgrade).toBe("dnf upgrade --refresh"); - expect(managerSpec("apk").family).toBe("alpine"); - }); -}); - -describe("parseVersion", () => { - it("extracts a semver from opencode --version output", () => { - expect(parseVersion("1.0.180")).toBe("1.0.180"); - expect(parseVersion("opencode v0.1.48\n")).toBe("0.1.48"); - expect(parseVersion("no version here")).toBeNull(); - }); -}); diff --git a/mcp-server/test/server.test.ts b/mcp-server/test/server.test.ts index 0dc7c31..d9168e2 100644 --- a/mcp-server/test/server.test.ts +++ b/mcp-server/test/server.test.ts @@ -26,7 +26,6 @@ describe("opencode-workbench server", () => { "install_component", "list_required_credentials", "plan_clone", - "provision_host", "remove_component", "run_auth_flow", "update_component", @@ -35,20 +34,6 @@ describe("opencode-workbench server", () => { await client.close(); }); - it("documents provision_host without touching a target", async () => { - const client = await connect(); - const res = await client.callTool({ name: "provision_host", arguments: { help: true } }); - expect(res.isError).toBeFalsy(); - const out = res.structuredContent as { - mode: string; - help?: { parameters: Array<{ name: string }> }; - }; - expect(out.mode).toBe("help"); - expect(out.help?.parameters.map((p) => p.name)).toContain("upgrade"); - expect(out.help?.parameters.map((p) => p.name)).toContain("opencodeVersion"); - await client.close(); - }); - it("answers describe_workbench with components and optional MCPs", async () => { const client = await connect(); const res = await client.callTool({ name: "describe_workbench", arguments: {} }); diff --git a/package.json b/package.json index 729321e..3f04653 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@simonmak-ascent/opencode-workbench", "mcpName": "io.github.simonmak-ascent/opencode-workbench", - "version": "3.1.0", + "version": "4.0.0", "description": "MCP server and connector that clones the OpenCode workbench configuration onto Linux machines (locally or over SSH).", "license": "MIT", "type": "module", diff --git a/public/index.html b/public/index.html index 1c3024b..0427846 100644 --- a/public/index.html +++ b/public/index.html @@ -149,7 +149,7 @@

Provision a reproducible OpenCode workstation on any Linux box.

-

11 tools over one MCP surface

+

10 tools over one MCP surface

Read-only discovery and planning; consent-gated mutations; value-blind credential handling.

@@ -167,7 +167,6 @@

11 tools over one MCP surface

-
verify_cloneRe-check config, env template, and every component.read-only
list_required_credentialsWhich credentials are missing and how to acquire each (value-blind).read-only
run_auth_flowEmit-and-verify acquisition guidance for one credential.read-only
provision_hostOne-call end-to-end provision: platform scan + upgrade plan, latest-stable OpenCode with recorded version, apply, verify; help:true.mutating
@@ -199,13 +198,7 @@

Register with OpenCode (or any MCP client)

} }

Then drive a clone

-
inspect_target  →  plan_clone  →  apply_clone { confirm: true }  →  verify_clone
-
-# or one call, end to end:
-provision_host  { target: { mode: "ssh", host: "box", user: "me" }, confirm: true }
-
-# see every parameter:
-provision_host  { help: true }
+
describe_workbench  →  inspect_target  →  plan_clone  →  apply_clone { confirm: true }  →  verify_clone
diff --git a/public/llms.txt b/public/llms.txt index 9d54490..4724ca4 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -11,11 +11,16 @@ ## MCP tools -- `provision_host`: install and configure the full OpenCode workstation on a host -- `workspace`: inspect and manage the provisioned workspace -- `upgrade`: update components to their latest versions -- `components`: list / manage optional components (github-mcp, docker, data-tools, sandbox, …) -- `help`: discover capabilities and configuration +- `describe_workbench`: describe the server — repo, components by tier, optional MCP add-ons +- `inspect_target`: probe a machine (OS, arch, package manager, Node, Docker, OpenCode) +- `plan_clone`: diff a target against the profile → install / present / manual steps +- `apply_clone`: consent-gated clone + install of the profile (idempotent) +- `verify_clone`: re-check config, env template, and every component +- `install_component`: install a single component by id (consent-gated) +- `remove_component`: uninstall a single component by id (consent-gated) +- `update_component`: update a single component in place (consent-gated) +- `list_required_credentials`: value-blind list of missing credentials +- `run_auth_flow`: emit-and-verify acquisition guidance for one credential ## Docs diff --git a/server.json b/server.json index d610f11..91d31f5 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.github.simonmak-ascent/opencode-workbench", "description": "Provision an OpenCode workbench and MCP stack on any Linux box, local or over SSH.", - "version": "3.1.0", + "version": "4.0.0", "repository": { "url": "https://github.com/simonmak-ascent/opencode-workbench", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@simonmak-ascent/opencode-workbench", - "version": "3.1.0", + "version": "4.0.0", "transport": { "type": "stdio" } } ], diff --git a/vdd/tactics.md b/vdd/tactics.md index 0e06bb8..98ce77c 100644 --- a/vdd/tactics.md +++ b/vdd/tactics.md @@ -77,8 +77,14 @@ Derived from: `vdd/strategy.md`. Scope: Infrastructure only. | A-010 | Registry-listing runbook + claim/verify each of the 5 registries; automate what the API allows | MUST | Distribution | M | A-009 | | A-011 | Capture a full `mcp-tdqs score` (tier A floor) and wire a CI gate when a scorer key is available | SHOULD | P1 | M | A-008 | | A-012 | Add an optional `sandbox` component: a bubblewrap wrapper (`scripts/sandbox/opencode-sandbox.sh`) confining the OpenCode CLI (read-only system, no `~/.ssh`/secret mounts) | SHOULD | Security | S | None | +| A-013 | Remove the orchestrator `provision_host` (and `bootstrap.ts`/`platform.ts`) to eliminate tool-purpose overlap with `apply_clone`, raising TDQS coherence | MUST | P1 | S | A-002 | -**AMEND dependency map:** `A-002 → A-008 → A-011`; `A-009 → A-010`. +**AMEND dependency map:** `A-002 → A-008 → A-011`; `A-009 → A-010`; `A-002 → A-013`. + +> **[AMEND 2026-10-03 — v4.0.0]** A-013 supersedes A-002's one-call orchestrator: +> `provision_host`, `bootstrap.ts` and `platform.ts` were removed (tool count 11 → 10). +> The `bootstrap-bare-host` spec's orchestrator ACs are retired; its inspect/apply/verify +> ACs are served by `clone.ts` via `plan_clone`/`apply_clone`/`verify_clone`. **AMEND blocker:** A-011 (full score) and Smithery/PulseMCP listing require credentials the agent does not hold (TDQS hosted API key, Smithery API key, PulseMCP submission); Glama is already live/claimable and the Official Registry supports GitHub OIDC. diff --git a/vdd/vision.md b/vdd/vision.md index a6b2dec..9884deb 100644 --- a/vdd/vision.md +++ b/vdd/vision.md @@ -46,6 +46,11 @@ configuration, on localhost or a remote SSH target. | I-006 | Every MCP tool passes TDQS with no defects (target 5/5; floor tier A) | Operator / agent consumers | `mcp-tdqs lint` 0 errors; `mcp-tdqs score` tier + score | | I-007 | Server discoverable in the top free MCP registries | Operator | Listed/live in Official MCP Registry + Glama + Smithery + PulseMCP + mcp.so | +> **[AMEND 2026-10-03]** I-001 and I-005 are served by the ordered chain +> `describe_workbench → inspect_target → plan_clone → apply_clone → verify_clone`; +> the overlapping one-call `provision_host` entry was removed in v4.0.0 to raise +> TDQS coherence. I-001's "minimal commands" is now measured on that chain. + ## Stakeholder Map | Role | Interest | Influence | Engagement Strategy |