Skip to content

Commit b0574a7

Browse files
Add JSON account status output
1 parent be5c6c2 commit b0574a7

10 files changed

Lines changed: 226 additions & 18 deletions

bin/moshcode.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,15 @@ async function main() {
546546
} catch (e) { console.error(String(e.message || e)); process.exitCode = 1; }
547547
return;
548548
}
549-
if (cmd === "whoami") { await whoami(); return; }
549+
if (cmd === "whoami") {
550+
if (rest.length > 1 || (rest.length === 1 && rest[0] !== "--json")) {
551+
console.error("usage: moshcode whoami [--json]");
552+
process.exitCode = 1;
553+
return;
554+
}
555+
await whoami({ json: rest[0] === "--json" });
556+
return;
557+
}
550558
if (cmd === "logout") { logout(); return; }
551559
if (cmd === "run") {
552560
let max = 3, dryRun = false;

src/auth.mjs

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,22 +167,88 @@ export async function loginAuto({ device = false, browser = false } = {}) {
167167
}
168168

169169
/** Print who is logged in (verified against the app). */
170-
export async function whoami() {
170+
export async function whoami({ json = false } = {}) {
171171
const creds = loadCreds();
172-
if (!creds?.token) { console.log("not logged in — run: moshcode login"); return; }
172+
if (!creds?.token) {
173+
if (json) {
174+
console.log(JSON.stringify({
175+
status: "not_logged_in",
176+
verified: false,
177+
api: API(),
178+
user: null,
179+
}, null, 2));
180+
} else {
181+
console.log("not logged in — run: moshcode login");
182+
}
183+
return;
184+
}
185+
const api = creds.api || API();
186+
const localUser = {
187+
id: creds.id ?? null,
188+
email: creds.email ?? null,
189+
name: null,
190+
credits: null,
191+
};
192+
const printJson = (value) => console.log(JSON.stringify(value, null, 2));
173193
try {
174-
const res = await fetch(`${creds.api || API()}/api/me`, { headers: { authorization: `Bearer ${creds.token}` } });
175-
if (res.status === 401) { console.log("session expired — run: moshcode login"); return; }
194+
const res = await fetch(`${api}/api/me`, { headers: { authorization: `Bearer ${creds.token}` } });
195+
if (res.status === 401) {
196+
if (json) {
197+
printJson({
198+
status: "expired",
199+
verified: false,
200+
api,
201+
user: localUser,
202+
error: { type: "auth", status: 401 },
203+
});
204+
}
205+
else console.log("session expired — run: moshcode login");
206+
return;
207+
}
176208
// Any other error status still has a body, and it isn't an account — reading
177209
// it as one prints a made-up identity for a session the app just refused.
178210
if (!res.ok) {
179-
console.log(`${creds.email || "logged in"} @ ${creds.api || API()} (couldn't verify — the app returned ${res.status})`);
211+
if (json) {
212+
printJson({
213+
status: "unverified",
214+
verified: false,
215+
api,
216+
user: localUser,
217+
error: { type: "http", status: res.status },
218+
});
219+
} else {
220+
console.log(`${creds.email || "logged in"} @ ${api} (couldn't verify — the app returned ${res.status})`);
221+
}
180222
return;
181223
}
182224
const me = await res.json();
183-
console.log(`${me.email || me.name || "moshcoder"} 🤘 (${me.credits ?? "?"} credits) @ ${creds.api || API()}`);
225+
if (json) {
226+
printJson({
227+
status: "authenticated",
228+
verified: true,
229+
api,
230+
user: {
231+
id: me.id ?? creds.id ?? null,
232+
email: me.email ?? creds.email ?? null,
233+
name: me.name ?? null,
234+
credits: me.credits ?? null,
235+
},
236+
});
237+
} else {
238+
console.log(`${me.email || me.name || "moshcoder"} 🤘 (${me.credits ?? "?"} credits) @ ${api}`);
239+
}
184240
} catch {
185-
console.log(`${creds.email || "logged in"} @ ${creds.api || API()} (couldn't reach the app to verify)`);
241+
if (json) {
242+
printJson({
243+
status: "unreachable",
244+
verified: false,
245+
api,
246+
user: localUser,
247+
error: { type: "network" },
248+
});
249+
} else {
250+
console.log(`${creds.email || "logged in"} @ ${api} (couldn't reach the app to verify)`);
251+
}
186252
}
187253
}
188254

src/cli-schema.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,9 @@ export const CORE_CLI_COMMANDS = [
161161
name: "whoami",
162162
group: "account",
163163
description: "show the logged-in account",
164-
synopsis: [["moshcode whoami", ""]],
164+
synopsis: [["moshcode whoami [--json]", ""]],
165+
flags: [["--json", "print account status as machine-readable JSON", ""]],
166+
examples: [["moshcode whoami --json", "inspect the current session from a script"]],
165167
seeAlso: ["login", "logout"],
166168
},
167169
{

src/completion.mjs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ Register-ArgumentCompleter -Native -CommandName moshcode -ScriptBlock {
215215
}
216216
}
217217
'login' { if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionLogin } }
218-
{ $_ -in @('engines', 'tools', 'commands') } {
218+
{ $_ -in @('whoami', 'engines', 'tools', 'commands') } {
219219
if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionJson }
220220
}
221221
'run' { if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionRun } }
@@ -321,7 +321,7 @@ _moshcode_completion() {
321321
login)
322322
[[ "$cur" == -* ]] && choices="--browser -b --device -d"
323323
;;
324-
engines|tools|commands)
324+
whoami|engines|tools|commands)
325325
[[ "$cur" == -* ]] && choices="--json"
326326
;;
327327
run)
@@ -464,7 +464,7 @@ _moshcode() {
464464
login)
465465
_values "login option" --browser -b --device -d
466466
;;
467-
engines|tools|commands)
467+
whoami|engines|tools|commands)
468468
_values "option" --json
469469
;;
470470
run)
@@ -556,7 +556,7 @@ complete -c moshcode -n '__moshcode_nested_is mcp list' -l json -d 'print JSON'
556556
complete -c moshcode -n '__moshcode_nested_is skill list; or __moshcode_nested_is skills list' -l json -d 'print JSON'
557557
complete -c moshcode -n '__moshcode_command_is login' -l browser -s b -d 'use browser authentication'
558558
complete -c moshcode -n '__moshcode_command_is login' -l device -s d -d 'use device-code authentication'
559-
complete -c moshcode -n '${atSecondToken("agents engines tools commands")}' -l json -d 'print JSON'
559+
complete -c moshcode -n '${atSecondToken("agents whoami engines tools commands")}' -l json -d 'print JSON'
560560
complete -c moshcode -n '__moshcode_command_is run' -l dry-run -d 'show actions without executing'
561561
complete -c moshcode -n '__moshcode_command_is run' -l max -s n -r -d 'maximum loop count'
562562
complete -c moshcode -n '__moshcode_command_is uninstall remove' -l yes -s y -d 'confirm deleting a binary'

