Skip to content

Add plugin system for reusable agent extensions#3

Merged
shreyas-lyzr merged 5 commits intoopen-gitagent:mainfrom
parshvadaftari:feat/plugin
Mar 17, 2026
Merged

Add plugin system for reusable agent extensions#3
shreyas-lyzr merged 5 commits intoopen-gitagent:mainfrom
parshvadaftari:feat/plugin

Conversation

@parshvadaftari
Copy link
Copy Markdown
Contributor

@parshvadaftari parshvadaftari commented Mar 9, 2026

Summary

Adds a plugin system that lets gitclaw agents load reusable extensions from local directories or git URLs. Plugins can provide tools, hooks, skills, and prompt
additions — all using gitclaw's existing YAML-first, git-native patterns.

What's included:

  • Three-tier plugin discovery (local → global → installed)
  • plugin.yaml manifest with config schema and env var support
  • Declarative YAML tools/hooks/skills + optional programmatic entry point
  • CLI commands: gitclaw plugin install|list|remove|enable|disable|init
  • SDK integration via existing query() API

New files: plugin-types.ts, plugins.ts, plugin-cli.ts, plugin-sdk.ts
Modified: loader.ts, sdk.ts, hooks.ts, index.ts, exports.ts

Test Plan

  1. npm install && npm run build
  2. Create a sample plugin: gitclaw plugin init my-plugin
  3. Add a tool YAML + script to plugins/my-plugin/tools/
  4. Enable in agent.yaml under plugins:
  5. Run gitclaw and verify the tool is available

Copy link
Copy Markdown
Contributor

@shreyas-lyzr shreyas-lyzr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin System Review

Strong foundation — the three-tier discovery, YAML-first design, and config schema with env fallback are well thought out. However, since this is the public API that plugin authors will build against, there are several issues that need to be fixed before merge.

Critical (blocks merge)

  1. Command injection in installPlugin()source and version are interpolated into a shell string via execSync. Double quotes don't protect against $(...) or backtick subshells. Use execFileSync (no shell).
  2. Programmatic hooks are brokenplugin-sdk.ts creates synthetic __programmatic_* script names with _handler, but executeHook() still spawns sh on the path. There's no code path that invokes _handler — all programmatic hooks will crash at runtime.
  3. agent.yaml destroyed on every plugin CLI operationyaml.dump() strips all comments, reorders keys, reformats. Plugin install/remove/enable/disable will corrupt users' hand-written YAML.

High

  1. Tool name collisions only warn — Two plugins providing a same-named tool both load. Should error or auto-namespace.
  2. Divergent programmatic tool wrappingindex.ts inlines conversion (missing details), sdk.ts uses toAgentTool(). Extract to shared module.
  3. Fragile --dir parsing in plugin subcommand — Manual argv.indexOf breaks on edge cases. Reuse parseArgs().

Medium

  1. No path traversal guard on plugin hook scriptsscript: "../../../.env" escapes the plugin directory.
  2. installPlugin silently returns stale dirs — Corrupt installs (no plugin.yaml) are accepted without validation.
  3. engine field defined but never enforced — Plugins requiring newer gitclaw versions load and break silently.
  4. Silent catch {} in manifest helpers — Plugin CLI operations fail with zero feedback.

See inline comments for specific locations and suggested fixes.

Comment thread src/plugins.ts
return pluginDir;
}

// ── Load a single plugin ───────────────────────────────────────────────
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Command injection. source and version are user-controlled strings interpolated into a shell command. Double quotes don't prevent $(...) or backtick subshell expansion.

Use execFileSync("git", ["clone", "--depth", "1", ...branchArgs, source, pluginDir], { stdio: "pipe" }) instead of execSync with string interpolation. execFileSync bypasses the shell entirely.

