From 61d33ca4df248d2bd0e660d95ae1b7673c139081 Mon Sep 17 00:00:00 2001 From: NB Date: Thu, 10 Sep 2026 02:01:07 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(plugins):=20make=20the=20plugin=20syste?= =?UTF-8?q?m=20actually=20work=20=E2=80=94=20closes=20#165?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chittycan shipped 20 files under src/plugins/ declaring 8 commands and 3 remote types. None could ever load. `can ext list` told users to `npm install @chitty/cloudflare @chitty/neon @chitty/linear` — three packages that return E404. Five independent breaks, each verified against a build of origin/main: 1. loadAll() only iterated config.extensions; nothing considered in-tree plugins. 2. loadPlugin() does `await import(bareSpecifier)`, which resolves from node_modules, so the bundled plugins were unreachable by construction — and the three names they declare are unpublished, so the configured path had no target either. 3. getAllCommands() was never called. src/index.ts awaited loadAll() and then never asked the loader for anything, so even a loaded plugin's commands never reached yargs — which is .strict(), and rejected them as unknown arguments. 4. src/plugins/ai/{openai,anthropic}.ts imported "./gateway" with no extension. That is invalid in ESM, so the ai barrel threw ERR_MODULE_NOT_FOUND on import. 5. Command names are space-separated paths ("neon branch list"). Passed to yargs directly that declares a command `neon` taking two positionals named branch and list — it would accept `can neon foo bar`. They need assembling into a tree. Fixes, in order: an explicit bundled-plugin list imported by relative path (a directory scan costs a stat per entry on every invocation, and an explicit list fails loudly in review when someone adds a plugin without registering it); the two ESM specifiers; a new src/lib/plugin-commands.ts that builds the name tree and registers nested yargs commands before .strict(); and the wiring in index.ts. Eager, not lazy. Measured: importing all bundled plugins costs ~17ms against a ~280ms baseline startup (~6%). Lazy registration would need a static command manifest kept in sync with each plugin's exports — a permanent drift surface — to buy that back. An earlier measurement said 9ms. That was wrong: every import was failing fast with ERR_MODULE_NOT_FOUND (break 4) and being swallowed, so it timed five failures. The recommendation to go lazy rested on it and is withdrawn. Also removes `console.log("[chitty] X extension loaded")` from the three plugins' init(). Those never ran before; once plugins load, they print three lines of noise ahead of every command, including `can --version`. This fix caused that regression and closes it. ai/ and chittyos/ are deliberately not bundled — their index modules are barrel files exporting helpers, with no `metadata`, so they are not plugins. Verified: `can --help` lists cf/linear/neon; `can neon branch` lists its subcommands; `can neon bogus` still fails with "Specify a neon subcommand" (strict intact); `can neon branch list` reaches the real handler, which errors with "Remote db-prod not found or not a Neon project" — the plugin's own logic on an unconfigured machine, not a wiring failure. Full suite: 138 passed. 10 new tests, mutation-tested (reverting the nesting fails 7; reverting discovery fails 2). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JpREaEiCRhvs7vmVrfhxHP --- src/index.ts | 11 +++- src/lib/plugin-commands.ts | 76 ++++++++++++++++++++++++++ src/lib/plugin.ts | 36 +++++++++++++ src/plugins/ai/anthropic.ts | 2 +- src/plugins/ai/openai.ts | 2 +- src/plugins/cloudflare/index.ts | 1 - src/plugins/linear/index.ts | 1 - src/plugins/neon/index.ts | 1 - tests/plugin-commands.test.ts | 95 +++++++++++++++++++++++++++++++++ 9 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 src/lib/plugin-commands.ts create mode 100644 tests/plugin-commands.test.ts diff --git a/src/index.ts b/src/index.ts index 2337ac9..b7ff4f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { installZsh, uninstallZsh } from "./commands/hook.js"; import { syncSetup, syncRun, syncStatus } from "./commands/sync.js"; import { listExtensions, enableExtension, disableExtension, installExtension } from "./commands/extension.js"; import { PluginLoader } from "./lib/plugin.js"; +import { registerPluginCommands } from "./lib/plugin-commands.js"; import { doctor } from "./commands/doctor.js"; import { briefCommand } from "./commands/brief.js"; import { chittyCommand } from "./commands/chitty.js"; @@ -123,7 +124,7 @@ if (firstArg && firstArg in CLI_CONFIGS) { process.exit(0); } -yargs(args) +const cli = yargs(args) .scriptName("can") .usage("$0 [options]") .command( @@ -1246,6 +1247,14 @@ yargs(args) process.exit(1); }) .demandCommand(1, "You must provide a command") + ; + +// Plugin commands must be registered BEFORE .strict(), which rejects anything unknown. +// loadAll() populated the loader above; without this call getAllCommands() was never +// consumed and every plugin-supplied command was unreachable. +registerPluginCommands(cli, pluginLoader.getAllCommands(), config); + +cli .strict() .help() .alias("h", "help") diff --git a/src/lib/plugin-commands.ts b/src/lib/plugin-commands.ts new file mode 100644 index 0000000..f3fa52f --- /dev/null +++ b/src/lib/plugin-commands.ts @@ -0,0 +1,76 @@ +import type { Argv } from "yargs"; +import type { CommandDefinition } from "./plugin.js"; +import type { Config } from "./config.js"; + +/** + * Register plugin-supplied commands with yargs. + * + * Plugin command names are space-separated paths ("neon branch list"), not yargs command + * strings. Passing one straight to `.command()` would declare a command `neon` taking two + * positionals called `branch` and `list` — accepting `can neon foo bar` and rejecting + * nothing. So the names are first assembled into a tree and registered as nested commands. + * + * Leaf handlers receive yargs' argv plus the CLI config, matching CommandDefinition. + */ + +interface Node { + children: Map; + leaf?: CommandDefinition; +} + +export function buildCommandTree(commands: CommandDefinition[]): Node { + const root: Node = { children: new Map() }; + for (const cmd of commands) { + const parts = cmd.name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) continue; + let node = root; + for (const part of parts) { + let next = node.children.get(part); + if (!next) { + next = { children: new Map() }; + node.children.set(part, next); + } + node = next; + } + // Last definition wins, matching the loader's "a configured extension wins" ordering. + node.leaf = cmd; + } + return root; +} + +function describe(node: Node, name: string): string { + if (node.leaf) return node.leaf.description; + const kids = Array.from(node.children.keys()).join(", "); + return `${name} subcommands: ${kids}`; +} + +function registerNode(y: Argv, name: string, node: Node, config: Config): Argv { + return y.command( + node.children.size > 0 ? `${name} ` : name, + describe(node, name), + (sub: Argv) => { + for (const [childName, child] of node.children) { + registerNode(sub, childName, child, config); + } + if (node.leaf?.options) sub.options(node.leaf.options); + // A branch with children but no handler of its own must not silently succeed. + return node.children.size > 0 ? sub.demandCommand(1, `Specify a ${name} subcommand`) : sub; + }, + async (argv: any) => { + if (!node.leaf?.handler) return; + await node.leaf.handler(argv, config); + } + ); +} + +export function registerPluginCommands( + cli: Argv, + commands: CommandDefinition[], + config: Config +): Argv { + const root = buildCommandTree(commands); + for (const [name, node] of root.children) { + registerNode(cli, name, node, config); + } + return cli; +} diff --git a/src/lib/plugin.ts b/src/lib/plugin.ts index 6d6bd5e..539f8d8 100644 --- a/src/lib/plugin.ts +++ b/src/lib/plugin.ts @@ -102,6 +102,8 @@ export class PluginLoader { * Load all plugins from config */ async loadAll(): Promise { + await this.loadBundledPlugins(); + const extensions = this.config.extensions || {}; for (const [name, extConfig] of Object.entries(extensions)) { @@ -116,6 +118,40 @@ export class PluginLoader { } } + /** + * Load the plugins that ship inside this package. + * + * These are listed explicitly rather than discovered by scanning: a directory scan at + * startup costs a stat per entry on every `can` invocation, and an explicit list fails + * loudly in review when a plugin is added without being registered. + * + * Measured cost of importing all of these: ~17ms against a ~280ms baseline startup. + * + * `ai/` and `chittyos/` are deliberately absent — their index modules are barrel files + * that re-export helpers and expose no `metadata`, so they are not plugins. + */ + private async loadBundledPlugins(): Promise { + const bundled = [ + () => import("../plugins/cloudflare/index.js"), + () => import("../plugins/linear/index.js"), + () => import("../plugins/neon/index.js") + ]; + + for (const load of bundled) { + try { + const mod: any = await load(); + const plugin: ChittyPlugin = mod.default || mod; + if (!this.isValidPlugin(plugin)) continue; + if (this.plugins.has(plugin.metadata.name)) continue; // a configured extension wins + this.plugins.set(plugin.metadata.name, plugin); + if (plugin.init) await plugin.init(this.config); + } catch (error: any) { + // Never let a bundled plugin break every command. Report, do not throw. + console.warn(`[chitty] Failed to load bundled plugin: ${error.message}`); + } + } + } + /** * Get a loaded plugin */ diff --git a/src/plugins/ai/anthropic.ts b/src/plugins/ai/anthropic.ts index 93a0fa7..57cd917 100644 --- a/src/plugins/ai/anthropic.ts +++ b/src/plugins/ai/anthropic.ts @@ -1,6 +1,6 @@ import type { ChittyPlugin, CommandDefinition, RemoteTypeDefinition } from "@/lib/plugin"; import type { Config } from "@/lib/config"; -import { CHITTYCLAW_PROVIDER_BASE_URLS, gatewayAuthHeaders } from "./gateway"; +import { CHITTYCLAW_PROVIDER_BASE_URLS, gatewayAuthHeaders } from "./gateway.js"; interface AnthropicRemote { type: "anthropic"; diff --git a/src/plugins/ai/openai.ts b/src/plugins/ai/openai.ts index 2b3a234..5fdfa93 100644 --- a/src/plugins/ai/openai.ts +++ b/src/plugins/ai/openai.ts @@ -1,6 +1,6 @@ import type { ChittyPlugin, CommandDefinition, RemoteTypeDefinition } from "@/lib/plugin"; import type { Config } from "@/lib/config"; -import { CHITTYCLAW_PROVIDER_BASE_URLS, gatewayAuthHeaders } from "./gateway"; +import { CHITTYCLAW_PROVIDER_BASE_URLS, gatewayAuthHeaders } from "./gateway.js"; interface OpenAIRemote { type: "openai"; diff --git a/src/plugins/cloudflare/index.ts b/src/plugins/cloudflare/index.ts index 3e65248..ad0de1d 100644 --- a/src/plugins/cloudflare/index.ts +++ b/src/plugins/cloudflare/index.ts @@ -191,7 +191,6 @@ const CloudflarePlugin: ChittyPlugin = { ], async init(config: Config) { - console.log("[chitty] Cloudflare extension loaded"); }, }; diff --git a/src/plugins/linear/index.ts b/src/plugins/linear/index.ts index 07fd681..4a2c46f 100644 --- a/src/plugins/linear/index.ts +++ b/src/plugins/linear/index.ts @@ -266,7 +266,6 @@ const LinearPlugin: ChittyPlugin = { ], async init(config: Config) { - console.log("[chitty] Linear extension loaded"); }, }; diff --git a/src/plugins/neon/index.ts b/src/plugins/neon/index.ts index 10e786b..2a94809 100644 --- a/src/plugins/neon/index.ts +++ b/src/plugins/neon/index.ts @@ -224,7 +224,6 @@ const NeonPlugin: ChittyPlugin = { ], async init(config: Config) { - console.log("[chitty] Neon extension loaded"); }, }; diff --git a/tests/plugin-commands.test.ts b/tests/plugin-commands.test.ts new file mode 100644 index 0000000..aa2c09f --- /dev/null +++ b/tests/plugin-commands.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { buildCommandTree, registerPluginCommands } from "../src/lib/plugin-commands.js"; +import { PluginLoader } from "../src/lib/plugin.js"; +import type { CommandDefinition } from "../src/lib/plugin.js"; + +const cmd = (name: string): CommandDefinition => ({ name, description: `desc ${name}`, handler: async () => {} }); + +describe("buildCommandTree", () => { + it("nests a space-separated name instead of treating it as positionals", () => { + // The whole reason this module exists: yargs would read "neon branch list" as a + // command `neon` taking two positionals, accepting `can neon foo bar`. + const root = buildCommandTree([cmd("neon branch list")]); + const neon = root.children.get("neon")!; + expect(neon).toBeDefined(); + expect(neon.leaf).toBeUndefined(); + const branch = neon.children.get("branch")!; + expect(branch.leaf).toBeUndefined(); + expect(branch.children.get("list")!.leaf!.name).toBe("neon branch list"); + }); + + it("shares intermediate nodes across sibling commands", () => { + const root = buildCommandTree([cmd("neon branch list"), cmd("neon branch create"), cmd("neon db list")]); + const neon = root.children.get("neon")!; + expect([...neon.children.keys()].sort()).toEqual(["branch", "db"]); + expect([...neon.children.get("branch")!.children.keys()].sort()).toEqual(["create", "list"]); + }); + + it("supports a single-word command", () => { + const root = buildCommandTree([cmd("linear teams")]); + expect(root.children.get("linear")!.children.get("teams")!.leaf!.name).toBe("linear teams"); + }); + + it("allows a node to be both a leaf and a parent", () => { + const root = buildCommandTree([cmd("linear issues"), cmd("linear issue create")]); + const linear = root.children.get("linear")!; + expect(linear.children.get("issues")!.leaf).toBeDefined(); + expect(linear.children.get("issue")!.leaf).toBeUndefined(); + }); + + it("ignores an empty or whitespace-only name rather than creating a blank node", () => { + const root = buildCommandTree([cmd(" "), cmd("")]); + expect(root.children.size).toBe(0); + }); + + it("collapses repeated whitespace", () => { + const root = buildCommandTree([cmd("cf worker list")]); + expect(root.children.get("cf")!.children.get("worker")!.children.get("list")!.leaf).toBeDefined(); + }); +}); + +describe("registerPluginCommands", () => { + it("registers one top-level yargs command per root child", () => { + const seen: string[] = []; + const fakeYargs: any = { command: (name: string) => { seen.push(name); return fakeYargs; } }; + registerPluginCommands(fakeYargs, [cmd("neon branch list"), cmd("cf worker list")], {} as any); + expect(seen.sort()).toEqual(["cf ", "neon "]); + }); + + it("passes the config through to the leaf handler", async () => { + let received: unknown = null; + const def: CommandDefinition = { name: "x go", description: "d", handler: async (_a, c) => { received = c; } }; + const captured: any[] = []; + const fakeYargs: any = { + command: (_n: string, _d: string, builder: any, handler: any) => { + captured.push({ builder, handler }); + builder({ command: fakeYargs.command, options: () => fakeYargs, demandCommand: () => fakeYargs }); + return fakeYargs; + }, + options: () => fakeYargs, + demandCommand: () => fakeYargs + }; + const config = { marker: 42 } as any; + registerPluginCommands(fakeYargs, [def], config); + const leaf = captured[captured.length - 1]; + await leaf.handler({}); + expect(received).toBe(config); + }); +}); + +describe("PluginLoader bundled plugins — real modules, no mocks", () => { + it("loads the in-tree plugins that shipped unreachable before this fix", async () => { + const loader = new PluginLoader({} as any); + await loader.loadAll(); + const names = loader.getAllPlugins().map(p => p.metadata.name).sort(); + expect(names).toEqual(["@chitty/cloudflare", "@chitty/linear", "@chitty/neon"]); + }); + + it("exposes their commands through getAllCommands, which nothing used to call", async () => { + const loader = new PluginLoader({} as any); + await loader.loadAll(); + const names = loader.getAllCommands().map(c => c.name); + expect(names).toContain("neon branch list"); + expect(names.length).toBeGreaterThanOrEqual(8); + }); +}); From da57d73c34b6e0db50b98d72f9e10d40aa8a7d4a Mon Sep 17 00:00:00 2001 From: NB Date: Thu, 10 Sep 2026 02:17:05 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(plugins):=20address=20adversarial=20rev?= =?UTF-8?q?iew=20=E2=80=94=2013=20dropped=20plugins,=20crash=20containment?= =?UTF-8?q?,=20shadowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separated adversarial review found 14 issues in the previous commit, 4 blocking. **The claim in my own commit message was false.** It excluded src/plugins/{ai,chittyos} saying they were "barrel files that re-export helpers and expose no metadata". Both `export default` an ARRAY of fully-formed plugins — 8 and 5 respectively. 13 plugins with 20 commands and 13 remote types were silently dropped, which is the very bug #165 reports. loadBundledPlugins now accepts both shapes; 16 plugins load where 3 did. **A malformed plugin killed every command.** isValidPlugin checked only metadata.name and metadata.version, never `commands`. A command with a non-string name reached `cmd.name.trim()` and threw a raw TypeError out of the bare registerPluginCommands call, taking down `can --version`, `can --help`, everything, with no plugin named in the message. Now isValidPlugin validates the commands array, buildCommandTree skips non-string names, and the registration call is wrapped. **A plugin could silently shadow a builtin.** Plugin commands register after builtins with no collision check and last registration wins, so any extension could take over `can config`, `can connect`, or `can export` (the ChittySecrets store command) and appear twice in --help. Now refused with a warning, which immediately caught a real collision: src/plugins/chittyos/chittyconnect.ts declares a command named `connect`. **init() failure left a live, uninitialised plugin.** `plugins.set()` ran before `await plugin.init()`, so a throwing init logged one line and then let the plugin's handlers run against state it never built. init() now runs first; registration is skipped on failure. **A node that is both leaf and parent had an unreachable handler** while --help advertised its description: `` + demandCommand(1) applied whenever children existed. Now `[subcommand]`, and no demand when the node has its own handler. Also: yargs metacharacters in a command name are refused (they produced a command listed in --help that could never be invoked); duplicate names across plugins warn instead of silently resolving last-wins; and 14 init()-time console banners are removed — they never ran while plugins were unreachable and would now print on every invocation. **Two of my own tests asserted coverage they did not provide.** The fake yargs only invoked the top-level builder, so nested nodes were never exercised and reverting the leaf/parent fix still passed. The fake now recurses. Re-verified by mutation: reverting the leaf/parent fix, removing demandCommand, and re-dropping ai/chittyos each fail a test now; the first two previously slipped through. 15 tests (was 10), full suite 143 passed, typecheck clean. Still open from the review and NOT fixed here: getAllRemoteTypes() has zero callers, so `can config` cannot create the remote types these plugins define — every command remains unusable end-to-end. That is a change to src/commands/config.ts, not the loader. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JpREaEiCRhvs7vmVrfhxHP --- src/index.ts | 25 +++++- src/lib/plugin-commands.ts | 24 +++++- src/lib/plugin.ts | 42 +++++++--- src/plugins/ai/anthropic.ts | 1 - src/plugins/ai/cohere.ts | 1 - src/plugins/ai/groq.ts | 1 - src/plugins/ai/huggingface.ts | 1 - src/plugins/ai/index.ts | 1 - src/plugins/ai/ollama.ts | 1 - src/plugins/ai/openai.ts | 1 - src/plugins/ai/replicate.ts | 1 - src/plugins/ai/together.ts | 1 - src/plugins/chittyos/chittyauth.ts | 1 - src/plugins/chittyos/chittyconnect.ts | 1 - src/plugins/chittyos/chittyid.ts | 1 - src/plugins/chittyos/chittyregistry.ts | 1 - src/plugins/chittyos/chittyrouter.ts | 1 - tests/plugin-commands.test.ts | 108 ++++++++++++++++++++----- 18 files changed, 162 insertions(+), 51 deletions(-) diff --git a/src/index.ts b/src/index.ts index b7ff4f5..6ffa570 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1252,7 +1252,30 @@ const cli = yargs(args) // Plugin commands must be registered BEFORE .strict(), which rejects anything unknown. // loadAll() populated the loader above; without this call getAllCommands() was never // consumed and every plugin-supplied command was unreachable. -registerPluginCommands(cli, pluginLoader.getAllCommands(), config); +try { + // yargs exposes the registered command list only through internal methods, which are + // untyped. If that shape ever changes this must fail LOUDLY rather than silently + // permitting a plugin to shadow a builtin. + const internal = (cli as any).getInternalMethods?.()?.getCommandInstance?.(); + const builtinNames = new Set(internal?.getCommands?.() ?? []); + if (builtinNames.size === 0) { + console.warn("[chitty] Could not enumerate built-in commands; plugin shadowing checks are disabled."); + } + const pluginCommands = pluginLoader.getAllCommands().filter(cmd => { + const head = typeof cmd?.name === "string" ? cmd.name.trim().split(/\s+/)[0] : ""; + if (head && builtinNames.has(head)) { + // Silently shadowing `can config` / `can connect` / `can export` would let any + // installed extension take over a builtin, including the credential store command. + console.warn(`[chitty] Plugin command "${cmd.name}" would shadow the built-in "${head}"; ignoring it.`); + return false; + } + return true; + }); + registerPluginCommands(cli, pluginCommands, config); +} catch (error: any) { + // A malformed plugin must not take down every command, including --version. + console.warn(`[chitty] Failed to register plugin commands: ${error.message}`); +} cli .strict() diff --git a/src/lib/plugin-commands.ts b/src/lib/plugin-commands.ts index f3fa52f..d33a5ec 100644 --- a/src/lib/plugin-commands.ts +++ b/src/lib/plugin-commands.ts @@ -21,8 +21,15 @@ interface Node { export function buildCommandTree(commands: CommandDefinition[]): Node { const root: Node = { children: new Map() }; for (const cmd of commands) { + if (typeof cmd?.name !== "string") continue; const parts = cmd.name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) continue; + // yargs metacharacters would be parsed as positional syntax and yield a command that + // is listed in --help but can never be invoked. + if (parts.some(p => /[<>[\]|.]/.test(p))) { + console.warn(`[chitty] Ignoring plugin command with reserved characters in its name: ${cmd.name}`); + continue; + } let node = root; for (const part of parts) { let next = node.children.get(part); @@ -32,7 +39,9 @@ export function buildCommandTree(commands: CommandDefinition[]): Node { } node = next; } - // Last definition wins, matching the loader's "a configured extension wins" ordering. + if (node.leaf && node.leaf !== cmd) { + console.warn(`[chitty] Two plugins declare the command "${cmd.name}"; the later one wins.`); + } node.leaf = cmd; } return root; @@ -45,16 +54,23 @@ function describe(node: Node, name: string): string { } function registerNode(y: Argv, name: string, node: Node, config: Config): Argv { + const hasChildren = node.children.size > 0; + const hasOwnHandler = Boolean(node.leaf?.handler); + // A node with children AND its own handler must accept both forms, so the positional is + // optional and demandCommand is not applied — otherwise its handler is dead code. + const commandString = hasChildren ? `${name} ${hasOwnHandler ? "[subcommand]" : ""}` : name; return y.command( - node.children.size > 0 ? `${name} ` : name, + commandString, describe(node, name), (sub: Argv) => { for (const [childName, child] of node.children) { registerNode(sub, childName, child, config); } if (node.leaf?.options) sub.options(node.leaf.options); - // A branch with children but no handler of its own must not silently succeed. - return node.children.size > 0 ? sub.demandCommand(1, `Specify a ${name} subcommand`) : sub; + // A branch with children and no handler of its own must not silently succeed. + return hasChildren && !hasOwnHandler + ? sub.demandCommand(1, `Specify a ${name} subcommand`) + : sub; }, async (argv: any) => { if (!node.leaf?.handler) return; diff --git a/src/lib/plugin.ts b/src/lib/plugin.ts index 539f8d8..d205353 100644 --- a/src/lib/plugin.ts +++ b/src/lib/plugin.ts @@ -131,23 +131,41 @@ export class PluginLoader { * that re-export helpers and expose no `metadata`, so they are not plugins. */ private async loadBundledPlugins(): Promise { - const bundled = [ - () => import("../plugins/cloudflare/index.js"), - () => import("../plugins/linear/index.js"), - () => import("../plugins/neon/index.js") + const bundled: Array<[string, () => Promise]> = [ + ["ai", () => import("../plugins/ai/index.js")], + ["chittyos", () => import("../plugins/chittyos/index.js")], + ["cloudflare", () => import("../plugins/cloudflare/index.js")], + ["linear", () => import("../plugins/linear/index.js")], + ["neon", () => import("../plugins/neon/index.js")] ]; - for (const load of bundled) { + for (const [dir, load] of bundled) { + let candidates: unknown[]; try { const mod: any = await load(); - const plugin: ChittyPlugin = mod.default || mod; - if (!this.isValidPlugin(plugin)) continue; - if (this.plugins.has(plugin.metadata.name)) continue; // a configured extension wins - this.plugins.set(plugin.metadata.name, plugin); - if (plugin.init) await plugin.init(this.config); + const exported = mod.default ?? mod; + // ai/ and chittyos/ export an ARRAY of plugins; the rest export one. + candidates = Array.isArray(exported) ? exported : [exported]; } catch (error: any) { - // Never let a bundled plugin break every command. Report, do not throw. - console.warn(`[chitty] Failed to load bundled plugin: ${error.message}`); + console.warn(`[chitty] Bundled plugin "${dir}" failed to import: ${error.message}`); + continue; + } + + for (const candidate of candidates) { + const plugin = candidate as ChittyPlugin; + if (!this.isValidPlugin(plugin)) { + console.warn(`[chitty] Bundled plugin in "${dir}" is malformed; skipping.`); + continue; + } + const name = plugin.metadata.name; + try { + // init() BEFORE registering. Registering first left a live, uninitialised plugin + // whose handlers ran against state init() never built. + if (plugin.init) await plugin.init(this.config); + this.plugins.set(name, plugin); + } catch (error: any) { + console.warn(`[chitty] Bundled plugin "${name}" (${dir}) failed to initialise, and will not be registered: ${error.message}`); + } } } } diff --git a/src/plugins/ai/anthropic.ts b/src/plugins/ai/anthropic.ts index 57cd917..509f4fc 100644 --- a/src/plugins/ai/anthropic.ts +++ b/src/plugins/ai/anthropic.ts @@ -259,7 +259,6 @@ export const anthropicPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init(config: Config) { - console.log("✓ Anthropic Claude connector initialized"); }, }; diff --git a/src/plugins/ai/cohere.ts b/src/plugins/ai/cohere.ts index eb9d47c..201aac8 100644 --- a/src/plugins/ai/cohere.ts +++ b/src/plugins/ai/cohere.ts @@ -83,7 +83,6 @@ export const coherePlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init() { - console.log("✓ Cohere connector initialized"); }, }; diff --git a/src/plugins/ai/groq.ts b/src/plugins/ai/groq.ts index 2a27ca4..39dbd30 100644 --- a/src/plugins/ai/groq.ts +++ b/src/plugins/ai/groq.ts @@ -224,7 +224,6 @@ export const groqPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init(config: Config) { - console.log("✓ Groq fast inference connector initialized"); }, }; diff --git a/src/plugins/ai/huggingface.ts b/src/plugins/ai/huggingface.ts index 0325988..ad06838 100644 --- a/src/plugins/ai/huggingface.ts +++ b/src/plugins/ai/huggingface.ts @@ -71,7 +71,6 @@ export const huggingfacePlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init() { - console.log("✓ Hugging Face connector initialized"); }, }; diff --git a/src/plugins/ai/index.ts b/src/plugins/ai/index.ts index 93e459c..4eaf2dc 100644 --- a/src/plugins/ai/index.ts +++ b/src/plugins/ai/index.ts @@ -52,7 +52,6 @@ export const aiPlugins: ChittyPlugin[] = [ // Export convenience loader export async function loadAIPlugins() { - console.log("Loading AI platform connectors..."); console.log(" ✓ OpenAI - GPT-4, GPT-3.5, DALL-E"); console.log(" ✓ Anthropic - Claude Sonnet, Opus, Haiku"); console.log(" ✓ Ollama - Local models (privacy-first)"); diff --git a/src/plugins/ai/ollama.ts b/src/plugins/ai/ollama.ts index ae32953..8767aa4 100644 --- a/src/plugins/ai/ollama.ts +++ b/src/plugins/ai/ollama.ts @@ -292,7 +292,6 @@ export const ollamaPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init(config: Config) { - console.log("✓ Ollama local models connector initialized"); }, }; diff --git a/src/plugins/ai/openai.ts b/src/plugins/ai/openai.ts index 5fdfa93..d65b314 100644 --- a/src/plugins/ai/openai.ts +++ b/src/plugins/ai/openai.ts @@ -337,7 +337,6 @@ export const openaiPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init(config: Config) { - console.log("✓ OpenAI connector initialized"); }, }; diff --git a/src/plugins/ai/replicate.ts b/src/plugins/ai/replicate.ts index d6f4612..dd1e43e 100644 --- a/src/plugins/ai/replicate.ts +++ b/src/plugins/ai/replicate.ts @@ -90,7 +90,6 @@ export const replicatePlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init() { - console.log("✓ Replicate connector initialized"); }, }; diff --git a/src/plugins/ai/together.ts b/src/plugins/ai/together.ts index 0cccdf3..f1af19d 100644 --- a/src/plugins/ai/together.ts +++ b/src/plugins/ai/together.ts @@ -84,7 +84,6 @@ export const togetherPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init() { - console.log("✓ Together AI connector initialized"); }, }; diff --git a/src/plugins/chittyos/chittyauth.ts b/src/plugins/chittyos/chittyauth.ts index fc0c539..d8cb0d5 100644 --- a/src/plugins/chittyos/chittyauth.ts +++ b/src/plugins/chittyos/chittyauth.ts @@ -272,7 +272,6 @@ const ChittyAuthPlugin: ChittyPlugin = { ], async init(config: Config) { - console.log("[chitty] ChittyAuth extension loaded"); }, }; diff --git a/src/plugins/chittyos/chittyconnect.ts b/src/plugins/chittyos/chittyconnect.ts index 35407d0..5f0d7a1 100644 --- a/src/plugins/chittyos/chittyconnect.ts +++ b/src/plugins/chittyos/chittyconnect.ts @@ -360,7 +360,6 @@ export const chittyconnectPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init(config: Config) { - console.log("✓ ChittyConnect plugin initialized"); }, }; diff --git a/src/plugins/chittyos/chittyid.ts b/src/plugins/chittyos/chittyid.ts index 2d72ee2..791ba8a 100644 --- a/src/plugins/chittyos/chittyid.ts +++ b/src/plugins/chittyos/chittyid.ts @@ -275,7 +275,6 @@ const ChittyIDPlugin: ChittyPlugin = { ], async init(config: Config) { - console.log("[chitty] ChittyID extension loaded"); }, }; diff --git a/src/plugins/chittyos/chittyregistry.ts b/src/plugins/chittyos/chittyregistry.ts index 2639051..2f37cd1 100644 --- a/src/plugins/chittyos/chittyregistry.ts +++ b/src/plugins/chittyos/chittyregistry.ts @@ -373,7 +373,6 @@ export const chittyregistryPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init(config: Config) { - console.log("✓ ChittyRegistry plugin initialized"); }, }; diff --git a/src/plugins/chittyos/chittyrouter.ts b/src/plugins/chittyos/chittyrouter.ts index 9285dc8..c05fce6 100644 --- a/src/plugins/chittyos/chittyrouter.ts +++ b/src/plugins/chittyos/chittyrouter.ts @@ -488,7 +488,6 @@ export const chittyrouterPlugin: ChittyPlugin = { remoteTypes: [remoteType], commands, async init(config: Config) { - console.log("✓ ChittyRouter plugin initialized"); }, }; diff --git a/tests/plugin-commands.test.ts b/tests/plugin-commands.test.ts index aa2c09f..d7d58e8 100644 --- a/tests/plugin-commands.test.ts +++ b/tests/plugin-commands.test.ts @@ -30,13 +30,29 @@ describe("buildCommandTree", () => { expect(root.children.get("linear")!.children.get("teams")!.leaf!.name).toBe("linear teams"); }); - it("allows a node to be both a leaf and a parent", () => { + it("keeps sibling leaf and parent nodes distinct", () => { + // NOTE: `issues` and `issue` are DIFFERENT nodes — one leaf-only, one parent-only. + // This does not exercise a node that is both; see the test below for that. const root = buildCommandTree([cmd("linear issues"), cmd("linear issue create")]); const linear = root.children.get("linear")!; expect(linear.children.get("issues")!.leaf).toBeDefined(); expect(linear.children.get("issue")!.leaf).toBeUndefined(); }); + it("marks a node that is genuinely BOTH a leaf and a parent", () => { + const root = buildCommandTree([cmd("lp thing"), cmd("lp thing create")]); + const thing = root.children.get("lp")!.children.get("thing")!; + expect(thing.leaf).toBeDefined(); // has its own handler + expect(thing.children.has("create")).toBe(true); // and children + }); + + it("drops a command whose name contains yargs metacharacters", () => { + // "zz " would create a literal "" tree segment: listed in --help, uninvokable. + const root = buildCommandTree([cmd("zz "), cmd("ok go")]); + expect(root.children.has("zz")).toBe(false); + expect(root.children.has("ok")).toBe(true); + }); + it("ignores an empty or whitespace-only name rather than creating a blank node", () => { const root = buildCommandTree([cmd(" "), cmd("")]); expect(root.children.size).toBe(0); @@ -46,43 +62,95 @@ describe("buildCommandTree", () => { const root = buildCommandTree([cmd("cf worker list")]); expect(root.children.get("cf")!.children.get("worker")!.children.get("list")!.leaf).toBeDefined(); }); + + it("ignores a command whose name is not a string instead of throwing", () => { + // A non-string name reached cmd.name.trim() and killed EVERY command, including + // `can --version`, with a raw TypeError and no plugin named in the message. + const bad = [{ name: 123, description: "d" }, { name: undefined, description: "d" }] as any; + expect(() => buildCommandTree(bad)).not.toThrow(); + expect(buildCommandTree(bad).children.size).toBe(0); + }); }); -describe("registerPluginCommands", () => { +describe("registerNode subcommand demands", () => { + // The fake must RECURSE: an earlier version only ran the top-level builder, so nested + // nodes were never exercised and reverting the leaf/parent fix still passed. A fake that + // does not mirror the real call graph is a test that asserts nothing. + function collect(commands: any[]) { + const strings: string[] = []; + const demands: string[] = []; + const makeArgv = (): any => { + const argv: any = { + command: (name: string, _d: string, builder?: any) => { + strings.push(name); + if (builder) builder(makeArgv()); + return argv; + }, + options: () => argv, + demandCommand: (_n: number, msg: string) => { demands.push(msg); return argv; } + }; + return argv; + }; + registerPluginCommands(makeArgv(), commands, {} as any); + return { strings, demands }; + } + it("registers one top-level yargs command per root child", () => { - const seen: string[] = []; - const fakeYargs: any = { command: (name: string) => { seen.push(name); return fakeYargs; } }; - registerPluginCommands(fakeYargs, [cmd("neon branch list"), cmd("cf worker list")], {} as any); - expect(seen.sort()).toEqual(["cf ", "neon "]); + const { strings } = collect([cmd("neon branch list"), cmd("cf worker list")]); + expect(strings).toContain("neon "); + expect(strings).toContain("cf "); }); it("passes the config through to the leaf handler", async () => { let received: unknown = null; const def: CommandDefinition = { name: "x go", description: "d", handler: async (_a, c) => { received = c; } }; - const captured: any[] = []; - const fakeYargs: any = { - command: (_n: string, _d: string, builder: any, handler: any) => { - captured.push({ builder, handler }); - builder({ command: fakeYargs.command, options: () => fakeYargs, demandCommand: () => fakeYargs }); - return fakeYargs; - }, - options: () => fakeYargs, - demandCommand: () => fakeYargs + const handlers: any[] = []; + const makeArgv = (): any => { + const argv: any = { + command: (_n: string, _d: string, builder?: any, handler?: any) => { + if (handler) handlers.push(handler); + if (builder) builder(makeArgv()); + return argv; + }, + options: () => argv, + demandCommand: () => argv + }; + return argv; }; const config = { marker: 42 } as any; - registerPluginCommands(fakeYargs, [def], config); - const leaf = captured[captured.length - 1]; - await leaf.handler({}); + registerPluginCommands(makeArgv(), [def], config); + await handlers[handlers.length - 1]({}); expect(received).toBe(config); }); + + it("demands a subcommand for a parent with no handler of its own", () => { + const { demands, strings } = collect([cmd("neon branch list")]); + expect(demands).toContain("Specify a neon subcommand"); + expect(demands).toContain("Specify a branch subcommand"); + expect(strings).toContain("neon "); + }); + + it("does NOT demand a subcommand when the parent has its own handler", () => { + // Otherwise that handler is dead code while --help advertises its description. + const { demands, strings } = collect([cmd("lp thing"), cmd("lp thing create")]); + expect(demands).not.toContain("Specify a thing subcommand"); + expect(strings).toContain("thing [subcommand]"); // optional, not + expect(strings).not.toContain("thing "); + }); }); describe("PluginLoader bundled plugins — real modules, no mocks", () => { it("loads the in-tree plugins that shipped unreachable before this fix", async () => { const loader = new PluginLoader({} as any); await loader.loadAll(); - const names = loader.getAllPlugins().map(p => p.metadata.name).sort(); - expect(names).toEqual(["@chitty/cloudflare", "@chitty/linear", "@chitty/neon"]); + const names = loader.getAllPlugins().map(p => p.metadata.name); + // cloudflare/linear/neon export a single plugin object... + expect(names).toEqual(expect.arrayContaining(["@chitty/cloudflare", "@chitty/linear", "@chitty/neon"])); + // ...while ai/ and chittyos/ export ARRAYS of them. An earlier version of this fix + // excluded both, claiming they were metadata-less barrel files. They are not, and 13 + // working plugins were silently dropped. This assertion is that regression's guard. + expect(names).toEqual(expect.arrayContaining(["openai", "anthropic", "@chitty/chittyid", "@chitty/chittyauth"])); + expect(names.length).toBeGreaterThanOrEqual(16); }); it("exposes their commands through getAllCommands, which nothing used to call", async () => { From 7c28da466cf8af5a442fa105d8297aed08e41668 Mon Sep 17 00:00:00 2001 From: NB Date: Thu, 10 Sep 2026 03:12:28 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(config):=20consume=20getAllRemoteTypes?= =?UTF-8?q?=20=E2=80=94=20plugins=20were=20registered=20but=20still=20unus?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last blocking finding from the adversarial review of #166. getAllRemoteTypes() existed with ZERO callers. The previous commits made plugin commands reachable, but `can config` still could not create the remote kinds those commands operate on — so every one of them failed with "Remote not found or not a ". I previously reported that error as "the plugin's own logic on an unconfigured machine, i.e. proof the wiring works". That was a misreading of my own evidence: the machine could not be configured, because the only UI for creating that remote type never learned the type existed. 16 remote types are now offered — neon-project, cloudflare-account, linear-workspace, and 13 from the ai/ and chittyos/ plugin arrays. Builtin handlers win: a plugin cannot silently replace `neon`, `cloudflare` or any other built-in remote type. Duplicates across plugins are de-duplicated. A failure to load plugin remote types degrades to the builtin list with a warning rather than breaking `can config`. Credential handling, deliberately narrow. The builtin addNeonRemote prompts for an API key with type "input" — echoed to the terminal and written to the config file. Generalising that across 16 plugin remote types would multiply a weak pattern, so the generic handler instead masks sensitive fields (type "password") and records an env-var reference rather than the value, matching the existing NEON_API_KEY fallback without widening plaintext storage. Fields are treated as sensitive when declared so, or when named like a key/token/secret/ password. Moving plugin credentials to ChittySecrets is follow-up work for the credential lane and is not decided here. Test added at the loader level (getAllRemoteTypes returns the 16). The config prompt itself is inquirer-driven and not unit-tested; stated plainly rather than implied. Full suite 144 passed, typecheck clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JpREaEiCRhvs7vmVrfhxHP --- src/commands/config.ts | 93 ++++++++++++++++++++++++++++++++++- tests/plugin-commands.test.ts | 11 +++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index 48718c8..a97a458 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -4,6 +4,7 @@ import { loadConfig, saveConfig, getConfigPath, type NotionRemote, type GitHubRe import fs from "fs"; import { execSync } from "child_process"; import os from "os"; +import { PluginLoader, type RemoteTypeDefinition } from "../lib/plugin.js"; import { installMcpConfig } from "./mcp-config.js"; import { connectSetup } from "./connect.js"; @@ -150,6 +151,8 @@ export async function configMenu(): Promise { } async function addRemote(cfg: Config): Promise { + const pluginRemoteTypes = await loadPluginRemoteTypes(cfg); + const { remoteType } = await inquirer.prompt([{ type: "list", name: "remoteType", @@ -164,7 +167,11 @@ async function addRemote(cfg: Config): Promise { { name: "Notion database", value: "notion-database" }, { name: "Notion page", value: "notion-page" }, { name: "Notion view", value: "notion-view" }, - { name: "GitHub project", value: "github-project" } + { name: "GitHub project", value: "github-project" }, + ...pluginRemoteTypes.map(rt => ({ + name: `${rt.name ?? rt.type}${rt.description ? ` — ${rt.description}` : ""} (plugin)`, + value: rt.type + })) ] }]); @@ -184,9 +191,93 @@ async function addRemote(cfg: Config): Promise { await addNotionRemote(cfg, remoteType); } else if (remoteType === "github-project") { await addGitHubRemote(cfg); + } else { + const definition = pluginRemoteTypes.find(rt => rt.type === remoteType); + if (definition) await addPluginRemote(cfg, definition); } } +/** + * Remote types contributed by plugins. + * + * getAllRemoteTypes() existed but had no callers, so a plugin could declare a remote type + * and `can config` would never offer it — making every command that depends on that remote + * unreachable in practice. + */ +async function loadPluginRemoteTypes(cfg: Config): Promise { + try { + const loader = new PluginLoader(cfg); + await loader.loadAll(); + const seen = new Set(); + // Builtin handlers win: a plugin must not silently replace `neon` or `cloudflare`. + const builtin = new Set(["ai-platform", "ssh", "mcp-server", "cloudflare", "neon", + "rclone", "notion-database", "notion-page", "notion-view", "github-project"]); + return loader.getAllRemoteTypes().filter(rt => { + if (!rt?.type || builtin.has(rt.type) || seen.has(rt.type)) return false; + seen.add(rt.type); + return true; + }); + } catch (error: any) { + console.warn(`[chitty] Could not load plugin remote types: ${error.message}`); + return []; + } +} + +/** + * Prompt for a plugin-defined remote using its declared schema/configFields. + * + * Sensitive fields are NOT stored in the config file. They are masked at the prompt and + * recorded as an env-var reference, matching the existing `NEON_API_KEY` fallback rather + * than widening plaintext credential storage across every plugin. Moving these to + * ChittySecrets is follow-up work owned by the credential lane, not this change. + */ +async function addPluginRemote(cfg: Config, definition: RemoteTypeDefinition): Promise { + const fields = definition.configFields ?? Object.entries(definition.schema ?? {}).map( + ([name, spec]: [string, any]) => ({ + name, + description: name, + required: Boolean(spec?.required), + sensitive: /key|token|secret|password/i.test(name) + }) + ); + + const { name } = await inquirer.prompt([{ type: "input", name: "name", message: "Remote name" }]); + if (!name) return; + + const remote: Record = { type: definition.type }; + for (const field of fields) { + const sensitive = (field as any).sensitive === true; + const envVar = `${definition.type.replace(/[^a-z0-9]+/gi, "_").toUpperCase()}_${field.name.replace(/[^a-z0-9]+/gi, "_").toUpperCase()}`; + const answer = await inquirer.prompt([{ + type: sensitive ? "password" : "input", + name: "value", + message: sensitive + ? `${field.description} (leave blank to read ${envVar} at run time)` + : `${field.description}${field.required ? "" : " (optional)"}`, + default: (field as any).default + }]); + if (!answer.value) continue; + // A supplied secret is referenced, never written to the config file. + remote[field.name] = sensitive ? `\${${envVar}}` : answer.value; + if (sensitive) { + console.log(` Set ${envVar} in your environment; the value was not written to the config.`); + } + } + + if (definition.validate) { + const verdict = definition.validate(remote); + if (verdict !== true) { + console.error(`[chitty] Invalid ${definition.type} remote: ${typeof verdict === "string" ? verdict : "validation failed"}`); + return; + } + } + + cfg.remotes = cfg.remotes || {}; + (cfg.remotes as any)[name] = remote; + saveConfig(cfg); + console.log(`Added ${definition.type} remote "${name}"`); +} + async function addNotionRemote(cfg: Config, type: string): Promise { const ans = await inquirer.prompt([ { diff --git a/tests/plugin-commands.test.ts b/tests/plugin-commands.test.ts index d7d58e8..d8a66c8 100644 --- a/tests/plugin-commands.test.ts +++ b/tests/plugin-commands.test.ts @@ -153,6 +153,17 @@ describe("PluginLoader bundled plugins — real modules, no mocks", () => { expect(names.length).toBeGreaterThanOrEqual(16); }); + it("exposes remote types through getAllRemoteTypes, which had ZERO callers", async () => { + // Without this, `can config` cannot create a neon-project remote, so every neon + // command fails with "Remote db-prod not found" and the plugin is unusable + // end-to-end even though its commands are registered. + const loader = new PluginLoader({} as any); + await loader.loadAll(); + const types = loader.getAllRemoteTypes().map(rt => rt.type); + expect(types).toEqual(expect.arrayContaining(["neon-project", "cloudflare-account", "linear-workspace"])); + expect(types.length).toBeGreaterThanOrEqual(16); + }); + it("exposes their commands through getAllCommands, which nothing used to call", async () => { const loader = new PluginLoader({} as any); await loader.loadAll();