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
15 changes: 15 additions & 0 deletions skills/outline-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Use this skill when the user wants to interact with their Outline wiki/knowledge
- `ol doc open <id>` - Open document in browser
- `ol doc create --title "Title" --collection <id>` - Create document
- `ol col list` - List collections
- `ol att create <file> --document <id>` - Upload attachment

## Output Formats

Expand Down Expand Up @@ -71,6 +72,13 @@ ol col update <id> --name "New Name"
ol col delete <id> --confirm
```

### Attachments
```bash
ol att create <file> --document <ref> # Upload file to document (content type auto-detected)
ol att create <file> --document <ref> --content-type "image/png" # Explicit content type
ol att create <file> --document <ref> --json # Output JSON with attachment URL
```

### Authentication
```bash
ol auth login # Configure API token and base URL
Expand Down Expand Up @@ -108,6 +116,13 @@ ol col list --json
ol doc list --collection <id> --sort title --direction ASC
```

### Upload an image to a document
```bash
ol att create ./screenshot.png --document <id> --json
# Returns: { "url": "/api/attachments.redirect?id=...", ... }
# Use the URL in markdown: ![alt text](/api/attachments.redirect?id=...)
```

### Bulk export with ndjson
```bash
ol doc list --ndjson --full | jq -r '.title'
Expand Down
60 changes: 60 additions & 0 deletions src/commands/attachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { existsSync, statSync } from 'node:fs'
import { basename, extname } from 'node:path'
import chalk from 'chalk'
import type { Command } from 'commander'
import { apiUpload } from '../lib/api.js'
import { resolveDocumentId } from '../lib/refs.js'

const CONTENT_TYPE_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
}

function guessContentType(filePath: string): string {
const ext = extname(filePath).toLowerCase()
return CONTENT_TYPE_MAP[ext] ?? 'application/octet-stream'
}

export function registerAttachmentCommand(program: Command): void {
const attachment = program.command('attachment').alias('att').description('Manage attachments')

attachment
.command('create <file>')
.description('Upload a file attachment to a document')
.requiredOption('--document <ref>', 'Document ID, URL, or name')
.option('--content-type <type>', 'MIME type (auto-detected from extension)')
.option('--json', 'Output JSON')
.action(async (file: string, opts) => {
if (!existsSync(file)) {
console.error(chalk.red(`File not found: ${file}`))
process.exit(1)
}

const documentId = await resolveDocumentId(opts.document)
const contentType = opts.contentType ?? guessContentType(file)
const fileSize = statSync(file).size

const result = await apiUpload(file, {
name: basename(file),
size: fileSize,
contentType,
documentId,
})

if (opts.json) {
console.log(JSON.stringify(result.attachment, null, 2))
} else {
const att = result.attachment
const name = chalk.bold(att.name)
const size = chalk.dim(`${Math.round(att.size / 1024)}KB`)
const url = chalk.cyan(att.url)
console.log(chalk.green('Uploaded:'), `${name} ${size}\n${url}`)
}
})
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { Command } from 'commander'
import { registerAttachmentCommand } from './commands/attachment.js'
import { registerAuthCommand } from './commands/auth.js'
import { registerChangelogCommand } from './commands/changelog.js'
import { registerCollectionCommand } from './commands/collection.js'
Expand All @@ -23,6 +24,7 @@ Note for AI/LLM agents:
Default JSON shows essential fields; use --full for all fields.`,
)

registerAttachmentCommand(program)
registerAuthCommand(program)
registerSearchCommand(program)
registerDocumentCommand(program)
Expand Down
67 changes: 67 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { fetchWithRetry } from '../transport/fetch-with-retry.js'
import { getApiToken, getBaseUrl } from './auth.js'
import { type SpinnerOptions, withSpinner } from './spinner.js'
Expand All @@ -22,6 +24,7 @@ const API_SPINNER_CONFIG: Record<string, SpinnerOptions> = {
'collections.create': { text: 'Creating collection...', color: 'green' },
'collections.update': { text: 'Updating collection...', color: 'yellow' },
'collections.delete': { text: 'Deleting collection...', color: 'yellow' },
'attachments.create': { text: 'Uploading attachment...', color: 'green' },
}

export interface Pagination {
Expand Down Expand Up @@ -91,3 +94,67 @@ export async function apiRequest<T>(path: string, body: object = {}): Promise<Pa

return withSpinner(spinnerConfig, () => rawApiRequest<T>(path, body))
}

interface AttachmentCreateResponse {
uploadUrl: string
form: Record<string, string>
attachment: {
id: string
name: string
contentType: string
size: number
url: string
documentId: string | null
}
}

/**
* Two-step file upload for Outline attachments:
* 1. POST JSON to attachments.create → get presigned upload URL + form fields
* 2. POST multipart form-data to the presigned URL with the file
*/
async function rawApiUpload(
filePath: string,
metadata: { name: string; size: number; contentType: string; documentId: string },
): Promise<AttachmentCreateResponse> {
// Step 1: Create attachment record and get presigned URL
const { data } = await rawApiRequest<AttachmentCreateResponse>('attachments.create', metadata)

// Step 2: Upload file to presigned URL
const form = new FormData()
for (const [key, value] of Object.entries(data.form)) {
form.append(key, value)
}
const fileBuffer = readFileSync(filePath)
const fileBlob = new Blob([fileBuffer.buffer as ArrayBuffer])
form.append('file', fileBlob, basename(filePath))

const uploadRes = await fetchWithRetry({
url: data.uploadUrl,
options: {
method: 'POST',
body: form,
},
})

if (!uploadRes.ok) {
throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`)
}

return data
}

/**
* Public file upload function with spinner support.
*/
export async function apiUpload(
filePath: string,
metadata: { name: string; size: number; contentType: string; documentId: string },
): Promise<AttachmentCreateResponse> {
const spinnerConfig = API_SPINNER_CONFIG['attachments.create'] ?? {
text: 'Uploading...',
color: 'green' as const,
}

return withSpinner(spinnerConfig, () => rawApiUpload(filePath, metadata))
}