Comment thread src/plugin-sdk.ts
// Wrap each programmatic handler as a HookDefinition
// that uses a synthetic script path with inline execution
result[event] = handlers.map((handler, i) => ({
script: `__programmatic_${pluginId}_${event}_${i}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Programmatic hooks are dead code. These synthetic __programmatic_* script names and _handler properties are never consumed. executeHook() in hooks.ts spawns sh on the script path, which will fail for these synthetic names.

You need to either:

  1. Add a check in executeHook() / runHooks() for _handler and call it directly instead of spawning a shell, or
  2. Use a completely separate execution path for programmatic hooks

Right now, any plugin using api.registerHook() will crash at runtime when the hook fires.

Comment thread src/plugin-cli.ts Outdated
manifest.plugins[name] = { ...(manifest.plugins[name] || {}), ...pluginConf };

await writeFile(manifestPath, yaml.dump(manifest, { lineWidth: -1 }), "utf-8");
} catch {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: yaml.dump() destroys agent.yaml formatting. This rewrites the entire file — all comments are stripped, key order changes, quoting style changes. Users' hand-written YAML gets mangled on every plugin install/remove/enable/disable.

Consider using the yaml package's parseDocument() + toString() which preserves comments, or do a targeted string append/replace instead of a full parse-dump roundtrip. This applies to both addPluginToManifest and removePluginFromManifest.

Comment thread src/plugins.ts

return loaded;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Tool collision should be an error, not a warning. Plugin authors won't notice a console.warn during development. Two plugins with a tool named search = the agent calls whichever was loaded last, silently shadowing the other.

Either:

  • Error and refuse to load the conflicting plugin
  • Auto-namespace tools as pluginId:toolName
  • At minimum, skip the duplicate tool instead of loading both

Comment thread src/index.ts

// Load hooks config
const hooksConfig = await loadHooksConfig(agentDir);
// Load hooks config (agent + plugin hooks merged)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Duplicated tool conversion logic. This inlines what toAgentTool() in sdk.ts already does, but with a subtle difference — the sdk.ts version handles result.details, this one hardcodes details: undefined.

Extract toAgentTool to a shared module and use it in both places. Divergent conversion = divergent behavior between CLI and SDK modes.

Comment thread src/index.ts
@@ -287,6 +288,15 @@
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Fragile --dir parsing. This manual process.argv.indexOf approach breaks when:

  • --dir appears as a value to another flag
  • The user writes gitclaw plugin install --dir (missing value)
  • -d appears before plugin in argv

Reuse the existing parseArgs() function, or at least extract the dir-parsing logic into a shared helper.

Comment thread src/plugins.ts Outdated
try {
execSync(`git clone --depth 1 ${branchArg} "${source}" "${pluginDir}" 2>/dev/null`, {
stdio: "pipe",
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Returns stale/corrupt directory. If the plugin dir exists but is missing plugin.yaml (e.g., previous install was interrupted), this silently returns the broken path. loadPlugin will then return null, and the user gets a vague "not found" message despite the directory existing.

Suggest: check for plugin.yaml before returning, and if missing, remove the directory and re-clone.

Comment thread src/plugin-types.ts
source?: string; // git URL for remote plugins
version?: string; // git branch/tag for remote plugins
config?: Record<string, any>;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: engine field is never checked. A plugin declaring engine: ">=2.0.0" loads on gitclaw 1.x and breaks in confusing ways.

Either add a semver check in loadPlugin() or remove the field from the type until you're ready to enforce it.

@shreyas-lyzr
Copy link
Copy Markdown
Contributor

Code Review — Plugin System

Overall this is well-architected — YAML-first, reuses existing loaders (loadDeclarativeTools, discoverSkills), clean three-tier discovery, and good security with the path traversal guard on hooks. A few issues to address before merge:


1. Plugin skills bypass manifest.skills whitelist

src/loader.ts ~line 279-284

Plugin skills are merged AFTER the whitelist filter, so they bypass it. If this is intentional (plugins are explicitly enabled in agent.yaml so their skills are trusted), add a comment explaining it. If not, apply the same filter.


2. Tool name collision check is incomplete

src/plugins.ts ~line 340-357

Plugin tools are checked against other plugin tools but NOT against builtin tools (cli, read, write, memory) or declarative tools from tools/*.yaml. A plugin could silently override read or write.

Fix: After adding plugin tools in src/index.ts and src/sdk.ts, warn on collisions:

const existingNames = new Set(tools.map(t => t.name));
for (const plugin of loaded.plugins) {
    for (const t of [...plugin.tools, ...plugin.programmaticTools]) {
        if (existingNames.has(t.name)) {
            console.warn(`[plugin:${plugin.manifest.id}] Tool "${t.name}" collides with existing tool — skipping`);
        }
    }
}

3. handleInstall always auto-enables

src/plugin-cli.ts ~line 85

addPluginToManifest hardcodes enabled: true. Add a --no-enable flag so users can install without auto-enabling.


4. No upgrade/reinstall path

src/plugin-cli.ts ~line 130-138

If a plugin is already installed, it's silently skipped. Add a --force flag to re-clone, and print a message when skipping:

Plugin "foo" already installed. Use --force to reinstall.

5. Memory extension point missing

src/plugin-sdk.ts

The plugin API supports tools, hooks, skills, and prompts — but not memory layers. GitClaw's memory system (src/tools/memory.ts) already supports layers via memory/memory.yaml. Add registerMemoryLayer() to GitclawPluginApi:

registerMemoryLayer(layer: { name: string; path: string; description: string }): void;

And add memoryLayers to LoadedPlugin in plugin-types.ts. This lets plugins extend the agent's memory system cleanly.


6. Minor: Add comment on yaml vs js-yaml dep

package.json

The PR adds yaml (v2) alongside existing js-yaml. This is justified — parseDocument() from yaml preserves formatting when editing agent.yaml, which js-yaml can't do. Add a comment in plugin-cli.ts explaining why yaml is used instead of js-yaml here.


Everything else looks good — hooks baseDir isolation, programmatic hook support, toAgentTool extraction, and the SDK/voice integration path all work correctly. Plugin tools flow through query()loadAgent() so they work in both CLI and voice mode without needing voice server changes. 👍

@shreyas-lyzr
Copy link
Copy Markdown
Contributor

Re-review — All Items Addressed ✅

Checked commit fb24718 ("Fix PR review round 2") against all 6 review items:

# Issue Status
1 Plugin skills whitelist comment ✅ Clear trust model comment in loader.ts
2 Tool name collision check ✅ Both index.ts and sdk.ts warn + skip colliding tools
3 --no-enable flag ✅ Added to plugin-cli.ts, wired to addPluginToManifest
4 --force reinstall ✅ Added to both git URL and local path install flows, prints "already installed" hint
5 Memory extension point ✅ Full chain: MemoryLayerDef type → registerMemoryLayer() API → BuiltinToolsConfigmemory.ts merge
6 yaml vs js-yaml comment ✅ Comment added in plugin-cli.ts

Memory integration is clean — plugin layers flow through loaded.plugins.flatMap(p => p.memoryLayers)createBuiltinTools({ pluginMemoryLayers })createMemoryTool(cwd, pluginLayers)loadMemoryConfig() merge. No unnecessary abstractions.

LGTM — ready to merge.

@shreyas-lyzr shreyas-lyzr merged commit 88017eb into open-gitagent:main Mar 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants