From 7ddf28c0d4403599cb788d0ad94c1b7fd87da622 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 10:56:44 +0100 Subject: [PATCH] feat: support git plugin installs --- CHANGELOG.md | 1 + docs/cli/plugins.md | 14 +- docs/plugins/building-plugins.md | 41 +++ docs/tools/plugin.md | 31 +- docs/tools/slash-commands.md | 2 +- scripts/e2e/lib/fixtures/plugins.mjs | 25 ++ scripts/e2e/lib/plugins/assertions.mjs | 77 ++++- scripts/e2e/lib/plugins/fixtures.sh | 12 + scripts/e2e/lib/plugins/marketplace.sh | 6 +- scripts/e2e/lib/plugins/sweep.sh | 29 +- .../reply/commands-plugins.install.test.ts | 77 ++++- src/auto-reply/reply/commands-plugins.ts | 31 ++ src/auto-reply/reply/plugins-commands.ts | 2 +- src/cli/plugins-cli-test-helpers.ts | 50 +++ src/cli/plugins-cli.install.test.ts | 66 ++++ src/cli/plugins-cli.ts | 2 +- src/cli/plugins-install-command.ts | 65 ++++ src/config/types.installs.ts | 5 +- src/config/zod-schema.installs.ts | 4 + src/plugins/git-install.test.ts | 124 ++++++++ src/plugins/git-install.ts | 298 ++++++++++++++++++ src/plugins/hook-types.ts | 3 +- src/plugins/install-security-scan.runtime.ts | 3 +- src/plugins/install-security-scan.ts | 3 +- src/plugins/install.ts | 2 +- .../installed-plugin-index-install-records.ts | 3 + .../installed-plugin-index-records.test.ts | 28 ++ src/plugins/installed-plugin-index-types.ts | 3 + src/plugins/update.test.ts | 95 ++++++ src/plugins/update.ts | 160 +++++++--- 30 files changed, 1188 insertions(+), 74 deletions(-) create mode 100644 src/plugins/git-install.test.ts create mode 100644 src/plugins/git-install.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bff485f52..6d1175850e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Agents/workspace: add `agents.defaults.skipOptionalBootstrapFiles` for skipping selected optional workspace files during bootstrap without disabling required workspace setup. (#62110) Thanks @mainstay22. +- Plugins/CLI: add first-class `git:` plugin installs with ref checkout, commit metadata, normal scanner/staging, and `plugins update` support for recorded git sources. Thanks @badlogic. - Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP. - macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti. - Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 493b45ed1ec..495e6e3357d 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -71,6 +71,8 @@ Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON Sch openclaw plugins install # ClawHub first, then npm openclaw plugins install clawhub: # ClawHub only openclaw plugins install npm: # npm only +openclaw plugins install git:github.com// # git repo +openclaw plugins install git:github.com//@ openclaw plugins install --force # overwrite existing install openclaw plugins install --pin # pin version openclaw plugins install --dangerously-force-unsafe-install @@ -108,7 +110,7 @@ current OpenClaw or a local checkout until a newer npm package is published. - `--pin` applies to npm installs only. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec. + `--pin` applies to npm installs only. It is not supported with `git:` installs; use an explicit git ref such as `git:github.com/acme/plugin@v1.2.3` when you want a pinned source. It is not supported with `--marketplace`, because marketplace installs persist marketplace source metadata instead of an npm spec. `--dangerously-force-unsafe-install` is a break-glass option for false positives in the built-in dangerous-code scanner. It allows the install to continue even when the built-in scanner reports `critical` findings, but it does **not** bypass plugin `before_install` hook policy blocks and does **not** bypass scan failures. @@ -129,6 +131,14 @@ current OpenClaw or a local checkout until a newer npm package is published. If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw installs the bundled plugin directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`). + + + Use `git:` to install directly from a git repository. Supported forms include `git:github.com/owner/repo`, `git:owner/repo`, full `https://`, `ssh://`, `git://`, `file://`, and `git@host:owner/repo.git` clone URLs. Add `@` or `#` to check out a branch, tag, or commit before install. + + Git installs clone into a temporary directory, check out the requested ref when present, then use the normal plugin directory installer. That means manifest validation, dangerous-code scanning, runtime dependency staging, and install records behave like local-path installs. Recorded git installs include the source URL/ref plus the resolved commit so `openclaw plugins update` can re-resolve the source later. + + After installing from git, use `openclaw plugins inspect --runtime --json` to verify runtime registrations such as gateway methods and CLI commands. If the plugin registered a CLI root with `api.registerCli`, execute that command directly through the OpenClaw root CLI, for example `openclaw demo-plugin ping`. + Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records. @@ -328,6 +338,8 @@ openclaw plugins inspect --json Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection fails with a repair hint when bundled runtime dependencies are missing; use `openclaw plugins deps --repair` to repair them explicitly. +Plugin-owned CLI commands are installed as root `openclaw` command groups. After `inspect --runtime` shows a command under `cliCommands`, run it as `openclaw ...`; for example a plugin that registers `demo-git` can be verified with `openclaw demo-git ping`. + Each plugin is classified by what it actually registers at runtime: - **plain-capability** — one capability type (e.g. a provider-only plugin) diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index b2af11bdb8c..06436557a1b 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -253,6 +253,47 @@ Users enable optional tools in config: - Use `optional: true` for tools with side effects or extra binary requirements - Users can enable all tools from a plugin by adding the plugin id to `tools.allow` +## Registering CLI commands + +Plugins can add root `openclaw` command groups with `api.registerCli`. Provide +`descriptors` for every top-level command root so OpenClaw can show and route +the command without eagerly loading every plugin runtime. + +```typescript +register(api) { + api.registerCli( + ({ program }) => { + const demo = program + .command("demo-plugin") + .description("Run demo plugin commands"); + + demo + .command("ping") + .description("Check that the plugin CLI is executable") + .action(() => { + console.log("demo-plugin:pong"); + }); + }, + { + descriptors: [ + { + name: "demo-plugin", + description: "Run demo plugin commands", + hasSubcommands: true, + }, + ], + }, + ); +} +``` + +After install, verify the runtime registration and execute the command: + +```bash +openclaw plugins inspect demo-plugin --runtime --json +openclaw demo-plugin ping +``` + ## Import conventions Always import from focused `openclaw/plugin-sdk/` paths: diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 31ff50d7129..c3f734f8309 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -30,6 +30,9 @@ temporary set of OpenClaw-owned plugin packages while that migration finishes. # From npm openclaw plugins install npm:@acme/openclaw-plugin + # From git + openclaw plugins install git:github.com/acme/openclaw-plugin@v1.0.0 + # From a local directory or archive openclaw plugins install ./my-plugin openclaw plugins install ./my-plugin.tgz @@ -45,6 +48,20 @@ temporary set of OpenClaw-owned plugin packages while that migration finishes. Then configure under `plugins.entries.\.config` in your config file. + + + ```bash + openclaw plugins inspect --runtime --json + + # If the plugin registered a CLI root, run one command from that root. + openclaw --help + ``` + + Use `--runtime` when you need to prove registered tools, services, gateway + methods, hooks, or plugin-owned CLI commands. Plain `inspect` is a cold + manifest/registry check and intentionally avoids importing plugin runtime. + + If you prefer chat-native control, enable `commands.plugins: true` and use: @@ -56,8 +73,8 @@ If you prefer chat-native control, enable `commands.plugins: true` and use: ``` The install path uses the same resolver as the CLI: local path/archive, explicit -`clawhub:`, explicit `npm:`, or bare package spec (ClawHub first, then -npm fallback). +`clawhub:`, explicit `npm:`, explicit `git:`, or bare package +spec (ClawHub first, then npm fallback). If config is invalid, install normally fails closed and points you at `openclaw doctor --fix`. The only recovery exception is a narrow bundled-plugin @@ -384,7 +401,7 @@ openclaw plugins list --enabled # only enabled plugins openclaw plugins list --verbose # per-plugin detail lines openclaw plugins list --json # machine-readable inventory openclaw plugins inspect # static detail -openclaw plugins inspect --runtime # registered hooks/tools/diagnostics +openclaw plugins inspect --runtime # registered hooks/tools/CLI/gateway methods openclaw plugins inspect --json # machine-readable openclaw plugins inspect --all # fleet-wide table openclaw plugins info # inspect alias @@ -396,6 +413,8 @@ openclaw doctor --fix # repair plugin registry state openclaw plugins install # install (ClawHub first, then npm) openclaw plugins install clawhub: # install from ClawHub only openclaw plugins install npm: # install from npm only +openclaw plugins install git: # install from git +openclaw plugins install git:@ # install from git ref openclaw plugins install --force # overwrite existing install openclaw plugins install # install from local path openclaw plugins install -l # link (no copy) for dev @@ -411,6 +430,12 @@ openclaw plugins uninstall --keep-files openclaw plugins marketplace list openclaw plugins marketplace list --json +# Verify runtime registrations after install. +openclaw plugins inspect --runtime --json + +# Run plugin-owned CLI commands directly from the OpenClaw root CLI. +openclaw --help + openclaw plugins enable openclaw plugins disable ``` diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 3d5c880630c..c8df00b788d 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -247,7 +247,7 @@ User-invocable skills are also exposed as slash commands: - In multi-account channels, config-targeted `/allowlist --account ` and `/config set channels..accounts....` also honor the target account's `configWrites`. - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs. - `/restart` is enabled by default; set `commands.restart: false` to disable it. - - `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, or `clawhub:`. + - `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, `git:`, or `clawhub:`. - `/plugins enable|disable` updates plugin config and may prompt for a restart. diff --git a/scripts/e2e/lib/fixtures/plugins.mjs b/scripts/e2e/lib/fixtures/plugins.mjs index 8cc13d820df..5f0dced1244 100644 --- a/scripts/e2e/lib/fixtures/plugins.mjs +++ b/scripts/e2e/lib/fixtures/plugins.mjs @@ -35,6 +35,30 @@ function writePlugin([dir, id, version, method, name]) { writePluginManifest(path.join(dir, "openclaw.plugin.json"), id); } +function writePluginWithCli([dir, id, version, method, name, cliRoot, cliOutput]) { + for (const [value, label] of [ + [dir, "dir"], + [id, "id"], + [version, "version"], + [method, "method"], + [name, "name"], + [cliRoot, "cliRoot"], + [cliOutput, "cliOutput"], + ]) { + requireArg(value, label); + } + writeJson(path.join(dir, "package.json"), { + name: `@openclaw/${id}`, + version, + openclaw: { extensions: ["./index.js"] }, + }); + write( + path.join(dir, "index.js"), + `module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`, + ); + writePluginManifest(path.join(dir, "openclaw.plugin.json"), id); +} + function writeClaudeBundle([root]) { root = requireArg(root, "root"); writeJson(path.join(root, ".claude-plugin", "plugin.json"), { name: "claude-bundle-e2e" }); @@ -75,6 +99,7 @@ function writePluginMarketplace([root]) { export const pluginCommands = { "plugin-demo": writePluginDemo, plugin: writePlugin, + "plugin-cli": writePluginWithCli, "plugin-manifest": ([file, id]) => writePluginManifest(requireArg(file, "file"), requireArg(id, "id")), "claude-bundle": writeClaudeBundle, diff --git a/scripts/e2e/lib/plugins/assertions.mjs b/scripts/e2e/lib/plugins/assertions.mjs index c1b68b4d9a8..0ec53f2f2c9 100644 --- a/scripts/e2e/lib/plugins/assertions.mjs +++ b/scripts/e2e/lib/plugins/assertions.mjs @@ -4,6 +4,20 @@ import path from "node:path"; const command = process.argv[2]; const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); +function getInstallRecords() { + const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); + const index = fs.existsSync(indexPath) ? readJson(indexPath) : {}; + const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); + const config = fs.existsSync(configPath) ? readJson(configPath) : {}; + const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; + if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); + } + return allowLegacyCompat + ? (index.installRecords ?? index.records ?? config.plugins?.installs ?? {}) + : (index.installRecords ?? {}); +} + function recordFixturePluginTrust() { const pluginId = process.argv[3]; const pluginRoot = process.argv[4]; @@ -173,17 +187,7 @@ function assertMarketplaceInstalled() { } function assertMarketplaceRecords() { - const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); - const index = readJson(indexPath); - const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); - const config = fs.existsSync(configPath) ? readJson(configPath) : {}; - const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; - if (!allowLegacyCompat && !index.installRecords) { - throw new Error("expected modern installRecords in installed plugin index"); - } - const installRecords = allowLegacyCompat - ? (index.installRecords ?? index.records ?? config.plugins?.installs ?? {}) - : (index.installRecords ?? {}); + const installRecords = getInstallRecords(); for (const id of ["marketplace-shortcut", "marketplace-direct"]) { const record = installRecords[id]; if (!record) { @@ -205,6 +209,56 @@ function assertMarketplaceRecords() { } } +function assertGitPlugin() { + const repoUrl = process.argv[3]; + const gitRef = process.argv[4]; + assertSimplePlugin( + "/tmp/plugins-git.json", + "/tmp/plugins-git-inspect.json", + "demo-plugin-git", + "demo.git", + ); + + const inspect = readJson("/tmp/plugins-git-inspect.json"); + if (!Array.isArray(inspect.cliCommands) || !inspect.cliCommands.includes("demo-git")) { + throw new Error(`expected demo-git cli command, got ${inspect.cliCommands?.join(", ")}`); + } + + const cliOutput = fs.readFileSync("/tmp/plugins-git-cli.txt", "utf8"); + if (!cliOutput.includes("demo-plugin-git:pong")) { + throw new Error(`unexpected git plugin cli output: ${cliOutput.trim()}`); + } + + const record = getInstallRecords()["demo-plugin-git"]; + if (!record) { + throw new Error("missing git install record for demo-plugin-git"); + } + if (record.source !== "git") { + throw new Error(`unexpected git install source: ${record.source}`); + } + if (record.gitUrl !== repoUrl) { + throw new Error(`unexpected git url: ${record.gitUrl}, expected ${repoUrl}`); + } + if (record.gitRef !== gitRef) { + throw new Error(`unexpected git ref: ${record.gitRef}, expected ${gitRef}`); + } + if (record.gitCommit !== gitRef) { + throw new Error(`unexpected git commit: ${record.gitCommit}, expected ${gitRef}`); + } + if (record.spec !== `git:${repoUrl}@${gitRef}`) { + throw new Error(`unexpected git spec: ${record.spec}`); + } + + const installPath = record.installPath?.replace(/^~(?=$|\/)/u, process.env.HOME); + if (!installPath || !fs.existsSync(installPath)) { + throw new Error(`git install path missing on disk: ${installPath}`); + } + const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); + if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { + throw new Error(`git install path is outside managed extensions root: ${installPath}`); + } +} + function assertRealPathInside(parentPath, childPath, label) { const parentRealPath = fs.realpathSync(parentPath); const childRealPath = fs.realpathSync(childPath); @@ -414,6 +468,7 @@ const commands = { "bundle-disabled": assertClaudeBundleDisabled, "bundle-inspect": assertClaudeBundleInspect, "slash-install": assertSlashInstall, + "plugin-git": assertGitPlugin, "marketplace-list": assertMarketplaceList, "marketplace-installed": assertMarketplaceInstalled, "marketplace-records": assertMarketplaceRecords, diff --git a/scripts/e2e/lib/plugins/fixtures.sh b/scripts/e2e/lib/plugins/fixtures.sh index 7320a8c9797..7c9d36ba85d 100644 --- a/scripts/e2e/lib/plugins/fixtures.sh +++ b/scripts/e2e/lib/plugins/fixtures.sh @@ -20,6 +20,18 @@ write_fixture_plugin() { node scripts/e2e/lib/fixture.mjs plugin "$dir" "$id" "$version" "$method" "$name" } +write_fixture_plugin_with_cli() { + local dir="$1" + local id="$2" + local version="$3" + local method="$4" + local name="$5" + local cli_root="$6" + local cli_output="$7" + + node scripts/e2e/lib/fixture.mjs plugin-cli "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" +} + write_fixture_manifest() { local file="$1" local id="$2" diff --git a/scripts/e2e/lib/plugins/marketplace.sh b/scripts/e2e/lib/plugins/marketplace.sh index c42e9a3a81e..e246e87b690 100644 --- a/scripts/e2e/lib/plugins/marketplace.sh +++ b/scripts/e2e/lib/plugins/marketplace.sh @@ -23,8 +23,8 @@ run_plugins_marketplace_scenario() { run_logged install-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures run_logged install-marketplace-direct node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace.json - node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-shortcut-inspect.json - node "$OPENCLAW_ENTRY" plugins inspect marketplace-direct --json >/tmp/plugins-marketplace-direct-inspect.json + node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --runtime --json >/tmp/plugins-marketplace-shortcut-inspect.json + node "$OPENCLAW_ENTRY" plugins inspect marketplace-direct --runtime --json >/tmp/plugins-marketplace-direct-inspect.json node scripts/e2e/lib/plugins/assertions.mjs marketplace-installed @@ -39,7 +39,7 @@ run_plugins_marketplace_scenario() { run_logged update-marketplace-shortcut-dry-run node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run run_logged update-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace-updated.json - node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-updated-inspect.json + node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --runtime --json >/tmp/plugins-marketplace-updated-inspect.json node scripts/e2e/lib/plugins/assertions.mjs marketplace-updated } diff --git a/scripts/e2e/lib/plugins/sweep.sh b/scripts/e2e/lib/plugins/sweep.sh index d5ba7660e7f..df814ad6304 100644 --- a/scripts/e2e/lib/plugins/sweep.sh +++ b/scripts/e2e/lib/plugins/sweep.sh @@ -22,7 +22,7 @@ write_demo_fixture_plugin "$demo_plugin_root" record_fixture_plugin_trust "$demo_plugin_id" "$demo_plugin_root" 1 node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin --json >/tmp/plugins-inspect.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin --runtime --json >/tmp/plugins-inspect.json node scripts/e2e/lib/plugins/assertions.mjs demo-plugin @@ -32,7 +32,7 @@ pack_fixture_plugin "$pack_dir" /tmp/demo-plugin-tgz.tgz demo-plugin-tgz 0.0.1 d run_logged install-tgz node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins2.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-tgz --json >/tmp/plugins2-inspect.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-tgz --runtime --json >/tmp/plugins2-inspect.json node scripts/e2e/lib/plugins/assertions.mjs plugin-tgz @@ -42,7 +42,7 @@ write_fixture_plugin "$dir_plugin" demo-plugin-dir 0.0.1 demo.dir "Demo Plugin D run_logged install-dir node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins3.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-dir --json >/tmp/plugins3-inspect.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-dir --runtime --json >/tmp/plugins3-inspect.json node scripts/e2e/lib/plugins/assertions.mjs plugin-dir @@ -52,10 +52,29 @@ write_fixture_plugin "$file_pack_dir/package" demo-plugin-file 0.0.1 demo.file " run_logged install-file node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins4.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-file --json >/tmp/plugins4-inspect.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-file --runtime --json >/tmp/plugins4-inspect.json node scripts/e2e/lib/plugins/assertions.mjs plugin-file +echo "Testing install from git repo and plugin CLI execution..." +git_fixture_root="$(mktemp -d "/tmp/openclaw-plugin-git.XXXXXX")" +git_repo="$git_fixture_root/repo" +git_repo_url="file://$git_repo" +write_fixture_plugin_with_cli "$git_repo" demo-plugin-git 0.0.1 demo.git "Demo Plugin Git" demo-git "demo-plugin-git:pong" +git -C "$git_repo" init -q +git -C "$git_repo" config user.email "docker-e2e@openclaw.local" +git -C "$git_repo" config user.name "OpenClaw Docker E2E" +git -C "$git_repo" add -A +git -C "$git_repo" commit -qm "test fixture" +git_ref="$(git -C "$git_repo" rev-parse HEAD)" + +run_logged install-git node "$OPENCLAW_ENTRY" plugins install "git:$git_repo_url@$git_ref" +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-git.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-git --runtime --json >/tmp/plugins-git-inspect.json +run_logged exec-git-plugin-cli bash -c 'node "$OPENCLAW_ENTRY" demo-git ping >/tmp/plugins-git-cli.txt' + +node scripts/e2e/lib/plugins/assertions.mjs plugin-git "$git_repo_url" "$git_ref" + echo "Testing Claude bundle enable and inspect flow..." bundle_plugin_id="claude-bundle-e2e" bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id" @@ -74,7 +93,7 @@ slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")" write_fixture_plugin "$slash_install_dir" slash-install-plugin 0.0.1 demo.slash.install "Slash Install Plugin" run_logged install-slash-plugin node "$OPENCLAW_ENTRY" plugins install "$slash_install_dir" -node "$OPENCLAW_ENTRY" plugins inspect slash-install-plugin --json >/tmp/plugin-command-install-show.json +node "$OPENCLAW_ENTRY" plugins inspect slash-install-plugin --runtime --json >/tmp/plugin-command-install-show.json node scripts/e2e/lib/plugins/assertions.mjs slash-install run_plugins_marketplace_scenario diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index 75e5ad3debf..e35311586a2 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -6,12 +6,17 @@ import { createCommandWorkspaceHarness } from "./commands-filesystem.test-suppor import { handlePluginsCommand } from "./commands-plugins.js"; import { buildPluginsCommandParams } from "./commands.test-harness.js"; -const { installPluginFromPathMock, installPluginFromClawHubMock, persistPluginInstallMock } = - vi.hoisted(() => ({ - installPluginFromPathMock: vi.fn(), - installPluginFromClawHubMock: vi.fn(), - persistPluginInstallMock: vi.fn(), - })); +const { + installPluginFromPathMock, + installPluginFromClawHubMock, + installPluginFromGitSpecMock, + persistPluginInstallMock, +} = vi.hoisted(() => ({ + installPluginFromPathMock: vi.fn(), + installPluginFromClawHubMock: vi.fn(), + installPluginFromGitSpecMock: vi.fn(), + persistPluginInstallMock: vi.fn(), +})); vi.mock("../../plugins/install.js", async () => { const actual = await vi.importActual( @@ -33,6 +38,16 @@ vi.mock("../../plugins/clawhub.js", async () => { }; }); +vi.mock("../../plugins/git-install.js", async () => { + const actual = await vi.importActual( + "../../plugins/git-install.js", + ); + return { + ...actual, + installPluginFromGitSpec: installPluginFromGitSpecMock, + }; +}); + vi.mock("../../cli/plugins-install-persist.js", () => ({ persistPluginInstall: persistPluginInstallMock, })); @@ -51,6 +66,7 @@ describe("handleCommands /plugins install", () => { afterEach(async () => { installPluginFromPathMock.mockReset(); installPluginFromClawHubMock.mockReset(); + installPluginFromGitSpecMock.mockReset(); persistPluginInstallMock.mockReset(); await workspaceHarness.cleanupWorkspaces(); }); @@ -149,6 +165,55 @@ describe("handleCommands /plugins install", () => { }); }); + it("installs from an explicit git: spec", async () => { + installPluginFromGitSpecMock.mockResolvedValue({ + ok: true, + pluginId: "git-demo", + targetDir: "/tmp/git-demo", + version: "1.2.3", + extensions: ["index.js"], + git: { + url: "https://github.com/acme/git-demo.git", + ref: "v1.2.3", + commit: "abc123", + resolvedAt: "2026-04-30T12:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install git:github.com/acme/git-demo@v1.2.3", + workspaceDir, + ); + const result = await handlePluginsCommand(params, true); + if (result === null) { + throw new Error("expected plugin install result"); + } + expect(result.reply?.text).toContain('Installed plugin "git-demo"'); + expect(installPluginFromGitSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "git:github.com/acme/git-demo@v1.2.3", + }), + ); + expect(persistPluginInstallMock).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "git-demo", + install: expect.objectContaining({ + source: "git", + spec: "git:github.com/acme/git-demo@v1.2.3", + installPath: "/tmp/git-demo", + version: "1.2.3", + gitUrl: "https://github.com/acme/git-demo.git", + gitRef: "v1.2.3", + gitCommit: "abc123", + }), + }), + ); + }); + }); + it("treats /plugin add as an install alias", async () => { installPluginFromClawHubMock.mockResolvedValue({ ok: true, diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 1f046ec2f07..05a3c9a3994 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -19,6 +19,7 @@ import type { PluginInstallRecord } from "../../config/types.plugins.js"; import { resolveArchiveKind } from "../../infra/archive.js"; import { parseClawHubPluginSpec } from "../../infra/clawhub.js"; import { installPluginFromClawHub } from "../../plugins/clawhub.js"; +import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import type { PluginRecord } from "../../plugins/registry.js"; @@ -197,6 +198,36 @@ async function installPluginFromPluginsCommand(params: { return { ok: false, error: `Path not found: ${resolved}` }; } + const gitPrefix = params.raw.trim().toLowerCase().startsWith("git:"); + const gitSpec = parseGitPluginSpec(params.raw); + if (gitPrefix && !gitSpec) { + return { ok: false, error: `unsupported git: plugin spec: ${params.raw}` }; + } + if (gitSpec) { + const result = await installPluginFromGitSpec({ + spec: params.raw, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: { + source: "git", + spec: params.raw, + installPath: result.targetDir, + version: result.version, + resolvedAt: result.git.resolvedAt, + gitUrl: result.git.url, + gitRef: result.git.ref, + gitCommit: result.git.commit, + }, + }); + return { ok: true, pluginId: result.pluginId }; + } + const clawhubSpec = parseClawHubPluginSpec(params.raw); if (clawhubSpec) { const result = await installPluginFromClawHub({ diff --git a/src/auto-reply/reply/plugins-commands.ts b/src/auto-reply/reply/plugins-commands.ts index 8fc050c7a75..da3d5a2cea6 100644 --- a/src/auto-reply/reply/plugins-commands.ts +++ b/src/auto-reply/reply/plugins-commands.ts @@ -43,7 +43,7 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null { if (!name) { return { action: "error", - message: "Usage: /plugins install ", + message: "Usage: /plugins install ", }; } return { action: "install", spec: name }; diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index d5ade74d7c3..4649a219dbb 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -13,6 +13,9 @@ type LoadConfigFn = (typeof import("../config/config.js"))["loadConfig"]; type ParseClawHubPluginSpecFn = (typeof import("../infra/clawhub.js"))["parseClawHubPluginSpec"]; type InstallPluginFromMarketplaceFn = (typeof import("../plugins/marketplace.js"))["installPluginFromMarketplace"]; +type InstallPluginFromGitSpecFn = + (typeof import("../plugins/git-install.js"))["installPluginFromGitSpec"]; +type ParseGitPluginSpecFn = (typeof import("../plugins/git-install.js"))["parseGitPluginSpec"]; type ListMarketplacePluginsFn = (typeof import("../plugins/marketplace.js"))["listMarketplacePlugins"]; type ResolveMarketplaceInstallShortcutFn = @@ -38,6 +41,8 @@ export const replaceConfigFile: AsyncUnknownMock = vi.fn( ) as AsyncUnknownMock; export const resolveStateDir: Mock<() => string> = vi.fn(() => "/tmp/openclaw-state"); export const installPluginFromMarketplace: Mock = vi.fn(); +export const installPluginFromGitSpec: Mock = vi.fn(); +export const parseGitPluginSpec: Mock = vi.fn(); export const listMarketplacePlugins: Mock = vi.fn(); export const resolveMarketplaceInstallShortcut: Mock = vi.fn(); export const enablePluginInConfig: UnknownMock = vi.fn(); @@ -430,6 +435,29 @@ vi.mock("../plugins/install.js", () => ({ )) as (typeof import("../plugins/install.js"))["installPluginFromPath"], })); +vi.mock("../plugins/git-install.js", () => ({ + installPluginFromGitSpec: (( + ...args: Parameters<(typeof import("../plugins/git-install.js"))["installPluginFromGitSpec"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/git-install.js"))["installPluginFromGitSpec"]>, + ReturnType<(typeof import("../plugins/git-install.js"))["installPluginFromGitSpec"]> + >( + installPluginFromGitSpec, + ...args, + )) as (typeof import("../plugins/git-install.js"))["installPluginFromGitSpec"], + parseGitPluginSpec: (( + ...args: Parameters<(typeof import("../plugins/git-install.js"))["parseGitPluginSpec"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/git-install.js"))["parseGitPluginSpec"]>, + ReturnType<(typeof import("../plugins/git-install.js"))["parseGitPluginSpec"]> + >( + parseGitPluginSpec, + ...args, + )) as (typeof import("../plugins/git-install.js"))["parseGitPluginSpec"], +})); + vi.mock("../hooks/install.js", () => ({ installHooksFromNpmSpec: (( ...args: Parameters<(typeof import("../hooks/install.js"))["installHooksFromNpmSpec"]> @@ -538,6 +566,8 @@ export function resetPluginsCliTestState() { updateNpmInstalledPlugins.mockReset(); updateNpmInstalledHookPacks.mockReset(); promptYesNo.mockReset(); + installPluginFromGitSpec.mockReset(); + parseGitPluginSpec.mockReset(); installPluginFromNpmSpec.mockReset(); installPluginFromPath.mockReset(); installPluginFromClawHub.mockReset(); @@ -662,6 +692,26 @@ export function resetPluginsCliTestState() { }); promptYesNo.mockResolvedValue(true); installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); + installPluginFromGitSpec.mockResolvedValue({ + ok: false, + error: "git install disabled in test", + }); + parseGitPluginSpec.mockImplementation((raw: string) => { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith("git:")) { + return null; + } + const body = trimmed.slice("git:".length).trim(); + if (!body) { + return null; + } + return { + input: trimmed, + url: body, + label: body, + normalizedSpec: trimmed, + }; + }); installPluginFromNpmSpec.mockResolvedValue({ ok: false, error: "npm install disabled in test", diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 0d8bdec54eb..95f7e671961 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -11,6 +11,7 @@ import { installHooksFromNpmSpec, installHooksFromPath, installPluginFromClawHub, + installPluginFromGitSpec, installPluginFromMarketplace, installPluginFromNpmSpec, installPluginFromPath, @@ -102,6 +103,24 @@ function createNpmPluginInstallResult( }; } +function createGitPluginInstallResult( + pluginId = "demo", +): Awaited> { + return { + ok: true, + pluginId, + targetDir: cliInstallPath(pluginId), + version: "1.2.3", + extensions: ["index.js"], + git: { + url: "https://github.com/acme/demo.git", + ref: "v1.2.3", + commit: "abc123", + resolvedAt: "2026-04-30T00:00:00.000Z", + }, + }; +} + function mockClawHubPackageNotFound(packageName: string) { installPluginFromClawHub.mockResolvedValue({ ok: false, @@ -852,6 +871,53 @@ describe("plugins cli install", () => { expect(runtimeErrors.at(-1)).toContain("unsupported npm: spec: missing package"); }); + it("installs directly from git when git: prefix is used", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("demo"); + + loadConfig.mockReturnValue(cfg); + installPluginFromGitSpec.mockResolvedValue(createGitPluginInstallResult("demo")); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand(["plugins", "install", "git:github.com/acme/demo@v1.2.3"]); + + expect(installPluginFromGitSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "git:github.com/acme/demo@v1.2.3", + mode: "install", + }), + ); + expect(installPluginFromClawHub).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + demo: expect.objectContaining({ + source: "git", + spec: "git:github.com/acme/demo@v1.2.3", + installPath: cliInstallPath("demo"), + gitUrl: "https://github.com/acme/demo.git", + gitRef: "v1.2.3", + gitCommit: "abc123", + }), + }); + expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg); + }); + + it("rejects --pin for git installs and points at git refs", async () => { + loadConfig.mockReturnValue({} as OpenClawConfig); + + await expect( + runPluginsCommand(["plugins", "install", "git:github.com/acme/demo", "--pin"]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromGitSpec).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain("use `git:@`"); + }); + it("passes dangerous force unsafe install to marketplace installs", async () => { await expect( runPluginsCommand([ diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index ba3cf52a312..8410816c5f7 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -353,7 +353,7 @@ export function registerPluginsCli(program: Command) { plugins .command("install") .description( - "Install a plugin or hook pack (path, archive, npm spec, clawhub:package, or marketplace entry)", + "Install a plugin or hook pack (path, archive, npm spec, git repo, clawhub:package, or marketplace entry)", ) .argument( "", diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 50301cbdbcf..e4dff4d958d 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -8,6 +8,7 @@ import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { installPluginFromClawHub } from "../plugins/clawhub.js"; +import { installPluginFromGitSpec, parseGitPluginSpec } from "../plugins/git-install.js"; import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; import { @@ -323,6 +324,42 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { return { ok: true }; } +async function tryInstallPluginFromGitSpec(params: { + snapshot: ConfigSnapshotForInstallPersist; + installMode: "install" | "update"; + spec: string; + safetyOverrides: InstallSafetyOverrides; + extensionsDir: string; +}): Promise<{ ok: true } | { ok: false }> { + const result = await installPluginFromGitSpec({ + ...params.safetyOverrides, + mode: params.installMode, + spec: params.spec, + extensionsDir: params.extensionsDir, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + defaultRuntime.error(result.error); + return { ok: false }; + } + + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: { + source: "git", + spec: params.spec, + installPath: result.targetDir, + version: result.version, + resolvedAt: result.git.resolvedAt, + gitUrl: result.git.url, + gitRef: result.git.ref, + gitCommit: result.git.commit, + }, + }); + return { ok: true }; +} + function isTerminalPluginInstallSecurityFailure(code?: string): boolean { return ( code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED || @@ -444,6 +481,20 @@ export async function runPluginInstallCommand(params: { return defaultRuntime.exit(1); } } + const gitPrefix = raw.trim().toLowerCase().startsWith("git:"); + const gitSpec = parseGitPluginSpec(raw); + if (gitPrefix && !gitSpec) { + defaultRuntime.error(`unsupported git: plugin spec: ${raw}`); + return defaultRuntime.exit(1); + } + if (gitSpec && opts.link) { + defaultRuntime.error("`--link` is not supported with `git:` installs."); + return defaultRuntime.exit(1); + } + if (gitSpec && opts.pin) { + defaultRuntime.error("`--pin` is not supported with `git:` installs; use `git:@`."); + return defaultRuntime.exit(1); + } if (opts.link && opts.force) { defaultRuntime.error("`--force` is not supported with `--link`."); return defaultRuntime.exit(1); @@ -625,6 +676,20 @@ export async function runPluginInstallCommand(params: { return; } + if (gitSpec) { + const gitResult = await tryInstallPluginFromGitSpec({ + snapshot, + installMode, + spec: raw, + safetyOverrides, + extensionsDir, + }); + if (!gitResult.ok) { + return defaultRuntime.exit(1); + } + return; + } + if ( looksLikeLocalInstallSpec(raw, [ ".ts", diff --git a/src/config/types.installs.ts b/src/config/types.installs.ts index cec0d133dad..ed75679466f 100644 --- a/src/config/types.installs.ts +++ b/src/config/types.installs.ts @@ -1,5 +1,5 @@ export type InstallRecordBase = { - source: "npm" | "archive" | "path" | "clawhub"; + source: "npm" | "archive" | "path" | "clawhub" | "git"; spec?: string; sourcePath?: string; installPath?: string; @@ -15,4 +15,7 @@ export type InstallRecordBase = { clawhubPackage?: string; clawhubFamily?: "code-plugin" | "bundle-plugin"; clawhubChannel?: "official" | "community" | "private"; + gitUrl?: string; + gitRef?: string; + gitCommit?: string; }; diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index dc09d3ea48f..20e5d444eac 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -5,6 +5,7 @@ export const InstallSourceSchema = z.union([ z.literal("archive"), z.literal("path"), z.literal("clawhub"), + z.literal("git"), ]); export const PluginInstallSourceSchema = z.union([InstallSourceSchema, z.literal("marketplace")]); @@ -28,6 +29,9 @@ export const InstallRecordShape = { clawhubChannel: z .union([z.literal("official"), z.literal("community"), z.literal("private")]) .optional(), + gitUrl: z.string().optional(), + gitRef: z.string().optional(), + gitCommit: z.string().optional(), } as const; export const PluginInstallRecordShape = { diff --git a/src/plugins/git-install.test.ts b/src/plugins/git-install.test.ts new file mode 100644 index 00000000000..36034e372cb --- /dev/null +++ b/src/plugins/git-install.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const runCommandWithTimeoutMock = vi.fn(); +const installPluginFromDirMock = vi.fn(); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +vi.mock("./install.js", async () => { + const actual = await vi.importActual("./install.js"); + return { + ...actual, + installPluginFromDir: (...args: unknown[]) => installPluginFromDirMock(...args), + }; +}); + +vi.resetModules(); + +const { installPluginFromGitSpec, parseGitPluginSpec } = await import("./git-install.js"); + +describe("parseGitPluginSpec", () => { + it("normalizes GitHub shorthand and ref selectors", () => { + expect(parseGitPluginSpec("git:github.com/acme/demo@v1.2.3")).toMatchObject({ + url: "https://github.com/acme/demo.git", + ref: "v1.2.3", + label: "acme/demo", + normalizedSpec: "git:https://github.com/acme/demo.git@v1.2.3", + }); + expect(parseGitPluginSpec("git:acme/demo#main")).toMatchObject({ + url: "https://github.com/acme/demo.git", + ref: "main", + }); + }); + + it("keeps scp-style clone URLs without treating git@ as a ref", () => { + expect(parseGitPluginSpec("git:git@github.com:acme/demo.git@release")).toMatchObject({ + url: "git@github.com:acme/demo.git", + ref: "release", + label: "git@github.com:acme/demo.git", + }); + }); +}); + +describe("installPluginFromGitSpec", () => { + beforeEach(() => { + runCommandWithTimeoutMock.mockReset(); + installPluginFromDirMock.mockReset(); + }); + + it("clones, checks out refs, installs from the clone, and returns commit metadata", async () => { + runCommandWithTimeoutMock + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }); + installPluginFromDirMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.2.3", + extensions: ["index.js"], + }); + + const result = await installPluginFromGitSpec({ + spec: "git:github.com/acme/demo@v1.2.3", + expectedPluginId: "demo", + }); + + expect(result).toMatchObject({ + ok: true, + pluginId: "demo", + git: { + url: "https://github.com/acme/demo.git", + ref: "v1.2.3", + commit: "abc123", + }, + }); + expect(runCommandWithTimeoutMock.mock.calls[0][0]).toEqual([ + "git", + "clone", + "https://github.com/acme/demo.git", + expect.stringContaining("/repo"), + ]); + expect(runCommandWithTimeoutMock.mock.calls[1][0]).toEqual([ + "git", + "checkout", + "--detach", + "v1.2.3", + ]); + expect(installPluginFromDirMock).toHaveBeenCalledWith( + expect.objectContaining({ + expectedPluginId: "demo", + installPolicyRequest: { + kind: "plugin-git", + requestedSpecifier: "git:github.com/acme/demo@v1.2.3", + }, + }), + ); + }); + + it("uses a shallow clone when no ref is requested", async () => { + runCommandWithTimeoutMock + .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" }) + .mockResolvedValueOnce({ code: 0, stdout: "abc123\n", stderr: "" }); + installPluginFromDirMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.2.3", + extensions: ["index.js"], + }); + + await installPluginFromGitSpec({ spec: "git:github.com/acme/demo" }); + + expect(runCommandWithTimeoutMock.mock.calls[0][0]).toEqual([ + "git", + "clone", + "--depth", + "1", + "https://github.com/acme/demo.git", + expect.stringContaining("/repo"), + ]); + }); +}); diff --git a/src/plugins/git-install.ts b/src/plugins/git-install.ts new file mode 100644 index 00000000000..44c4000091a --- /dev/null +++ b/src/plugins/git-install.ts @@ -0,0 +1,298 @@ +import { withTempDir } from "../infra/install-source-utils.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; +import { resolveUserPath } from "../utils.js"; +import type { InstallSafetyOverrides } from "./install-security-scan.js"; +import { installPluginFromDir, type InstallPluginResult } from "./install.js"; + +const GIT_SPEC_PREFIX = "git:"; +const DEFAULT_GIT_TIMEOUT_MS = 120_000; + +type PluginInstallLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +export type GitPluginResolution = { + url: string; + ref?: string; + commit?: string; + resolvedAt: string; +}; + +export type GitPluginInstallResult = + | (Extract & { git: GitPluginResolution }) + | Extract; + +export type ParsedGitPluginSpec = { + input: string; + url: string; + ref?: string; + label: string; + normalizedSpec: string; +}; + +function splitGitSpecRef(input: string): { base: string; ref?: string } { + const hashIndex = input.lastIndexOf("#"); + if (hashIndex > 0) { + return { + base: input.slice(0, hashIndex), + ref: normalizeOptionalString(input.slice(hashIndex + 1)), + }; + } + + const atIndex = input.lastIndexOf("@"); + const lastSlashIndex = Math.max(input.lastIndexOf("/"), input.lastIndexOf("\\")); + if (atIndex > lastSlashIndex && atIndex > 0) { + return { + base: input.slice(0, atIndex), + ref: normalizeOptionalString(input.slice(atIndex + 1)), + }; + } + + return { base: input }; +} + +function looksLikeGitHubRepoShorthand(value: string): boolean { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value); +} + +function looksLikeGitHubHostPath(value: string): boolean { + return /^github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/i.test(value); +} + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function isGitUrl(value: string): boolean { + return ( + /^(?:ssh|git|file):\/\//i.test(value) || + /^[^@\s]+@[^:\s]+:.+/.test(value) || + value.endsWith(".git") + ); +} + +function stripGitSuffix(value: string): string { + return value.replace(/\.git$/i, ""); +} + +function normalizeGitHubRepo(value: string): { url: string; label: string } { + const repo = stripGitSuffix(value.replace(/^github\.com\//i, "")); + return { + url: `https://github.com/${repo}.git`, + label: repo, + }; +} + +function normalizeGitLabel(value: string): string { + if (isHttpUrl(value) || /^(?:ssh|git|file):\/\//i.test(value)) { + try { + const url = new URL(value); + return stripGitSuffix(`${url.hostname}${url.pathname}`).replace(/^\/+/, ""); + } catch { + return value; + } + } + return value; +} + +export function parseGitPluginSpec(raw: string): ParsedGitPluginSpec | null { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith(GIT_SPEC_PREFIX)) { + return null; + } + + const body = trimmed.slice(GIT_SPEC_PREFIX.length).trim(); + if (!body) { + return null; + } + + const split = splitGitSpecRef(body); + const base = split.base.trim(); + if (!base) { + return null; + } + + if (looksLikeGitHubRepoShorthand(base) || looksLikeGitHubHostPath(base)) { + const normalized = normalizeGitHubRepo(base); + return { + input: trimmed, + url: normalized.url, + ref: split.ref, + label: normalized.label, + normalizedSpec: `${GIT_SPEC_PREFIX}${normalized.url}${split.ref ? `@${split.ref}` : ""}`, + }; + } + + if ( + isHttpUrl(base) || + isGitUrl(base) || + base.startsWith("./") || + base.startsWith("../") || + base.startsWith("~/") + ) { + const url = + base.startsWith("./") || base.startsWith("../") || base.startsWith("~/") + ? resolveUserPath(base) + : base; + return { + input: trimmed, + url, + ref: split.ref, + label: normalizeGitLabel(url), + normalizedSpec: `${GIT_SPEC_PREFIX}${url}${split.ref ? `@${split.ref}` : ""}`, + }; + } + + return null; +} + +function createGitCommandEnv(): NodeJS.ProcessEnv { + return { + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TEMPLATE_DIR: "", + GIT_EDITOR: "", + GIT_SEQUENCE_EDITOR: "", + GIT_EXTERNAL_DIFF: "", + GIT_DIR: undefined, + GIT_WORK_TREE: undefined, + GIT_COMMON_DIR: undefined, + GIT_INDEX_FILE: undefined, + GIT_OBJECT_DIRECTORY: undefined, + GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined, + GIT_NAMESPACE: undefined, + GIT_EXEC_PATH: undefined, + GIT_SSL_NO_VERIFY: undefined, + }; +} + +function formatGitCommandFailure(params: { + action: string; + source: ParsedGitPluginSpec; + stdout: string; + stderr: string; +}): string { + const detail = sanitizeForLog(params.stderr.trim() || params.stdout.trim() || "git failed"); + return `failed to ${params.action} ${sanitizeForLog(redactSensitiveUrlLikeString(params.source.label))}: ${detail}`; +} + +async function runGitCommand(params: { + argv: string[]; + action: string; + source: ParsedGitPluginSpec; + cwd?: string; + timeoutMs?: number; +}): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> { + const result = await runCommandWithTimeout(params.argv, { + cwd: params.cwd, + timeoutMs: params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS, + env: createGitCommandEnv(), + }); + if (result.code !== 0) { + return { + ok: false, + error: formatGitCommandFailure({ + action: params.action, + source: params.source, + stdout: result.stdout, + stderr: result.stderr, + }), + }; + } + return { ok: true, stdout: result.stdout }; +} + +export async function installPluginFromGitSpec( + params: InstallSafetyOverrides & { + spec: string; + extensionsDir?: string; + timeoutMs?: number; + logger?: PluginInstallLogger; + mode?: "install" | "update"; + dryRun?: boolean; + expectedPluginId?: string; + }, +): Promise { + const parsed = parseGitPluginSpec(params.spec); + if (!parsed) { + return { + ok: false, + error: `unsupported git: plugin spec: ${params.spec}`, + }; + } + + return await withTempDir("openclaw-git-plugin-", async (tmpDir) => { + const repoDir = `${tmpDir}/repo`; + params.logger?.info?.( + `Cloning ${sanitizeForLog(redactSensitiveUrlLikeString(parsed.label))}...`, + ); + const cloneArgs = parsed.ref + ? ["git", "clone", parsed.url, repoDir] + : ["git", "clone", "--depth", "1", parsed.url, repoDir]; + const clone = await runGitCommand({ + argv: cloneArgs, + action: "clone", + source: parsed, + timeoutMs: params.timeoutMs, + }); + if (!clone.ok) { + return clone; + } + + if (parsed.ref) { + const checkout = await runGitCommand({ + argv: ["git", "checkout", "--detach", parsed.ref], + action: `checkout ${parsed.ref}`, + source: parsed, + cwd: repoDir, + timeoutMs: params.timeoutMs, + }); + if (!checkout.ok) { + return checkout; + } + } + + const rev = await runGitCommand({ + argv: ["git", "rev-parse", "HEAD"], + action: "resolve commit for", + source: parsed, + cwd: repoDir, + timeoutMs: params.timeoutMs, + }); + if (!rev.ok) { + return rev; + } + + const result = await installPluginFromDir({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + dirPath: repoDir, + dryRun: params.dryRun, + expectedPluginId: params.expectedPluginId, + extensionsDir: params.extensionsDir, + logger: params.logger, + mode: params.mode, + timeoutMs: params.timeoutMs, + installPolicyRequest: { + kind: "plugin-git", + requestedSpecifier: parsed.input, + }, + }); + if (!result.ok) { + return result; + } + + return { + ...result, + git: { + url: parsed.url, + ref: parsed.ref, + commit: normalizeOptionalString(rev.stdout), + resolvedAt: new Date().toISOString(), + }, + }; + }); +} diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 7ef0e9c85d5..ae2c06c3cf6 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -703,7 +703,8 @@ export type PluginInstallRequestKind = | "plugin-dir" | "plugin-archive" | "plugin-file" - | "plugin-npm"; + | "plugin-npm" + | "plugin-git"; export type PluginInstallSourcePathKind = "file" | "directory"; export type PluginInstallFinding = { diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index 6815fc04857..30056a429b2 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -71,7 +71,8 @@ type PluginInstallRequestKind = | "plugin-dir" | "plugin-archive" | "plugin-file" - | "plugin-npm"; + | "plugin-npm" + | "plugin-git"; type SkillInstallSpec = { id?: string; diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 48095a99d5e..5e15c3894cb 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -16,7 +16,8 @@ export type PluginInstallRequestKind = | "plugin-dir" | "plugin-archive" | "plugin-file" - | "plugin-npm"; + | "plugin-npm" + | "plugin-git"; export type SkillInstallSpecMetadata = { id?: string; diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 71dee0fc087..bb9a36c7e6a 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -89,7 +89,7 @@ export type PluginNpmIntegrityDriftParams = { }; type PluginInstallPolicyRequest = { - kind: "plugin-dir" | "plugin-archive" | "plugin-file" | "plugin-npm"; + kind: "plugin-dir" | "plugin-archive" | "plugin-file" | "plugin-npm" | "plugin-git"; requestedSpecifier?: string; }; diff --git a/src/plugins/installed-plugin-index-install-records.ts b/src/plugins/installed-plugin-index-install-records.ts index a0096211530..aa17d4f9275 100644 --- a/src/plugins/installed-plugin-index-install-records.ts +++ b/src/plugins/installed-plugin-index-install-records.ts @@ -42,6 +42,9 @@ function normalizeInstallRecord( setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage); setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily); setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel); + setInstallStringField(normalized, "gitUrl", record.gitUrl); + setInstallStringField(normalized, "gitRef", record.gitRef); + setInstallStringField(normalized, "gitCommit", record.gitCommit); setInstallStringField(normalized, "marketplaceName", record.marketplaceName); setInstallStringField(normalized, "marketplaceSource", record.marketplaceSource); setInstallStringField(normalized, "marketplacePlugin", record.marketplacePlugin); diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index 598a3332c7b..817699d3661 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -164,6 +164,34 @@ describe("plugin index install records store", () => { }); }); + it("preserves git install resolution fields in persisted records", async () => { + const stateDir = makeStateDir(); + const candidate = createPluginCandidate(stateDir, "git-demo"); + await writePersistedInstalledPluginIndexInstallRecords( + { + "git-demo": { + source: "git", + spec: "git:file:///tmp/git-demo@abc123", + installPath: path.join(stateDir, "plugins", "git-demo"), + gitUrl: "file:///tmp/git-demo", + gitRef: "abc123", + gitCommit: "abc123", + }, + }, + { stateDir, candidates: [candidate] }, + ); + + await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toMatchObject({ + "git-demo": { + source: "git", + spec: "git:file:///tmp/git-demo@abc123", + gitUrl: "file:///tmp/git-demo", + gitRef: "abc123", + gitCommit: "abc123", + }, + }); + }); + it("returns an empty record map when no plugin index exists", () => { const stateDir = makeStateDir(); diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index 655797e4691..4f64d54323e 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -48,6 +48,9 @@ export type InstalledPluginInstallRecordInfo = Pick< | "clawhubPackage" | "clawhubFamily" | "clawhubChannel" + | "gitUrl" + | "gitRef" + | "gitCommit" | "marketplaceName" | "marketplaceSource" | "marketplacePlugin" diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index acd134135d2..657650645fd 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -15,6 +15,7 @@ function appBundledPluginRoot(pluginId: string): string { const installPluginFromNpmSpecMock = vi.fn(); const installPluginFromMarketplaceMock = vi.fn(); const installPluginFromClawHubMock = vi.fn(); +const installPluginFromGitSpecMock = vi.fn(); const resolveBundledPluginSourcesMock = vi.fn(); const runCommandWithTimeoutMock = vi.fn(); const tempDirs: string[] = []; @@ -28,6 +29,10 @@ vi.mock("./install.js", () => ({ }, })); +vi.mock("./git-install.js", () => ({ + installPluginFromGitSpec: (...args: unknown[]) => installPluginFromGitSpecMock(...args), +})); + vi.mock("./marketplace.js", () => ({ installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplaceMock(...args), })); @@ -143,6 +148,26 @@ function createClawHubInstallConfig(params: { }; } +function createGitInstallConfig(params: { + pluginId: string; + spec: string; + installPath: string; + commit?: string; +}): OpenClawConfig { + return { + plugins: { + installs: { + [params.pluginId]: { + source: "git" as const, + spec: params.spec, + installPath: params.installPath, + ...(params.commit ? { gitCommit: params.commit } : {}), + }, + }, + }, + }; +} + function createBundledPathInstallConfig(params: { loadPaths: string[]; installPath: string; @@ -277,6 +302,7 @@ describe("updateNpmInstalledPlugins", () => { installPluginFromNpmSpecMock.mockReset(); installPluginFromMarketplaceMock.mockReset(); installPluginFromClawHubMock.mockReset(); + installPluginFromGitSpecMock.mockReset(); resolveBundledPluginSourcesMock.mockReset(); runCommandWithTimeoutMock.mockReset(); }); @@ -1181,6 +1207,50 @@ describe("updateNpmInstalledPlugins", () => { }); }); + it("updates git installs and records resolved commit metadata", async () => { + installPluginFromGitSpecMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/demo", + version: "1.3.0", + extensions: ["index.ts"], + git: { + url: "https://github.com/acme/demo.git", + ref: "main", + commit: "def456", + resolvedAt: "2026-04-30T00:00:00.000Z", + }, + }); + + const result = await updateNpmInstalledPlugins({ + config: createGitInstallConfig({ + pluginId: "demo", + installPath: "/tmp/demo", + spec: "git:github.com/acme/demo@main", + commit: "abc123", + }), + pluginIds: ["demo"], + }); + + expect(installPluginFromGitSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "git:github.com/acme/demo@main", + expectedPluginId: "demo", + mode: "update", + }), + ); + expect(result.changed).toBe(true); + expect(result.config.plugins?.installs?.demo).toMatchObject({ + source: "git", + spec: "git:github.com/acme/demo@main", + installPath: "/tmp/demo", + version: "1.3.0", + gitUrl: "https://github.com/acme/demo.git", + gitRef: "main", + gitCommit: "def456", + }); + }); + it("forwards dangerous force unsafe install to plugin update installers", async () => { installPluginFromNpmSpecMock.mockResolvedValue( createSuccessfulNpmUpdateResult({ @@ -1242,6 +1312,19 @@ describe("updateNpmInstalledPlugins", () => { marketplaceSource: "acme/plugins", marketplacePlugin: "demo", }); + installPluginFromGitSpecMock.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: installPath, + version: "1.2.0", + extensions: ["index.ts"], + git: { + url: "https://github.com/acme/demo.git", + ref: "main", + commit: "abc123", + resolvedAt: "2026-04-30T00:00:00.000Z", + }, + }); await updateNpmInstalledPlugins({ config: createNpmInstallConfig({ @@ -1271,6 +1354,14 @@ describe("updateNpmInstalledPlugins", () => { }), pluginIds: ["demo"], }); + await updateNpmInstalledPlugins({ + config: createGitInstallConfig({ + pluginId: "demo", + installPath, + spec: "git:github.com/acme/demo@main", + }), + pluginIds: ["demo"], + }); expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( expect.objectContaining({ extensionsDir }), @@ -1281,12 +1372,16 @@ describe("updateNpmInstalledPlugins", () => { expect(installPluginFromMarketplaceMock).toHaveBeenCalledWith( expect.objectContaining({ extensionsDir }), ); + expect(installPluginFromGitSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ extensionsDir }), + ); }); }); describe("syncPluginsForUpdateChannel", () => { beforeEach(() => { installPluginFromNpmSpecMock.mockReset(); + installPluginFromGitSpecMock.mockReset(); resolveBundledPluginSourcesMock.mockReset(); }); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index f54724ae936..040f82573a1 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -18,6 +18,7 @@ import { getExternalizedBundledPluginTargetId, type ExternalizedBundledPluginBridge, } from "./externalized-bundled-plugins.js"; +import { installPluginFromGitSpec } from "./git-install.js"; import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE, @@ -105,6 +106,15 @@ function formatClawHubInstallFailure(params: { return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`; } +function formatGitInstallFailure(params: { + pluginId: string; + spec: string; + phase: "check" | "update"; + error: string; +}): string { + return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (git ${params.spec}).`; +} + type InstallIntegrityDrift = { spec: string; expectedIntegrity: string; @@ -523,7 +533,12 @@ export async function updateNpmInstalledPlugins(params: { } } - if (record.source !== "npm" && record.source !== "marketplace" && record.source !== "clawhub") { + if ( + record.source !== "npm" && + record.source !== "marketplace" && + record.source !== "clawhub" && + record.source !== "git" + ) { outcomes.push({ pluginId, status: "skipped", @@ -548,6 +563,15 @@ export async function updateNpmInstalledPlugins(params: { continue; } + if (record.source === "git" && !effectiveSpec) { + outcomes.push({ + pluginId, + status: "skipped", + message: `Skipping "${pluginId}" (missing git spec).`, + }); + continue; + } + if (record.source === "clawhub" && !record.clawhubPackage) { outcomes.push({ pluginId, @@ -621,6 +645,7 @@ export async function updateNpmInstalledPlugins(params: { let probe: | Awaited> | Awaited> + | Awaited> | Awaited>; try { probe = @@ -654,17 +679,28 @@ export async function updateNpmInstalledPlugins(params: { expectedPluginId: pluginId, logger, }) - : await installPluginFromMarketplace({ - marketplace: record.marketplaceSource!, - plugin: record.marketplacePlugin!, - mode: "update", - extensionsDir, - timeoutMs: params.timeoutMs, - dryRun: true, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - expectedPluginId: pluginId, - logger, - }); + : record.source === "git" + ? await installPluginFromGitSpec({ + spec: effectiveSpec!, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dryRun: true, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + logger, + }) + : await installPluginFromMarketplace({ + marketplace: record.marketplaceSource!, + plugin: record.marketplacePlugin!, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dryRun: true, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + logger, + }); } catch (err) { outcomes.push({ pluginId, @@ -692,20 +728,36 @@ export async function updateNpmInstalledPlugins(params: { phase: "check", error: probe.error, }) - : formatMarketplaceInstallFailure({ - pluginId, - marketplaceSource: record.marketplaceSource!, - marketplacePlugin: record.marketplacePlugin!, - phase: "check", - error: probe.error, - }), + : record.source === "git" + ? formatGitInstallFailure({ + pluginId, + spec: effectiveSpec!, + phase: "check", + error: probe.error, + }) + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "check", + error: probe.error, + }), }); continue; } const nextVersion = probe.version ?? "unknown"; const currentLabel = currentVersion ?? "unknown"; - if (currentVersion && probe.version && currentVersion === probe.version) { + const gitProbe = + record.source === "git" + ? (probe as Extract>, { ok: true }>) + .git + : undefined; + const unchanged = + record.source === "git" && record.gitCommit && gitProbe?.commit + ? record.gitCommit === gitProbe.commit + : Boolean(currentVersion && probe.version && currentVersion === probe.version); + if (unchanged) { outcomes.push({ pluginId, status: "unchanged", @@ -728,6 +780,7 @@ export async function updateNpmInstalledPlugins(params: { let result: | Awaited> | Awaited> + | Awaited> | Awaited>; try { result = @@ -759,16 +812,26 @@ export async function updateNpmInstalledPlugins(params: { expectedPluginId: pluginId, logger, }) - : await installPluginFromMarketplace({ - marketplace: record.marketplaceSource!, - plugin: record.marketplacePlugin!, - mode: "update", - extensionsDir, - timeoutMs: params.timeoutMs, - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - expectedPluginId: pluginId, - logger, - }); + : record.source === "git" + ? await installPluginFromGitSpec({ + spec: effectiveSpec!, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + logger, + }) + : await installPluginFromMarketplace({ + marketplace: record.marketplaceSource!, + plugin: record.marketplacePlugin!, + mode: "update", + extensionsDir, + timeoutMs: params.timeoutMs, + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + expectedPluginId: pluginId, + logger, + }); } catch (err) { outcomes.push({ pluginId, @@ -796,13 +859,20 @@ export async function updateNpmInstalledPlugins(params: { phase: "update", error: result.error, }) - : formatMarketplaceInstallFailure({ - pluginId, - marketplaceSource: record.marketplaceSource!, - marketplacePlugin: record.marketplacePlugin!, - phase: "update", - error: result.error, - }), + : record.source === "git" + ? formatGitInstallFailure({ + pluginId, + spec: effectiveSpec!, + phase: "update", + error: result.error, + }) + : formatMarketplaceInstallFailure({ + pluginId, + marketplaceSource: record.marketplaceSource!, + marketplacePlugin: record.marketplacePlugin!, + phase: "update", + error: result.error, + }), }); continue; } @@ -840,6 +910,22 @@ export async function updateNpmInstalledPlugins(params: { clawhubFamily: clawhubResult.clawhub.clawhubFamily, clawhubChannel: clawhubResult.clawhub.clawhubChannel, }); + } else if (record.source === "git") { + const gitResult = result as Extract< + Awaited>, + { ok: true } + >; + next = recordPluginInstall(next, { + pluginId: resolvedPluginId, + source: "git", + spec: effectiveSpec ?? record.spec, + installPath: result.targetDir, + version: nextVersion, + resolvedAt: gitResult.git.resolvedAt, + gitUrl: gitResult.git.url, + gitRef: gitResult.git.ref, + gitCommit: gitResult.git.commit, + }); } else { const marketplaceResult = result as Extract< Awaited>,