From eb7d3da34369f9a3c1ccdb18fd094d5bc2fffa68 Mon Sep 17 00:00:00 2001 From: Khaldoon Sinjab Date: Thu, 15 Oct 2026 16:15:46 +0400 Subject: [PATCH] feat(cli): --json output for chats and messages Print raw API objects as one JSON array on stdout (diagnostics to stderr, errors exit nonzero, table output unchanged). Raw items keep properties/amsreferences/string ids, so file attachments (properties.files[].filePreview.previewUrl) become visible to reads for the first time. Implemented via claude relay lane (dispatch C2CE5820), reviewed + tested live by the calling session. --- README.md | 9 ++ src/cli.ts | 16 ++++ tests/cli-json.test.ts | 207 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 tests/cli-json.test.ts diff --git a/README.md b/README.md index a5a5a7d..27a07f0 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,15 @@ teams messages a1b2 --limit 40 teams search "budget" --limit 10 ``` +`chats` and `messages` accept `--json` to print the raw API objects (including +`properties`, e.g. file attachments) as a single JSON array on stdout, suitable +for piping to `jq`: + +```sh +teams chats --json | jq '.[].id' +teams messages a1b2 --json -n 5 | jq '.[].properties.files // empty | fromjson' +``` + Run `teams --help` or `teams --help` for full details. ## Development diff --git a/src/cli.ts b/src/cli.ts index 9a8750a..88c002e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -71,6 +71,11 @@ function printMessages(msgs: FormattedMessage[]): void { } } +// `--json` output: exactly one JSON document on stdout, nothing else. +function printJson(items: unknown[]): void { + process.stdout.write(`${JSON.stringify(items, null, 2)}\n`); +} + function getVersion(): string { try { const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -124,12 +129,18 @@ export function createProgram(): Command { .command("chats") .description("List recent conversations.") .option("-n, --limit ", "Number of chats to list", "30") + .option("--json", "Print raw conversation objects as a JSON array") .action(async (opts) => { const client = await getChatClient(); const convos = await listConversations(client, Number(opts.limit)); const formatted = formatChatList(convos); saveChatIndex(formatted); + if (opts.json) { + printJson(convos); + return; + } + const table = new Table({ head: ["ID", "Name", "Type", "Last Message", "Time"], style: { head: ["cyan"] }, @@ -151,10 +162,15 @@ export function createProgram(): Command { .command("messages ") .description("Read messages from a conversation.") .option("-n, --limit ", "Number of messages to fetch", "20") + .option("--json", "Print raw message objects as a JSON array") .action(async (chat: string, opts) => { const convId = resolveConversationId(chat); const client = await getChatClient(); const raw = await getMessages(client, convId, Number(opts.limit)); + if (opts.json) { + printJson(raw); + return; + } const msgs = raw .map((r) => formatMessage(r)) .filter((m): m is FormattedMessage => m !== null); diff --git a/tests/cli-json.test.ts b/tests/cli-json.test.ts new file mode 100644 index 0000000..b13e628 --- /dev/null +++ b/tests/cli-json.test.ts @@ -0,0 +1,207 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const DATA_DIR = mkdtempSync(join(tmpdir(), "teams-cli-json-")); + +vi.mock("../src/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DATA_DIR, + LAST_CHATS_FILE: join(DATA_DIR, "last_chats.json"), + }; +}); + +vi.mock("../src/client.js", () => ({ + getChatClient: vi.fn(async () => ({ + baseUrl: "https://test", + fetch: vi.fn(), + })), + getSearchClient: vi.fn(), + getStreamClient: vi.fn(), +})); + +vi.mock("../src/api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listConversations: vi.fn(), + getMessages: vi.fn(), + }; +}); + +const { getMessages, listConversations } = await import("../src/api.js"); +const { createProgram } = await import("../src/cli.js"); + +const CONV_ID = "19:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@thread.v2"; + +const conversations = [ + { + id: CONV_ID, + threadProperties: { topic: "Test Group", threadType: "chat" }, + properties: { lastimreceivedtime: "2026-01-01T10:00:00.000Z" }, + lastMessage: { + id: "1767261600000", + content: "

hello

", + imdisplayname: "Test User", + originalarrivaltime: "2026-01-01T10:00:00.000Z", + }, + }, + { id: "48:notifications", properties: {}, lastMessage: {} }, +]; + +// Newest first, as the API returns them. +const messages = [ + { + id: "1767261600001", + version: "1767261600001", + messagetype: "RichText/Html", + content: "

second

", + imdisplayname: "Test User", + originalarrivaltime: "2026-01-01T10:00:01.000Z", + amsreferences: ["0-wus-d1-00000000000000000000000000000000"], + properties: { + files: JSON.stringify([ + { + fileName: "image.png", + filePreview: { previewUrl: "https://example.invalid/preview.png" }, + }, + ]), + }, + }, + { + id: "1767261600000", + version: "1767261600000", + messagetype: "RichText/Html", + content: "

first

", + imdisplayname: "Test User", + originalarrivaltime: "2026-01-01T10:00:00.000Z", + properties: {}, + }, +]; + +let stdout: string; + +async function run(...args: string[]): Promise { + const program = createProgram(); + program.exitOverride(); + await program.parseAsync(["node", "teams", ...args]); +} + +beforeEach(() => { + stdout = ""; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { + stdout += `${a.map(String).join(" ")}\n`; + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(listConversations).mockResolvedValue(conversations); + vi.mocked(getMessages).mockResolvedValue(messages); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +process.on("exit", () => rmSync(DATA_DIR, { recursive: true, force: true })); + +describe("chats --json", () => { + it("prints only a JSON array of raw conversation objects", async () => { + await run("chats", "--json", "-n", "5"); + + const parsed = JSON.parse(stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual(conversations); + expect(stdout.trim().startsWith("[")).toBe(true); + expect(stdout.trim().endsWith("]")).toBe(true); + expect(stdout).not.toContain("\u001b["); + expect(listConversations).toHaveBeenCalledWith(expect.anything(), 5); + }); + + it("still saves the chat index", async () => { + await run("chats", "--json"); + + const index = JSON.parse( + readFileSync(join(DATA_DIR, "last_chats.json"), "utf-8"), + ); + expect(index).toHaveLength(1); + expect(index[0].id).toBe(CONV_ID); + }); + + it("keeps stdout clean on API errors", async () => { + vi.mocked(listConversations).mockRejectedValue(new Error("HTTP 401: nope")); + + await expect(run("chats", "--json")).rejects.toThrow("HTTP 401"); + expect(stdout).toBe(""); + }); +}); + +describe("chats (table)", () => { + it("renders the table unchanged without --json", async () => { + await run("chats"); + + expect(() => JSON.parse(stdout)).toThrow(); + for (const col of ["ID", "Name", "Type", "Last Message", "Time"]) { + expect(stdout).toContain(col); + } + expect(stdout).toContain("Test Group"); + expect(stdout).not.toContain("48:notifications"); + }); +}); + +describe("messages --json", () => { + it("prints only a JSON array of raw message objects", async () => { + await run("messages", CONV_ID, "--json", "-n", "2"); + + const parsed = JSON.parse(stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual(messages); + expect(stdout).not.toContain("\u001b["); + expect(getMessages).toHaveBeenCalledWith(expect.anything(), CONV_ID, 2); + }); + + it("preserves properties, amsreferences, and string ids/versions", async () => { + await run("messages", CONV_ID, "--json"); + + const [first] = JSON.parse(stdout); + expect(typeof first.id).toBe("string"); + expect(typeof first.version).toBe("string"); + expect(first.amsreferences).toEqual(messages[0].amsreferences); + const files = JSON.parse(first.properties.files); + expect(files[0].filePreview.previewUrl).toBe( + "https://example.invalid/preview.png", + ); + }); + + it("prints an empty array when there are no messages", async () => { + vi.mocked(getMessages).mockResolvedValue([]); + + await run("messages", CONV_ID, "--json"); + + expect(JSON.parse(stdout)).toEqual([]); + }); + + it("keeps stdout clean on API errors", async () => { + vi.mocked(getMessages).mockRejectedValue(new Error("HTTP 404: gone")); + + await expect(run("messages", CONV_ID, "--json")).rejects.toThrow( + "HTTP 404", + ); + expect(stdout).toBe(""); + }); +}); + +describe("messages (table)", () => { + it("renders oldest-first text output unchanged without --json", async () => { + await run("messages", CONV_ID); + + expect(() => JSON.parse(stdout)).toThrow(); + expect(stdout).toContain("Test User"); + expect(stdout.indexOf("first")).toBeLessThan(stdout.indexOf("second")); + }); +});