diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index a130cb2..410f263 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -546,7 +546,15 @@ async function main() { } catch (e) { console.error(String(e.message || e)); process.exitCode = 1; } return; } - if (cmd === "whoami") { await whoami(); return; } + if (cmd === "whoami") { + if (rest.length > 1 || (rest.length === 1 && rest[0] !== "--json")) { + console.error("usage: moshcode whoami [--json]"); + process.exitCode = 1; + return; + } + await whoami({ json: rest[0] === "--json" }); + return; + } if (cmd === "logout") { logout(); return; } if (cmd === "run") { let max = 3, dryRun = false; diff --git a/src/auth.mjs b/src/auth.mjs index a2025d3..e4484ee 100644 --- a/src/auth.mjs +++ b/src/auth.mjs @@ -167,22 +167,88 @@ export async function loginAuto({ device = false, browser = false } = {}) { } /** Print who is logged in (verified against the app). */ -export async function whoami() { +export async function whoami({ json = false } = {}) { const creds = loadCreds(); - if (!creds?.token) { console.log("not logged in — run: moshcode login"); return; } + if (!creds?.token) { + if (json) { + console.log(JSON.stringify({ + status: "not_logged_in", + verified: false, + api: API(), + user: null, + }, null, 2)); + } else { + console.log("not logged in — run: moshcode login"); + } + return; + } + const api = creds.api || API(); + const localUser = { + id: creds.id ?? null, + email: creds.email ?? null, + name: null, + credits: null, + }; + const printJson = (value) => console.log(JSON.stringify(value, null, 2)); try { - const res = await fetch(`${creds.api || API()}/api/me`, { headers: { authorization: `Bearer ${creds.token}` } }); - if (res.status === 401) { console.log("session expired — run: moshcode login"); return; } + const res = await fetch(`${api}/api/me`, { headers: { authorization: `Bearer ${creds.token}` } }); + if (res.status === 401) { + if (json) { + printJson({ + status: "expired", + verified: false, + api, + user: localUser, + error: { type: "auth", status: 401 }, + }); + } + else console.log("session expired — run: moshcode login"); + return; + } // Any other error status still has a body, and it isn't an account — reading // it as one prints a made-up identity for a session the app just refused. if (!res.ok) { - console.log(`${creds.email || "logged in"} @ ${creds.api || API()} (couldn't verify — the app returned ${res.status})`); + if (json) { + printJson({ + status: "unverified", + verified: false, + api, + user: localUser, + error: { type: "http", status: res.status }, + }); + } else { + console.log(`${creds.email || "logged in"} @ ${api} (couldn't verify — the app returned ${res.status})`); + } return; } const me = await res.json(); - console.log(`${me.email || me.name || "moshcoder"} 🤘 (${me.credits ?? "?"} credits) @ ${creds.api || API()}`); + if (json) { + printJson({ + status: "authenticated", + verified: true, + api, + user: { + id: me.id ?? creds.id ?? null, + email: me.email ?? creds.email ?? null, + name: me.name ?? null, + credits: me.credits ?? null, + }, + }); + } else { + console.log(`${me.email || me.name || "moshcoder"} 🤘 (${me.credits ?? "?"} credits) @ ${api}`); + } } catch { - console.log(`${creds.email || "logged in"} @ ${creds.api || API()} (couldn't reach the app to verify)`); + if (json) { + printJson({ + status: "unreachable", + verified: false, + api, + user: localUser, + error: { type: "network" }, + }); + } else { + console.log(`${creds.email || "logged in"} @ ${api} (couldn't reach the app to verify)`); + } } } diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 9dc0eba..3c4e640 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -161,7 +161,9 @@ export const CORE_CLI_COMMANDS = [ name: "whoami", group: "account", description: "show the logged-in account", - synopsis: [["moshcode whoami", ""]], + synopsis: [["moshcode whoami [--json]", ""]], + flags: [["--json", "print account status as machine-readable JSON", ""]], + examples: [["moshcode whoami --json", "inspect the current session from a script"]], seeAlso: ["login", "logout"], }, { diff --git a/src/completion.mjs b/src/completion.mjs index 4e76c48..58d47c2 100644 --- a/src/completion.mjs +++ b/src/completion.mjs @@ -215,7 +215,7 @@ Register-ArgumentCompleter -Native -CommandName moshcode -ScriptBlock { } } 'login' { if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionLogin } } - { $_ -in @('engines', 'tools', 'commands') } { + { $_ -in @('whoami', 'engines', 'tools', 'commands') } { if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionJson } } 'run' { if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionRun } } @@ -321,7 +321,7 @@ _moshcode_completion() { login) [[ "$cur" == -* ]] && choices="--browser -b --device -d" ;; - engines|tools|commands) + whoami|engines|tools|commands) [[ "$cur" == -* ]] && choices="--json" ;; run) @@ -464,7 +464,7 @@ _moshcode() { login) _values "login option" --browser -b --device -d ;; - engines|tools|commands) + whoami|engines|tools|commands) _values "option" --json ;; run) @@ -556,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 '${atSecondToken("agents engines tools commands")}' -l json -d 'print JSON' +complete -c moshcode -n '${atSecondToken("agents whoami 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 6d5efad..f109bf5 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -539,7 +539,14 @@ export async function tui() { catch (e) { console.log(err(String(e.message || e))); } continue; } - if (cmd === "whoami") { await whoami(); continue; } + if (cmd === "whoami") { + if (rest.length > 1 || (rest.length === 1 && rest[0] !== "--json")) { + console.log(err("usage: /whoami [--json]")); + continue; + } + await whoami({ json: rest[0] === "--json" }); + continue; + } if (cmd === "logout") { logout(); continue; } if (cmd === "run") { await runFile(rest); diff --git a/test/auth.test.mjs b/test/auth.test.mjs index a37dfd1..b33ddd0 100644 --- a/test/auth.test.mjs +++ b/test/auth.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -19,19 +19,27 @@ const { saveCreds, whoami } = await import("../src/auth.mjs"); const posixMode = process.platform === "win32" ? { skip: "POSIX permission bits" } : {}; /** Run whoami against a canned app response and collect what it printed. */ -async function whoamiAgainst({ status, body }) { +async function whoamiWithFetch(fetchImpl, options) { const realFetch = globalThis.fetch; const realLog = console.log; const lines = []; - globalThis.fetch = async () => ({ status, ok: status >= 200 && status < 300, json: async () => body }); + globalThis.fetch = fetchImpl; console.log = (...args) => lines.push(args.join(" ")); - try { await whoami(); } finally { + try { await whoami(options); } finally { globalThis.fetch = realFetch; console.log = realLog; } return lines.join("\n"); } +/** Run whoami against a canned app response and collect what it printed. */ +function whoamiAgainst({ status, body }, options) { + return whoamiWithFetch( + async () => ({ status, ok: status >= 200 && status < 300, json: async () => body }), + options, + ); +} + test("whoami does not report an account when the app refuses the token", async () => { const out = await whoamiAgainst({ status: 403, body: { error: "token revoked" } }); assert.doesNotMatch(out, /credits/); @@ -57,6 +65,83 @@ test("whoami still calls out an expired session on 401", async () => { assert.match(out, /session expired/); }); +test("whoami JSON exposes verified account status without credentials", async () => { + const out = await whoamiAgainst( + { status: 200, body: { id: "user_1", email: "me@example.test", name: "Me", credits: 42 } }, + { json: true }, + ); + const result = JSON.parse(out); + + assert.deepEqual(result, { + status: "authenticated", + verified: true, + api: "https://app.example.test", + user: { id: "user_1", email: "me@example.test", name: "Me", credits: 42 }, + }); + assert.doesNotMatch(out, /tok_revoked/); +}); + +test("whoami JSON stays machine-readable when verification fails", async () => { + const out = await whoamiAgainst( + { status: 403, body: { error: "token revoked" } }, + { json: true }, + ); + const result = JSON.parse(out); + + assert.equal(result.status, "unverified"); + assert.equal(result.verified, false); + assert.equal(result.error.status, 403); + assert.equal(result.user.email, "me@example.test"); + assert.doesNotMatch(out, /tok_revoked/); +}); + +test("whoami JSON reports when no credentials are available", async () => { + const credentials = join(home, ".moshcode", "credentials.json"); + const original = readFileSync(credentials); + writeFileSync(credentials, JSON.stringify({ api: "https://app.example.test", token: "" })); + + let out; + try { + out = await whoamiWithFetch( + async () => { throw new Error("fetch should not be called without a token"); }, + { json: true }, + ); + } finally { + writeFileSync(credentials, original); + } + + const result = JSON.parse(out); + assert.equal(result.status, "not_logged_in"); + assert.equal(result.verified, false); + assert.equal(result.user, null); +}); + +test("whoami JSON reports an expired session", async () => { + const out = await whoamiAgainst( + { status: 401, body: { error: "unauthorized" } }, + { json: true }, + ); + const result = JSON.parse(out); + + assert.equal(result.status, "expired"); + assert.equal(result.verified, false); + assert.deepEqual(result.error, { type: "auth", status: 401 }); + assert.doesNotMatch(out, /tok_revoked/); +}); + +test("whoami JSON stays machine-readable when the app is unreachable", async () => { + const out = await whoamiWithFetch( + async () => { throw new Error("network failed with tok_revoked"); }, + { json: true }, + ); + const result = JSON.parse(out); + + assert.equal(result.status, "unreachable"); + assert.equal(result.verified, false); + assert.deepEqual(result.error, { type: "network" }); + assert.doesNotMatch(out, /tok_revoked/); +}); + test("saving credentials tightens a world-readable existing file", posixMode, () => { chmodSync(join(home, ".moshcode", "credentials.json"), 0o644); diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 6720664..e82fa59 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -53,6 +53,16 @@ for (const command of ["agents", "engines", "tools"]) { }); } +test("moshcode whoami rejects unknown arguments", () => { + const result = spawnSync(process.execPath, [BIN, "whoami", "--josn"], { + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /usage: moshcode whoami \[--json\]/); +}); + test("moshcode agents preserves engine arguments named --json", () => { const result = spawnSync( process.execPath, diff --git a/test/completion-powershell.test.mjs b/test/completion-powershell.test.mjs index 78bf3ad..7e95b40 100644 --- a/test/completion-powershell.test.mjs +++ b/test/completion-powershell.test.mjs @@ -62,6 +62,7 @@ test("PowerShell completes commands, install targets, and nested options", (t) = const templateListOptions = expand(executable, "moshcode template list --j"); const templateOptions = expand(executable, "moshcode template install --d"); const templateAliasOptions = expand(executable, "moshcode templates install --i"); + const whoamiOptions = expand(executable, "moshcode whoami --j"); assert.ok(commands.includes("engines"), JSON.stringify(commands)); assert.ok(installs.includes("claude"), JSON.stringify(installs)); assert.ok(options.includes("--json"), JSON.stringify(options)); @@ -69,4 +70,5 @@ test("PowerShell completes commands, install targets, and nested options", (t) = assert.ok(templateListOptions.includes("--json"), JSON.stringify(templateListOptions)); assert.ok(templateOptions.includes("--dry-run"), JSON.stringify(templateOptions)); assert.ok(templateAliasOptions.includes("--into"), JSON.stringify(templateAliasOptions)); + assert.ok(whoamiOptions.includes("--json"), JSON.stringify(whoamiOptions)); }); diff --git a/test/completion.test.mjs b/test/completion.test.mjs index 7416c8c..d4a53fd 100644 --- a/test/completion.test.mjs +++ b/test/completion.test.mjs @@ -119,6 +119,7 @@ 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", "whoami", "--"]), ["--json"]); assert.deepEqual(bashCompletions(["moshcode", "agents", "claude", "--"]), []); assert.ok(bashCompletions(["moshcode", "install", ""]).includes("claude")); assert.deepEqual(bashCompletions(["moshcode", "install", "claude", ""]), []); @@ -185,6 +186,16 @@ test("every shell offers the dns trust verb, so it does not silently drift", () } }); +test("every shell offers the whoami JSON option", () => { + assert.match(completionScript("bash"), /whoami\|engines\|tools\|commands/); + assert.match(completionScript("zsh"), /whoami\|engines\|tools\|commands/); + assert.match(completionScript("fish"), /agents whoami engines tools commands/); + assert.match( + completionScript("powershell"), + /@\('whoami', 'engines', 'tools', 'commands'\)/, + ); +}); + test("completion normalizes shell names and rejects unsupported values", () => { assert.equal(completionScript(" BASH "), completionScript("bash")); assert.equal(completionScript("pwsh"), completionScript("powershell")); diff --git a/test/tui.test.mjs b/test/tui.test.mjs index 0334759..b4e6600 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -69,6 +69,23 @@ test("TUI /agents --json prints machine-readable engine status", async () => { assert.doesNotMatch(result.stdout, /unknown engine "--json"/); }); +test("TUI /whoami --json forwards the option and prints JSON", async () => { + const home = mkdtempSync(join(tmpdir(), "moshcode-whoami-")); + const result = await runTuiWithHome(home, "/whoami --json\n/quit\n"); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const json = result.stdout.match(/\{\s*"status":\s*"not_logged_in"[\s\S]*?\n\}/); + assert.ok(json, "expected JSON account status"); + assert.equal(JSON.parse(json[0]).status, "not_logged_in"); +}); + +test("TUI /whoami rejects unknown options", async () => { + const result = await runTui("/whoami --josn\n/quit\n"); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /usage: \/whoami \[--json\]/); +}); + test("TUI /run rejects unknown options before reading a script file", async () => { const result = await runTui("/run --dryrun\n/quit\n");