diff --git a/README.md b/README.md index 1e87c77..61e8f00 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # outline-cli -Agent-friendly CLI for [Outline](https://github.com/outline/outline) page (document) CRUD operations. +Agent-friendly CLI for [Outline](https://github.com/outline/outline) page (document) and collection CRUD operations. -This project talks directly to Outline's RPC API (`/api/documents.*`) and is designed for automation and AI-agent workflows. +This project talks directly to Outline's RPC API (`/api/documents.*`, `/api/collections.*`) and is designed for automation and AI-agent workflows. ## Features (current MVP) @@ -11,6 +11,7 @@ This project talks directly to Outline's RPC API (`/api/documents.*`) and is des - `page append`, `prepend` - `page move`, `archive`, `delete`, `restore` - `page search`, `find` (title search) +- `collection get`, `list`, `create`, `update`, `delete` - JSON output for scripting (`--json`) - `.env` compatibility with both: - `OUTLINE_BASE_URL` / `OUTLINE_API_KEY` @@ -101,6 +102,8 @@ node dist/cli.js page get --json node dist/cli.js page create --title "Draft" --text "Hello" --json node dist/cli.js page append --stdin --json node dist/cli.js page delete --json +node dist/cli.js collection list --limit 10 --json +node dist/cli.js collection create --name "Engineering Docs" --description "Team knowledge" --json ``` ## Authentication precedence diff --git a/package.json b/package.json index ffd5d2d..278b81c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "outline-cli", "version": "0.1.0", "private": true, - "description": "Agent-friendly CLI for Outline document CRUD operations", + "description": "Agent-friendly CLI for Outline document and collection CRUD operations", "type": "module", "packageManager": "bun@1.3.6", "bin": { diff --git a/src/cli.ts b/src/cli.ts index f7f7a98..606f24b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { OutlineClient } from "./core/client.js"; import { CliUsageError, OutlineApiError, mapErrorToExitCode } from "./core/errors.js"; import { printOutput } from "./core/output.js"; import { runPageCommand, pageHelp } from "./commands/page/index.js"; +import { runCollectionCommand, collectionHelp } from "./commands/collection/index.js"; import { loginHelp, runLoginCommand } from "./commands/login.js"; import { logoutHelp, runLogoutCommand } from "./commands/logout.js"; import { authHelp, runAuthCommand } from "./commands/auth.js"; @@ -128,6 +129,12 @@ async function dispatch( } return runPageCommand(client, args); } + case "collection": { + if (dryRun) { + return buildDryRunForCollection(args); + } + return runCollectionCommand(client, args); + } case "help": throw new CliUsageError(rootHelp()); case "login": @@ -176,6 +183,34 @@ async function buildDryRunForPage(args: ReturnType) { return { method, request, response: { dryRun: true } }; } +async function buildDryRunForCollection(args: ReturnType) { + const subcommand = args.positionals[1]; + if (!subcommand) { + throw new CliUsageError(collectionHelp()); + } + + const methodBySubcommand: Record = { + get: "collections.info", + list: "collections.list", + create: "collections.create", + update: "collections.update", + delete: "collections.delete", + }; + + const method = methodBySubcommand[subcommand]; + if (!method) { + throw new CliUsageError(`Unsupported dry-run for collection subcommand: ${subcommand}`); + } + + const request = { + note: "Dry-run only previews method mapping. Full request payload preview will be added in a later iteration.", + positionals: args.positionals, + flags: args.flags, + }; + + return { method, request, response: { dryRun: true } }; +} + function rootHelp(): string { return [ "Outline CLI (MVP)", @@ -198,10 +233,13 @@ function rootHelp(): string { " outline logout [options]", " outline auth [options]", " outline page [options]", + " outline collection [options]", "", authHelp(), "", pageHelp(), + "", + collectionHelp(), ].join("\n"); } diff --git a/src/commands/collection/create.ts b/src/commands/collection/create.ts new file mode 100644 index 0000000..a8fb043 --- /dev/null +++ b/src/commands/collection/create.ts @@ -0,0 +1,23 @@ +import { getFlagBoolean, getFlagString } from "../../core/args.js"; +import { parseNullableString } from "../../core/utils.js"; +import type { CommandContext, CommandExecution } from "./shared.js"; + +export async function runCollectionCreate(ctx: CommandContext): Promise { + const request: Record = {}; + const name = getFlagString(ctx.args, "name"); + const description = parseNullableString(getFlagString(ctx.args, "description")); + const permission = getFlagString(ctx.args, "permission"); + const privateCollection = getFlagBoolean(ctx.args, "private"); + const color = parseNullableString(getFlagString(ctx.args, "color")); + const icon = parseNullableString(getFlagString(ctx.args, "icon")); + + if (name !== undefined) request.name = name; + if (description !== undefined) request.description = description; + if (permission !== undefined) request.permission = permission; + if (privateCollection !== undefined) request.private = privateCollection; + if (color !== undefined) request.color = color; + if (icon !== undefined) request.icon = icon; + + const response = await ctx.client.post("collections.create", request); + return { method: "collections.create", request, response }; +} diff --git a/src/commands/collection/delete.ts b/src/commands/collection/delete.ts new file mode 100644 index 0000000..da03444 --- /dev/null +++ b/src/commands/collection/delete.ts @@ -0,0 +1,9 @@ +import type { CommandContext, CommandExecution } from "./shared.js"; +import { getCollectionId } from "./shared.js"; + +export async function runCollectionDelete(ctx: CommandContext): Promise { + const id = getCollectionId(ctx.args); + const request: Record = { id }; + const response = await ctx.client.post("collections.delete", request); + return { method: "collections.delete", request, response }; +} diff --git a/src/commands/collection/get.ts b/src/commands/collection/get.ts new file mode 100644 index 0000000..6e370c2 --- /dev/null +++ b/src/commands/collection/get.ts @@ -0,0 +1,9 @@ +import type { CommandContext, CommandExecution } from "./shared.js"; +import { getCollectionId } from "./shared.js"; + +export async function runCollectionGet(ctx: CommandContext): Promise { + const id = getCollectionId(ctx.args); + const request: Record = { id }; + const response = await ctx.client.post("collections.info", request); + return { method: "collections.info", request, response }; +} diff --git a/src/commands/collection/index.ts b/src/commands/collection/index.ts new file mode 100644 index 0000000..8ce1547 --- /dev/null +++ b/src/commands/collection/index.ts @@ -0,0 +1,46 @@ +import type { ParsedArgs } from "../../core/args.js"; +import { CliUsageError } from "../../core/errors.js"; +import type { OutlineClient } from "../../core/client.js"; +import { runCollectionCreate } from "./create.js"; +import { runCollectionDelete } from "./delete.js"; +import { runCollectionGet } from "./get.js"; +import { runCollectionList } from "./list.js"; +import { runCollectionUpdate } from "./update.js"; +import type { CommandExecution } from "./shared.js"; + +export async function runCollectionCommand(client: OutlineClient, args: ParsedArgs): Promise { + const subcommand = args.positionals[1]; + if (!subcommand) { + throw new CliUsageError(collectionHelp()); + } + + const ctx = { client, args }; + + switch (subcommand) { + case "get": + return runCollectionGet(ctx); + case "list": + return runCollectionList(ctx); + case "create": + return runCollectionCreate(ctx); + case "update": + return runCollectionUpdate(ctx); + case "delete": + return runCollectionDelete(ctx); + case "help": + throw new CliUsageError(collectionHelp()); + default: + throw new CliUsageError(`Unknown collection subcommand: ${subcommand}\n\n${collectionHelp()}`); + } +} + +export function collectionHelp(): string { + return [ + "Collection commands:", + " outline collection get ", + " outline collection list [--limit N] [--offset N]", + " outline collection create --name NAME [--description TXT|null] [--permission read_write|read] [--private true|false] [--color HEX|null] [--icon ICON|null]", + " outline collection update [--name NAME] [--description TXT|null] [--permission read_write|read] [--private true|false] [--color HEX|null] [--icon ICON|null]", + " outline collection delete ", + ].join("\n"); +} diff --git a/src/commands/collection/list.ts b/src/commands/collection/list.ts new file mode 100644 index 0000000..b60e7e5 --- /dev/null +++ b/src/commands/collection/list.ts @@ -0,0 +1,14 @@ +import { getFlagNumber } from "../../core/args.js"; +import type { CommandContext, CommandExecution } from "./shared.js"; + +export async function runCollectionList(ctx: CommandContext): Promise { + const request: Record = {}; + const limit = getFlagNumber(ctx.args, "limit"); + const offset = getFlagNumber(ctx.args, "offset"); + + if (limit !== undefined) request.limit = limit; + if (offset !== undefined) request.offset = offset; + + const response = await ctx.client.post("collections.list", request); + return { method: "collections.list", request, response }; +} diff --git a/src/commands/collection/shared.ts b/src/commands/collection/shared.ts new file mode 100644 index 0000000..45aa5c8 --- /dev/null +++ b/src/commands/collection/shared.ts @@ -0,0 +1,23 @@ +import type { ParsedArgs } from "../../core/args.js"; +import { getFlagString } from "../../core/args.js"; +import { CliUsageError } from "../../core/errors.js"; +import type { OutlineClient } from "../../core/client.js"; + +export interface CommandContext { + client: OutlineClient; + args: ParsedArgs; +} + +export interface CommandExecution { + method: string; + request: Record; + response: unknown; +} + +export function getCollectionId(args: ParsedArgs): string { + const id = args.positionals[2] ?? getFlagString(args, "id"); + if (!id) { + throw new CliUsageError("Missing collection id. Usage: outline collection "); + } + return id; +} diff --git a/src/commands/collection/update.ts b/src/commands/collection/update.ts new file mode 100644 index 0000000..842a1c5 --- /dev/null +++ b/src/commands/collection/update.ts @@ -0,0 +1,25 @@ +import { getFlagBoolean, getFlagString } from "../../core/args.js"; +import { parseNullableString } from "../../core/utils.js"; +import type { CommandContext, CommandExecution } from "./shared.js"; +import { getCollectionId } from "./shared.js"; + +export async function runCollectionUpdate(ctx: CommandContext): Promise { + const id = getCollectionId(ctx.args); + const request: Record = { id }; + const name = getFlagString(ctx.args, "name"); + const description = parseNullableString(getFlagString(ctx.args, "description")); + const permission = getFlagString(ctx.args, "permission"); + const privateCollection = getFlagBoolean(ctx.args, "private"); + const color = parseNullableString(getFlagString(ctx.args, "color")); + const icon = parseNullableString(getFlagString(ctx.args, "icon")); + + if (name !== undefined) request.name = name; + if (description !== undefined) request.description = description; + if (permission !== undefined) request.permission = permission; + if (privateCollection !== undefined) request.private = privateCollection; + if (color !== undefined) request.color = color; + if (icon !== undefined) request.icon = icon; + + const response = await ctx.client.post("collections.update", request); + return { method: "collections.update", request, response }; +} diff --git a/src/core/output.ts b/src/core/output.ts index 1866193..da3aeb9 100644 --- a/src/core/output.ts +++ b/src/core/output.ts @@ -1,4 +1,4 @@ -import type { OutlineDocument, OutlineRpcEnvelope } from "../types/outline.js"; +import type { OutlineCollection, OutlineDocument, OutlineRpcEnvelope } from "../types/outline.js"; export interface CommandOutput { command: string; @@ -21,9 +21,9 @@ export function printOutput(output: CommandOutput, options: { json: boolean; qui } if (options.quiet) { - const maybeDoc = extractSingleDocument(output.response); - if (maybeDoc?.id) { - process.stdout.write(`${maybeDoc.id}\n`); + const maybeItem = extractSingleIdItem(output.response); + if (maybeItem?.id) { + process.stdout.write(`${maybeItem.id}\n`); return; } } @@ -41,6 +41,8 @@ function formatHuman(output: CommandOutput): string { for (const item of maybeEnvelope.data.slice(0, 10)) { if (isDocument(item)) { lines.push(`- ${item.id} | ${item.title ?? "(untitled)"}`); + } else if (isCollection(item)) { + lines.push(`- ${item.id} | ${item.name ?? "(unnamed)"}`); } else { lines.push(`- ${summarize(item)}`); } @@ -63,6 +65,14 @@ function formatHuman(output: CommandOutput): string { if (data.updatedAt) lines.push(`updatedAt: ${data.updatedAt}`); return `${lines.join("\n")}\n`; } + if (isCollection(data)) { + lines.push(`id: ${data.id}`); + lines.push(`name: ${data.name ?? "(unnamed)"}`); + if (data.permission) lines.push(`permission: ${data.permission}`); + if (data.private !== undefined) lines.push(`private: ${String(data.private)}`); + if (data.updatedAt) lines.push(`updatedAt: ${data.updatedAt}`); + return `${lines.join("\n")}\n`; + } lines.push(summarize(data)); return `${lines.join("\n")}\n`; @@ -72,16 +82,32 @@ function formatHuman(output: CommandOutput): string { return `${lines.join("\n")}\n`; } -function extractSingleDocument(response: unknown): OutlineDocument | undefined { +function extractSingleIdItem(response: unknown): { id: string } | undefined { if (!response || typeof response !== "object") return undefined; const record = response as Record; if (!("data" in record)) return undefined; const data = record.data; - return isDocument(data) ? data : undefined; + if (isDocument(data)) return data; + if (isCollection(data)) return data; + return undefined; } function isDocument(value: unknown): value is OutlineDocument { - return Boolean(value && typeof value === "object" && typeof (value as Record).id === "string"); + return Boolean( + value && + typeof value === "object" && + typeof (value as Record).id === "string" && + ("title" in (value as Record) || "urlId" in (value as Record)), + ); +} + +function isCollection(value: unknown): value is OutlineCollection { + return Boolean( + value && + typeof value === "object" && + typeof (value as Record).id === "string" && + ("name" in (value as Record) || "permission" in (value as Record)), + ); } function summarize(value: unknown): string { @@ -92,4 +118,3 @@ function summarize(value: unknown): string { return String(value); } } - diff --git a/src/types/outline.ts b/src/types/outline.ts index 70c2b80..ddf8caa 100644 --- a/src/types/outline.ts +++ b/src/types/outline.ts @@ -32,6 +32,19 @@ export interface OutlineSearchResult { [key: string]: unknown; } +export interface OutlineCollection { + id: string; + name?: string; + description?: string | null; + permission?: string; + private?: boolean; + color?: string | null; + icon?: string | null; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +} + export interface OutlineRpcEnvelope { ok?: boolean; status?: number; @@ -43,4 +56,3 @@ export interface OutlineRpcEnvelope { policies?: OutlinePolicy[]; [key: string]: unknown; } - diff --git a/test/collection-commands.test.ts b/test/collection-commands.test.ts new file mode 100644 index 0000000..268a8da --- /dev/null +++ b/test/collection-commands.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { parseArgv } from "../src/core/args.js"; +import { CliUsageError } from "../src/core/errors.js"; +import { runCollectionCommand } from "../src/commands/collection/index.js"; + +describe("runCollectionCommand", () => { + test("list maps to collections.list and forwards pagination", async () => { + const calls: Array<{ method: string; body: Record }> = []; + + const execution = await runCollectionCommand( + { + async post(method: string, body: Record) { + calls.push({ method, body }); + return { ok: true, data: [] } as never; + }, + } as never, + parseArgv(["collection", "list", "--limit", "5", "--offset", "10"]), + ); + + expect(calls).toEqual([{ method: "collections.list", body: { limit: 5, offset: 10 } }]); + expect(execution.method).toBe("collections.list"); + }); + + test("create maps to collections.create", async () => { + const calls: Array<{ method: string; body: Record }> = []; + + const execution = await runCollectionCommand( + { + async post(method: string, body: Record) { + calls.push({ method, body }); + return { ok: true, data: { id: "c1" } } as never; + }, + } as never, + parseArgv([ + "collection", + "create", + "--name", + "Engineering", + "--description", + "Team docs", + "--permission", + "read_write", + "--private", + "true", + ]), + ); + + expect(calls).toEqual([ + { + method: "collections.create", + body: { name: "Engineering", description: "Team docs", permission: "read_write", private: true }, + }, + ]); + expect(execution.method).toBe("collections.create"); + }); + + test("get/update/delete include id", async () => { + const calls: Array<{ method: string; body: Record }> = []; + const client = { + async post(method: string, body: Record) { + calls.push({ method, body }); + return { ok: true } as never; + }, + } as never; + + await runCollectionCommand(client, parseArgv(["collection", "get", "col-1"])); + await runCollectionCommand(client, parseArgv(["collection", "update", "col-1", "--name", "New Name"])); + await runCollectionCommand(client, parseArgv(["collection", "delete", "col-1"])); + + expect(calls).toEqual([ + { method: "collections.info", body: { id: "col-1" } }, + { method: "collections.update", body: { id: "col-1", name: "New Name" } }, + { method: "collections.delete", body: { id: "col-1" } }, + ]); + }); + + test("throws usage error on unknown subcommand", async () => { + await expect(runCollectionCommand({} as never, parseArgv(["collection", "bogus"]))).rejects.toBeInstanceOf( + CliUsageError, + ); + }); +});