@alwith-ai/dsh-agent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/codec.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pure translation between the ACP wire format and the harness lifecycle.
3
+ * Adapted from @deepseek-ai/dsh-acp's codec (MIT); upstream deliberately
4
+ * removed its interactive ACP surface (automation-only, design note
5
+ * 2026-07-23), so this layer is self-contained rather than importing the
6
+ * upstream package.
7
+ */
8
+
9
+ import type { ContentBlock as AcpContentBlock, StopReason } from "@agentclientprotocol/sdk/experimental/v2"
10
+ import type { TurnEndReason } from "@deepseek-ai/dsh-session"
11
+
12
+ /** Map a harness turn ending to ACP's terminal reason vocabulary. */
13
+ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
14
+ switch (reason.kind) {
15
+ case "completed":
16
+ return "end_turn"
17
+ case "max-tokens":
18
+ return "max_tokens"
19
+ // `cancelled` is reserved for explicit session/cancel and disposal, both
20
+ // settled out of band; a turn aborted by a hook or another owner is
21
+ // ordinary quiescence and reports end_turn.
22
+ case "aborted":
23
+ return "end_turn"
24
+ case "interrupted":
25
+ return "cancelled"
26
+ case "blocked":
27
+ case "error":
28
+ return "end_turn"
29
+ default:
30
+ return "end_turn"
31
+ }
32
+ }
33
+
34
+ // The v2 unions carry an open fallback variant ({ type: string; [key: string]: unknown }),
35
+ // so a `case` switch cannot narrow them — take variants with Extract instead.
36
+ type BlockVariant<K extends string> = Extract<AcpContentBlock, { type: K }>
37
+
38
+ /** Flatten baseline ACP prompt blocks to text; resource links become explicit textual references. */
39
+ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
40
+ return prompt
41
+ .flatMap((block): string[] => {
42
+ if (block.type === "text") return [(block as BlockVariant<"text">).text]
43
+ if (block.type === "resource_link") {
44
+ const link = block as BlockVariant<"resource_link">
45
+ return [`\n[resource_link name=${JSON.stringify(link.name)} uri=${JSON.stringify(link.uri)}]\n`]
46
+ }
47
+ return []
48
+ })
49
+ .join("")
50
+ }
51
+
52
+ /** Whether the prompt carries content beyond the baseline (text / resource_link). */
53
+ export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
54
+ return prompt.some(block => block.type !== "text" && block.type !== "resource_link")
55
+ }
package/src/compose.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Composes the dsh runtime from the plugin manifest (src/plugins.ts).
3
+ *
4
+ * The roster per preset is fixed in code — a platform decision, not a
5
+ * user-patchable tree — but users keep dsh's two degrees of freedom through
6
+ * the overrides file: per-plugin enable/disable and per-plugin config.
7
+ * Composition stays deterministic: same preset + same overrides ⟹ same tree.
8
+ *
9
+ * Why not dsh's loader: the sidecar spawns one process per session, so
10
+ * overrides naturally take effect on the next session — no HMR needed — and
11
+ * dsh's HMR machinery requires Node loader internals Bun does not provide.
12
+ */
13
+
14
+ import { Context } from "@deepseek-ai/cordis"
15
+ import {
16
+ type HarnessPreset,
17
+ type PermissionMode,
18
+ type PluginOverrides,
19
+ pluginRows,
20
+ resolvePlugins,
21
+ } from "./plugins.ts"
22
+
23
+ export type { HarnessPreset, PermissionMode }
24
+
25
+ export interface ComposeOptions {
26
+ /**
27
+ * Root directory for JSONL session logs. Absent means no persistence —
28
+ * session/resume then fails loud (`session persistence is not configured`).
29
+ */
30
+ sessionsRoot?: string
31
+ /** Sandbox workspace root (writes allowed under it in workspace-write mode). */
32
+ workspaceRoot?: string
33
+ /** Deployment permission mode; mirrors dsh's DSH_PERMISSION_MODE. */
34
+ permissionMode?: PermissionMode
35
+ /** Tool-surface preset; defaults to the standard coding agent. */
36
+ preset?: HarnessPreset
37
+ /** Per-plugin enable/disable + config, from the plugins.json overrides file. */
38
+ overrides?: PluginOverrides
39
+ /** Extra pi-ai provider routes (see ResolvedComposeOptions.piProviders). */
40
+ piProviders?: Record<string, unknown>
41
+ /** The harness credential file (see ResolvedComposeOptions.credentialsFile). */
42
+ credentialsFile?: string
43
+ }
44
+
45
+ export async function composeRuntime(options: ComposeOptions = {}): Promise<Context> {
46
+ const rows = pluginRows({
47
+ sessionsRoot: options.sessionsRoot,
48
+ workspaceRoot: options.workspaceRoot ?? process.cwd(),
49
+ permissionMode: options.permissionMode ?? "workspace-write",
50
+ preset: options.preset ?? "standard",
51
+ piProviders: options.piProviders,
52
+ credentialsFile: options.credentialsFile,
53
+ })
54
+ const { mounted } = resolvePlugins(rows, options.overrides ?? {})
55
+ const ctx = new Context()
56
+ for (const entry of mounted) {
57
+ await entry.row.mount(ctx, entry.config)
58
+ }
59
+ return ctx
60
+ }
package/src/main.ts ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * stdio entry: `bun src/main.ts` starts an ACP v2 server for a host to spawn.
4
+ * The DeepSeek key resolves through llm-deepseek's default credential lookup
5
+ * ($DEEPSEEK_API_KEY).
6
+ */
7
+
8
+ import { homedir } from "node:os"
9
+ import { join } from "node:path"
10
+ import { credentialKey } from "@deepseek-ai/dsh-credentials"
11
+ import { composeRuntime } from "./compose.ts"
12
+ import { defaultCredentialsFile } from "./oauth.ts"
13
+ import { loadPluginOverrides } from "./plugins.ts"
14
+ import { defaultPluginsFile, runPluginsCli } from "./plugins-cli.ts"
15
+ import * as Bridge from "./bridge.ts"
16
+
17
+ // `plugins` / `sessions` subcommands: host-facing management without an ACP
18
+ // server. Failures exit 1 with the reason as a single stderr line — the host
19
+ // surfaces stderr verbatim, so no runtime stack noise here. No process.exit()
20
+ // on the success path: Bun's exit does not flush pipes and a session dump
21
+ // exceeds the 64KB pipe buffer — the drained event loop ends the process.
22
+ const subcommand = process.argv[2]
23
+ if (subcommand === "plugins" || subcommand === "sessions" || subcommand === "oauth") {
24
+ try {
25
+ if (subcommand === "plugins") await runPluginsCli(process.argv.slice(3))
26
+ else if (subcommand === "oauth") await (await import("./oauth.ts")).runOauthCli(process.argv.slice(3))
27
+ else await (await import("./sessions-cli.ts")).runSessionsCli(process.argv.slice(3))
28
+ } catch (error) {
29
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
30
+ process.exitCode = 1
31
+ }
32
+ } else {
33
+ await startServer()
34
+ }
35
+
36
+ async function startServer(): Promise<void> {
37
+ const provider = process.env.ALWITH_DSH_PROVIDER ?? "deepseek-official"
38
+ const model = process.env.ALWITH_DSH_MODEL ?? "deepseek-v4-flash"
39
+ // Host-facing provider identity for _meta.alwith turn metadata (the ALwith
40
+ // Desktop provider vocabulary, not the dsh adapter route).
41
+ const providerId = process.env.ALWITH_DSH_PROVIDER_ID ?? "deepseek"
42
+ // Background LLM session titles ride the session's model unless overridden.
43
+ const titleModel = process.env.ALWITH_DSH_TITLE_MODEL ?? model
44
+ // Session logs live under the sidecar's own home by default; the host
45
+ // (ALwith Desktop) overrides this to its managed location.
46
+ const sessionsRoot = process.env.ALWITH_DSH_SESSIONS_ROOT ?? join(homedir(), ".dsh-agent", "sessions")
47
+ // The host spawns one sidecar per session and pins the sandbox workspace to
48
+ // that session's cwd; standalone runs default to the process cwd.
49
+ const workspaceRoot = process.env.ALWITH_DSH_WORKSPACE_ROOT ?? process.cwd()
50
+ const permissionMode = (process.env.ALWITH_DSH_PERMISSION_MODE ?? "workspace-write") as
51
+ | "read-only"
52
+ | "workspace-write"
53
+ | "danger-full-access"
54
+ // Preset gate fails loud on the modes this sidecar does not compose yet —
55
+ // the host UI disables them, and a misrouted value must not silently degrade.
56
+ const rawPreset = process.env.ALWITH_DSH_PRESET ?? "standard"
57
+ const PRESETS = ["standard", "minimal", "anchored", "code", "cordis"] as const
58
+ if (!(PRESETS as readonly string[]).includes(rawPreset)) {
59
+ throw new Error(`unsupported harness preset "${rawPreset}": this sidecar composes ${PRESETS.join(", ")}`)
60
+ }
61
+
62
+ // Per-plugin enable/disable + config; invalid content fails the spawn loud.
63
+ const overrides = loadPluginOverrides(defaultPluginsFile())
64
+
65
+ // Extra pi-ai provider routes (JSON dict, full upstream config shape:
66
+ // apiKeyEnv / baseURL / api / models / compat / …). Absent = DeepSeek-only.
67
+ let piProviders: Record<string, unknown> | undefined
68
+ const rawPiProviders = process.env.ALWITH_DSH_PI_PROVIDERS
69
+ if (rawPiProviders !== undefined) {
70
+ const parsed: unknown = JSON.parse(rawPiProviders)
71
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
72
+ throw new Error("ALWITH_DSH_PI_PROVIDERS must be a JSON object keyed by provider route")
73
+ }
74
+ piProviders = parsed as Record<string, unknown>
75
+ }
76
+
77
+ const ctx = await composeRuntime({
78
+ sessionsRoot,
79
+ workspaceRoot,
80
+ permissionMode,
81
+ preset: rawPreset as (typeof PRESETS)[number],
82
+ overrides,
83
+ piProviders,
84
+ // The credential plane rides with the pi-ai seat: subscription grants and
85
+ // their refreshes live in the harness credential file, not in this process.
86
+ credentialsFile: piProviders === undefined ? undefined : defaultCredentialsFile(),
87
+ })
88
+ // A route with no apiKeyEnv authenticates through a stored subscription
89
+ // grant. Check it here, where the credential plane is the single source of
90
+ // truth, so a session never boots into a first request that must fail —
91
+ // the host cannot read the harness credential file and should not try.
92
+ for (const [route, config] of Object.entries(piProviders ?? {})) {
93
+ const declared = config as { apiKeyEnv?: unknown }
94
+ if (declared.apiKeyEnv !== undefined) continue
95
+ const record = await ctx.credentials.describeRecord(credentialKey("llm-pi-ai", route))
96
+ if (!record.configured) {
97
+ throw new Error(
98
+ `provider route "${route}" declares no API key and has no stored subscription credential: `
99
+ + "add an API key (Settings → Model providers) or sign in with a subscription (Settings → Harness)",
100
+ )
101
+ }
102
+ }
103
+ await ctx.plugin(
104
+ { name: Bridge.name, inject: [...Bridge.inject], apply: (inner: typeof ctx) => Bridge.apply(inner, { provider, model, providerId, titleModel }) },
105
+ )
106
+ // stdin keeps the process alive; the bridge's quiesce handles connection close.
107
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Subscription OAuth for the pi-ai seat, on the harness's own credential plane.
3
+ *
4
+ * Since dsh 0.1.2 the pi-ai adapter ships the whole sign-in translation:
5
+ * `dsh-llm-pi-ai` registers one `ctx.authorization` flow per catalog provider
6
+ * that offers a login, and persists what pi-ai's `Models.login()` produces
7
+ * (and later refreshes under its own lock) as `llm-pi-ai/<provider>` records in
8
+ * `ctx.credentials`. This module only composes that plane and drives it:
9
+ *
10
+ * - `dsh-credentials-local` at `ALWITH_DSH_OAUTH_CREDENTIALS` (default
11
+ * `~/.dsh-agent/.credentials.yaml`; the file is private to the OS user),
12
+ * - `dsh-authorization` — the flow registry the adapter registers into,
13
+ * - the `oauth login/status/logout` CLI. `login` emits the flow's notices as
14
+ * JSON lines on stdout (`{"type":"auth_url",…}` → the host opens the
15
+ * browser); the provider's local callback server completes the exchange.
16
+ */
17
+
18
+ import { homedir } from "node:os"
19
+ import { join } from "node:path"
20
+ import { Context } from "@deepseek-ai/cordis"
21
+ import AuthorizationService, {
22
+ type AuthorizationInteraction,
23
+ type AuthorizationNotice,
24
+ type AuthorizationPrompt,
25
+ } from "@deepseek-ai/dsh-authorization"
26
+ import { credentialKey, credentialKeyId, credentialKeyScope } from "@deepseek-ai/dsh-credentials"
27
+ import LocalCredentialProvider from "@deepseek-ai/dsh-credentials-local"
28
+ import LlmRuntime from "@deepseek-ai/dsh-llm"
29
+ // Namespace import: a module-plugin default export drops `inject` (dsh postmortem 0001).
30
+ import * as LlmPiAi from "@deepseek-ai/dsh-llm-pi-ai"
31
+
32
+ /** The record scope the pi-ai adapter writes under (its registered plugin name). */
33
+ const PI_AI_RECORD_SCOPE = "llm-pi-ai"
34
+
35
+ export function defaultCredentialsFile(): string {
36
+ return process.env.ALWITH_DSH_OAUTH_CREDENTIALS ?? join(homedir(), ".dsh-agent", ".credentials.yaml")
37
+ }
38
+
39
+ function emit(event: Record<string, unknown>): void {
40
+ process.stdout.write(`${JSON.stringify(event)}\n`)
41
+ }
42
+
43
+ /**
44
+ * The minimal composition that owns subscription credentials: the store, the
45
+ * flow registry, and the adapter that registers the flows. No session, no
46
+ * sandbox, no tools — signing in is not a conversation. `watch` is off: this
47
+ * process is the only writer for its lifetime and must exit when done.
48
+ */
49
+ export async function composeCredentialPlane(credentialsFile: string): Promise<Context> {
50
+ const ctx = new Context()
51
+ await ctx.plugin(LocalCredentialProvider, { path: credentialsFile, watch: false } as never)
52
+ await ctx.plugin(AuthorizationService)
53
+ await ctx.plugin(LlmRuntime)
54
+ await ctx.plugin(LlmPiAi, { providers: {} } as never)
55
+ return ctx
56
+ }
57
+
58
+ /**
59
+ * Interaction callbacks for a host-driven login, in the seam's neutral
60
+ * vocabulary. Notices pass through as JSON lines: one carrying a page becomes
61
+ * `auth_url` (the host opens it), one carrying a code becomes `device_code`,
62
+ * the rest `info`. A prompt carrying `signal` is an alternative input path
63
+ * raced against the flow's callback server (anthropic's paste-the-redirect-URL
64
+ * question); answering is optional, so it stays pending until the flow aborts
65
+ * it after login settles. A signal-less prompt is required input this
66
+ * non-interactive host cannot supply — fail loud rather than hang. (A plain
67
+ * rejection, not `AuthorizationDeclinedError`: "no" would settle the attempt
68
+ * as cancelled and hide the fact that the host cannot answer.)
69
+ */
70
+ export function hostLoginInteraction(): AuthorizationInteraction {
71
+ return {
72
+ notify: (notice: AuthorizationNotice) => {
73
+ if (notice.url !== undefined && notice.code !== undefined) {
74
+ emit({ type: "device_code", verificationUri: notice.url, userCode: notice.code, message: notice.message })
75
+ } else if (notice.url !== undefined) {
76
+ emit({ type: "auth_url", url: notice.url, instructions: notice.message })
77
+ } else {
78
+ emit({ type: "info", message: notice.message })
79
+ }
80
+ },
81
+ prompt: (prompt: AuthorizationPrompt) =>
82
+ new Promise<string>((_resolve, reject) => {
83
+ if (!prompt.signal) {
84
+ reject(new Error(`interactive prompt not supported in host login flow: ${JSON.stringify(prompt)}`))
85
+ return
86
+ }
87
+ prompt.signal.addEventListener(
88
+ "abort",
89
+ () => reject(new Error(`prompt "${prompt.kind}" cancelled: login settled out of band`)),
90
+ { once: true }
91
+ )
92
+ }),
93
+ }
94
+ }
95
+
96
+ /** `oauth login <provider>` / `oauth status` / `oauth logout <provider>`; stdout is JSON lines. */
97
+ export async function runOauthCli(argv: string[]): Promise<void> {
98
+ const [command, providerId] = argv
99
+ const ctx = await composeCredentialPlane(defaultCredentialsFile())
100
+ try {
101
+ if (command === "status") {
102
+ const stored = await ctx.credentials.listRecords()
103
+ const credentials = stored
104
+ .filter((entry) => credentialKeyScope(entry.key) === PI_AI_RECORD_SCOPE)
105
+ .map((entry) => ({ providerId: credentialKeyId(entry.key), type: entry.kind === "api-key" ? "api_key" : "oauth" }))
106
+ emit({ type: "status", credentials })
107
+ return
108
+ }
109
+ if (command === "logout") {
110
+ if (!providerId) throw new Error("usage: oauth logout <provider>")
111
+ await ctx.credentials.deleteRecord(credentialKey(PI_AI_RECORD_SCOPE, providerId))
112
+ emit({ type: "logged-out", providerId })
113
+ return
114
+ }
115
+ if (command === "login") {
116
+ if (!providerId) throw new Error("usage: oauth login <provider>")
117
+ const key = credentialKey(PI_AI_RECORD_SCOPE, providerId)
118
+ const flow = ctx.authorization.describe(key)
119
+ if (flow === undefined) {
120
+ const available = ctx.authorization
121
+ .list()
122
+ .filter((entry) => credentialKeyScope(entry.key) === PI_AI_RECORD_SCOPE)
123
+ .map((entry) => credentialKeyId(entry.key))
124
+ throw new Error(`no subscription login wired for "${providerId}" (available: ${available.join(", ")})`)
125
+ }
126
+ if (!flow.methods.some((method) => method.id === "oauth")) {
127
+ throw new Error(`"${providerId}" offers no OAuth login (methods: ${flow.methods.map((m) => m.id).join(", ")})`)
128
+ }
129
+ const outcome = await ctx.authorization.begin({ key, method: "oauth", interaction: hostLoginInteraction() })
130
+ if (outcome.status !== "authorized") throw new Error(`subscription login for "${providerId}" was cancelled`)
131
+ emit({ type: "logged-in", providerId })
132
+ return
133
+ }
134
+ throw new Error(`unknown oauth command "${command ?? ""}": expected login, status or logout`)
135
+ } finally {
136
+ await ctx.fiber.dispose()
137
+ }
138
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * `plugins` subcommand: lets a host read and edit the plugin overrides file
3
+ * without a live session (`bun src/main.ts plugins list --json`).
4
+ *
5
+ * Output is JSON on stdout; validation failures throw (non-zero exit) with
6
+ * the reason on stderr — the host surfaces it verbatim.
7
+ */
8
+
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ HARNESS_PRESETS,
13
+ type HarnessPreset,
14
+ type PluginConfigField,
15
+ loadPluginOverrides,
16
+ pluginRows,
17
+ resolvePlugins,
18
+ savePluginOverrides,
19
+ } from "./plugins.ts";
20
+
21
+ export function defaultPluginsFile(): string {
22
+ return (
23
+ process.env.ALWITH_DSH_PLUGINS_FILE ??
24
+ join(homedir(), ".dsh-agent", "plugins.json")
25
+ );
26
+ }
27
+
28
+ function configurableFields(id: string): PluginConfigField[] {
29
+ const fields = new Map<string, PluginConfigField>();
30
+ for (const preset of HARNESS_PRESETS) {
31
+ const row = pluginRows({
32
+ sessionsRoot: "/",
33
+ workspaceRoot: "/",
34
+ permissionMode: "workspace-write",
35
+ preset,
36
+ }).find((candidate) => candidate.id === id);
37
+ for (const field of row?.configurable ?? []) fields.set(field.key, field);
38
+ }
39
+ if (fields.size === 0)
40
+ throw new Error(`plugin "${id}" has no desktop-configurable fields`);
41
+ return [...fields.values()];
42
+ }
43
+
44
+ function parseConfigPatch(
45
+ id: string,
46
+ raw: string,
47
+ ): Record<string, unknown | null> {
48
+ let parsed: unknown;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ } catch (error) {
52
+ throw new Error(
53
+ `plugin "${id}" config patch is not valid JSON: ${String(error)}`,
54
+ );
55
+ }
56
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
57
+ throw new Error(`plugin "${id}" config patch must be a JSON object`);
58
+ }
59
+ const fields = new Map(
60
+ configurableFields(id).map((field) => [field.key, field]),
61
+ );
62
+ for (const [key, value] of Object.entries(parsed)) {
63
+ const field = fields.get(key);
64
+ if (field === undefined)
65
+ throw new Error(
66
+ `plugin "${id}" field "${key}" is not desktop-configurable`,
67
+ );
68
+ if (value === null) continue;
69
+ if (field.type === "number") {
70
+ if (
71
+ !Number.isInteger(value) ||
72
+ (field.minimum !== undefined && (value as number) < field.minimum)
73
+ ) {
74
+ throw new Error(
75
+ `plugin "${id}" field "${key}" must be an integer >= ${field.minimum}`,
76
+ );
77
+ }
78
+ } else if (typeof value !== "string" || !URL.canParse(value)) {
79
+ throw new Error(`plugin "${id}" field "${key}" must be an absolute URL`);
80
+ }
81
+ }
82
+ return parsed as Record<string, unknown | null>;
83
+ }
84
+
85
+ interface CliFlags {
86
+ preset: HarnessPreset;
87
+ file: string;
88
+ positional: string[];
89
+ }
90
+
91
+ function parseFlags(argv: string[]): CliFlags {
92
+ let preset = process.env.ALWITH_DSH_PRESET ?? "standard";
93
+ let file = defaultPluginsFile();
94
+ const positional: string[] = [];
95
+ for (let index = 0; index < argv.length; index += 1) {
96
+ const argument = argv[index]!;
97
+ if (argument === "--preset") {
98
+ const value = argv[index + 1];
99
+ if (value === undefined) throw new Error("--preset requires a value");
100
+ preset = value;
101
+ index += 1;
102
+ } else if (argument === "--file") {
103
+ const value = argv[index + 1];
104
+ if (value === undefined) throw new Error("--file requires a value");
105
+ file = value;
106
+ index += 1;
107
+ } else if (argument.startsWith("--")) {
108
+ throw new Error(`unknown flag ${argument}`);
109
+ } else {
110
+ positional.push(argument);
111
+ }
112
+ }
113
+ if (!(HARNESS_PRESETS as readonly string[]).includes(preset)) {
114
+ throw new Error(
115
+ `unsupported preset "${preset}": expected one of ${HARNESS_PRESETS.join(", ")}`,
116
+ );
117
+ }
118
+ return { preset: preset as HarnessPreset, file, positional };
119
+ }
120
+
121
+ function listingFor(preset: HarnessPreset, file: string) {
122
+ const overrides = loadPluginOverrides(file);
123
+ // Listing does not touch the sandbox or session log; placeholder roots keep
124
+ // the row configs representative without requiring the host's real paths.
125
+ const rows = pluginRows({
126
+ sessionsRoot: join(homedir(), ".dsh-agent", "sessions"),
127
+ workspaceRoot: process.cwd(),
128
+ permissionMode: "workspace-write",
129
+ preset,
130
+ });
131
+ return { preset, file, plugins: resolvePlugins(rows, overrides).listing };
132
+ }
133
+
134
+ /** Rejects an overrides state that any preset would refuse to compose. */
135
+ function assertValidAcrossPresets(
136
+ overrides: ReturnType<typeof loadPluginOverrides>,
137
+ ): void {
138
+ for (const preset of HARNESS_PRESETS) {
139
+ const rows = pluginRows({
140
+ sessionsRoot: "/",
141
+ workspaceRoot: "/",
142
+ permissionMode: "workspace-write",
143
+ preset,
144
+ });
145
+ resolvePlugins(rows, overrides);
146
+ }
147
+ }
148
+
149
+ export async function runPluginsCli(argv: string[]): Promise<void> {
150
+ const { preset, file, positional } = parseFlags(argv);
151
+ const [command, ...rest] = positional;
152
+ if (command === "list") {
153
+ process.stdout.write(`${JSON.stringify(listingFor(preset, file))}\n`);
154
+ return;
155
+ }
156
+ if (command === "set") {
157
+ const [id, state] = rest;
158
+ if (id === undefined || (state !== "enabled" && state !== "disabled")) {
159
+ throw new Error(
160
+ "usage: plugins set <id> <enabled|disabled> [--file <path>]",
161
+ );
162
+ }
163
+ const overrides = loadPluginOverrides(file);
164
+ const disabled = new Set(overrides.disabled ?? []);
165
+ if (state === "disabled") disabled.add(id);
166
+ else disabled.delete(id);
167
+ const next = { ...overrides, disabled: [...disabled].sort() };
168
+ // Validate before touching disk: the file must never hold a state compose would refuse.
169
+ assertValidAcrossPresets(next);
170
+ savePluginOverrides(file, next);
171
+ process.stdout.write(`${JSON.stringify(listingFor(preset, file))}\n`);
172
+ return;
173
+ }
174
+ if (command === "configure") {
175
+ const [id, raw] = rest;
176
+ if (id === undefined || raw === undefined) {
177
+ throw new Error(
178
+ "usage: plugins configure <id> <json-patch> [--file <path>]",
179
+ );
180
+ }
181
+ const patch = parseConfigPatch(id, raw);
182
+ const overrides = loadPluginOverrides(file);
183
+ const nextConfig = { ...overrides.config };
184
+ const pluginConfig = { ...nextConfig[id] };
185
+ for (const [key, value] of Object.entries(patch)) {
186
+ if (value === null) delete pluginConfig[key];
187
+ else pluginConfig[key] = value;
188
+ }
189
+ if (Object.keys(pluginConfig).length === 0) delete nextConfig[id];
190
+ else nextConfig[id] = pluginConfig;
191
+ const next = { ...overrides, config: nextConfig };
192
+ assertValidAcrossPresets(next);
193
+ savePluginOverrides(file, next);
194
+ process.stdout.write(`${JSON.stringify(listingFor(preset, file))}\n`);
195
+ return;
196
+ }
197
+ throw new Error(
198
+ `unknown plugins command "${command ?? ""}": expected list, set, or configure`,
199
+ );
200
+ }