Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
80 changes: 73 additions & 7 deletions src/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)`);
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
{
Expand Down
8 changes: 4 additions & 4 deletions src/completion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -321,7 +321,7 @@ _moshcode_completion() {
login)
[[ "$cur" == -* ]] && choices="--browser -b --device -d"
;;
engines|tools|commands)
whoami|engines|tools|commands)
[[ "$cur" == -* ]] && choices="--json"
;;
run)
Expand Down Expand Up @@ -464,7 +464,7 @@ _moshcode() {
login)
_values "login option" --browser -b --device -d
;;
engines|tools|commands)
whoami|engines|tools|commands)
_values "option" --json
;;
run)
Expand Down Expand Up @@ -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'
Expand Down
9 changes: 8 additions & 1 deletion src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
93 changes: 89 additions & 4 deletions test/auth.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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/);
Expand All @@ -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);

Expand Down
10 changes: 10 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions test/completion-powershell.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ 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));
assert.ok(templateCommands.includes("install"), JSON.stringify(templateCommands));
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));
});
11 changes: 11 additions & 0 deletions test/completion.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""]), []);
Expand Down Expand Up @@ -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"));
Expand Down
17 changes: 17 additions & 0 deletions test/tui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading