diff --git a/README.md b/README.md index 6e7fdd4..e6c6cc0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ defaults. Choose one of the three model-access paths in - [Architecture](#architecture) - [Features](#features) - [Workspace integration](#workspace-integration) +- [Plugin management](#plugin-management) - [Requirements](#requirements) - [Configuration](#configuration) - [Local development](#local-development) @@ -85,9 +86,9 @@ bytes. ## Features **Editor and input.** pi-tui differential rendering with a CJK-aware -multi-line editor; slash-command, workspace-path and `$` Skill completion; persisted -prompt history through ZCode's history API; `--no-color` and `NO_COLOR` -support. +multi-line editor; slash-command, unified `@` workspace/Plugin references and +`$` Skill completion; persisted prompt history through ZCode's history API; +`--no-color` and `NO_COLOR` support. **Streaming and conversation.** Streamed assistant text from official ZCode session events; `/mode`, `/model`, `/resume`, `/plugins` and other upstream @@ -145,6 +146,23 @@ Suggestions come from the official ZCode runtime, stay inside the current workspace and exclude common repository metadata and dependency directories. Paths containing spaces are inserted in the quoted `@"..."` form. +### Referencing plugins + +The same `@` picker includes enabled, unambiguous Plugins that expose at least +one Skill, connected MCP server or Subagent. Plugin rows are labelled with an +`@name` and their marketplace. Selecting one inserts the runtime's native +Markdown reference: + +```text +Use [@browser-use](plugin://browser-use@zcode-plugins-official) to check this page +``` + +The terminal editor shows the Markdown source because it has no desktop-style +inline chips. The runtime resolves the link against the active session and +adds only that Plugin's live capabilities as metadata. A Plugin reference does +not install, enable, authorize or force the use of any capability. Disabled, +ambiguous or stale references are ignored by the runtime. + ### Invoking skills Type `$` at the start of the prompt or after whitespace to open the Skill @@ -162,6 +180,10 @@ into a request that loads each selected Skill through the runtime's `Skill` tool before carrying out the visible user request. Unknown `$` tokens remain ordinary prompt text. +Use `@plugin` when the whole Plugin is relevant, including its MCP servers or +Subagents. Use `$plugin:skill` when one exact Skill must be loaded before the +task starts. + ### Active-turn input While a regular agent turn is running, press `Enter` to send the current text @@ -238,6 +260,83 @@ selection it toggles all expandable content. During transcript search, `n` and `PageDown`) page through an oversized selected block without rendering the entire message at once. `Esc` leaves search or transcript navigation. +## Plugin management + +Built-in Plugins such as Browser Use, document skills and Skill Creator are +seeded by the official runtime. Existing installed-plugin commands continue to +use the runtime directly: + +```bash +zcode plugins list --json +zcode plugins enable +zcode plugins disable +zcode plugins uninstall --force +``` + +The npm launcher adds marketplace operations by calling the runtime's public +`app-server` protocol; it does not patch or reimplement the Plugin subsystem. +Run `zcode plugins --help` for the full command list. A typical third-party +installation is: + +```bash +zcode plugins discover +zcode plugins marketplace add owner/repository --dry-run +zcode plugins marketplace add owner/repository +zcode plugins describe plugin-name@marketplace-name +zcode plugins install plugin-name@marketplace-name --dry-run +zcode plugins install plugin-name@marketplace-name +``` + +Marketplace addition and installation validate first, display the Plugin's +components and dependency closure, and ask for confirmation. Use `--yes` only +for intentional non-interactive execution, `--json` for structured output and +`--scope user|workspace` to choose installation scope. Marketplace Git access +behind a proxy uses `ZCODE_HTTP_PROXY`. + +Plugins with configuration can load options from a JSON file without exposing +values in the process argument list: + +```bash +zcode plugins configure plugin-name@marketplace-name \ + --options-file ./plugin-options.json --dry-run +zcode plugins configure plugin-name@marketplace-name \ + --options-file ./plugin-options.json +``` + +Keep files containing secrets private. Install, update, configure, enable and +disable changes apply to new sessions. + +### Browser Use in the CLI + +The launcher enables the CLI-managed headless Chromium backend by default for +TUI, `--prompt`, `--print` and `--target` sessions. This makes an enabled +`browser-use` Plugin usable from the normal `zcode` command without a separate +startup flag: + +```bash +zcode +zcode --prompt \ + 'Use $browser-use:control-browser to inspect https://example.com' +``` + +The explicit `--browser-use=headless` form remains supported, including with +`--browser-executable ` when Chromium needs to be selected manually. +The managed backend still requires a usable local Chrome/Chromium executable; +if automatic discovery fails, pass its absolute path with +`--browser-executable`. +The launcher never injects Browser Use into `plugins`, `skills`, `doctor`, +`app-server` or other management commands. Existing sessions must be restarted +before the backend becomes available. + +The managed browser is an ephemeral headless context. It does not reuse the +ZCode Desktop in-app browser profile, cookies or login state, so public search +engines may close connections or request verification more often, especially +on VPN, proxy or shared egress IPs. `--browser-executable` only selects the +Chrome/Chromium binary; it does not make the browser headful or persistent. +For general fact finding, avoid forcing Browser Use when a search capability is +available. Use direct page URLs where possible, and use the Desktop in-app +browser for interactive login or verification flows. + ## Requirements - Node.js 22.19 or newer; diff --git a/bun.lock b/bun.lock index ea7a800..800b44d 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "zcode-app-cli", "dependencies": { "@earendil-works/pi-tui": "^0.80.6", + "playwright-core": "1.59.1", }, "devDependencies": { "beautiful-mermaid": "^1.1.3", @@ -227,6 +228,8 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2780405..f92eec5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -90,8 +90,36 @@ zcode --version zcode doctor --json zcode --prompt "Explain this repository" zcode app-server +zcode plugins list --json +zcode plugins discover --json ``` +Marketplace and install commands are launcher-owned adapters over the +runtime's public `app-server` NDJSON methods. Keep protocol framing in +`src/app-server-client.ts` and command parsing in `src/plugin-cli.ts`; do not +add these operations to the minified runtime bridge. The TUI queries +`plugins/referenceCatalog` through the same client and inserts native +`plugin://` links for `@` Plugin completion. + +Browser automation is enabled by the launcher only for agent-producing +invocations: + +```bash +zcode +zcode --prompt "Inspect https://example.com" +zcode --print "Inspect https://example.com" +zcode --browser-use=headless --browser-executable /path/to/chromium +``` + +The npm package supplies the runtime-compatible `playwright-core` library but +does not download a browser binary. Keep the executable discovery and launch +logic in the official runtime; use `--browser-executable` for environments +where the system Chrome/Chromium path is non-standard. + +Keep the injection classifier covered when runtime global options change. Do +not add the flag to protocol or management commands; the runtime rejects it +outside TUI, `--prompt` and `--target` invocations. + `zcode version`, `zcode --version` and `zcode -v` identify both packaged layers explicitly: diff --git a/package.json b/package.json index 9ed3dc3..725ebc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-app-cli", - "version": "3.6.5-9", + "version": "3.7.3-9", "description": "Unofficial terminal client for the ZCode agent runtime", "keywords": [ "agent", @@ -68,7 +68,8 @@ "provenance": true }, "dependencies": { - "@earendil-works/pi-tui": "^0.80.6" + "@earendil-works/pi-tui": "^0.80.6", + "playwright-core": "1.59.1" }, "devDependencies": { "beautiful-mermaid": "^1.1.3", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 42e5a7e..7fb48f6 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -77,6 +77,7 @@ import { type UserQuestion } from "./interactions.ts"; import { PermissionPreview } from "./permission-view.ts"; +import { createRuntimePluginReferenceLister } from "./plugin-references.ts"; import { formatWorkflowPanel, isMcpPickerRequest, @@ -542,12 +543,14 @@ class ZCodeTui { this.ui.addChild(this.status); const commands = this.autocompleteCommands(); + const workspaceDirectory = this.options.workspaceDirectory ?? process.cwd(); this.editor.setAutocompleteProvider( new WorkspaceAutocompleteProvider( commands, - this.options.workspaceDirectory ?? process.cwd(), + workspaceDirectory, this.options.listWorkspacePathSuggestions, - this.skillCatalog + this.skillCatalog, + this.options.listPluginReferences ?? createRuntimePluginReferenceLister(workspaceDirectory) ) ); this.editor.onSubmit = (text) => void this.submit(text); diff --git a/packages/zcode-tui/src/plugin-references.ts b/packages/zcode-tui/src/plugin-references.ts new file mode 100644 index 0000000..4a4eeef --- /dev/null +++ b/packages/zcode-tui/src/plugin-references.ts @@ -0,0 +1,159 @@ +import { basename } from "node:path"; + +import type { AutocompleteItem } from "@earendil-works/pi-tui"; + +import { requestAppServer } from "../../../src/app-server-client.ts"; +import { pluginProtocolMethods, pluginWorkspace } from "../../../src/plugin-protocol.ts"; +import { isRecord, type ListPluginReferences } from "./types.ts"; + +const pluginIdentifierPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +const pluginReferenceValuePattern = /^\[@[A-Za-z0-9][A-Za-z0-9._-]*\]\(plugin:\/\/[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9._-]*\)$/u; + +export interface PluginReferenceEntry { + description: string; + marketplace: string; + name: string; + pluginId: string; +} + +export class PluginReferenceCatalog { + private cached?: PluginReferenceEntry[]; + private inFlight?: Promise; + + constructor(private readonly listPluginReferences?: ListPluginReferences) {} + + async list(): Promise { + if (!this.listPluginReferences) return []; + if (this.cached) return this.cached; + if (this.inFlight) return await this.inFlight; + + const request = Promise.resolve() + .then(() => this.listPluginReferences!()) + .then((result) => { + const plugins = normalizePluginReferenceEntries(result); + this.cached = plugins; + return plugins; + }) + .catch(() => []); + this.inFlight = request; + try { + return await request; + } finally { + if (this.inFlight === request) this.inFlight = undefined; + } + } +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function capabilityLabel(count: number, singular: string): string | undefined { + return count > 0 ? `${count} ${singular}${count === 1 ? "" : "s"}` : undefined; +} + +export function normalizePluginReferenceEntries(result: unknown): PluginReferenceEntry[] { + if (!isRecord(result) || !Array.isArray(result.plugins)) return []; + const plugins: PluginReferenceEntry[] = []; + const seen = new Set(); + + for (const candidate of result.plugins) { + if (!isRecord(candidate) || candidate.enabled !== true) continue; + const name = typeof candidate.name === "string" ? candidate.name : ""; + const marketplace = typeof candidate.marketplace === "string" ? candidate.marketplace : ""; + const pluginId = typeof candidate.pluginId === "string" ? candidate.pluginId : ""; + if ( + !pluginIdentifierPattern.test(name) + || !pluginIdentifierPattern.test(marketplace) + || pluginId !== `${name}@${marketplace}` + || seen.has(pluginId) + || stringArray(candidate.conflictingPluginIds).length > 0 + ) { + continue; + } + + const skills = stringArray(candidate.skillQualifiedNames).length; + const mcpServers = stringArray(candidate.mcpServerNames).length; + const subagents = stringArray(candidate.subagentNames).length; + if (skills + mcpServers + subagents === 0) continue; + const capabilities = [ + capabilityLabel(skills, "skill"), + capabilityLabel(mcpServers, "MCP server"), + capabilityLabel(subagents, "subagent") + ].filter((value): value is string => Boolean(value)); + + seen.add(pluginId); + plugins.push({ + description: `Plugin | ${marketplace} | ${capabilities.join(", ")}`, + marketplace, + name, + pluginId + }); + } + + return plugins.sort((left, right) => left.pluginId.localeCompare(right.pluginId)); +} + +export function pluginReferenceMarkdown(plugin: PluginReferenceEntry): string { + return `[@${plugin.name}](plugin://${plugin.pluginId})`; +} + +export function isPluginReferenceValue(value: string): boolean { + return pluginReferenceValuePattern.test(value); +} + +export function pluginReferenceSuggestions( + plugins: PluginReferenceEntry[], + query: string, + limit: number +): AutocompleteItem[] { + const normalizedQuery = query.toLowerCase(); + return plugins + .filter((plugin) => ( + normalizedQuery.length === 0 + || plugin.name.toLowerCase().includes(normalizedQuery) + || plugin.pluginId.toLowerCase().includes(normalizedQuery) + )) + .sort((left, right) => { + const leftStarts = left.name.toLowerCase().startsWith(normalizedQuery); + const rightStarts = right.name.toLowerCase().startsWith(normalizedQuery); + return leftStarts === rightStarts ? left.pluginId.localeCompare(right.pluginId) : leftStarts ? -1 : 1; + }) + .slice(0, limit) + .map((plugin) => ({ + value: pluginReferenceMarkdown(plugin), + label: `@${plugin.name}`, + description: plugin.description + })); +} + +export function createRuntimePluginReferenceLister( + workspaceDirectory: string, + env: NodeJS.ProcessEnv = process.env, + argv: string[] = process.argv +): ListPluginReferences | undefined { + const runtimeEntry = argv[1]; + const directRuntime = runtimeEntry && basename(runtimeEntry) === "zcode.cjs" + ? { args: [runtimeEntry, "app-server"], command: process.execPath } + : undefined; + const launcherExecutable = env.ZCODE_APP_CLI_EXECUTABLE?.trim(); + const launcherEntry = env.ZCODE_APP_CLI_ENTRY?.trim(); + const transport = directRuntime ?? ( + launcherExecutable && launcherEntry + ? { args: [launcherEntry, "app-server"], command: launcherExecutable } + : undefined + ); + if (!transport) return undefined; + + return async () => await requestAppServer({ + method: pluginProtocolMethods.referenceCatalog, + params: { workspace: pluginWorkspace(workspaceDirectory) }, + transport: { + ...transport, + cwd: workspaceDirectory, + env + } + }); +} diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index f1373bf..d786581 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -64,6 +64,8 @@ export interface SkillSuggestionResult { export type ListSkills = () => Promise; +export type ListPluginReferences = () => Promise; + export interface TuiOptions { initialMode?: string; initialModel?: unknown; @@ -83,6 +85,7 @@ export interface TuiOptions { stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream; loadSessionTranscript?: () => Promise; + listPluginReferences?: ListPluginReferences; listWorkspacePathSuggestions?: ListWorkspacePathSuggestions; listSkills?: ListSkills; recallPreviousInput?: (skip: number) => Promise; diff --git a/packages/zcode-tui/src/workspace-autocomplete.ts b/packages/zcode-tui/src/workspace-autocomplete.ts index e9da9d2..236d44a 100644 --- a/packages/zcode-tui/src/workspace-autocomplete.ts +++ b/packages/zcode-tui/src/workspace-autocomplete.ts @@ -8,13 +8,20 @@ import { import { isRecord, + type ListPluginReferences, type ListSkills, type ListWorkspacePathSuggestions } from "./types.ts"; +import { + isPluginReferenceValue, + PluginReferenceCatalog, + pluginReferenceSuggestions +} from "./plugin-references.ts"; import { SkillCatalog } from "./skills.ts"; const workspaceSuggestionLimit = 50; const skillSuggestionLimit = 50; +const pluginSuggestionLimit = 20; const controlCharacterPattern = /[\u0000-\u001f\u007f]/u; const windowsDrivePattern = /^[a-zA-Z]:(?:\/|$)/u; @@ -31,11 +38,12 @@ interface SkillDollarPrefix { /** * Adds official-runtime workspace suggestions to pi-tui without replacing its - * slash-command, local path or completion-editing behavior. Also serves the - * `$`-prefix skill picker by delegating to the runtime `listSkills` callback. + * slash-command, local path or completion-editing behavior. `@` merges files + * with runtime-native Plugin references, while `$` selects a specific Skill. */ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { private readonly fallback: CombinedAutocompleteProvider; + private readonly plugins: PluginReferenceCatalog; private readonly skills: SkillCatalog; public readonly triggerCharacters: string[] = ["$"]; @@ -43,10 +51,14 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { commands: (AutocompleteItem | SlashCommand)[] | undefined, basePath: string, private readonly listWorkspacePathSuggestions?: ListWorkspacePathSuggestions, - skillSource?: ListSkills | SkillCatalog + skillSource?: ListSkills | SkillCatalog, + pluginSource?: ListPluginReferences | PluginReferenceCatalog ) { this.fallback = new CombinedAutocompleteProvider(commands, basePath, null); this.skills = skillSource instanceof SkillCatalog ? skillSource : new SkillCatalog(skillSource); + this.plugins = pluginSource instanceof PluginReferenceCatalog + ? pluginSource + : new PluginReferenceCatalog(pluginSource); } async getSuggestions( @@ -79,25 +91,36 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { } const atPrefix = extractWorkspaceAtPrefix(currentLine.slice(0, cursorCol)); - if (!atPrefix || !this.listWorkspacePathSuggestions) { + if (!atPrefix) { return await this.fallback.getSuggestions(lines, cursorLine, cursorCol, options); } - try { - const result: unknown = await this.listWorkspacePathSuggestions({ - token: atPrefix.callbackToken, - limit: workspaceSuggestionLimit, - abortSignal: options.signal - }); - if (options.signal.aborted) return null; + const pluginQuery = atPrefix.quoted || /[\\/]/u.test(atPrefix.callbackToken) + ? undefined + : atPrefix.callbackToken.slice(1); + const [workspaceItems, plugins] = await Promise.all([ + this.listWorkspacePathSuggestions + ? this.listWorkspacePathSuggestions({ + token: atPrefix.callbackToken, + limit: workspaceSuggestionLimit, + abortSignal: options.signal + }) + .then((result) => normalizeWorkspaceSuggestions(result, atPrefix.quoted)) + .catch(() => []) + : this.fallback.getSuggestions(lines, cursorLine, cursorCol, options) + .then((suggestions) => suggestions?.items ?? []) + .catch(() => []), + pluginQuery === undefined ? Promise.resolve([]) : this.plugins.list() + ]); + if (options.signal.aborted) return null; - const items = normalizeWorkspaceSuggestions(result, atPrefix.quoted); - return items.length > 0 ? { items, prefix: atPrefix.prefix } : null; - } catch { - // Completion is optional UI assistance; runtime search failures must not - // interrupt editing or prompt submission. - return null; - } + const items = [ + ...(pluginQuery === undefined + ? [] + : pluginReferenceSuggestions(plugins, pluginQuery, pluginSuggestionLimit)), + ...workspaceItems + ]; + return items.length > 0 ? { items, prefix: atPrefix.prefix } : null; } applyCompletion( @@ -107,9 +130,9 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string ): { lines: string[]; cursorLine: number; cursorCol: number } { - if (prefix.startsWith("$")) { - // Skill completions are simple identifiers; insert the value followed by a - // single trailing space so the user can keep typing their prompt. + if (prefix.startsWith("$") || isPluginReferenceValue(item.value)) { + // Skill and Plugin completions are complete tokens. Plugin values use the + // runtime's Markdown link protocol rather than a synthetic @name token. const currentLine = lines[cursorLine] ?? ""; const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); const afterCursor = currentLine.slice(cursorCol); diff --git a/scripts/build-release.ts b/scripts/build-release.ts index 3949ae6..42f7508 100644 --- a/scripts/build-release.ts +++ b/scripts/build-release.ts @@ -21,8 +21,8 @@ if (args.some((arg) => arg !== "--latest")) throw new Error(`Unknown argument: $ const latest = args.includes("--latest"); await run(["run", "typecheck"]); -await run(["test"]); await run(["run", latest ? "sync" : "sync:locked"]); +await run(["test"]); await run(["run", "check"]); await run(["run", "check:tui"]); await run(["scripts/check-package.ts"]); diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 4651a5c..4754dbe 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -79,10 +79,13 @@ export async function validatePackageTree(base = root): Promise { "bin/zcode.ts", "config.example.json", "package.json", + "src/app-server-client.ts", "src/command.ts", "src/darwin-oauth-callback.ts", "src/launcher.ts", "src/model-access.ts", + "src/plugin-cli.ts", + "src/plugin-protocol.ts", "src/zai-oauth.ts", "tsdown.config.ts", "vendor/extraction.json", @@ -125,6 +128,9 @@ export async function validatePackageTree(base = root): Promise { if (packageJson.dependencies?.zigpty !== undefined || packageJson.dependencies?.bun !== undefined) { throw new Error("The published package must not depend on a second PTY runtime or Bun."); } + if (packageJson.dependencies?.["playwright-core"] !== "1.59.1") { + throw new Error("The published package must pin the runtime-compatible playwright-core version."); + } const lock = parseRuntimeLock(JSON.parse(await readFile(join(base, "zcode-runtime.lock.json"), "utf8"))); const extraction = JSON.parse( diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index c636f72..48bbdcc 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -11,13 +12,30 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const runtime = join(root, "vendor", "zcode.cjs"); const tui = join(root, "vendor", "node_modules", "@zcode", "tui", "dist", "index.js"); const node = process.env.ZCODE_NODE || Bun.which("node"); +const require = createRequire(import.meta.url); if (!existsSync(runtime)) throw new Error("vendor/zcode.cjs is missing; run `bun run sync` first."); if (!existsSync(tui)) throw new Error("The local @zcode/tui adapter is missing; run `bun run sync` first."); if (!node) throw new Error("Node.js >=22.19 is required by the official ZCode runtime."); +const packageManifest = await Bun.file(join(root, "package.json")).json() as { + dependencies?: Record; +}; +const playwrightManifest = await Bun.file(require.resolve("playwright-core/package.json")).json() as { + version?: unknown; +}; +if (packageManifest.dependencies?.["playwright-core"] !== "1.59.1" + || playwrightManifest.version !== packageManifest.dependencies["playwright-core"]) { + throw new Error("The runtime-compatible playwright-core dependency is missing or has the wrong version."); +} + const runtimeSource = await Bun.file(runtime).text(); -if (runtimeSource.includes('"OAuth response is not valid JSON",{httpStatus:void 0}') +if (!runtimeSource.includes('"plugin://"') + || !runtimeSource.includes('return await import("playwright-core")') + || !runtimeSource.includes('pluginsReferenceCatalog:"plugins/referenceCatalog"') + || !runtimeSource.includes('pluginsMarketplaceAdd:"plugins/marketplace/add"') + || !runtimeSource.includes('pluginsInstall:"plugins/install"') + || runtimeSource.includes('"OAuth response is not valid JSON",{httpStatus:void 0}') || !runtimeSource.includes('ZCODE_CLI_OAUTH_CALLBACK_STDIN==="1"') || !runtimeSource.includes(".loadSessionTranscript=async()=>await(await") || !runtimeSource.includes(".readGoal=async()=>await(await") diff --git a/scripts/smoke-tui.ts b/scripts/smoke-tui.ts index 2d3ea9c..78e7449 100755 --- a/scripts/smoke-tui.ts +++ b/scripts/smoke-tui.ts @@ -168,6 +168,18 @@ try { || initialConfig.provider?.zai?.options?.apiKey !== undefined) { throw new Error("The launcher created an invalid initial config.json."); } + await sendAndWait( + "@bro", + "runtime Plugin suggestions", + /@browser-use[\s\S]*Plugin \| zcode-plugins-official \| 2 skills/i + ); + await sendAndWait( + "\r", + "runtime Plugin completion", + /\[@browser-use\]\(plugin:\/\/browser-use@zcode-plugins-official\)/i + ); + terminal.write("\x15"); + await Bun.sleep(50); await sendAndWait("$smoke", "runtime skill suggestions", /smoke-review[\s\S]*Review the runtime Skill bridge\./i); await sendAndWait("\r", "runtime skill completion", /\$smoke-review/i); terminal.write("\x15"); @@ -235,6 +247,9 @@ if (!/custom provider/i.test(plain)) { if (!/smoke-review[\s\S]*Review the runtime Skill bridge\./i.test(plain)) { throw new Error(`The runtime Skill picker was not rendered.\n${plain.slice(-4_000)}`); } +if (!/\[@browser-use\]\(plugin:\/\/browser-use@zcode-plugins-official\)/i.test(plain)) { + throw new Error(`The runtime Plugin reference was not completed.\n${plain.slice(-4_000)}`); +} if (!/Configured Z\.AI Coding Plan|已配置 Z\.AI Coding Plan/i.test(plain)) { throw new Error(`The masked API-key setup did not complete.\n${plain.slice(-4_000)}`); } diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 3a5a5ba..eb760d3 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -112,6 +112,28 @@ export function parseRuntimeLock(value: unknown): RuntimeLock { }; } +function compareAppVersions(left: string, right: string): number { + const leftParts = left.split(".").map(Number); + const rightParts = right.split(".").map(Number); + for (let index = 0; index < leftParts.length; index += 1) { + const difference = leftParts[index]! - rightParts[index]!; + if (difference !== 0) return difference; + } + return 0; +} + +export function selectRuntimeLock(candidate: RuntimeLock, current?: RuntimeLock): RuntimeLock { + if ( + current + && current.platform === candidate.platform + && current.arch === candidate.arch + && compareAppVersions(current.appVersion, candidate.appVersion) > 0 + ) { + return current; + } + return candidate; +} + export function manifestUrl(platform: SyncOptions["platform"], arch: string): string { if (platform === "darwin") return `${cdnRoot}/update/mac/${arch}/latest-mac.yml`; if (platform === "linux") return `${cdnRoot}/update/linux/${arch}/latest-linux.yml`; @@ -604,7 +626,7 @@ async function resolveSource(options: SyncOptions, temporaryDirectory: string): const artifact = chooseArtifact(manifest, options.platform); const artifactUrl = resolveArtifactUrl(url, artifact.url); if (manifest.version === undefined) throw new Error("The update manifest does not contain a version."); - const lock = parseRuntimeLock({ + const candidate = parseRuntimeLock({ schemaVersion: 1, appVersion: String(manifest.version), platform: options.platform, @@ -612,6 +634,16 @@ async function resolveSource(options: SyncOptions, temporaryDirectory: string): url: artifactUrl, sha512: artifact.sha512 }); + const currentLockPath = join(root, "zcode-runtime.lock.json"); + const current = existsSync(currentLockPath) + ? parseRuntimeLock(JSON.parse(await readFile(currentLockPath, "utf8"))) + : undefined; + const lock = selectRuntimeLock(candidate, current); + if (lock === current) { + console.log( + `Keeping locked runtime ${current.appVersion}; the ${options.platform}-${options.arch} manifest reports older ${candidate.appVersion}.` + ); + } return resolveLockedSource(lock, temporaryDirectory); } diff --git a/src/app-server-client.ts b/src/app-server-client.ts new file mode 100644 index 0000000..0d7fe02 --- /dev/null +++ b/src/app-server-client.ts @@ -0,0 +1,152 @@ +import { spawn } from "node:child_process"; +import type { Readable } from "node:stream"; + +const maximumOutputBytes = 16 * 1024 * 1024; +const forceTerminationDelayMilliseconds = 500; + +export interface AppServerTransport { + args: string[]; + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; +} + +export interface AppServerRequest { + method: string; + params: Record; + signal?: AbortSignal; + transport: AppServerTransport; +} + +interface AppServerEnvelope { + error?: { + code?: number; + data?: unknown; + message?: string; + }; + id?: unknown; + result?: unknown; +} + +export class AppServerRequestError extends Error { + constructor( + message: string, + public readonly code?: number, + public readonly data?: unknown + ) { + super(message); + this.name = "AppServerRequestError"; + } +} + +function cancellationError(): Error { + const error = new Error("App-server request cancelled."); + error.name = "AbortError"; + return error; +} + +async function readBounded(stream: Readable | null, onOverflow: () => void): Promise { + if (!stream) return ""; + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > maximumOutputBytes) { + onOverflow(); + throw new Error(`App-server output exceeded ${maximumOutputBytes} bytes.`); + } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function responseEnvelope(stdout: string): AppServerEnvelope | undefined { + for (const line of stdout.split(/\r?\n/u)) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const envelope = parsed as AppServerEnvelope; + if (envelope.id === 1) return envelope; + } + } catch { + // Ignore non-protocol stdout and continue looking for the response envelope. + } + } + return undefined; +} + +export async function requestAppServer(request: AppServerRequest): Promise { + if (request.signal?.aborted) throw cancellationError(); + + const child = spawn(request.transport.command, request.transport.args, { + cwd: request.transport.cwd, + env: request.transport.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + let launchError: Error | undefined; + let overflow = false; + let forceTerminationTimer: NodeJS.Timeout | undefined; + const exited = new Promise((resolve) => { + let settled = false; + const finish = (code: number) => { + if (settled) return; + settled = true; + resolve(code); + }; + child.once("error", (error) => { + launchError = error; + finish(1); + }); + child.once("close", (code) => finish(code ?? 1)); + }); + const terminateForOverflow = () => { + overflow = true; + child.kill("SIGKILL"); + }; + const onAbort = () => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + forceTerminationTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, forceTerminationDelayMilliseconds); + forceTerminationTimer.unref(); + }; + request.signal?.addEventListener("abort", onAbort, { once: true }); + if (request.signal?.aborted) onAbort(); + + child.stdin.on("error", () => {}); + child.stdin.end(`${JSON.stringify({ id: 1, method: request.method, params: request.params })}\n`); + + try { + const [code, stdout, stderr] = await Promise.all([ + exited, + readBounded(child.stdout, terminateForOverflow), + readBounded(child.stderr, terminateForOverflow) + ]); + if (request.signal?.aborted) throw cancellationError(); + if (overflow) throw new Error(`App-server output exceeded ${maximumOutputBytes} bytes.`); + if (launchError) throw launchError; + + const envelope = responseEnvelope(stdout); + if (envelope?.error) { + throw new AppServerRequestError( + envelope.error.message?.trim() || "App-server request failed.", + envelope.error.code, + envelope.error.data + ); + } + if (code !== 0) { + throw new Error(stderr.trim() || `App-server exited with status ${code}.`); + } + if (!envelope || !("result" in envelope)) { + throw new Error(stderr.trim() || "App-server did not return a response envelope."); + } + return envelope.result; + } finally { + if (forceTerminationTimer) clearTimeout(forceTerminationTimer); + request.signal?.removeEventListener("abort", onAbort); + } +} diff --git a/src/launcher.ts b/src/launcher.ts index f4cfcee..d8328b5 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -10,6 +10,8 @@ import { runZaiOAuthLogin, type OfficialLoginPayload } from "./zai-oauth.ts"; +import { requestAppServer } from "./app-server-client.ts"; +import { runPluginCommand } from "./plugin-cli.ts"; const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const packageManifestPath = join(packageRoot, "package.json"); @@ -17,7 +19,33 @@ const extractionMetadataPath = join(packageRoot, "vendor", "extraction.json"); const runtimePath = join(packageRoot, "vendor", "zcode.cjs"); const launcherPath = join(packageRoot, "bin", "zcode.js"); const defaultModelRetryMaxRetries = "5"; +const defaultBrowserUseArgument = "--browser-use=headless"; const versionArguments = new Set(["version", "--version", "-v"]); +const runtimeBooleanOptions = new Set([ + "--allow-main-worktree-yolo", + "--continue", + "--force", + "--force-mcs", + "--json", + "--no-browser", + "--no-color", + "--stdio", + "--target-replace", + "--verbose" +]); +const runtimeValueOptions = new Set([ + "--allowed-tools", + "--attach", + "--browser-executable", + "--cwd", + "--locale", + "--max-turns", + "--mode", + "--permission-mode", + "--resume", + "--settings" +]); +const runtimeVariadicOptions = new Set(["--disallowedTools", "--disallowed-tools"]); export function resolveModelRetryMaxRetries(env: NodeJS.ProcessEnv): string { return env.ZCODE_MODEL_RETRY_MAX_RETRIES?.trim() || defaultModelRetryMaxRetries; @@ -70,6 +98,80 @@ export function normalizeLoginArgs(args: string[]): { args: string[]; checkConfi return { args, checkConfiguredAccess: false }; } +function longOptionName(argument: string): string { + const separator = argument.indexOf("="); + return separator < 0 ? argument : argument.slice(0, separator); +} + +export function withDefaultBrowserUse(args: string[]): string[] { + let agentInvocation = false; + let command: string | undefined; + let invalid = false; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--") { + command ??= args[index + 1]; + break; + } + if (argument.startsWith("--")) { + const option = longOptionName(argument); + const inlineValue = option.length !== argument.length; + if (option === "--browser-use") return args; + if (option === "--help" || option === "--version") return args; + if (option === "--print") { + if (inlineValue) invalid = true; + else agentInvocation = true; + continue; + } + if (option === "--prompt" || option === "--target") { + agentInvocation = true; + if (!inlineValue) { + if (index + 1 >= args.length || args[index + 1]!.startsWith("-")) invalid = true; + else index += 1; + } + continue; + } + if (runtimeVariadicOptions.has(option)) { + if (!inlineValue) { + const firstValue = index + 1; + while (index + 1 < args.length && !args[index + 1]!.startsWith("-")) index += 1; + if (index < firstValue) invalid = true; + } + continue; + } + if (runtimeValueOptions.has(option)) { + if (!inlineValue) { + if (index + 1 >= args.length || args[index + 1]!.startsWith("-")) invalid = true; + else index += 1; + } + continue; + } + if (runtimeBooleanOptions.has(option) && !inlineValue) continue; + invalid = true; + continue; + } + if (argument.startsWith("-")) { + if (argument === "-h" || argument === "-v") return args; + if (argument === "-p" || argument.startsWith("-p")) { + agentInvocation = true; + if (argument === "-p") { + if (index + 1 >= args.length || args[index + 1]!.startsWith("-")) invalid = true; + else index += 1; + } + continue; + } + if (argument === "-c" || argument === "-f") continue; + invalid = true; + continue; + } + command ??= argument; + } + + if (invalid || (!agentInvocation && command !== undefined && command !== "tui")) return args; + return [defaultBrowserUseArgument, ...args]; +} + function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}): Record { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.ZCODE_CLI_OAUTH_CALLBACK_STDIN; @@ -184,6 +286,33 @@ export async function main(args: string[]): Promise { return 1; } + const node = resolveNodeExecutable(); + const pluginAbortController = new AbortController(); + const cancelPluginCommand = () => pluginAbortController.abort(); + process.once("SIGINT", cancelPluginCommand); + process.once("SIGTERM", cancelPluginCommand); + let pluginCommand: number | undefined; + try { + pluginCommand = await runPluginCommand(args, { + request: async ({ method, params, signal, workingDirectory }) => await requestAppServer({ + method, + params, + signal: signal ?? pluginAbortController.signal, + transport: { + args: [runtimePath, "app-server"], + command: node, + cwd: workingDirectory, + env: runtimeEnvironment() + } + }), + signal: pluginAbortController.signal + }); + } finally { + process.off("SIGINT", cancelPluginCommand); + process.off("SIGTERM", cancelPluginCommand); + } + if (pluginCommand !== undefined) return pluginCommand; + const login = normalizeLoginArgs(args); const zaiOAuth = classifyZaiOAuthInvocation(args); if (login.checkConfiguredAccess) { @@ -198,9 +327,6 @@ export async function main(args: string[]): Promise { } } - const node = resolveNodeExecutable(); - - if (zaiOAuth) { const abortController = new AbortController(); const cancel = () => abortController.abort(new Error("Login cancelled.")); @@ -228,7 +354,7 @@ export async function main(args: string[]): Promise { } try { - return await runRuntime(node, login.args); + return await runRuntime(node, withDefaultBrowserUse(login.args)); } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); return 1; diff --git a/src/plugin-cli.ts b/src/plugin-cli.ts new file mode 100644 index 0000000..e4bf7f3 --- /dev/null +++ b/src/plugin-cli.ts @@ -0,0 +1,588 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import type { Readable, Writable } from "node:stream"; +import { parseArgs } from "node:util"; + +import { pluginProtocolMethods, pluginWorkspace } from "./plugin-protocol.ts"; + +const coordinateSegmentPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +const managedActions = new Set([ + "configure", + "describe", + "discover", + "help", + "install", + "marketplace", + "overview", + "restore", + "update", + "validate" +]); + +const pluginUsage = `Usage: + zcode plugins discover [--json] + zcode plugins overview [--json] + zcode plugins marketplace list + zcode plugins marketplace add [--dry-run] [--yes] + zcode plugins marketplace remove [--yes] + zcode plugins marketplace update [marketplace] + zcode plugins install @ [--scope user|workspace] [--dry-run] [--yes] + zcode plugins update [plugin-id] [--marketplace ] + zcode plugins describe @ + zcode plugins validate @ + zcode plugins validate --source + zcode plugins configure --options-file [--dry-run] + zcode plugins restore + +Existing list, enable, disable, and uninstall commands are handled by the ZCode runtime.`; + +interface ParsedPluginCommand { + action: string; + baseDirectory: string; + dryRun: boolean; + help: boolean; + json: boolean; + marketplace?: string; + optionsFile?: string; + positionals: string[]; + scope?: "user" | "workspace"; + source?: string; + yes: boolean; +} + +export interface PluginRequestInput { + method: string; + params: Record; + signal?: AbortSignal; + workingDirectory: string; +} + +export interface RunPluginCommandOptions { + confirm?: (question: string) => Promise; + cwd?: string; + request: (input: PluginRequestInput) => Promise; + signal?: AbortSignal; + stderr?: Writable & { isTTY?: boolean }; + stdin?: Readable & { isTTY?: boolean }; + stdout?: Writable & { isTTY?: boolean }; +} + +interface PluginCoordinate { + marketplace: string; + pluginName: string; +} + +type PluginSpecificOption = "dryRun" | "marketplace" | "optionsFile" | "scope" | "source" | "yes"; + +const leadingBooleanOptions = new Set(["--json", "--no-color", "--verbose"]); +const leadingValueOptions = new Set(["--cwd", "--locale"]); + +function managedAction(args: string[]): string | undefined { + let index = 0; + while (index < args.length) { + const argument = args[index]!; + if (leadingBooleanOptions.has(argument)) { + index += 1; + continue; + } + if (leadingValueOptions.has(argument)) { + index += 2; + continue; + } + if (["--cwd=", "--locale="].some((prefix) => argument.startsWith(prefix))) { + index += 1; + continue; + } + break; + } + if (args[index] !== "plugins") return undefined; + const action = args[index + 1]; + if ((!action || action.startsWith("-")) && args.includes("--help")) return "help"; + return action && managedActions.has(action) ? action : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function printable(value: unknown): string { + const raw = typeof value === "string" ? value : String(value ?? ""); + return Array.from(raw, (character) => { + const code = character.codePointAt(0) ?? 0; + return code < 0x20 || (code >= 0x7f && code <= 0x9f) ? "?" : character; + }).join(""); +} + +function printableError(value: unknown): string { + const raw = value instanceof Error ? value.message : String(value); + return raw.split(/\r\n?|\n/u).map((line) => printable(line)).join("\n"); +} + +function parseCoordinate(value: string): PluginCoordinate { + const separator = value.indexOf("@"); + const pluginName = separator < 0 ? value : value.slice(0, separator); + const marketplace = separator < 0 ? undefined : value.slice(separator + 1); + if ( + !marketplace + || separator !== value.lastIndexOf("@") + || !coordinateSegmentPattern.test(pluginName) + || !coordinateSegmentPattern.test(marketplace) + ) { + throw new Error(`Invalid plugin coordinate: ${value}. Expected @.`); + } + return { marketplace, pluginName }; +} + +function parsePluginCommand(args: string[], cwd: string): ParsedPluginCommand | undefined { + const expectedAction = managedAction(args); + if (!expectedAction) return undefined; + const parsed = parseArgs({ + allowPositionals: true, + args, + options: { + cwd: { type: "string" }, + "dry-run": { type: "boolean" }, + help: { short: "h", type: "boolean" }, + json: { type: "boolean" }, + locale: { type: "string" }, + marketplace: { type: "string" }, + "no-color": { type: "boolean" }, + "options-file": { type: "string" }, + scope: { type: "string" }, + source: { type: "string" }, + verbose: { type: "boolean" }, + yes: { short: "y", type: "boolean" } + }, + strict: true + }); + + if (parsed.positionals[0] !== "plugins") return undefined; + const action = parsed.positionals[1] ?? expectedAction; + + const scope = parsed.values.scope; + if (scope !== undefined && scope !== "user" && scope !== "workspace") { + throw new Error(`Unsupported plugin scope: ${scope}. Expected user or workspace.`); + } + return { + action, + baseDirectory: resolve(cwd, text(parsed.values.cwd) ?? "."), + dryRun: parsed.values["dry-run"] === true, + help: parsed.values.help === true, + json: parsed.values.json === true, + marketplace: text(parsed.values.marketplace), + optionsFile: text(parsed.values["options-file"]), + positionals: parsed.positionals.slice(2), + scope, + source: text(parsed.values.source), + yes: parsed.values.yes === true + }; +} + +function assertSupportedOptions( + command: ParsedPluginCommand, + supported: PluginSpecificOption[], + usage: string +): void { + const allowed = new Set(supported); + const used: Array<[PluginSpecificOption, boolean]> = [ + ["dryRun", command.dryRun], + ["marketplace", command.marketplace !== undefined], + ["optionsFile", command.optionsFile !== undefined], + ["scope", command.scope !== undefined], + ["source", command.source !== undefined], + ["yes", command.yes] + ]; + const unsupported = used.filter(([name, present]) => present && !allowed.has(name)); + if (unsupported.length === 0) return; + + const displayNames: Record = { + dryRun: "--dry-run", + marketplace: "--marketplace", + optionsFile: "--options-file", + scope: "--scope", + source: "--source", + yes: "--yes" + }; + const options = unsupported.map(([name]) => displayNames[name]).join(", "); + throw new Error(`${options} ${unsupported.length === 1 ? "is" : "are"} not supported.\n${usage}`); +} + +function writeJson(output: Writable, value: unknown): void { + output.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function diagnostics(value: unknown): string[] { + if (!isRecord(value) || !Array.isArray(value.diagnostics)) return []; + return value.diagnostics.flatMap((entry) => { + if (!isRecord(entry)) return []; + const message = text(entry.message); + return message ? [`${printable(text(entry.severity) ?? "warning")}: ${printable(message)}`] : []; + }); +} + +function hasErrorDiagnostics(value: unknown): boolean { + return isRecord(value) + && Array.isArray(value.diagnostics) + && value.diagnostics.some((entry) => isRecord(entry) && entry.severity === "error"); +} + +function resultFailed(value: unknown): boolean { + return hasErrorDiagnostics(value) || (isRecord(value) && value.ok === false); +} + +function listSection( + output: Writable, + title: string, + values: unknown, + format: (item: Record) => string +): void { + output.write(`${title}:\n`); + const items = Array.isArray(values) ? values.filter(isRecord) : []; + if (items.length === 0) { + output.write(" (none)\n"); + return; + } + for (const item of items) output.write(` - ${format(item)}\n`); +} + +function renderOverview(output: Writable, result: unknown, marketplacesOnly = false): void { + const overview = isRecord(result) ? result : {}; + listSection(output, "Marketplaces", overview.marketplaces, (item) => { + const count = typeof item.pluginCount === "number" ? ` (${item.pluginCount} plugins)` : ""; + return `${printable(item.id)}${count}${item.isOfficial === true ? " [official]" : ""}`; + }); + if (marketplacesOnly) return; + listSection(output, "Available plugins", overview.availablePlugins, (item) => { + const version = text(item.version) ? ` v${printable(item.version)}` : ""; + return `${printable(item.name)}@${printable(item.marketplace)}${version}`; + }); + listSection(output, "Installed marketplace plugins", overview.installedPlugins, (item) => ( + `${printable(item.id)}${item.enabled === false ? " [disabled]" : " [enabled]"}` + )); + listSection(output, "Restorable built-ins", overview.restorableBuiltins, (item) => printable(item.id)); + for (const diagnostic of diagnostics(overview)) output.write(`Diagnostic: ${diagnostic}\n`); +} + +function componentSummary(result: unknown): string[] { + if (!isRecord(result) || !Array.isArray(result.components)) return []; + return result.components.flatMap((component) => { + if (!isRecord(component) || !text(component.kind) || !Array.isArray(component.items)) return []; + const names = component.items.flatMap((item) => isRecord(item) && text(item.name) ? [printable(item.name)] : []); + return names.length > 0 ? [`${printable(component.kind)}: ${names.join(", ")}`] : []; + }); +} + +function renderInstallPreview( + output: Writable, + coordinate: PluginCoordinate, + description: unknown, + plan?: unknown +): void { + output.write(`Plugin: ${coordinate.pluginName}@${coordinate.marketplace}\n`); + if (isRecord(description) && isRecord(description.metadata)) { + const metadata = description.metadata; + const details = [ + text(metadata.version) && `v${printable(metadata.version)}`, + text(metadata.author) && printable(metadata.author) + ].filter(Boolean); + if (details.length > 0) output.write(`Metadata: ${details.join(" | ")}\n`); + } + const components = componentSummary(description); + output.write(`Components: ${components.length > 0 ? components.join("; ") : "none declared"}\n`); + if (isRecord(plan) && Array.isArray(plan.dependencyClosure)) { + const closure = plan.dependencyClosure.map(printable); + output.write(`Dependencies: ${closure.length > 0 ? closure.join(", ") : "none"}\n`); + } + for (const diagnostic of [...diagnostics(description), ...diagnostics(plan)]) { + output.write(`Diagnostic: ${diagnostic}\n`); + } +} + +function renderMutation( + output: Writable, + result: unknown, + labels: { failure: string; success: string }, + changesApply = true +): void { + const failed = resultFailed(result); + output.write(`${failed ? labels.failure : labels.success}\n`); + if (isRecord(result)) { + const installed = Array.isArray(result.installedPlugins) ? result.installedPlugins.filter(isRecord) : []; + for (const plugin of installed) output.write(` - ${printable(plugin.id)}\n`); + if (isRecord(result.marketplace)) output.write(` - ${printable(result.marketplace.id)}\n`); + if (text(result.pluginId)) output.write(` - ${printable(result.pluginId)}\n`); + } + for (const diagnostic of diagnostics(result)) output.write(`Diagnostic: ${diagnostic}\n`); + if (changesApply && !failed) output.write("Plugin capability changes apply to new sessions.\n"); +} + +async function defaultConfirm( + question: string, + input: Readable & { isTTY?: boolean }, + output: Writable & { isTTY?: boolean }, + signal?: AbortSignal +): Promise { + if (!input.isTTY || !output.isTTY) return false; + const readline = createInterface({ input, output }); + try { + const answer = await readline.question(`${question} [y/N] `, { signal }); + return /^(?:y|yes)$/iu.test(answer.trim()); + } finally { + readline.close(); + } +} + +async function readOptionsFile(path: string): Promise> { + const parsed: unknown = JSON.parse(await readFile(path, "utf8")); + if (!isRecord(parsed)) throw new Error("Plugin options file must contain a JSON object."); + return parsed; +} + +export async function runPluginCommand( + args: string[], + options: RunPluginCommandOptions +): Promise { + let command: ParsedPluginCommand | undefined; + try { + command = parsePluginCommand(args, options.cwd ?? process.cwd()); + } catch (error) { + (options.stderr ?? process.stderr).write(`Error: ${printableError(error)}\n`); + return 1; + } + if (!command) return undefined; + + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const stdin = options.stdin ?? process.stdin; + const confirm = options.confirm ?? ( + (question: string) => defaultConfirm(question, stdin, stderr, options.signal) + ); + const workspace = pluginWorkspace(command.baseDirectory); + const call = async (method: string, params: Record) => await options.request({ + method, + params: { workspace, ...params }, + signal: options.signal, + workingDirectory: command!.baseDirectory + }); + const ensureCatalog = async () => { + const result = await call(pluginProtocolMethods.overview, {}); + if (hasErrorDiagnostics(result)) { + throw new Error(`Plugin catalog is unavailable: ${diagnostics(result).join("; ")}`); + } + }; + const print = (value: unknown, human: () => void) => command!.json ? writeJson(stdout, value) : human(); + + if (command.help || command.action === "help") { + stdout.write(`${pluginUsage}\n`); + return 0; + } + + try { + if (command.action === "discover" || command.action === "overview") { + const usage = `Usage: zcode plugins ${command.action}`; + assertSupportedOptions(command, [], usage); + if (command.positionals.length > 0) throw new Error(usage); + const result = await call(pluginProtocolMethods.overview, {}); + print(result, () => renderOverview(stdout, result)); + return hasErrorDiagnostics(result) ? 1 : 0; + } + + if (command.action === "marketplace") { + const [operation, value, ...extra] = command.positionals; + if (!operation || extra.length > 0) throw new Error(pluginUsage); + if (operation === "list") { + const usage = "Usage: zcode plugins marketplace list"; + assertSupportedOptions(command, [], usage); + if (value) throw new Error(usage); + const result = await call(pluginProtocolMethods.overview, {}); + print(result, () => renderOverview(stdout, result, true)); + return hasErrorDiagnostics(result) ? 1 : 0; + } + if (operation === "add") { + const usage = "Usage: zcode plugins marketplace add [--dry-run] [--yes]"; + assertSupportedOptions(command, ["dryRun", "yes"], usage); + if (!value) throw new Error(usage); + const preview = await call(pluginProtocolMethods.marketplaceAdd, { source: value, dryRun: true }); + if (command.dryRun) { + print(preview, () => renderMutation(stdout, preview, { + failure: "Marketplace validation failed.", + success: "Marketplace validation succeeded." + }, false)); + return resultFailed(preview) ? 1 : 0; + } + if (resultFailed(preview)) { + print(preview, () => renderMutation(stdout, preview, { + failure: "Marketplace validation failed.", + success: "Marketplace validation succeeded." + }, false)); + return 1; + } + if (!command.json) { + renderMutation(stdout, preview, { + failure: "Marketplace validation failed.", + success: "Marketplace validation succeeded." + }, false); + } + if (!command.yes && !await confirm(`Add marketplace from ${printable(value)}?`)) { + throw new Error("Marketplace addition cancelled. Use --yes for non-interactive use."); + } + const result = await call(pluginProtocolMethods.marketplaceAdd, { source: value }); + print(result, () => renderMutation(stdout, result, { + failure: "Marketplace addition failed.", + success: "Marketplace added." + }, false)); + return resultFailed(result) ? 1 : 0; + } + if (operation === "remove") { + const usage = "Usage: zcode plugins marketplace remove [--yes]"; + assertSupportedOptions(command, ["yes"], usage); + if (!value) throw new Error(usage); + if (!command.yes && !await confirm(`Remove marketplace ${printable(value)}?`)) { + throw new Error("Marketplace removal cancelled. Use --yes for non-interactive use."); + } + const result = await call(pluginProtocolMethods.marketplaceRemove, { marketplace: value }); + print(result, () => renderMutation(stdout, result, { + failure: "Marketplace removal failed.", + success: "Marketplace removed." + }, false)); + return resultFailed(result) ? 1 : 0; + } + if (operation === "update") { + assertSupportedOptions(command, [], "Usage: zcode plugins marketplace update [marketplace]"); + const result = await call(pluginProtocolMethods.marketplaceUpdate, value ? { marketplace: value } : {}); + print(result, () => renderMutation(stdout, result, { + failure: "Marketplace index update failed.", + success: "Marketplace index updated." + }, false)); + return resultFailed(result) ? 1 : 0; + } + throw new Error(`Unknown marketplace command: ${operation}\n${pluginUsage}`); + } + + if (command.action === "install") { + const [target, ...extra] = command.positionals; + const usage = "Usage: zcode plugins install @ [--scope user|workspace] [--dry-run] [--yes]"; + assertSupportedOptions(command, ["dryRun", "scope", "yes"], usage); + if (!target || extra.length > 0) throw new Error(usage); + const coordinate = parseCoordinate(target); + await ensureCatalog(); + const baseParams = { + marketplace: coordinate.marketplace, + pluginName: coordinate.pluginName, + ...(command.scope ? { scope: command.scope } : {}) + }; + const description = await call(pluginProtocolMethods.describe, { ...coordinate }); + const plan = await call(pluginProtocolMethods.install, { ...baseParams, dryRun: true }); + if (command.dryRun) { + const preview = { description, plan }; + print(preview, () => renderInstallPreview(stdout, coordinate, description, plan)); + return resultFailed(description) || resultFailed(plan) ? 1 : 0; + } + if (resultFailed(description) || resultFailed(plan)) { + const preview = { description, plan }; + print(preview, () => renderInstallPreview(stdout, coordinate, description, plan)); + return 1; + } + if (!command.json) renderInstallPreview(stdout, coordinate, description, plan); + if (!command.yes && !await confirm(`Install ${coordinate.pluginName}@${coordinate.marketplace}?`)) { + throw new Error("Plugin installation cancelled. Use --yes for non-interactive use."); + } + const result = await call(pluginProtocolMethods.install, baseParams); + print(result, () => renderMutation(stdout, result, { + failure: "Plugin installation failed.", + success: "Plugin installed." + })); + return resultFailed(result) ? 1 : 0; + } + + if (command.action === "describe") { + const [target, ...extra] = command.positionals; + const usage = "Usage: zcode plugins describe @"; + assertSupportedOptions(command, [], usage); + if (!target || extra.length > 0) throw new Error(usage); + const coordinate = parseCoordinate(target); + await ensureCatalog(); + const result = await call(pluginProtocolMethods.describe, { ...coordinate }); + print(result, () => renderInstallPreview(stdout, coordinate, result)); + return resultFailed(result) ? 1 : 0; + } + + if (command.action === "validate") { + const [target, ...extra] = command.positionals; + const usage = "Usage: zcode plugins validate @ | --source "; + assertSupportedOptions(command, ["source"], usage); + if (extra.length > 0 || (!target && !command.source) || (target && command.source)) { + throw new Error(usage); + } + const params = target ? { ...parseCoordinate(target) } : { source: command.source! }; + if (target) await ensureCatalog(); + const result = await call(pluginProtocolMethods.validate, params); + print(result, () => renderMutation(stdout, result, { + failure: "Plugin validation failed.", + success: "Plugin validation completed." + }, false)); + return resultFailed(result) ? 1 : 0; + } + + if (command.action === "update") { + const [pluginId, ...extra] = command.positionals; + const usage = "Usage: zcode plugins update [plugin-id] [--marketplace ]"; + assertSupportedOptions(command, ["marketplace"], usage); + if (extra.length > 0 || (pluginId && command.marketplace)) { + throw new Error(usage); + } + const result = await call(pluginProtocolMethods.update, { + ...(pluginId ? { pluginId } : {}), + ...(command.marketplace ? { marketplace: command.marketplace } : {}) + }); + print(result, () => renderMutation(stdout, result, { + failure: "Plugin update failed.", + success: "Plugin update completed." + })); + return resultFailed(result) ? 1 : 0; + } + + if (command.action === "configure") { + const [pluginId, ...extra] = command.positionals; + const usage = "Usage: zcode plugins configure --options-file [--dry-run]"; + assertSupportedOptions(command, ["dryRun", "optionsFile"], usage); + if (!pluginId || extra.length > 0 || !command.optionsFile) { + throw new Error(usage); + } + const configuredOptions = await readOptionsFile(resolve(command.baseDirectory, command.optionsFile)); + const result = await call(pluginProtocolMethods.configure, { + pluginId, + options: configuredOptions, + ...(command.dryRun ? { dryRun: true } : {}) + }); + print(result, () => renderMutation(stdout, result, { + failure: "Plugin configuration failed.", + success: command.dryRun ? "Plugin configuration is valid." : "Plugin configured." + }, !command.dryRun)); + return resultFailed(result) ? 1 : 0; + } + + if (command.action === "restore") { + const [pluginId, ...extra] = command.positionals; + const usage = "Usage: zcode plugins restore "; + assertSupportedOptions(command, [], usage); + if (!pluginId || extra.length > 0) throw new Error(usage); + const result = await call(pluginProtocolMethods.restoreBuiltin, { pluginId }); + print(result, () => renderMutation(stdout, result, { + failure: "Built-in plugin restore failed.", + success: "Built-in plugin restored." + })); + return resultFailed(result) ? 1 : 0; + } + + throw new Error(pluginUsage); + } catch (error) { + stderr.write(`Error: ${printableError(error)}\n`); + return error instanceof Error && error.name === "AbortError" ? 130 : 1; + } +} diff --git a/src/plugin-protocol.ts b/src/plugin-protocol.ts new file mode 100644 index 0000000..d89f668 --- /dev/null +++ b/src/plugin-protocol.ts @@ -0,0 +1,42 @@ +import { resolve } from "node:path"; + +export const pluginProtocolMethods = { + configure: "plugins/configure", + describe: "plugins/describe", + install: "plugins/install", + marketplaceAdd: "plugins/marketplace/add", + marketplaceRemove: "plugins/marketplace/remove", + marketplaceUpdate: "plugins/marketplace/update", + overview: "plugins/overview", + referenceCatalog: "plugins/referenceCatalog", + restoreBuiltin: "plugins/restoreBuiltin", + update: "plugins/update", + validate: "plugins/validate" +} as const; + +export interface PluginWorkspace { + workspaceKey: string; + workspacePath: string; +} + +export function pluginWorkspace(path: string): PluginWorkspace { + const workspacePath = resolve(path); + return { workspaceKey: workspacePath, workspacePath }; +} + +export interface PluginReferenceSummary { + conflictingPluginIds: string[]; + enabled: boolean; + icon?: string; + marketplace: string; + mcpServerNames: string[]; + name: string; + pluginId: string; + skillQualifiedNames: string[]; + subagentNames: string[]; +} + +export interface PluginReferenceCatalogResult { + authority: "session" | "workspace"; + plugins: PluginReferenceSummary[]; +} diff --git a/test/app-server-client.test.ts b/test/app-server-client.test.ts new file mode 100644 index 0000000..e0c8097 --- /dev/null +++ b/test/app-server-client.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; + +import { AppServerRequestError, requestAppServer } from "../src/app-server-client.ts"; + +const node = Bun.which("node"); + +function transport(script: string) { + if (!node) throw new Error("Node.js is required for app-server client tests."); + return { + args: ["--input-type=module", "--eval", script], + command: node, + cwd: process.cwd(), + env: process.env + }; +} + +describe("app-server NDJSON client", () => { + test("returns the matching response envelope", async () => { + const script = ` + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", chunk => input += chunk); + process.stdin.on("end", () => { + const request = JSON.parse(input.trim()); + console.log(JSON.stringify({ id: request.id, result: { method: request.method, params: request.params } })); + }); + `; + + expect(await requestAppServer({ + method: "plugins/overview", + params: { workspace: { workspacePath: "/tmp/project", workspaceKey: "/tmp/project" } }, + transport: transport(script) + })).toEqual({ + method: "plugins/overview", + params: { workspace: { workspacePath: "/tmp/project", workspaceKey: "/tmp/project" } } + }); + }); + + test("surfaces protocol errors with code and data", async () => { + const script = ` + process.stdin.resume(); + process.stdin.on("end", () => console.log(JSON.stringify({ + id: 1, + error: { code: -32602, message: "Invalid params", data: { field: "source" } } + }))); + `; + + try { + await requestAppServer({ method: "plugins/install", params: {}, transport: transport(script) }); + throw new Error("Expected request to fail."); + } catch (error) { + expect(error).toBeInstanceOf(AppServerRequestError); + expect(error).toMatchObject({ code: -32602, data: { field: "source" } }); + expect((error as Error).message).toBe("Invalid params"); + } + }); + + test("rejects missing envelopes and honours cancellation", async () => { + await expect(requestAppServer({ + method: "plugins/list", + params: {}, + transport: transport("process.stdin.resume(); process.stdin.on('end', () => console.log('not-json')); ") + })).rejects.toThrow(/did not return a response envelope/u); + + const controller = new AbortController(); + controller.abort(); + await expect(requestAppServer({ + method: "plugins/list", + params: {}, + signal: controller.signal, + transport: transport("") + })).rejects.toMatchObject({ name: "AbortError" }); + }); + + test("finishes cancellation when the app-server ignores SIGTERM", async () => { + const controller = new AbortController(); + const pending = requestAppServer({ + method: "plugins/list", + params: {}, + signal: controller.signal, + transport: transport(` + process.on("SIGTERM", () => {}); + process.stdin.resume(); + setInterval(() => {}, 1000); + `) + }); + setTimeout(() => controller.abort(), 150); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + }, 3_000); +}); diff --git a/test/launcher-runtime.test.ts b/test/launcher-runtime.test.ts new file mode 100644 index 0000000..70f98b5 --- /dev/null +++ b/test/launcher-runtime.test.ts @@ -0,0 +1,185 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { readRuntimeVersion } from "../src/launcher.ts"; + +let home = ""; +const node = Bun.which("node"); +const root = fileURLToPath(new URL("..", import.meta.url)); + +beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), "zcode-launcher-runtime-")); +}); + +afterAll(async () => { + if (home) await rm(home, { recursive: true, force: true }); +}); + +async function run(args: string[], input = "") { + if (!node) throw new Error("Node.js is required for launcher/runtime integration tests."); + const child = Bun.spawn([process.execPath, "bin/zcode.ts", ...args], { + cwd: root, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + ZCODE_NODE: node + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe" + }); + child.stdin.write(input); + child.stdin.end(); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text() + ]); + return { code, stdout, stderr }; +} + +describe("launcher/runtime integration", () => { + test("keeps non-agent runtime subcommands usable", async () => { + const doctor = await run(["doctor", "--json"]); + expect(doctor.code).toBe(0); + expect(JSON.parse(doctor.stdout)).toMatchObject({ cli: { version: readRuntimeVersion() } }); + + const plugins = await run(["plugins", "list", "--json"]); + expect(plugins.code).toBe(0); + expect(JSON.parse(plugins.stdout).plugins).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "browser-use", enabled: true }) + ])); + + const skills = await run(["skills", "list", "--json"]); + expect(skills.code).toBe(0); + expect(JSON.parse(skills.stdout).skills).toEqual(expect.arrayContaining([ + expect.objectContaining({ qualifiedName: "browser-use:control-browser" }) + ])); + }, 30_000); + + test("passes app-server through unchanged and exposes Plugin references", async () => { + const workspacePath = root.replace(/\/$/u, ""); + const request = { + id: 1, + method: "plugins/referenceCatalog", + params: { + workspace: { workspacePath, workspaceKey: workspacePath } + } + }; + const result = await run(["app-server"], `${JSON.stringify(request)}\n`); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + id: 1, + result: { + plugins: expect.arrayContaining([ + expect.objectContaining({ + pluginId: "browser-use@zcode-plugins-official", + skillQualifiedNames: expect.arrayContaining(["browser-use:control-browser"]) + }) + ]) + } + }); + }, 30_000); + + test("resolves a real Plugin install dry-run without changing storage", async () => { + const result = await run([ + "plugins", + "install", + "browser-use@zcode-plugins-official", + "--dry-run", + "--json" + ]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + description: { + components: expect.arrayContaining([ + expect.objectContaining({ + kind: "skill", + items: expect.arrayContaining([ + expect.objectContaining({ name: "control-browser" }) + ]) + }) + ]) + }, + plan: { + dependencyClosure: [], + diagnostics: [] + } + }); + }, 30_000); + + test("adds a local marketplace and installs its Plugin end to end", async () => { + const marketplace = join(home, "fixture-marketplace"); + const plugin = join(marketplace, "plugin"); + await mkdir(join(plugin, ".zcode-plugin"), { recursive: true }); + await mkdir(join(plugin, "skills", "smoke-skill"), { recursive: true }); + await writeFile(join(marketplace, "marketplace.json"), `${JSON.stringify({ + name: "cli-smoke-marketplace", + pluginRoot: ".", + plugins: [{ + description: "CLI smoke plugin", + name: "cli-smoke-plugin", + source: "./plugin", + version: "1.0.0" + }] + }, null, 2)}\n`); + await writeFile(join(plugin, ".zcode-plugin", "plugin.json"), `${JSON.stringify({ + description: "CLI smoke plugin", + name: "cli-smoke-plugin", + skills: "skills", + version: "1.0.0" + }, null, 2)}\n`); + await writeFile(join(plugin, "skills", "smoke-skill", "SKILL.md"), [ + "---", + "name: smoke-skill", + "description: Verify marketplace installation.", + "---", + "", + "Verify installation.", + "" + ].join("\n")); + + const added = await run(["plugins", "marketplace", "add", marketplace, "--yes", "--json"]); + expect(added.code).toBe(0); + expect(JSON.parse(added.stdout)).toMatchObject({ + marketplace: { id: "cli-smoke-marketplace", pluginCount: 1 }, + diagnostics: [] + }); + + const installed = await run([ + "plugins", + "install", + "cli-smoke-plugin@cli-smoke-marketplace", + "--yes", + "--json" + ]); + expect(installed.code).toBe(0); + expect(JSON.parse(installed.stdout)).toMatchObject({ + installedPlugins: expect.arrayContaining([ + expect.objectContaining({ + enabled: true, + id: "cli-smoke-plugin@cli-smoke-marketplace" + }) + ]), + diagnostics: [] + }); + + const plugins = await run(["plugins", "list", "--json"]); + expect(JSON.parse(plugins.stdout).plugins).toEqual(expect.arrayContaining([ + expect.objectContaining({ + enabled: true, + id: "cli-smoke-plugin@cli-smoke-marketplace", + skillCount: 1 + }) + ])); + + const skills = await run(["skills", "list", "--json"]); + expect(JSON.parse(skills.stdout).skills).toEqual(expect.arrayContaining([ + expect.objectContaining({ qualifiedName: "cli-smoke-plugin:smoke-skill" }) + ])); + }, 30_000); +}); diff --git a/test/launcher.test.ts b/test/launcher.test.ts index a15b21e..c186182 100644 --- a/test/launcher.test.ts +++ b/test/launcher.test.ts @@ -10,7 +10,8 @@ import { normalizeLoginArgs, readDistributionVersion, readRuntimeVersion, - resolveModelRetryMaxRetries + resolveModelRetryMaxRetries, + withDefaultBrowserUse } from "../src/launcher.ts"; import { classifyZaiOAuthInvocation } from "../src/zai-oauth.ts"; @@ -70,6 +71,80 @@ describe("launcher routing", () => { }); }); + test("enables Browser Use only for agent-producing runtime invocations", () => { + expect(withDefaultBrowserUse([])).toEqual(["--browser-use=headless"]); + expect(withDefaultBrowserUse(["tui"])).toEqual(["--browser-use=headless", "tui"]); + expect(withDefaultBrowserUse(["--cwd", "/tmp/project", "--continue"])).toEqual([ + "--browser-use=headless", + "--cwd", + "/tmp/project", + "--continue" + ]); + expect(withDefaultBrowserUse(["--prompt", "inspect this page"])).toEqual([ + "--browser-use=headless", + "--prompt", + "inspect this page" + ]); + expect(withDefaultBrowserUse(["--target=verify the site"])).toEqual([ + "--browser-use=headless", + "--target=verify the site" + ]); + expect(withDefaultBrowserUse(["--print", "inspect this page"])).toEqual([ + "--browser-use=headless", + "--print", + "inspect this page" + ]); + expect(withDefaultBrowserUse([ + "--settings", + "custom.json", + "--permission-mode", + "plan", + "--max-turns", + "3", + "--allowed-tools", + "Skill", + "--prompt", + "inspect this page" + ])).toEqual([ + "--browser-use=headless", + "--settings", + "custom.json", + "--permission-mode", + "plan", + "--max-turns", + "3", + "--allowed-tools", + "Skill", + "--prompt", + "inspect this page" + ]); + expect(withDefaultBrowserUse(["--browser-executable", "/opt/chrome", "tui"])).toEqual([ + "--browser-use=headless", + "--browser-executable", + "/opt/chrome", + "tui" + ]); + }); + + test("preserves explicit Browser Use and never injects it into management commands", () => { + const explicit = ["--browser-use", "headless", "tui"]; + expect(withDefaultBrowserUse(explicit)).toBe(explicit); + for (const args of [ + ["plugins", "list", "--json"], + ["--settings", "custom.json", "plugins", "list"], + ["skills", "list"], + ["doctor"], + ["app-server"], + ["login"], + ["commands", "list"], + ["--help"], + ["--version"], + ["--unknown"] + ]) { + expect(withDefaultBrowserUse(args)).toBe(args); + } + }); + test("routes only the plain Z.AI login command through the Desktop OAuth bridge", () => { expect(classifyZaiOAuthInvocation(["login"])).toEqual({ json: false, diff --git a/test/plugin-cli.test.ts b/test/plugin-cli.test.ts new file mode 100644 index 0000000..07dccaf --- /dev/null +++ b/test/plugin-cli.test.ts @@ -0,0 +1,286 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; + +import { runPluginCommand, type PluginRequestInput } from "../src/plugin-cli.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { + recursive: true, + force: true + }))); +}); + +function output(): { stream: Writable & { isTTY?: boolean }; text: () => string } { + let value = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + value += String(chunk); + callback(); + } + }) as Writable & { isTTY?: boolean }; + return { stream, text: () => value }; +} + +function harness(responses: Record = {}) { + const stdout = output(); + const stderr = output(); + const calls: PluginRequestInput[] = []; + return { + calls, + options: { + confirm: async () => true, + cwd: "/workspace", + request: async (request: PluginRequestInput) => { + calls.push(request); + return responses[request.method] ?? {}; + }, + stderr: stderr.stream, + stdout: stdout.stream + }, + stderr, + stdout + }; +} + +describe("plugin CLI routing", () => { + test("delegates runtime-owned commands and prompt text", async () => { + const testHarness = harness(); + expect(await runPluginCommand(["plugins", "list", "--json"], testHarness.options)).toBeUndefined(); + expect(await runPluginCommand(["--prompt", "plugins", "install"], testHarness.options)).toBeUndefined(); + expect(testHarness.calls).toHaveLength(0); + }); + + test("shows extended plugin help and rejects unknown options locally", async () => { + const helpHarness = harness(); + expect(await runPluginCommand(["plugins", "--help"], helpHarness.options)).toBe(0); + expect(helpHarness.stdout.text()).toContain("zcode plugins marketplace add"); + + const invalidHarness = harness(); + expect(await runPluginCommand( + ["plugins", "install", "audit@example", "--unknown"], + invalidHarness.options + )).toBe(1); + expect(invalidHarness.stderr.text()).toContain("Unknown option"); + expect(invalidHarness.calls).toHaveLength(0); + }); + + test("lists marketplace discovery through the public protocol", async () => { + const overview = { + marketplaces: [{ id: "official", pluginCount: 2, isOfficial: true }], + availablePlugins: [], + installedPlugins: [], + restorableBuiltins: [] + }; + const testHarness = harness({ "plugins/overview": overview }); + + expect(await runPluginCommand(["--json", "plugins", "discover"], testHarness.options)).toBe(0); + expect(testHarness.calls).toHaveLength(1); + expect(testHarness.calls[0]).toMatchObject({ + method: "plugins/overview", + params: { + workspace: { workspacePath: "/workspace", workspaceKey: "/workspace" } + }, + workingDirectory: "/workspace" + }); + expect(JSON.parse(testHarness.stdout.text())).toEqual(overview); + }); + + test("previews plugin components and dependencies before installing", async () => { + const testHarness = harness({ + "plugins/describe": { + components: [{ kind: "skill", items: [{ name: "audit" }] }], + metadata: { author: "Example", version: "1.0.0" } + }, + "plugins/install": { + dependencyClosure: ["base@example"], + installedPlugins: [{ id: "audit@example" }] + } + }); + + expect(await runPluginCommand( + ["plugins", "install", "audit@example", "--scope", "workspace", "--yes"], + testHarness.options + )).toBe(0); + expect(testHarness.calls.map((call) => call.method)).toEqual([ + "plugins/overview", + "plugins/describe", + "plugins/install", + "plugins/install" + ]); + expect(testHarness.calls[2]?.params).toMatchObject({ + dryRun: true, + marketplace: "example", + pluginName: "audit", + scope: "workspace" + }); + expect(testHarness.calls[3]?.params).not.toHaveProperty("dryRun"); + expect(testHarness.stdout.text()).toContain("Components: skill: audit"); + expect(testHarness.stdout.text()).toContain("Dependencies: base@example"); + }); + + test("does not invent dependency information for describe", async () => { + const testHarness = harness({ + "plugins/describe": { + components: [{ kind: "skill", items: [{ name: "audit" }] }] + } + }); + + expect(await runPluginCommand( + ["plugins", "describe", "audit@example"], + testHarness.options + )).toBe(0); + expect(testHarness.stdout.text()).toContain("Components: skill: audit"); + expect(testHarness.stdout.text()).not.toContain("Dependencies:"); + }); + + test("does not mutate when installation confirmation is declined", async () => { + const testHarness = harness({ + "plugins/describe": { components: [] }, + "plugins/install": { dependencyClosure: [], installedPlugins: [] } + }); + testHarness.options.confirm = async () => false; + + expect(await runPluginCommand(["plugins", "install", "audit@example"], testHarness.options)).toBe(1); + expect(testHarness.calls.map((call) => call.method)).toEqual([ + "plugins/overview", + "plugins/describe", + "plugins/install" + ]); + expect(testHarness.stderr.text()).toContain("installation cancelled"); + }); + + test("validates a marketplace before adding it", async () => { + const testHarness = harness({ + "plugins/marketplace/add": { marketplace: { id: "example" }, diagnostics: [] } + }); + + expect(await runPluginCommand( + ["plugins", "marketplace", "add", "owner/repository", "--yes"], + testHarness.options + )).toBe(0); + expect(testHarness.calls.map((call) => call.params.dryRun)).toEqual([true, undefined]); + expect(testHarness.stdout.text()).not.toContain("capability changes apply"); + }); + + test("rejects ignored dry-run flags before destructive operations", async () => { + const testHarness = harness(); + + expect(await runPluginCommand([ + "plugins", + "marketplace", + "remove", + "example", + "--dry-run", + "--yes" + ], testHarness.options)).toBe(1); + expect(testHarness.stderr.text()).toContain("--dry-run is not supported"); + expect(testHarness.calls).toHaveLength(0); + }); + + test("keeps JSON stdout parseable while prompting on stderr", async () => { + const testHarness = harness({ + "plugins/marketplace/add": { marketplace: { id: "example" }, diagnostics: [] } + }); + const stdin = Readable.from(["yes\n"]) as Readable & { isTTY?: boolean }; + stdin.isTTY = true; + testHarness.stdout.stream.isTTY = true; + testHarness.stderr.stream.isTTY = true; + + expect(await runPluginCommand( + ["plugins", "marketplace", "add", "owner/repository", "--json"], + { + ...testHarness.options, + confirm: undefined, + stdin + } + )).toBe(0); + expect(JSON.parse(testHarness.stdout.text())).toMatchObject({ marketplace: { id: "example" } }); + expect(testHarness.stderr.text()).toContain("Add marketplace from owner/repository?"); + }); + + test("loads plugin configuration from a JSON file", async () => { + const directory = await mkdtemp(join(tmpdir(), "zcode-plugin-options-")); + temporaryDirectories.push(directory); + await writeFile(join(directory, "options.json"), JSON.stringify({ endpoint: "https://example.com" })); + const testHarness = harness({ "plugins/configure": { pluginId: "audit@example", diagnostics: [] } }); + testHarness.options.cwd = directory; + + expect(await runPluginCommand([ + "plugins", + "configure", + "audit@example", + "--options-file", + "options.json", + "--dry-run" + ], testHarness.options)).toBe(0); + expect(testHarness.calls[0]?.params).toMatchObject({ + dryRun: true, + options: { endpoint: "https://example.com" }, + pluginId: "audit@example" + }); + }); + + test("rejects malformed coordinates before calling the runtime", async () => { + const testHarness = harness(); + expect(await runPluginCommand( + ["plugins", "describe", "missing-marketplace"], + testHarness.options + )).toBe(1); + expect(testHarness.calls).toHaveLength(0); + expect(testHarness.stderr.text()).toContain("Expected @"); + }); + + test("removes terminal control characters from errors", async () => { + const testHarness = harness(); + expect(await runPluginCommand( + ["plugins", "describe", "bad\u001b[31m@example"], + testHarness.options + )).toBe(1); + expect(testHarness.stderr.text()).not.toContain("\u001b"); + expect(testHarness.stderr.text()).toContain("bad?[31m@example"); + }); + + test("does not install when dry-run diagnostics contain errors", async () => { + const testHarness = harness({ + "plugins/describe": { + components: [], + diagnostics: [{ code: "plugin_not_found", message: "Missing", severity: "error" }] + }, + "plugins/install": { + dependencyClosure: [], + installedPlugins: [], + diagnostics: [{ code: "plugin_not_found", message: "Missing", severity: "error" }] + } + }); + + expect(await runPluginCommand( + ["plugins", "install", "missing@example", "--yes", "--json"], + testHarness.options + )).toBe(1); + expect(testHarness.calls.map((call) => call.method)).toEqual([ + "plugins/overview", + "plugins/describe", + "plugins/install" + ]); + }); + + test("maps validation error diagnostics to a failed exit status and message", async () => { + const testHarness = harness({ + "plugins/validate": { + diagnostics: [{ message: "Invalid manifest", severity: "error" }] + } + }); + + expect(await runPluginCommand( + ["plugins", "validate", "--source", "./plugin"], + testHarness.options + )).toBe(1); + expect(testHarness.stdout.text()).toContain("Plugin validation failed."); + expect(testHarness.stdout.text()).not.toContain("changes apply"); + }); +}); diff --git a/test/plugin-references.test.ts b/test/plugin-references.test.ts new file mode 100644 index 0000000..6b6b478 --- /dev/null +++ b/test/plugin-references.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createRuntimePluginReferenceLister, + isPluginReferenceValue, + normalizePluginReferenceEntries, + PluginReferenceCatalog, + pluginReferenceMarkdown, + pluginReferenceSuggestions +} from "../packages/zcode-tui/src/plugin-references.ts"; + +const browserPlugin = { + conflictingPluginIds: [], + enabled: true, + marketplace: "zcode-plugins-official", + mcpServerNames: [], + name: "browser-use", + pluginId: "browser-use@zcode-plugins-official", + skillQualifiedNames: ["browser-use:control-browser", "browser-use:web-gui-tester"], + subagentNames: [] +}; + +describe("runtime Plugin references", () => { + test("keeps only unambiguous enabled plugins with referenceable capabilities", () => { + expect(normalizePluginReferenceEntries({ + authority: "workspace", + plugins: [ + browserPlugin, + { ...browserPlugin, pluginId: "disabled@example", name: "disabled", marketplace: "example", enabled: false }, + { + ...browserPlugin, + pluginId: "ambiguous@example", + name: "ambiguous", + marketplace: "example", + conflictingPluginIds: ["other@example"] + }, + { ...browserPlugin, pluginId: "empty@example", name: "empty", marketplace: "example", skillQualifiedNames: [] }, + { ...browserPlugin, pluginId: "bad-id", name: "bad", marketplace: "example" } + ] + })).toEqual([{ + description: "Plugin | zcode-plugins-official | 2 skills", + marketplace: "zcode-plugins-official", + name: "browser-use", + pluginId: "browser-use@zcode-plugins-official" + }]); + }); + + test("inserts the runtime-native plugin Markdown link", () => { + const plugin = normalizePluginReferenceEntries({ plugins: [browserPlugin] })[0]!; + const value = pluginReferenceMarkdown(plugin); + expect(value).toBe("[@browser-use](plugin://browser-use@zcode-plugins-official)"); + expect(isPluginReferenceValue(value)).toBe(true); + expect(isPluginReferenceValue("@browser-use")).toBe(false); + expect(pluginReferenceSuggestions([plugin], "browser", 20)).toEqual([{ + value, + label: "@browser-use", + description: "Plugin | zcode-plugins-official | 2 skills" + }]); + }); + + test("caches successful discovery and retries transient catalog errors", async () => { + let calls = 0; + const catalog = new PluginReferenceCatalog(async () => { + calls += 1; + if (calls > 1) throw new Error("unavailable"); + return { plugins: [browserPlugin] }; + }); + + expect(await catalog.list()).toHaveLength(1); + expect(await catalog.list()).toHaveLength(1); + expect(calls).toBe(1); + + let transientCalls = 0; + const unavailable = new PluginReferenceCatalog(async () => { + transientCalls += 1; + if (transientCalls === 1) throw new Error("unavailable"); + return { plugins: [browserPlugin] }; + }); + expect(await unavailable.list()).toEqual([]); + expect(await unavailable.list()).toHaveLength(1); + expect(transientCalls).toBe(2); + }); + + test("queries app-server directly from the running runtime without a bridge patch", async () => { + const directory = await mkdtemp(join(tmpdir(), "zcode-plugin-reference-runtime-")); + const runtime = join(directory, "zcode.cjs"); + await writeFile(runtime, ` + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", chunk => input += chunk); + process.stdin.on("end", () => { + const request = JSON.parse(input.trim()); + console.log(JSON.stringify({ id: request.id, result: { + authority: "workspace", + plugins: [], + received: request + } })); + }); + `); + try { + const list = createRuntimePluginReferenceLister(directory, {}, [process.execPath, runtime]); + expect(list).toBeFunction(); + expect(await list!()).toMatchObject({ + authority: "workspace", + received: { + method: "plugins/referenceCatalog", + params: { + workspace: { workspacePath: directory, workspaceKey: directory } + } + } + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/test/release-package.test.ts b/test/release-package.test.ts index 68565b4..7f35c81 100644 --- a/test/release-package.test.ts +++ b/test/release-package.test.ts @@ -69,6 +69,7 @@ describe("release package", () => { expect(packageJson.engines).toEqual({ node: ">=22.19.0" }); expect(packageJson.dependencies.zigpty).toBeUndefined(); expect(packageJson.dependencies.bun).toBeUndefined(); + expect(packageJson.dependencies["playwright-core"]).toBe("1.59.1"); expect(packageJson.homepage).toBe("https://github.com/kingsword09/zcode-cli#readme"); expect(packageJson.bugs.url).toBe("https://github.com/kingsword09/zcode-cli/issues"); expect(packageJson.repository).toEqual({ @@ -78,6 +79,15 @@ describe("release package", () => { expect(packageJson.keywords).toEqual(expect.arrayContaining(["cli", "node", "terminal", "tui", "zcode"])); }); + test("syncs the runtime before running runtime-backed integration tests", async () => { + const source = await Bun.file(new URL("../scripts/build-release.ts", import.meta.url)).text(); + const syncStep = source.indexOf('await run(["run", latest ? "sync" : "sync:locked"]);'); + const testStep = source.indexOf('await run(["test"]);'); + + expect(syncStep).toBeGreaterThan(-1); + expect(testStep).toBeGreaterThan(syncStep); + }); + test("accepts reviewed paths and rejects omissions or development files", () => { const packageJson = { name: "zcode-app-cli", version: "3.3.5-1" }; @@ -117,7 +127,10 @@ describe("release package", () => { bin: { zcode: "bin/zcode.js" }, files: ["bin/zcode.js", "vendor", "config.example.json", "zcode-runtime.lock.json", "README.md", "LICENSE"], publishConfig: { access: "public", provenance: true }, - dependencies: { "@earendil-works/pi-tui": "^0.80.6" } + dependencies: { + "@earendil-works/pi-tui": "^0.80.6", + "playwright-core": "1.59.1" + } }; const tuiPackage = { name: "@zcode/tui", @@ -131,10 +144,13 @@ describe("release package", () => { "bin/zcode.ts": "export {};\n", "config.example.json": "{}\n", "package.json": `${JSON.stringify(packageJson)}\n`, + "src/app-server-client.ts": "export {};\n", "src/command.ts": "export {};\n", "src/darwin-oauth-callback.ts": "export {};\n", "src/launcher.ts": "export {};\n", "src/model-access.ts": "export {};\n", + "src/plugin-cli.ts": "export {};\n", + "src/plugin-protocol.ts": "export {};\n", "src/zai-oauth.ts": "export {};\n", "tsdown.config.ts": "export default [];\n", "packages/zcode-tui/dist/index.js": "export const value = 1;\n", diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index 54e679e..69ceb0d 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -9,6 +9,7 @@ import { patchRuntimeTuiBridge, patchRuntimeZaiDesktopOAuth, resolveArtifactUrl, + selectRuntimeLock, supportsMultiMessageFileRewind } from "../scripts/sync-runtime.ts"; import { @@ -79,6 +80,27 @@ describe("runtime synchronization", () => { expect(() => parseRuntimeLock({ ...lock, sha512: `${lock.sha512.slice(0, -2)}!!` })).toThrow(/SHA-512/); }); + test("does not downgrade a newer lock when a release manifest lags behind", () => { + const candidate = parseRuntimeLock({ + schemaVersion: 1, + appVersion: "3.6.5", + platform: "linux", + arch: "x64", + url: "https://example.com/3.6.5.deb", + sha512: Buffer.alloc(64, 6).toString("base64") + }); + const current = parseRuntimeLock({ + ...candidate, + appVersion: "3.7.3", + url: "https://example.com/3.7.3.deb", + sha512: Buffer.alloc(64, 7).toString("base64") + }); + + expect(selectRuntimeLock(candidate, current)).toBe(current); + expect(selectRuntimeLock(current, candidate)).toBe(current); + expect(selectRuntimeLock(candidate, { ...current, arch: "arm64" })).toBe(candidate); + }); + test("preserves the HTTP status when an OAuth error body is not JSON", () => { const runtime = [ "class Rx extends Error{}", diff --git a/test/workspace-autocomplete.test.ts b/test/workspace-autocomplete.test.ts index e71cd8b..82c647a 100644 --- a/test/workspace-autocomplete.test.ts +++ b/test/workspace-autocomplete.test.ts @@ -201,4 +201,59 @@ describe("workspace @ autocomplete", () => { expect(root?.items[0]?.value).toBe("@源码/入口.ts"); expect(unicode?.prefix).toBe("@入口"); }); + + test("merges enabled Plugins into @ completion and inserts their native reference", async () => { + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + async () => ({ + items: [{ kind: "file", path: "browser-notes.md" }], + truncated: false + }), + undefined, + async () => ({ + authority: "workspace", + plugins: [{ + conflictingPluginIds: [], + enabled: true, + marketplace: "zcode-plugins-official", + mcpServerNames: [], + name: "browser-use", + pluginId: "browser-use@zcode-plugins-official", + skillQualifiedNames: ["browser-use:control-browser"], + subagentNames: [] + }] + }) + ); + const input = "Use @bro"; + const suggestions = await provider.getSuggestions([input], 0, input.length, { signal: signal() }); + + expect(suggestions).toEqual({ + prefix: "@bro", + items: [ + { + value: "[@browser-use](plugin://browser-use@zcode-plugins-official)", + label: "@browser-use", + description: "Plugin | zcode-plugins-official | 1 skill" + }, + { + value: "@browser-notes.md", + label: "browser-notes.md", + description: "browser-notes.md" + } + ] + }); + + const completion = provider.applyCompletion( + [input], + 0, + input.length, + suggestions!.items[0]!, + suggestions!.prefix + ); + expect(completion.lines).toEqual([ + "Use [@browser-use](plugin://browser-use@zcode-plugins-official) " + ]); + expect(completion.cursorCol).toBe(completion.lines[0]!.length); + }); }); diff --git a/zcode-runtime.lock.json b/zcode-runtime.lock.json index 8ec983e..5503362 100644 --- a/zcode-runtime.lock.json +++ b/zcode-runtime.lock.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "appVersion": "3.6.5", + "appVersion": "3.7.3", "platform": "linux", "arch": "x64", - "url": "https://cdn-zcode.z.ai/zcode/electron/releases/3.6.5/linux-x64/ZCode-3.6.5-linux-x64.deb", - "sha512": "B4zIhJPTC/F/M4QzPSCuM7WPNY1KuoTS3U1phtQgEXuS8uyNhOBjg5b7H/1Q0i/5g74Xh24nyXEwjdvK94tgFQ==" + "url": "https://cdn-zcode.z.ai/zcode/electron/releases/3.7.3/linux-x64/ZCode-3.7.3-linux-x64.deb", + "sha512": "YwYP2b8z0XPYdys/3Elgg7KvwsBb12Pn2PbP0VCdcdvlxAnJCLUgUMq8n/OCSXUp3xEdriqfb5riEF7eJhJldA==" }