Skip to content
Open
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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,22 @@ Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
(project scope — resolved from the current working directory, not the repo
root), and managed with `zero plugins`. A manifest can declare:
root), and managed with `zero plugins`. Plugin catalogs are registered in
`~/.config/zero/marketplaces.json` or `<cwd>/.zero/marketplaces.json`:

```bash
zero plugins marketplace validate ./catalog.json
zero plugins marketplace add ./catalog.json --allow-unverified
zero plugins marketplace list
zero plugins browse lookup --catalog team
zero plugins install zero.demo@team --yes --allow-unverified
zero plugins disable zero.demo
zero plugins enable zero.demo
```

Disabled managed plugins are physically quarantined under
`<plugins-root>/.disabled/<id>` and remain visible in `zero plugins list` without
activating. A manifest can declare:

- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
Expand Down
19 changes: 16 additions & 3 deletions docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,28 @@ Install and manage:

```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins marketplace add ./catalog.json --allow-unverified
zero plugins browse review --catalog team
zero plugins install github-pr-review@team --yes --allow-unverified
zero plugins list
zero plugins disable github-pr-review
zero plugins enable github-pr-review
zero plugins pin github-pr-review --version 1.0.0
zero plugins unpin github-pr-review
zero plugins remove github-pr-review # alias: rm
```

A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Marketplace catalogs are local-first. User catalogs are registered in
`~/.config/zero/marketplaces.json`; project catalogs are registered in
`./.zero/marketplaces.json`. Unsigned catalogs can be browsed, but adding one
requires `--allow-unverified`, and installs require both `--yes` and
`--allow-unverified`.

Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
A disabled managed plugin is moved to `<plugins-root>/.disabled/<id>` and its
`plugins.lock` entry records `enabled:false`. This keeps older Zero versions
from activating disabled plugin contents by directory presence alone.

> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.

## 7. Configuration locations

Expand Down
2 changes: 1 addition & 1 deletion docs/HOW_ZERO_WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ reading the deeper flows below.
| `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. |
| `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. |
| `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. |
| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. |
| `internal/plugins` / `internal/marketplace` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. The marketplace package validates local-first plugin catalogs and feeds managed installs into the existing plugin runtime. |
| `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. |

## Startup Flow
Expand Down
82 changes: 82 additions & 0 deletions docs/PLUGIN_MARKETPLACE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Plugin Marketplace

Zero's plugin marketplace is local-first. Catalogs are JSON files that describe
plugin releases, signed by an optional detached Ed25519 `catalog.sig`.

## Catalogs

Register catalogs with:

```bash
zero plugins marketplace validate ./catalog.json
zero plugins marketplace add ./catalog.json --allow-unverified
zero plugins marketplace list
```

User catalogs are stored in `~/.config/zero/marketplaces.json`. Project catalogs
are stored in `<workspace>/.zero/marketplaces.json`.

## Catalog Format

`catalog.json` has schema version `1`, a catalog id, owner, and a `plugins`
array. Each plugin has curated review metadata and one or more immutable
releases:

```json
{
"schemaVersion": 1,
"id": "team",
"owner": "Platform",
"plugins": [
{
"id": "zero.demo",
"name": "Zero Demo",
"author": {"name": "Platform"},
"license": "MIT",
"review": {
"status": "community",
"date": "2026-07-10",
"reviewer": "Zero Security",
"url": "https://github.com/Gitlawb/zero-plugins/pull/1"
},
"releases": [
{
"version": "0.1.0",
"repository": "https://github.com/Gitlawb/zero-demo-plugin.git",
"commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"treeHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"components": {
"tools": [{"name": "lookup", "permission": "prompt"}],
"hooks": [{"name": "preflight", "event": "beforeTool"}]
}
}
]
}
]
}
```

Supported hook events are exactly `beforeTool`, `afterTool`, `sessionStart`,
and `sessionEnd`. Specialist hook events are rejected by catalog validation.

## Install Safety

Marketplace installs require `--yes` and verify the fetched plugin against
catalog metadata before publishing:

```bash
zero plugins browse lookup --catalog team
zero plugins install zero.demo@team --yes --allow-unverified
```

