Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 207 additions & 1 deletion __tests__/run-command.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'.",
})
);
});
});
1 change: 1 addition & 0 deletions cli/help-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ns> <res> <act>" },
run: { description: "Sync catalog, install plugin, and execute a command", usage: "supercli run <plugin> <resource> <action> [--args]" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the required default command executor.

These entries direct users to execute plugins with supercli run. Update them to use the required sc-zig default invocation.

  • cli/help-json.js#L12-L12: update the machine-readable run usage.
  • cli/help.js#L23-L23: update structured JSON help.
  • cli/help.js#L79-L79: update console help.

As per coding guidelines, use sc-zig by default for plugin discovery and command execution.

📍 Affects 2 files
  • cli/help-json.js#L12-L12 (this comment)
  • cli/help.js#L23-L23
  • cli/help.js#L79-L79
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/help-json.js` at line 12, Update the run help usage to invoke the
required sc-zig default executor instead of supercli in cli/help-json.js (line
12), cli/help.js (line 23), and cli/help.js (line 79). Keep the command syntax
and descriptions consistent across all machine-readable, structured JSON, and
console help entries.

Source: Coding guidelines

plan: { description: "Create execution plan", usage: "supercli plan <ns> <res> <act> [--args]" },
execute: { description: "Execute a stored plan", usage: "supercli execute <plan_id>" },
skills: {
Expand Down
2 changes: 2 additions & 0 deletions cli/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function displayJsonHelp() {
],
core_commands: [
"supercli <namespace> <resource> <action> # Execute capability",
"supercli run <plugin> <resource> <action> # Sync catalog, install plugin, and execute",
"supercli inspect <ns> <res> <act> # View command details",
"supercli plan <ns> <res> <act> # Create execution plan",
"supercli execute <plan_id> # Run stored plan",
Expand Down Expand Up @@ -75,6 +76,7 @@ function displayComprehensiveHelp() {
console.log(' supercli discover --intent "<task>" # Find capabilities for a task\n');
console.log(" 🔧 CORE COMMANDS:");
console.log(" supercli <namespace> <resource> <action> # Execute capability");
console.log(" supercli run <plugin> <resource> <action> # Sync catalog, install plugin, and execute");
console.log(" supercli inspect <ns> <res> <act> # View command details");
console.log(" supercli plan <ns> <res> <act> # Create execution plan");
console.log(" supercli execute <plan_id> # Run stored plan");
Expand Down
72 changes: 55 additions & 17 deletions cli/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +25 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Treat future catalog timestamps as stale.

A future mtimeMs produces a negative age, which passes the current check. Clock skew or restored metadata can then suppress synchronization until the system clock passes that timestamp.

Require age >= 0 before accepting the catalog as fresh. Add a future-timestamp test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/run.js` around lines 25 - 26, Update the catalog freshness check to
require a nonnegative age in addition to age being below CATALOG_FRESH_MS, so
future mtimeMs values are treated as stale. Add a test covering a future catalog
timestamp and verify it does not suppress synchronization.

} 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);
Comment on lines +32 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use equality before the fuzzy lookup.

listRegistryPlugins({ name, nameOnly: true }) uses substring matching. A query such as git returns github-* entries in the first lookup, so the fuzzy lookup never runs despite no exact plugin name.

  • cli/run.js#L32-L36: filter name-only results for normalized equality before returning them, then run the fuzzy lookup when no equal name exists.
  • __tests__/run-command.test.js#L147-L152: mock an empty first lookup and fuzzy results in the second lookup. Assert both filter calls.
📍 Affects 2 files
  • cli/run.js#L32-L36 (this comment)
  • __tests__/run-command.test.js#L147-L152
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/run.js` around lines 32 - 36, Update findSuggestions in cli/run.js (lines
32-36) to filter name-only results by normalized exact name equality before
returning; only perform the fuzzy lookup when no exact match remains. Update
__tests__/run-command.test.js (lines 147-152) to mock empty exact results and
fuzzy results, then assert both listRegistryPlugins calls.

}

async function handleRunCommand({ positional, flags, humanMode, output, outputError }) {
const pluginName = positional[1];
Expand Down Expand Up @@ -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;
Comment on lines +74 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Persist successful no-op catalog checks.

updatePlugins returns before writeLocalCatalog(remoteCatalog) when the remote catalog has no changes. The catalog mtime then remains stale, so every later run repeats the remote synchronization.

Persist a successful check timestamp, or rewrite/touch the local catalog after a successful no-op update. Add a regression test that runs twice after an unchanged stale-catalog sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/run.js` around lines 74 - 75, Update the successful no-op path around
updatePlugins so an unchanged remote catalog still persists the local catalog or
its successful-check timestamp before marking the run updated. Preserve the
existing behavior for catalogs with changes, and add a regression test that runs
twice with an unchanged stale catalog and verifies the second run does not
repeat synchronization.

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 ─────────────────────────────
Expand All @@ -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
);
Expand Down Expand Up @@ -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,
});
Expand All @@ -146,4 +184,4 @@ async function handleRunCommand({ positional, flags, humanMode, output, outputEr
}
}

