diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 855adea..a130cb2 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -283,7 +283,10 @@ async function main() { return; } if (cmd === "agents") { - if (!rest.length) { printEngineStatus(); return; } + if (!rest.length || (rest.length === 1 && rest[0] === "--json")) { + printEngineStatus(rest[0] === "--json"); + return; + } const resolved = resolveEngine(rest[0]); if (!resolved) { console.error(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`); diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index b077447..9dc0eba 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -35,10 +35,15 @@ export const CORE_CLI_COMMANDS = [ description: "list engines or launch one autonomously", synopsis: [ ["moshcode agents", "list engines and their install status"], + ["moshcode agents --json", "list engine status as machine-readable JSON"], ["moshcode agents [args…]", "open the engine's agent view, or start it autonomously"], ], + flags: [ + ["--json", "print engine status as JSON", ""], + ], examples: [ ["moshcode agents", "which engines are here"], + ["moshcode agents --json", "pipe engine status into a script"], ["moshcode agents claude", "claude's agent list"], ], seeAlso: ["start", "engines", "install"], diff --git a/src/completion.mjs b/src/completion.mjs index a88a919..4e76c48 100644 --- a/src/completion.mjs +++ b/src/completion.mjs @@ -164,7 +164,11 @@ Register-ArgumentCompleter -Native -CommandName moshcode -ScriptBlock { } else { switch ($command) { { $_ -in @('agents', 'start') } { - if ($argumentIndex -eq 2) { $choices = $script:MoshcodeCompletionEngines } + if ($argumentIndex -eq 2) { + $choices = if ($command -eq 'agents' -and $wordToComplete.StartsWith('-')) { + $script:MoshcodeCompletionJson + } else { $script:MoshcodeCompletionEngines } + } } 'install' { if ($argumentIndex -eq 2) { $choices = $script:MoshcodeCompletionInstall } @@ -262,7 +266,12 @@ _moshcode_completion() { choices="${names(model.top)}" else case "$subcommand" in - agents|start) + agents) + if (( COMP_CWORD == 2 )); then + if [[ "$cur" == -* ]]; then choices="--json"; else choices="${names(model.engines)}"; fi + fi + ;; + start) (( COMP_CWORD == 2 )) && choices="${names(model.engines)}" ;; install) @@ -365,7 +374,19 @@ _moshcode() { fi case "\${words[2]}" in - agents|start) + agents) + if (( CURRENT == 3 )); then + if [[ "$PREFIX" == -* ]]; then + _values "agent option" --json + else + choices=(${zshValues(model.engines)}) + _describe "engine" choices + fi + else + _files + fi + ;; + start) if (( CURRENT == 3 )); then choices=(${zshValues(model.engines)}) _describe "engine" choices @@ -535,7 +556,7 @@ complete -c moshcode -n '__moshcode_nested_is mcp list' -l json -d 'print JSON' complete -c moshcode -n '__moshcode_nested_is skill list; or __moshcode_nested_is skills list' -l json -d 'print JSON' complete -c moshcode -n '__moshcode_command_is login' -l browser -s b -d 'use browser authentication' complete -c moshcode -n '__moshcode_command_is login' -l device -s d -d 'use device-code authentication' -complete -c moshcode -n '__moshcode_command_is engines tools commands' -l json -d 'print JSON' +complete -c moshcode -n '${atSecondToken("agents engines tools commands")}' -l json -d 'print JSON' complete -c moshcode -n '__moshcode_command_is run' -l dry-run -d 'show actions without executing' complete -c moshcode -n '__moshcode_command_is run' -l max -s n -r -d 'maximum loop count' complete -c moshcode -n '__moshcode_command_is uninstall remove' -l yes -s y -d 'confirm deleting a binary' diff --git a/src/tui.mjs b/src/tui.mjs index 5428ecf..6d5efad 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -127,7 +127,16 @@ function commandRemainder(line) { return firstWord ? String(line).slice(firstWord[0].length).trim() : ""; } -function printEngines() { +function printEngines(json = false) { + if (json) { + console.log(JSON.stringify(engineStatus().map(({ key, desc, bin, installed }) => ({ + name: key, + description: desc, + binary: bin, + installed, + })), null, 2)); + return; + } console.log(bone(" engines") + ash(" — autonomous ") + acid("/agents ") + ash(" · raw ") + acid("/start ")); for (const e of engineStatus()) { const dot = e.installed ? acid("●") : ash("○"); @@ -582,7 +591,10 @@ export async function tui() { continue; } if (cmd === "agents" || cmd === "agent" || cmd === "engines") { - if (!rest[0]) { printEngines(); continue; } + if (!rest[0] || (rest.length === 1 && rest[0] === "--json")) { + printEngines(rest[0] === "--json"); + continue; + } const resolved = resolveEngine(rest[0]); if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; } const [key, engine] = resolved; diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 9f3396b..6720664 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -33,7 +33,7 @@ for (const command of ["help", "--help", "-h"]) { }); } -for (const command of ["engines", "tools"]) { +for (const command of ["agents", "engines", "tools"]) { test(`moshcode ${command} --json prints machine-readable install status`, () => { const result = spawnSync(process.execPath, [BIN, command, "--json"], { encoding: "utf8", @@ -53,6 +53,18 @@ for (const command of ["engines", "tools"]) { }); } +test("moshcode agents preserves engine arguments named --json", () => { + const result = spawnSync( + process.execPath, + [BIN, "agents", "nonexistent-engine-for-passthrough-test", "--json"], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /unknown engine "nonexistent-engine-for-passthrough-test"/); +}); + test("moshcode commands --json prints the machine-readable vocabulary", () => { const result = spawnSync(process.execPath, [BIN, "commands", "--json"], { encoding: "utf8", diff --git a/test/completion.test.mjs b/test/completion.test.mjs index c50cd38..7416c8c 100644 --- a/test/completion.test.mjs +++ b/test/completion.test.mjs @@ -118,6 +118,8 @@ for (const [shell, args] of [ test("bash completion respects argument depth and preserves file fallbacks", () => { assert.ok(bashCompletions(["moshcode", "cl"]).includes("claude")); assert.ok(bashCompletions(["moshcode", "agents", ""]).includes("cc")); + assert.deepEqual(bashCompletions(["moshcode", "agents", "--"]), ["--json"]); + assert.deepEqual(bashCompletions(["moshcode", "agents", "claude", "--"]), []); assert.ok(bashCompletions(["moshcode", "install", ""]).includes("claude")); assert.deepEqual(bashCompletions(["moshcode", "install", "claude", ""]), []); diff --git a/test/tui.test.mjs b/test/tui.test.mjs index 4f6dc0f..0334759 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -51,6 +51,24 @@ test("TUI command parsing rejects incomplete quoting", () => { assert.throws(() => splitCommandLine("/ugig trailing\\"), /trailing escape/); }); +test("TUI /agents --json prints machine-readable engine status", async () => { + const result = await runTui("/agents --json\n/quit\n"); + + assert.equal(result.status, 0); + const json = result.stdout.match(/\[\s*\{[\s\S]*?\}\s*\]/); + assert.ok(json, "expected a JSON status array"); + const statuses = JSON.parse(json[0]); + assert.ok(statuses.some(({ name }) => name === "claude")); + for (const status of statuses) { + assert.deepEqual(Object.keys(status), ["name", "description", "binary", "installed"]); + assert.equal(typeof status.name, "string"); + assert.equal(typeof status.description, "string"); + assert.equal(typeof status.binary, "string"); + assert.equal(typeof status.installed, "boolean"); + } + assert.doesNotMatch(result.stdout, /unknown engine "--json"/); +}); + test("TUI /run rejects unknown options before reading a script file", async () => { const result = await runTui("/run --dryrun\n/quit\n");