Add plugin system for reusable agent extensions#3
Add plugin system for reusable agent extensions#3shreyas-lyzr merged 5 commits intoopen-gitagent:mainfrom
Conversation
shreyas-lyzr
left a comment
There was a problem hiding this comment.
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)
- Command injection in
installPlugin()—sourceandversionare interpolated into a shell string viaexecSync. Double quotes don't protect against$(...)or backtick subshells. UseexecFileSync(no shell). - Programmatic hooks are broken —
plugin-sdk.tscreates synthetic__programmatic_*script names with_handler, butexecuteHook()still spawnsshon the path. There's no code path that invokes_handler— all programmatic hooks will crash at runtime. agent.yamldestroyed on every plugin CLI operation —yaml.dump()strips all comments, reorders keys, reformats. Plugin install/remove/enable/disable will corrupt users' hand-written YAML.
High
- Tool name collisions only warn — Two plugins providing a same-named tool both load. Should error or auto-namespace.
- Divergent programmatic tool wrapping —
index.tsinlines conversion (missingdetails),sdk.tsusestoAgentTool(). Extract to shared module. - Fragile
--dirparsing in plugin subcommand — Manualargv.indexOfbreaks on edge cases. ReuseparseArgs().
Medium
- No path traversal guard on plugin hook scripts —
script: "../../../.env"escapes the plugin directory. installPluginsilently returns stale dirs — Corrupt installs (noplugin.yaml) are accepted without validation.enginefield defined but never enforced — Plugins requiring newer gitclaw versions load and break silently.- Silent
catch {}in manifest helpers — Plugin CLI operations fail with zero feedback.
See inline comments for specific locations and suggested fixes.
| return pluginDir; | ||
| } | ||
|
|
||
| // ── Load a single plugin ─────────────────────────────────────────────── |
There was a problem hiding this comment.
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.
| // 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}`, |
There was a problem hiding this comment.
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:
- Add a check in
executeHook()/runHooks()for_handlerand call it directly instead of spawning a shell, or - Use a completely separate execution path for programmatic hooks
Right now, any plugin using api.registerHook() will crash at runtime when the hook fires.
| manifest.plugins[name] = { ...(manifest.plugins[name] || {}), ...pluginConf }; | ||
|
|
||
| await writeFile(manifestPath, yaml.dump(manifest, { lineWidth: -1 }), "utf-8"); | ||
| } catch { |
There was a problem hiding this comment.
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.
|
|
||
| return loaded; | ||
| } | ||
|
|
There was a problem hiding this comment.
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
|
|
||
| // Load hooks config | ||
| const hooksConfig = await loadHooksConfig(agentDir); | ||
| // Load hooks config (agent + plugin hooks merged) |
There was a problem hiding this comment.
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.
| @@ -287,6 +288,15 @@ | |||
There was a problem hiding this comment.
Medium: Fragile --dir parsing. This manual process.argv.indexOf approach breaks when:
--dirappears as a value to another flag- The user writes
gitclaw plugin install --dir(missing value) -dappears beforepluginin argv
Reuse the existing parseArgs() function, or at least extract the dir-parsing logic into a shared helper.
| try { | ||
| execSync(`git clone --depth 1 ${branchArg} "${source}" "${pluginDir}" 2>/dev/null`, { | ||
| stdio: "pipe", | ||
| }); |
There was a problem hiding this comment.
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.
| source?: string; // git URL for remote plugins | ||
| version?: string; // git branch/tag for remote plugins | ||
| config?: Record<string, any>; | ||
| } |
There was a problem hiding this comment.
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.
Code Review — Plugin SystemOverall this is well-architected — YAML-first, reuses existing loaders ( 1. Plugin skills bypass
|
Re-review — All Items Addressed ✅Checked commit
Memory integration is clean — plugin layers flow through LGTM — ready to merge. |
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:
plugin.yamlmanifest with config schema and env var supportgitclaw plugin install|list|remove|enable|disable|initquery()APINew files:
plugin-types.ts,plugins.ts,plugin-cli.ts,plugin-sdk.tsModified:
loader.ts,sdk.ts,hooks.ts,index.ts,exports.tsTest Plan
npm install && npm run buildgitclaw plugin init my-pluginplugins/my-plugin/tools/agent.yamlunderplugins:gitclawand verify the tool is available