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
16 changes: 7 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,9 @@ agent session connect # inside an Ellipsis sandbox: connects to
agent session stop <session-id> # stop an in-flight session

agent review 123 # review a pull request now, instead of waiting for a push
agent review # review the work in your tree; findings print here
agent review get <review-id> # a review's findings, scope, and whether it posted
agent review list --repo api # list a repository's reviews, newest first
agent review init [path] # scaffold a starter review pipeline (default: agents/code_review.yaml)
agent review default # the effective review pipeline for the repo you are standing in
agent review default set <config-id> # set the account default pipeline (--repo [owner/name] for one repo)
agent review init # scaffold a starter review pipeline (code_review.yaml)

agent config list # list saved agent configs
agent config get <config-id> # show one config as YAML (--json for JSON)
Expand All @@ -92,10 +90,10 @@ agent slack members # workspace members, with linked GitHub identi
agent linear teams # teams in the connected Linear organization
agent sentry orgs # connected Sentry organizations

agent asset upload shot.png # store a PNG; prints an org-gated link to paste into a PR comment
agent asset list # list stored assets (--session <id> scopes to one run's uploads)
agent asset get <asset-id> -o shot.png # show one asset, or download its bytes with -o
agent asset delete <asset-id> # delete an asset (it disappears from list/get and its link stops resolving)
agent file upload shot.png # store a PNG; prints an org-gated link to paste into a PR comment
agent file list # list stored files (--session <id> scopes to one run's uploads)
agent file get <file-id> -o shot.png # show one file, or download its bytes with -o
agent file delete <file-id> # delete a file (it disappears from list/get and its link stops resolving)