src/tui.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,14 @@ export async function tui() {
539539
catch (e) { console.log(err(String(e.message || e))); }
540540
continue;
541541
}
542-
if (cmd === "whoami") { await whoami(); continue; }
542+
if (cmd === "whoami") {
543+
if (rest.length > 1 || (rest.length === 1 && rest[0] !== "--json")) {
544+
console.log(err("usage: /whoami [--json]"));
545+
continue;
546+
}
547+
await whoami({ json: rest[0] === "--json" });
548+
continue;
549+
}
543550
if (cmd === "logout") { logout(); continue; }
544551
if (cmd === "run") {
545552
await runFile(rest);

test/auth.test.mjs

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict";
2-
import { chmodSync, mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
2+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import test from "node:test";
@@ -19,19 +19,27 @@ const { saveCreds, whoami } = await import("../src/auth.mjs");
1919
const posixMode = process.platform === "win32" ? { skip: "POSIX permission bits" } : {};
2020

2121
/** Run whoami against a canned app response and collect what it printed. */
22-
async function whoamiAgainst({ status, body }) {
22+
async function whoamiWithFetch(fetchImpl, options) {
2323
const realFetch = globalThis.fetch;
2424
const realLog = console.log;
2525
const lines = [];
26-
globalThis.fetch = async () => ({ status, ok: status >= 200 && status < 300, json: async () => body });
26+
globalThis.fetch = fetchImpl;
2727
console.log = (...args) => lines.push(args.join(" "));
28-
try { await whoami(); } finally {
28+
try { await whoami(options); } finally {
2929
globalThis.fetch = realFetch;
3030
console.log = realLog;
3131
}
3232
return lines.join("\n");
3333
}
3434

35+
/** Run whoami against a canned app response and collect what it printed. */
36+
function whoamiAgainst({ status, body }, options) {
37+
return whoamiWithFetch(
38+
async () => ({ status, ok: status >= 200 && status < 300, json: async () => body }),
39+
options,
40+
);
41+
}
42+
3543
test("whoami does not report an account when the app refuses the token", async () => {
3644
const out = await whoamiAgainst({ status: 403, body: { error: "token revoked" } });
3745
assert.doesNotMatch(out, /credits/);
@@ -57,6 +65,83 @@ test("whoami still calls out an expired session on 401", async () => {
5765
assert.match(out, /session expired/);
5866
});
5967

68+
test("whoami JSON exposes verified account status without credentials", async () => {
69+
const out = await whoamiAgainst(
70+
{ status: 200, body: { id: "user_1", email: "me@example.test", name: "Me", credits: 42 } },
71+
{ json: true },
72+
);
73+
const result = JSON.parse(out);
74+
75+
assert.deepEqual(result, {
76+
status: "authenticated",
77+
verified: true,
78+
api: "https://app.example.test",
79+
user: { id: "user_1", email: "me@example.test", name: "Me", credits: 42 },
80+
});
81+
assert.doesNotMatch(out, /tok_revoked/);
82+
});
83+
84+
test("whoami JSON stays machine-readable when verification fails", async () => {
85+
const out = await whoamiAgainst(
86+
{ status: 403, body: { error: "token revoked" } },
87+
{ json: true },
88+
);
89+
const result = JSON.parse(out);
90+
91+
assert.equal(result.status, "unverified");
92+
assert.equal(result.verified, false);
93+
assert.equal(result.error.status, 403);
94+
assert.equal(result.user.email, "me@example.test");
95+
assert.doesNotMatch(out, /tok_revoked/);
96+
});
97+
98+
test("whoami JSON reports when no credentials are available", async () => {
99+
const credentials = join(home, ".moshcode", "credentials.json");
100+
const original = readFileSync(credentials);
101+
writeFileSync(credentials, JSON.stringify({ api: "https://app.example.test", token: "" }));
102+
103+
let out;
104+
try {
105+
out = await whoamiWithFetch(
106+
async () => { throw new Error("fetch should not be called without a token"); },
107+
{ json: true },
108+
);
109+
} finally {
110+
writeFileSync(credentials, original);
111+
}
112+
113+
const result = JSON.parse(out);
114+
assert.equal(result.status, "not_logged_in");
115+
assert.equal(result.verified, false);
116+
assert.equal(result.user, null);
117+
});
118+
119+
test("whoami JSON reports an expired session", async () => {
120+
const out = await whoamiAgainst(
121+
{ status: 401, body: { error: "unauthorized" } },
122+
{ json: true },
123+
);
124+
const result = JSON.parse(out);
125+
126+
assert.equal(result.status, "expired");
127+
assert.equal(result.verified, false);
128+
assert.deepEqual(result.error, { type: "auth", status: 401 });
129+
assert.doesNotMatch(out, /tok_revoked/);
130+
});
131+
132+
test("whoami JSON stays machine-readable when the app is unreachable", async () => {
133+
const out = await whoamiWithFetch(
134+
async () => { throw new Error("network failed with tok_revoked"); },
135+
{ json: true },
136+
);
137+
const result = JSON.parse(out);
138+
139+
assert.equal(result.status, "unreachable");
140+
assert.equal(result.verified, false);
141+
assert.deepEqual(result.error, { type: "network" });
142+
assert.doesNotMatch(out, /tok_revoked/);
143+
});
144+
60145
test("saving credentials tightens a world-readable existing file", posixMode, () => {
61146
chmodSync(join(home, ".moshcode", "credentials.json"), 0o644);
62147

test/cli.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ for (const command of ["agents", "engines", "tools"]) {
5353
});
5454
}
5555

56+
test("moshcode whoami rejects unknown arguments", () => {
57+
const result = spawnSync(process.execPath, [BIN, "whoami", "--josn"], {
58+
encoding: "utf8",
59+
});
60+
61+
assert.equal(result.status, 1);
62+
assert.equal(result.stdout, "");
63+
assert.match(result.stderr, /usage: moshcode whoami \[--json\]/);
64+
});
65+
5666
test("moshcode agents preserves engine arguments named --json", () => {
5767
const result = spawnSync(
5868
process.execPath,

test/completion-powershell.test.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,13 @@ test("PowerShell completes commands, install targets, and nested options", (t) =
6262
const templateListOptions = expand(executable, "moshcode template list --j");
6363
const templateOptions = expand(executable, "moshcode template install --d");
6464
const templateAliasOptions = expand(executable, "moshcode templates install --i");
65+
const whoamiOptions = expand(executable, "moshcode whoami --j");
6566
assert.ok(commands.includes("engines"), JSON.stringify(commands));
6667
assert.ok(installs.includes("claude"), JSON.stringify(installs));
6768
assert.ok(options.includes("--json"), JSON.stringify(options));
6869
assert.ok(templateCommands.includes("install"), JSON.stringify(templateCommands));
6970
assert.ok(templateListOptions.includes("--json"), JSON.stringify(templateListOptions));
7071
assert.ok(templateOptions.includes("--dry-run"), JSON.stringify(templateOptions));
7172
assert.ok(templateAliasOptions.includes("--into"), JSON.stringify(templateAliasOptions));
73+
assert.ok(whoamiOptions.includes("--json"), JSON.stringify(whoamiOptions));
7274
});

test/completion.test.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ test("bash completion respects argument depth and preserves file fallbacks", ()
119119
assert.ok(bashCompletions(["moshcode", "cl"]).includes("claude"));
120120
assert.ok(bashCompletions(["moshcode", "agents", ""]).includes("cc"));
121121
assert.deepEqual(bashCompletions(["moshcode", "agents", "--"]), ["--json"]);
122+
assert.deepEqual(bashCompletions(["moshcode", "whoami", "--"]), ["--json"]);
122123
assert.deepEqual(bashCompletions(["moshcode", "agents", "claude", "--"]), []);
123124
assert.ok(bashCompletions(["moshcode", "install", ""]).includes("claude"));
124125
assert.deepEqual(bashCompletions(["moshcode", "install", "claude", ""]), []);
@@ -185,6 +186,16 @@ test("every shell offers the dns trust verb, so it does not silently drift", ()
185186
}
186187
});
187188

189+
test("every shell offers the whoami JSON option", () => {
190+
assert.match(completionScript("bash"), /whoami\|engines\|tools\|commands/);
191+
assert.match(completionScript("zsh"), /whoami\|engines\|tools\|commands/);
192+
assert.match(completionScript("fish"), /agents whoami engines tools commands/);
193+
assert.match(
194+
completionScript("powershell"),
195+
/@\('whoami', 'engines', 'tools', 'commands'\)/,
196+
);
197+
});
198+
188199
test("completion normalizes shell names and rejects unsupported values", () => {
189200
assert.equal(completionScript(" BASH "), completionScript("bash"));
190201
assert.equal(completionScript("pwsh"), completionScript("powershell"));

test/tui.test.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ test("TUI /agents --json prints machine-readable engine status", async () => {
6969
assert.doesNotMatch(result.stdout, /unknown engine "--json"/);
7070
});
7171

72+
test("TUI /whoami --json forwards the option and prints JSON", async () => {
73+
const home = mkdtempSync(join(tmpdir(), "moshcode-whoami-"));
74+
const result = await runTuiWithHome(home, "/whoami --json\n/quit\n");
75+
76+
assert.equal(result.status, 0, result.stderr || result.stdout);
77+
const json = result.stdout.match(/\{\s*"status":\s*"not_logged_in"[\s\S]*?\n\}/);
78+
assert.ok(json, "expected JSON account status");
79+
assert.equal(JSON.parse(json[0]).status, "not_logged_in");
80+
});
81+
82+
test("TUI /whoami rejects unknown options", async () => {
83+
const result = await runTui("/whoami --josn\n/quit\n");
84+
85+
assert.equal(result.status, 0, result.stderr || result.stdout);
86+
assert.match(result.stdout, /usage: \/whoami \[--json\]/);
87+
});
88+
7289
test("TUI /run rejects unknown options before reading a script file", async () => {
7390
const result = await runTui("/run --dryrun\n/quit\n");
7491

0 commit comments

Comments
 (0)