Skip to content
Merged
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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`
Expand Down Expand Up @@ -101,6 +102,8 @@ node dist/cli.js page get <id> --json
node dist/cli.js page create --title "Draft" --text "Hello" --json
node dist/cli.js page append <id> --stdin --json
node dist/cli.js page delete <id> --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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
38 changes: 38 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -176,6 +183,34 @@ async function buildDryRunForPage(args: ReturnType<typeof parseArgv>) {
return { method, request, response: { dryRun: true } };
}

async function buildDryRunForCollection(args: ReturnType<typeof parseArgv>) {
const subcommand = args.positionals[1];
if (!subcommand) {
throw new CliUsageError(collectionHelp());
}

const methodBySubcommand: Record<string, string> = {
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)",
Expand All @@ -198,10 +233,13 @@ function rootHelp(): string {
" outline logout [options]",
" outline auth <status|whoami> [options]",
" outline page <subcommand> [options]",
" outline collection <subcommand> [options]",
"",
authHelp(),
"",
pageHelp(),
"",
collectionHelp(),
].join("\n");
}

Expand Down
23 changes: 23 additions & 0 deletions src/commands/collection/create.ts
Original file line number Diff line number Diff line change
@@ -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<CommandExecution> {
const request: Record<string, unknown> = {};
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 };
}
9 changes: 9 additions & 0 deletions src/commands/collection/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { CommandContext, CommandExecution } from "./shared.js";
import { getCollectionId } from "./shared.js";

export async function runCollectionDelete(ctx: CommandContext): Promise<CommandExecution> {
const id = getCollectionId(ctx.args);
const request: Record<string, unknown> = { id };
const response = await ctx.client.post("collections.delete", request);
return { method: "collections.delete", request, response };
}
9 changes: 9 additions & 0 deletions src/commands/collection/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { CommandContext, CommandExecution } from "./shared.js";
import { getCollectionId } from "./shared.js";

export async function runCollectionGet(ctx: CommandContext): Promise<CommandExecution> {
const id = getCollectionId(ctx.args);
const request: Record<string, unknown> = { id };
const response = await ctx.client.post("collections.info", request);
return { method: "collections.info", request, response };
}
46 changes: 46 additions & 0 deletions src/commands/collection/index.ts
Original file line number Diff line number Diff line change
@@ -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<CommandExecution> {
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 <id>",
" 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 <id> [--name NAME] [--description TXT|null] [--permission read_write|read] [--private true|false] [--color HEX|null] [--icon ICON|null]",
" outline collection delete <id>",
].join("\n");
}
14 changes: 14 additions & 0 deletions src/commands/collection/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getFlagNumber } from "../../core/args.js";
import type { CommandContext, CommandExecution } from "./shared.js";

export async function runCollectionList(ctx: CommandContext): Promise<CommandExecution> {
const request: Record<string, unknown> = {};
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 };
}
23 changes: 23 additions & 0 deletions src/commands/collection/shared.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 <command> <id>");
}
return id;
}
25 changes: 25 additions & 0 deletions src/commands/collection/update.ts
Original file line number Diff line number Diff line change
@@ -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<CommandExecution> {
const id = getCollectionId(ctx.args);
const request: Record<string, unknown> = { 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 };
}
41 changes: 33 additions & 8 deletions src/core/output.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
}
Expand All @@ -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)}`);
}
Expand All @@ -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`;
Expand All @@ -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<string, unknown>;
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<string, unknown>).id === "string");
return Boolean(
value &&
typeof value === "object" &&
typeof (value as Record<string, unknown>).id === "string" &&
("title" in (value as Record<string, unknown>) || "urlId" in (value as Record<string, unknown>)),
);
}

function isCollection(value: unknown): value is OutlineCollection {
return Boolean(
value &&
typeof value === "object" &&
typeof (value as Record<string, unknown>).id === "string" &&
("name" in (value as Record<string, unknown>) || "permission" in (value as Record<string, unknown>)),
);
}

function summarize(value: unknown): string {
Expand All @@ -92,4 +118,3 @@ function summarize(value: unknown): string {
return String(value);
}
}

14 changes: 13 additions & 1 deletion src/types/outline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
ok?: boolean;
status?: number;
Expand All @@ -43,4 +56,3 @@ export interface OutlineRpcEnvelope<T> {
policies?: OutlinePolicy[];
[key: string]: unknown;
}

Loading