@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/LICENSE +21 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/THIRD_PARTY_NOTICES.md +32 -0
- package/THIRD_PARTY_NOTICES.zh.md +30 -0
- package/package.json +94 -0
- package/skills/cordis-plugin-development/SKILL.md +420 -0
- package/src/bridge.ts +903 -0
- package/src/codec.ts +55 -0
- package/src/compose.ts +60 -0
- package/src/main.ts +107 -0
- package/src/oauth.ts +138 -0
- package/src/plugins-cli.ts +200 -0
- package/src/plugins.ts +747 -0
- package/src/sessions-cli.ts +78 -0
- package/src/vendor/anchored-tool-bootstrap.d.mts +19 -0
- package/src/vendor/anchored-tool-bootstrap.mjs +495 -0
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
|
+
}
|