Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 102 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 <plugin-id>
zcode plugins disable <plugin-id>
zcode plugins uninstall <plugin-id> --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 <path>` 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;
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
159 changes: 159 additions & 0 deletions packages/zcode-tui/src/plugin-references.ts
Original file line number Diff line number Diff line change
@@ -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<PluginReferenceEntry[]>;

constructor(private readonly listPluginReferences?: ListPluginReferences) {}

async list(): Promise<PluginReferenceEntry[]> {
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<string>();

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
}
});
}
Loading