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/src/index.ts b/src/index.ts index 2337ac9..6ffa570 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,37 @@ 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. +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() .help() .alias("h", "help") diff --git a/src/lib/plugin-commands.ts b/src/lib/plugin-commands.ts new file mode 100644 index 0000000..d33a5ec --- /dev/null +++ b/src/lib/plugin-commands.ts @@ -0,0 +1,92 @@ +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) { + 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); + if (!next) { + next = { children: new Map() }; + node.children.set(part, next); + } + node = next; + } + 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; +} + +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 { + 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( + 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 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; + 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..d205353 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,58 @@ 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: 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 [dir, load] of bundled) { + let candidates: unknown[]; + try { + const mod: any = await load(); + 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) { + 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}`); + } + } + } + } + /** * Get a loaded plugin */ diff --git a/src/plugins/ai/anthropic.ts b/src/plugins/ai/anthropic.ts index 93a0fa7..509f4fc 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"; @@ -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 2b3a234..d65b314 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"; @@ -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/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..d8a66c8 --- /dev/null +++ b/tests/plugin-commands.test.ts @@ -0,0 +1,174 @@ +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("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); + }); + + it("collapses repeated whitespace", () => { + 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("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 { 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 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(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); + // 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 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(); + const names = loader.getAllCommands().map(c => c.name); + expect(names).toContain("neon branch list"); + expect(names.length).toBeGreaterThanOrEqual(8); + }); +});