agent variable list # list sandbox env variable names (values are write-only)
agent variable set A=1 B=2 # create/update variables (or --from-file .env/.json)
Expand All @@ -110,7 +108,7 @@ agent analytics review --repo my-service # review totals + top reviewers
agent ping # check authenticated API connectivity
```

Every command shown is singular. The plural spelling of each (`agent assets`,
Every command shown is singular. The plural spelling of each (`agent files`,
`agent sessions`, `agent analytics prs`) is a hidden alias that works but is
left out of `--help`. See
[`skills/cli-conventions`](skills/cli-conventions/SKILL.md) for the full
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ellipsis-dev/sdk": "^0.5.0",
"@ellipsis-dev/sdk": "^0.6.0",
"chalk": "^5.6.2",
"cli-table3": "^0.6.5",
"commander": "^12.1.0",
Expand Down
4 changes: 2 additions & 2 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { registerSession } from './commands/session'
import { registerReview } from './commands/review'
import { registerConfig } from './commands/config'
import { registerVariable } from './commands/variable'
import { registerAsset } from './commands/asset'
import { registerFile } from './commands/file'
import { registerHook } from './commands/hooks'
import { registerTemplate } from './commands/template'
import { registerModel } from './commands/model'
Expand Down Expand Up @@ -44,7 +44,7 @@ registerSession(program)
registerReview(program)
registerConfig(program)
registerVariable(program)
registerAsset(program)
registerFile(program)
registerHook(program)
registerTemplate(program)
registerModel(program)
Expand Down
16 changes: 8 additions & 8 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function registerConfig(program: Command): void {
config.command('list').description('List your saved agent configs'),
'ls',
),
'GET /configs',
'GET /agents/configs',
)
.option('--json', 'output raw JSON')
.action(async (opts: { json?: boolean }) => {
Expand Down Expand Up @@ -59,7 +59,7 @@ export function registerConfig(program: Command): void {
config
.command('get <config-id>')
.description('Print one agent config as YAML, or as JSON with --json'),
'GET /configs/{id}',
'GET /agents/configs/{id}',
)
.option('--json', 'output raw JSON')
.action(async (configId: string, opts: { json?: boolean }) => {
Expand All @@ -85,7 +85,7 @@ export function registerConfig(program: Command): void {
config
.command('create')
.description('Create an agent config by opening a pull request that adds it to a repo'),
'POST /configs',
'POST /agents/configs',
)
.requiredOption(
'-r, --repo <name>',
Expand Down Expand Up @@ -148,7 +148,7 @@ export function registerConfig(program: Command): void {
.description('Show or set which agent config runs when a session names none'),
'defaults',
),
'GET /defaults',
'GET /agents/defaults',
)
.option('--json', 'output raw JSON')
// Bare `agent config default`: the effective default for the repo you're
Expand Down Expand Up @@ -189,7 +189,7 @@ export function registerConfig(program: Command): void {
.description('List every default that is set, account rung and per-repo rungs'),
'ls',
),
'GET /defaults',
'GET /agents/defaults',
)
.option('--json', 'output raw JSON')
// The group also defines --json (for the bare view), and commander parses
Expand Down Expand Up @@ -223,7 +223,7 @@ export function registerConfig(program: Command): void {
defaults
.command('set <config-id>')
.description('Set the account default agent config, or a repo default with --repo'),
'PUT /defaults',
'PUT /agents/defaults',
)
.option(
'-r, --repo [repository]',
Expand Down Expand Up @@ -256,7 +256,7 @@ export function registerConfig(program: Command): void {
'rm',
'delete',
),
'DELETE /defaults',
'DELETE /agents/defaults',
)
.option(
'-r, --repo [repository]',
Expand All @@ -278,7 +278,7 @@ export function registerConfig(program: Command): void {
.description(
`Scaffold a starter agent config YAML locally (default: ${DEFAULT_CONFIG_PATH})`,
),
'POST /configs with --template',
'POST /agents/configs with --template',
)
// No `-f` short: CLI-wide, `-f` means an input file (see `config create`).
.option('--force', 'overwrite the file if it already exists')
Expand Down
118 changes: 57 additions & 61 deletions src/commands/asset.ts → src/commands/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,19 @@ import { basename } from 'node:path'
import { ApiClient, ApiError } from '../lib/api'
import { alsoKnownAs, apiRoutes } from '../lib/help'
import { formatTs, printJson, printTable, runAction } from '../lib/output'
import type { AssetView, CreateAssetRequest, GetAssetResponse } from '../lib/types'
import type { CreateFileRequest, FileView, GetFileResponse } from '../lib/types'

// `agent asset <verb>`: persist files to Ellipsis platform storage and get
// back an org-membership-gated link (documents/eng/AGENT_ASSET_STORAGE.md in
// the ellipsis repo). The primary caller is an agent inside a sandbox that
// took a screenshot of a UI change and wants a link to paste into a PR
// comment — the injected sandbox token authenticates it with zero setup, and
// the same commands work on a laptop with a device-login token.
// `agent file <verb>`: persist files to Ellipsis platform storage and get back
// an org-membership-gated link. The primary caller is an agent inside a sandbox
// that took a screenshot of a UI change and wants a link to paste into a PR
// comment: the injected sandbox token authenticates it with zero setup, and the
// same commands work on a laptop with a device-login token.

// Client-side mirrors of the server limits (assets_service.py), so an
// Client-side mirrors of the server limits (files_service.py), so an
// oversized or non-PNG file fails fast with a clear message instead of a
// base64-inflated round trip to a 400. The server re-validates; these are
// UX, not enforcement.
export const MAX_ASSET_SIZE_BYTES = 10 * 1024 * 1024
export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])

// Well-known magic bytes we can name in the "not a PNG" error, so an agent
Expand Down Expand Up @@ -45,12 +44,12 @@ function sniffFormat(bytes: Buffer): string | null {

// Build the upload request from a file's bytes, throwing the fast client-side
// errors (empty, oversized, not a PNG). Exported for tests.
export function buildUploadRequest(path: string, bytes: Buffer): CreateAssetRequest {
export function buildUploadRequest(path: string, bytes: Buffer): CreateFileRequest {
if (bytes.length === 0) throw new Error(`${path} is empty`)
if (bytes.length > MAX_ASSET_SIZE_BYTES) {
if (bytes.length > MAX_FILE_SIZE_BYTES) {
throw new Error(
`${path} is ${formatSize(bytes.length)}; the limit is ` +
`${formatSize(MAX_ASSET_SIZE_BYTES)} per asset`,
`${formatSize(MAX_FILE_SIZE_BYTES)} per file`,
)
}
if (!bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) {
Expand All @@ -75,25 +74,27 @@ export function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
}

export function registerAsset(program: Command): void {
const asset = alsoKnownAs(
export function registerFile(program: Command): void {
const file = alsoKnownAs(
program
.command('asset')
.command('file')
.description('Store files on the platform and share them as org-gated links'),
'files',
'asset',
'assets',
)

apiRoutes(
asset
file
.command('upload <path>')
.description('Upload a PNG and print its org-gated URL, ready to paste into a PR comment'),
'POST /assets',
'POST /files',
)
.option('--json', 'output raw JSON')
.action(async (path: string, opts: { json?: boolean }) => {
await runAction(async () => {
const req = buildUploadRequest(path, readFileSync(path))
const res = await new ApiClient().uploadAsset(req)
const res = await new ApiClient().uploadFile(req)
// The URL is the whole point — keep it the bare primary output so an
// agent (or $(...) in a script) can capture it directly.
if (opts.json) printJson(res)
Expand All @@ -102,87 +103,82 @@ export function registerAsset(program: Command): void {
})

apiRoutes(
alsoKnownAs(
asset.command('list').description('List your stored assets, newest first'),
'ls',
),
'GET /assets',
alsoKnownAs(file.command('list').description('List your stored files, newest first'), 'ls'),
'GET /files',
)
.option('--session <id>', 'only assets uploaded by this agent session')
.option('--session <id>', 'only files uploaded by this agent session')
.option('-l, --limit <n>', 'max results (server cap: 250)', parsePositiveInt)
.option('--json', 'output raw JSON')
.action(async (opts: { session?: string; limit?: number; json?: boolean }) => {
await runAction(async () => {
const assets = await new ApiClient().listAssets({
const files = await new ApiClient().listFiles({
agent_session_id: opts.session,
limit: opts.limit,
})
if (opts.json) {
printJson(assets)
printJson(files)
return
}
if (assets.length === 0) {
console.log('No assets.')
if (files.length === 0) {
console.log('No files.')
return
}
printTable(
['ID', 'FILENAME', 'SIZE', 'CREATED', 'SESSION'],
assets.map((a) => [
a.id,
a.filename,
formatSize(a.size_bytes),
formatTs(a.created_at),
a.agent_session_id ?? '-',
files.map((f) => [
f.id,
f.filename,
formatSize(f.size_bytes),
formatTs(f.created_at),
f.agent_session_id ?? '-',
]),
)
})
})

apiRoutes(
asset
.command('get <asset-id>')
.description("Print one asset's metadata, or download its bytes with -o"),
'GET /assets/{id}',
file
.command('get <file-id>')
.description("Print one file's metadata, or download its bytes with -o"),
'GET /files/{id}',
'presigned S3 GET',
)
.option('-o, --output <path>', 'write the file contents to this path')
.option('--json', 'output raw JSON (includes the short-lived download_url)')
.action(async (assetId: string, opts: { output?: string; json?: boolean }) => {
.action(async (fileId: string, opts: { output?: string; json?: boolean }) => {
await runAction(async () => {
const res = await new ApiClient().getAsset(assetId)
const res = await new ApiClient().getFile(fileId)
if (opts.output) {
// download_url is a ~60s presigned S3 GET — fetch it immediately,
// while it's fresh. The JSON API never carries the bytes itself.
await downloadTo(res.download_url, opts.output)
if (!opts.json) {
console.log(`✓ wrote ${opts.output} (${formatSize(res.asset.size_bytes)})`)
console.log(`✓ wrote ${opts.output} (${formatSize(res.file.size_bytes)})`)
}
}
if (opts.json) printJson(res)
else if (!opts.output) renderAsset(res)
else if (!opts.output) renderFile(res)
})
})

apiRoutes(
alsoKnownAs(
asset
.command('delete <asset-id>')
.description('Delete an asset, so its link stops resolving'),
file.command('delete <file-id>').description('Delete a file, so its link stops resolving'),
'rm',
),
'DELETE /assets/{id}',
'DELETE /files/{id}',
)
.option('--json', 'output raw JSON')
.action(async (assetId: string, opts: { json?: boolean }) => {
.action(async (fileId: string, opts: { json?: boolean }) => {
await runAction(async () => {
try {
await new ApiClient().deleteAsset(assetId)
await new ApiClient().deleteFile(fileId)
} catch (err) {
// A 404 covers "never existed", "someone else's", and "already
// deleted" — all the same "there's nothing here to delete" to the
// caller, so give one clear message instead of the raw HTTP error.
if (err instanceof ApiError && err.status === 404) {
throw new Error(`asset not found: ${assetId}`)
throw new Error(`file not found: ${fileId}`)
}
// A 403 is a real policy decision (e.g. sandbox tokens can't delete);
// surface the server's own explanation rather than masking it.
Expand All @@ -192,26 +188,26 @@ export function registerAsset(program: Command): void {
throw err
}
// 204 No Content — nothing to echo, so confirm with the id.
if (opts.json) printJson({ id: assetId, deleted: true })
else console.log(`✓ deleted ${assetId}`)
if (opts.json) printJson({ id: fileId, deleted: true })
else console.log(`✓ deleted ${fileId}`)
})
})
}

function renderAsset(res: GetAssetResponse): void {
const a: AssetView = res.asset
console.log(`id: ${a.id}`)
console.log(`filename: ${a.filename}`)
console.log(`type: ${a.content_type}`)
console.log(`size: ${formatSize(a.size_bytes)}`)
console.log(`created: ${formatTs(a.created_at)}`)
if (a.agent_session_id) console.log(`session: ${a.agent_session_id}`)
function renderFile(res: GetFileResponse): void {
const f: FileView = res.file
console.log(`id: ${f.id}`)
console.log(`filename: ${f.filename}`)
console.log(`type: ${f.content_type}`)
console.log(`size: ${formatSize(f.size_bytes)}`)
console.log(`created: ${formatTs(f.created_at)}`)
if (f.agent_session_id) console.log(`session: ${f.agent_session_id}`)
console.log(`url: ${res.url}`)
console.log(`\ndownload the file with: agent asset get ${a.id} -o ${a.filename}`)
console.log(`\ndownload the file with: agent file get ${f.id} -o ${f.filename}`)
}

// Pull the bytes from the presigned S3 URL. Deliberately bare fetch (no
// bearer header — the signature in the URL is the credential). Assets are
// bearer header — the signature in the URL is the credential). Files are
// ≤10 MiB, so buffering in memory is fine.
async function downloadTo(url: string, path: string): Promise<void> {
const res = await fetch(url)
Expand Down
Loading