Install checks include plugin id, manifest version, tree hash, component names,
tool permissions, and hook events. Managed plugins are recorded in `plugins.lock`
with catalog, version, commit, subdir, hash, pinned, and enabled state.

Install/update/remove operations take a plugin-root lock. On Unix this uses an
OS file lock, so crash-left lock files do not block later operations. On Windows
stale lock files are recovered after a short age threshold.

Disabled managed plugins move to `<plugins-root>/.disabled/<id>`. The loader
lists quarantined plugins but never activates them; a disabled project plugin
still shadows a same-id user plugin.
4 changes: 1 addition & 3 deletions internal/agenteval/score.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,7 @@ func Score(suite Suite, input ScoreInput) Report {
if len(task.RequiredTraceEvents) > 0 {
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
}
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
report.Results = append(report.Results, result)
}
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
report.finishSummary()
return report
}
Expand Down
6 changes: 3 additions & 3 deletions internal/agentinit/agentinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ func FormatFacts(info repoinfo.Info) string {
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")

if info.PrimaryLanguage != "" {
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
fmt.Fprintf(&b, "- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount)
}
if langs := topLanguages(info.Languages, 5); langs != "" {
b.WriteString("- Languages: " + langs + "\n")
}
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
fmt.Fprintf(&b, "- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth)
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
fmt.Fprintf(&b, "- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount)
}
if len(info.BuildTools) > 0 {
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
Expand Down
6 changes: 2 additions & 4 deletions internal/background/process_posix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,10 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {

// The forked child must be gone too (poll until reaped by init).
deadline := time.Now().Add(2 * time.Second)
for {
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
if errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
break // child no longer exists
}

if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
Expand Down
4 changes: 1 addition & 3 deletions internal/cli/agent_eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,9 +515,7 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
if task.Agent.Truncated {
converted.Truncated = true
}
for _, failure := range agentEvalFailuresFromTaskReport(task) {
converted.Failures = append(converted.Failures, failure)
}
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
}
return converted
}
Expand Down
154 changes: 154 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
Expand All @@ -19,6 +20,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/marketplace"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/observability"
Expand Down Expand Up @@ -828,6 +830,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
ExitCode: exitCode,
}
},
PluginCommand: tuiPluginCommand(workspaceRoot, deps),
SandboxSetupCommand: tuiSandboxSetupCommand(sandboxBackend, deps),
AgentOptions: agent.Options{
MaxTurns: resolved.MaxTurns,
Expand Down Expand Up @@ -885,6 +888,157 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.
}
}