module.exports = { handleRunCommand };
module.exports = { handleRunCommand, catalogIsFresh, findSuggestions };
6 changes: 5 additions & 1 deletion cli/supercli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +5 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files matching supercli =="
fd -a 'supercli\.js$' . || true

echo "== target file excerpt =="
if [ -f cli/supercli.js ]; then
  sed -n '1,80p' cli/supercli.js | cat -n
fi

echo "== dotenv references =="
rg -n "dotenv|config\\(\\{|MODULE_NOT_FOUND" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo "== package metadata =="
for f in package.json cli/package.json package-lock.json npm-shrinkwrap.json yarn.lock pnpm-lock.yaml; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    sed -n '1,120p' "$f"
  fi
done

echo "== behavioral probe: catch behavior for require/config failures =="
node - <<'JS'
const cases = [
  "missing package require",
  "missing package config",
  "sync require failing before export",
  ".config() throwing",
];
for (const name of cases) {
  const errors = [];
  const handlers = [];
  const fn = () => {
    handlers.push("loaded");
    (function () {
      if (name.startsWith(".config")) throw new Error("config failed");
      if (name === "missing package require") throw Object.assign(new Error(), { code: "MODULE_NOT_FOUND", message: "Cannot find module 'dotenv'" });
      if (name === "missing package config") throw Object.assign(new Error(), { code: "MODULE_NOT_FOUND", message: "Cannot find module 'dotenv'" });
      if (name.includes("synchronous") || name === "sync require failing before export") throw new TypeError("require failed");
      handlers.push("configured");
    })();
    throw new Error("post-config");
  });
  try { fn(); } catch (e) { errors.push(String(typeof e === 'object' && e.code ? e.code : e.message), e.code); }
  console.log(name, JSON.stringify({ errors, handlers }));
}
JS

Repository: javimosch/supercli

Length of output: 41592


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== behavioral probe: Node catch behavior for chained require/config errors =="
node - <<'JS'
function simulateMissingPackage() {
  const errors = [];
  const handlers = [];
  let dotenv;
  try {
    handlers.push("before require");
    try {
      dotenv = require("dotenv");
    } catch (error) {
      handlers.push("require caught");
      if (error?.code !== "MODULE_NOT_FOUND" || !String(error.message).includes("'dotenv'")) throw error;
    }
    dotenv?.config({ quiet: true });
    handlers.push("configure complete");
  } catch (error) {
    errors.push(String(typeof error === "object" && error.code ? error.code : error.message));
  }
  return { errors, handlers };
}

function simulateConfigFailure() {
  const errors = [];
  const handlers = [];
  let dotenv;
  const fakeModule = {
    config: function () {
      handlers.push("configure entered");
      throw new Error("dot configuration failed");
    }
  };
  try {
    handlers.push("before require");
    try {
      dotenv = fakeModule;
    } catch (error) {
      handlers.push("require caught");
      if (error?.code !== "MODULE_NOT_FOUND" || !String(error.message).includes("'dotenv'")) throw error;
    }
    dotenv?.config({ quiet: true });
    handlers.push("configure complete");
  } catch (error) {
    errors.push(String(typeof error === "object" && error.code ? error.code : error.message));
  }
  return { errors, handlers };
}

console.log("missing-package path:", JSON.stringify(simulateMissingPackage()));
console.log("config failure path:", JSON.stringify(simulateConfigFailure()));
JS

echo "== read-only check: catch shape in cli/supercli.js =="
python3 - <<'PY'
from pathlib import Path
text = Path("cli/supercli.js").read_text()
start = text.index("try {\n  require(\"dotenv\").config({ quiet: true });\n} catch (e) {")
end = text.index("}", start) + 1
print("first catch block:", text[start:end])
PY

Repository: javimosch/supercli

Length of output: 571


Do not swallow all dotenv initialization errors.

This catch covers both require("dotenv") and .config(), so any thrown dotenv error is treated as a missing package and ignored. Catch the missing-module case from require("dotenv") separately, then call .config({ quiet: true }) outside that block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/supercli.js` around lines 5 - 9, Update the dotenv initialization around
the top-level require so only a missing-module error from require("dotenv") is
handled as an optional dependency; rethrow any other require failure, retain the
loaded module, and invoke its config({ quiet: true }) method outside the catch
so configuration errors propagate.

const { loadConfig, syncConfig, showConfig, setMcpServer, removeMcpServer, listMcpServers, upsertCommand, getClientId } = require("./config");
const { handleMcpRegistryCommand } = require("./mcp-local");
const { handlePluginsCommand } = require("./plugins-command");
Expand Down
Loading