From 3147a749042a0a0aff65733c4ddd74e3a7f6aed4 Mon Sep 17 00:00:00 2001 From: SuperCLI Dev Date: Sat, 1 Aug 2026 14:47:34 +0000 Subject: [PATCH] fix(#335): implement `run ` one-shot command Implements `supercli run `, which syncs the plugin catalog from GitHub, installs the plugin if it is not already registered, and executes the requested command in a single invocation. Key details: - Adds a 1-hour freshness check for the local remote catalog, skipping the network fetch when the catalog is current. - Adds fuzzy plugin suggestions when a requested plugin is not found. - Defends `supercli.js` against a missing `dotenv` dependency so `npx` works. - Surfaces the new `run` command in help and help-json output. - Expands `__tests__/run-command.test.js` to cover validation, fast-path, install, execute, and not-found paths. Fixes #335 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- __tests__/run-command.test.js | 208 +++++++++++++++++++++++++++++++++- cli/help-json.js | 1 + cli/help.js | 2 + cli/run.js | 72 +++++++++--- cli/supercli.js | 6 +- 5 files changed, 270 insertions(+), 19 deletions(-) diff --git a/__tests__/run-command.test.js b/__tests__/run-command.test.js index 014a7db50..76a4848b4 100644 --- a/__tests__/run-command.test.js +++ b/__tests__/run-command.test.js @@ -10,9 +10,32 @@ * native sandbox, which these tests have no need to exercise. */ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + jest.mock("../cli/executor", () => ({ execute: jest.fn() })); +jest.mock("../cli/plugins-update", () => ({ updatePlugins: jest.fn() })); +jest.mock("../cli/plugins-install", () => ({ installPlugin: jest.fn(), getPlugin: jest.fn() })); +jest.mock("../cli/config", () => ({ loadConfig: jest.fn() })); +jest.mock("../cli/plugins-store", () => ({ + REMOTE_CATALOG_FILE: "/tmp/supercli-test-remote-catalog.json", +})); +jest.mock("../cli/plugins-registry", () => ({ listRegistryPlugins: jest.fn() })); + +const { handleRunCommand, catalogIsFresh, findSuggestions } = require("../cli/run"); +const { updatePlugins } = require("../cli/plugins-update"); +const { installPlugin, getPlugin } = require("../cli/plugins-install"); +const { loadConfig } = require("../cli/config"); +const { execute } = require("../cli/executor"); +const { listRegistryPlugins } = require("../cli/plugins-registry"); +const { REMOTE_CATALOG_FILE } = require("../cli/plugins-store"); -const { handleRunCommand } = require("../cli/run"); +function cleanupCatalog() { + try { + if (fs.existsSync(REMOTE_CATALOG_FILE)) fs.unlinkSync(REMOTE_CATALOG_FILE); + } catch (e) {} +} describe("handleRunCommand — usage validation", () => { test("missing plugin name reports invalid_argument with usage message", async () => { @@ -79,3 +102,186 @@ describe("handleRunCommand — usage validation", () => { ); }); }); + +describe("catalogIsFresh", () => { + test("returns true for a catalog file modified within the last hour", () => { + const tmp = path.join(os.tmpdir(), `supercli-run-fresh-${Date.now()}.json`); + fs.writeFileSync(tmp, "{}"); + try { + const now = Date.now(); + expect(catalogIsFresh(tmp, now)).toBe(true); + } finally { + fs.unlinkSync(tmp); + } + }); + + test("returns false for a catalog file older than one hour", () => { + const tmp = path.join(os.tmpdir(), `supercli-run-stale-${Date.now()}.json`); + fs.writeFileSync(tmp, "{}"); + try { + const twoHoursLater = Date.now() + 2 * 60 * 60 * 1000; + expect(catalogIsFresh(tmp, twoHoursLater)).toBe(false); + } finally { + fs.unlinkSync(tmp); + } + }); + + test("returns false when the catalog file is missing", () => { + const missing = path.join(os.tmpdir(), `supercli-run-missing-${Date.now()}.json`); + expect(catalogIsFresh(missing, Date.now())).toBe(false); + }); +}); + +describe("findSuggestions", () => { + test("returns exact-name matches first", () => { + listRegistryPlugins.mockReturnValue([ + { name: "claude-session-optimizer" }, + { name: "claude-cli" }, + ]); + expect(findSuggestions("claude-session-optimizer")).toEqual([ + "claude-session-optimizer", + "claude-cli", + ]); + }); + + test("falls back to fuzzy matches when no exact name match exists", () => { + listRegistryPlugins.mockReturnValue([ + { name: "github-mcp" }, + { name: "github-cli" }, + ]); + expect(findSuggestions("git")).toEqual(["github-mcp", "github-cli"]); + }); +}); + +describe("handleRunCommand — run flow", () => { + beforeEach(() => { + jest.clearAllMocks(); + cleanupCatalog(); + }); + + afterAll(() => { + cleanupCatalog(); + }); + + test("skips catalog update when the local catalog is fresh", async () => { + fs.mkdirSync(path.dirname(REMOTE_CATALOG_FILE), { recursive: true }); + fs.writeFileSync(REMOTE_CATALOG_FILE, "{}"); + + getPlugin.mockReturnValue({ name: "beads" }); + loadConfig.mockResolvedValue({ + commands: [ + { namespace: "beads", resource: "install", action: "steps", adapter: "shell" }, + ], + }); + execute.mockResolvedValue({ ok: true }); + + const output = jest.fn(); + const outputError = jest.fn(); + + await handleRunCommand({ + positional: ["run", "beads", "install", "steps"], + flags: {}, + humanMode: false, + output, + outputError, + }); + + expect(updatePlugins).not.toHaveBeenCalled(); + expect(installPlugin).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalled(); + expect(outputError).not.toHaveBeenCalled(); + }); + + test("updates catalog, installs, and executes when plugin is not installed", async () => { + getPlugin.mockReturnValue(null); + updatePlugins.mockResolvedValue({ added: 1, changed: 0 }); + installPlugin.mockReturnValue({ installed_commands: 3 }); + loadConfig.mockResolvedValue({ + commands: [ + { namespace: "beads", resource: "install", action: "steps", adapter: "shell" }, + ], + }); + execute.mockResolvedValue({ ok: true }); + + const output = jest.fn(); + const outputError = jest.fn(); + + await handleRunCommand({ + positional: ["run", "beads", "install", "steps"], + flags: {}, + humanMode: false, + output, + outputError, + }); + + expect(updatePlugins).toHaveBeenCalledWith({ check: false }); + expect(installPlugin).toHaveBeenCalled(); + expect(execute).toHaveBeenCalled(); + expect(outputError).not.toHaveBeenCalled(); + expect(output).toHaveBeenCalledWith( + expect.objectContaining({ command: "beads.install.steps" }) + ); + }); + + test("reports plugin not found with available plugin suggestions", async () => { + getPlugin.mockReturnValue(null); + updatePlugins.mockResolvedValue({ added: 0, changed: 0 }); + installPlugin.mockImplementation(() => { + throw new Error("not found"); + }); + listRegistryPlugins.mockReturnValue([ + { name: "claude-session-optimizer" }, + { name: "github-mcp" }, + ]); + + const output = jest.fn(); + const outputError = jest.fn(); + + await handleRunCommand({ + positional: ["run", "claude-sesion-optimizer", "self", "auto"], + flags: {}, + humanMode: false, + output, + outputError, + }); + + expect(output).not.toHaveBeenCalled(); + expect(outputError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 92, + type: "resource_not_found", + message: expect.stringContaining("claude-sesion-optimizer"), + suggestions: expect.arrayContaining([ + expect.stringContaining("Available plugins"), + ]), + }) + ); + }); + + test("reports command not found when plugin is installed but resource/action is missing", async () => { + getPlugin.mockReturnValue({ name: "beads" }); + loadConfig.mockResolvedValue({ + commands: [{ namespace: "beads", resource: "install", action: "steps" }], + }); + + const output = jest.fn(); + const outputError = jest.fn(); + + await handleRunCommand({ + positional: ["run", "beads", "missing", "action"], + flags: {}, + humanMode: false, + output, + outputError, + }); + + expect(output).not.toHaveBeenCalled(); + expect(outputError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 92, + type: "resource_not_found", + message: "Command 'beads.missing.action' not found in plugin 'beads'.", + }) + ); + }); +}); diff --git a/cli/help-json.js b/cli/help-json.js index f23cec979..6f0a9e2ba 100644 --- a/cli/help-json.js +++ b/cli/help-json.js @@ -9,6 +9,7 @@ function buildCapabilities(config, hasServer) { mcp: { subcommands: ["list", "add", "tools", "call", "bind", "doctor", "remove"], description: "Manage local MCP server registry and invoke MCP tools" }, commands: { description: "List all commands" }, inspect: { description: "Inspect command details", usage: "supercli inspect " }, + run: { description: "Sync catalog, install plugin, and execute a command", usage: "supercli run [--args]" }, plan: { description: "Create execution plan", usage: "supercli plan [--args]" }, execute: { description: "Execute a stored plan", usage: "supercli execute " }, skills: { diff --git a/cli/help.js b/cli/help.js index 046c31150..cbf1645c4 100644 --- a/cli/help.js +++ b/cli/help.js @@ -20,6 +20,7 @@ function displayJsonHelp() { ], core_commands: [ "supercli # Execute capability", + "supercli run # Sync catalog, install plugin, and execute", "supercli inspect # View command details", "supercli plan # Create execution plan", "supercli execute # Run stored plan", @@ -75,6 +76,7 @@ function displayComprehensiveHelp() { console.log(' supercli discover --intent "" # Find capabilities for a task\n'); console.log(" 🔧 CORE COMMANDS:"); console.log(" supercli # Execute capability"); + console.log(" supercli run # Sync catalog, install plugin, and execute"); console.log(" supercli inspect # View command details"); console.log(" supercli plan # Create execution plan"); console.log(" supercli execute # Run stored plan"); diff --git a/cli/run.js b/cli/run.js index a61f415d9..05acf7f84 100644 --- a/cli/run.js +++ b/cli/run.js @@ -7,11 +7,34 @@ * Designed for first-time / npx users — a single tweetable command. */ +const fs = require("fs"); const { updatePlugins } = require("./plugins-update"); const { installPlugin, getPlugin } = require("./plugins-install"); const { loadConfig } = require("./config"); const { execute } = require("./executor"); -const { makeOutput, makeOutputError, makeStreamEmitter } = require("./output"); +const { makeStreamEmitter } = require("./output"); +const { REMOTE_CATALOG_FILE } = require("./plugins-store"); +const { listRegistryPlugins } = require("./plugins-registry"); + +const CATALOG_FRESH_MS = 60 * 60 * 1000; // 1 hour + +function catalogIsFresh(filePath = REMOTE_CATALOG_FILE, now = Date.now()) { + try { + if (!fs.existsSync(filePath)) return false; + const st = fs.statSync(filePath); + const age = now - st.mtimeMs; + return age < CATALOG_FRESH_MS; + } catch { + return false; + } +} + +function findSuggestions(pluginName) { + const exact = listRegistryPlugins({ name: pluginName, nameOnly: true }).slice(0, 5); + if (exact.length > 0) return exact.map((p) => p.name); + const fuzzy = listRegistryPlugins({ name: pluginName }).slice(0, 5); + return fuzzy.map((p) => p.name); +} async function handleRunCommand({ positional, flags, humanMode, output, outputError }) { const pluginName = positional[1]; @@ -40,16 +63,25 @@ async function handleRunCommand({ positional, flags, humanMode, output, outputEr return; } - // ── Step 1: Sync plugin catalog from GitHub ────────────────────────── - try { - if (humanMode) process.stderr.write("Syncing plugin catalog...\n"); - const updateResult = await updatePlugins({ check: false }); - if (humanMode && updateResult.added + updateResult.changed > 0) { - process.stderr.write(` → ${updateResult.added} new, ${updateResult.changed} changed\n`); + const fullCommand = `${pluginName}.${resource}.${action}`; + const fresh = catalogIsFresh(); + let updated = false; + + // ── Step 1: Sync plugin catalog from GitHub if stale or missing ──────── + if (!fresh) { + try { + if (humanMode) process.stderr.write("Syncing plugin catalog...\n"); + const updateResult = await updatePlugins({ check: false }); + updated = true; + if (humanMode && updateResult.added + updateResult.changed > 0) { + process.stderr.write(` → ${updateResult.added} new, ${updateResult.changed} changed\n`); + } + } catch (err) { + // Non-fatal: if update fails, try anyway with local catalog + if (humanMode) process.stderr.write(` ⚠ Catalog sync failed (${err.message}), continuing with local catalog\n`); } - } catch (err) { - // Non-fatal: if update fails, try anyway with local catalog - if (humanMode) process.stderr.write(` ⚠ Catalog sync failed (${err.message}), continuing with local catalog\n`); + } else if (humanMode) { + process.stderr.write("Using fresh local catalog.\n"); } // ── Step 2: Install plugin if not already ───────────────────────────── @@ -64,23 +96,27 @@ async function handleRunCommand({ positional, flags, humanMode, output, outputEr }); if (humanMode) process.stderr.write(` → ${result.installed_commands} commands registered\n`); } catch (err) { + const available = findSuggestions(pluginName); + const suggestions = ["Check the plugin name and try again"]; + if (available.length > 0) { + suggestions.unshift(`Available plugins: ${available.join(", ")}`); + } + suggestions.push("Run: supercli plugins explore --json"); outputError({ code: 92, type: "resource_not_found", - message: `Plugin '${pluginName}' not found after catalog sync.`, - suggestions: [ - "Check the plugin name and try again", - "Run: supercli plugins explore --json", - ], + message: `Plugin '${pluginName}' not found${updated ? " after catalog sync" : " in the local catalog"}.`, + suggestions, recoverable: false, }); return; } + } else if (humanMode) { + process.stderr.write(`Plugin already installed: ${pluginName}\n`); } // ── Step 3: Reload config and execute ──────────────────────────────── const config = await loadConfig(); - const fullCommand = `${pluginName}.${resource}.${action}`; const cmd = config.commands.find( (c) => c.namespace === pluginName && c.resource === resource && c.action === action ); @@ -122,6 +158,8 @@ async function handleRunCommand({ positional, flags, humanMode, output, outputEr const start = Date.now(); try { const result = await execute(cmd, cmdFlags, { + server: process.env.SUPERCLI_SERVER || "", + config, onStreamEvent: cmd.adapterConfig?.stream === "jsonl" ? makeStreamEmitter(`${pluginName}.run`, { humanMode, output }) : null, }); @@ -146,4 +184,4 @@ async function handleRunCommand({ positional, flags, humanMode, output, outputEr } } -module.exports = { handleRunCommand }; +module.exports = { handleRunCommand, catalogIsFresh, findSuggestions }; diff --git a/cli/supercli.js b/cli/supercli.js index 28cafb68a..65db19036 100755 --- a/cli/supercli.js +++ b/cli/supercli.js @@ -2,7 +2,11 @@ "use strict"; -require("dotenv").config({ quiet: true }); +try { + require("dotenv").config({ quiet: true }); +} catch (e) { + // dotenv not installed, proceed without .env loading +} const { loadConfig, syncConfig, showConfig, setMcpServer, removeMcpServer, listMcpServers, upsertCommand, getClientId } = require("./config"); const { handleMcpRegistryCommand } = require("./mcp-local"); const { handlePluginsCommand } = require("./plugins-command");