func tuiPluginCommand(workspaceRoot string, deps appDeps) func(context.Context, []string) tui.PluginCommandResult {
return func(ctx context.Context, args []string) tui.PluginCommandResult {
if ctx == nil {
ctx = context.Background()
}
var stdout, stderr bytes.Buffer
exitCode := runPluginsWithContext(ctx, args, &stdout, &stderr, deps)
return tui.PluginCommandResult{
Snapshot: tuiPluginSnapshot(workspaceRoot, deps),
Output: strings.TrimSpace(stdout.String()),
Error: strings.TrimSpace(stderr.String()),
ExitCode: exitCode,
RestartRequired: pluginCommandNeedsRestart(args) && exitCode == exitSuccess,
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func tuiPluginSnapshot(workspaceRoot string, deps appDeps) tui.PluginSnapshot {
excludeProject, trustErr := resolveTrust(workspaceRoot)
result, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
snapshot := tui.PluginSnapshot{ProjectPluginsShown: !excludeProject}
if err != nil {
snapshot.LoadError = err.Error()
} else {
snapshot.Plugins = result.Plugins
snapshot.Diagnostics = result.Diagnostics
}
snapshot.Installed = tuiPluginInstalledSnapshot(deps, !excludeProject)
installed := map[string]tui.PluginInstalledSnapshot{}
for _, item := range snapshot.Installed {
if item.ID == "" {
continue
}
installed[item.ID+"|"+string(item.Source)] = item
if item.Catalog != "" {
installed[item.ID+"@"+item.Catalog] = item
}
}
catalogs, catalogErr := registeredCatalogs(workspaceRoot, !excludeProject)
if catalogErr != nil {
if snapshot.LoadError == "" {
snapshot.LoadError = catalogErr.Error()
}
} else {
for _, entry := range catalogs {
catalogSnapshot := tui.PluginCatalogSnapshot{
ID: entry.ID,
Source: entry.Source,
Scope: entry.Scope,
Verification: marketplace.Verification{
Status: entry.VerificationStatus,
},
}
catalog, verification, err := loadCatalogEntryCachedOnly(entry)
if err != nil {
catalogSnapshot.LoadError = err.Error()
} else {
catalogSnapshot.Owner = catalog.Owner
catalogSnapshot.Description = catalog.Description
catalogSnapshot.Verification = verification
for _, plugin := range catalog.Plugins {
release, ok := selectRelease(plugin, "")
if !ok {
continue
}
state, ok := installed[plugin.ID+"@"+catalog.ID]
snapshot.MarketplacePlugins = append(snapshot.MarketplacePlugins, tui.PluginMarketplaceSnapshot{
CatalogID: catalog.ID,
CatalogScope: entry.Scope,
Verification: verification,
Plugin: plugin,
Release: release,
Risk: marketplace.RiskForRelease(release),
Installed: ok,
Pinned: ok && state.Pinned,
})
}
}
snapshot.Catalogs = append(snapshot.Catalogs, catalogSnapshot)
}
}
sort.SliceStable(snapshot.Installed, func(left int, right int) bool {
if snapshot.Installed[left].Source != snapshot.Installed[right].Source {
return snapshot.Installed[left].Source < snapshot.Installed[right].Source
}
return snapshot.Installed[left].ID < snapshot.Installed[right].ID
})
sort.SliceStable(snapshot.Catalogs, func(left int, right int) bool {
if snapshot.Catalogs[left].Scope != snapshot.Catalogs[right].Scope {
return snapshot.Catalogs[left].Scope < snapshot.Catalogs[right].Scope
}
return snapshot.Catalogs[left].ID < snapshot.Catalogs[right].ID
})
sort.SliceStable(snapshot.MarketplacePlugins, func(left int, right int) bool {
if snapshot.MarketplacePlugins[left].CatalogID != snapshot.MarketplacePlugins[right].CatalogID {
return snapshot.MarketplacePlugins[left].CatalogID < snapshot.MarketplacePlugins[right].CatalogID
}
return snapshot.MarketplacePlugins[left].Plugin.ID < snapshot.MarketplacePlugins[right].Plugin.ID
})
if trustErr {
snapshot.TrustNotice = "project plugins hidden: trust store error"
} else if excludeProject {
snapshot.TrustNotice = "project plugins hidden until workspace trusted"
}
return snapshot
}

func tuiPluginInstalledSnapshot(deps appDeps, includeProject bool) []tui.PluginInstalledSnapshot {
scopes := []plugins.Source{plugins.SourceUser}
if includeProject {
scopes = append(scopes, plugins.SourceProject)
}
items := []tui.PluginInstalledSnapshot{}
for _, scope := range scopes {
dir, err := pluginDirForScope(deps, scope)
if err != nil {
items = append(items, tui.PluginInstalledSnapshot{Source: scope, Error: err.Error()})
continue
}
lock, err := plugins.ReadLock(dir)
if err != nil {
items = append(items, tui.PluginInstalledSnapshot{Source: scope, Error: err.Error()})
continue
}
for id, entry := range lock {
items = append(items, tui.PluginInstalledSnapshot{
ID: id,
Source: scope,
Catalog: entry.Catalog,
Version: entry.Version,
Commit: entry.Commit,
Enabled: entry.Enabled,
Pinned: entry.Pinned,
})
}
}
return items
}

func pluginCommandNeedsRestart(args []string) bool {
if len(args) == 0 {
return false
}
switch strings.TrimSpace(args[0]) {
case "install", "add", "remove", "rm", "enable", "disable", "update":
return true
default:
return false
}
}

// buildProvider constructs the run's provider at STARTUP — it is called only from
// the two launch paths (interactive TUI and headless exec), never from mid-run
// rebuilds (escalation, wizard, ACP go through deps.newProvider directly).
Expand Down
Loading
Loading