diff --git a/README.md b/README.md index 746fd12..f236597 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,29 @@ Everything the Bluesky app does, and the raw atproto layer beneath it: every command (as JSON too), stable exit codes, and a skill that teaches agents to use it (`atcli skill install`). +## What writes + +Most commands only read. The ones that write: + +- **On the network** — they publish something other people can see, or + change the account: `post`, `reply`, `quote`, `delete`, `like`/`unlike`, + `repost`/`unrepost`, `bookmark add`/`remove`, `gate`, `profile + update`/`pin`/`unpin`, `follow`/`unfollow`, `block`/`unblock`, + `mute`/`unmute`, `list create`/`edit`/`delete`/`add`/`remove` and the + moderation-list actions, `feed save`/`unsave`/`pin`/`unpin`, + `notifications seen`, `chat send` and every chat-management command, + `record create`/`put`/`delete`, `upload`, `report`. +- **On this machine only** — sessions and settings: `login`, `logout`, + `account remove`/`default`/`refresh`, `config set`/`unset`/`forget`, + `skill install`, `completion --install`. +- **Either, by method**: `xrpc` — queries (GET) read, procedures (POST) + write. + +The generated command reference marks every writing command and lists them +all up top, and the agent skill (`atcli skill show`) carries the same list +with what each one does. `post --dry-run` prints the exact record without +writing anything. + ## Install atcli ships as one signed file, built with diff --git a/skills/atcli/SKILL.md b/skills/atcli/SKILL.md index a3cd771..8fe4d96 100644 --- a/skills/atcli/SKILL.md +++ b/skills/atcli/SKILL.md @@ -32,16 +32,63 @@ exit code. Use it instead of hand-writing HTTP calls. | 6 | needs input there was no terminal for | pass the option named in the message | On failure with `--json`, stdout holds `{"error": {"code", "message", "exit", "status"?}}`. -4. **Ask before acting in public.** Posting, replying, quoting, reposting, - following, blocking, reporting, sending DMs and deleting are visible to - other people or cannot be undone. Unless the user has clearly asked for - that exact action, show them what you intend to write and get a yes first. - Use `--dry-run` with `post`/`reply`/`quote` to show the exact record without - writing anything. +4. **Ask before acting in public.** Posting, replying, quoting, liking, + reposting, bookmarking, following, blocking, muting, reporting, sending + DMs, editing your profile and deleting are visible to other people or + change the account. Unless the user has clearly asked for that exact + action, show them what you intend to write and get a yes first. Use + `--dry-run` with `post`/`reply`/`quote` to show the exact record without + writing anything. *Which commands write* below lists every one. 5. **Never print or store secrets.** Do not echo passwords, and do not read atcli's credential store. Sessions live in the system keychain; atcli handles them. +## Which commands write + +Every command reads unless it is listed here. Writes come in two kinds: + +**Writes to the network** — they change the account or publish something +other people can see. Confirm with the user first (Ground rule 4) unless the +user asked for that exact action: + +| command | what it does | +|---|---| +| `post` / `reply` / `quote` | publish a post, a reply, a quote (`--dry-run` writes nothing) | +| `delete` | delete your posts, or any of your records, by at:// URI | +| `like` / `unlike` | like a post, or take the like back | +| `repost` / `unrepost` | repost a post, or take the repost back | +| `bookmark add` / `bookmark remove` | save posts privately, or unsave them | +| `gate` | change who may reply to or quote your post, or hide replies | +| `profile update` | change your display name, description, avatar, banner | +| `profile pin` / `profile unpin` | pin or unpin a post on your profile | +| `follow` / `unfollow` | follow or unfollow accounts | +| `block` / `unblock` | block or unblock accounts | +| `mute` / `unmute` | mute or unmute accounts, threads or words | +| `list create` / `list edit` / `list delete` / `list add` / `list remove` | make and change lists | +| `list mute` / `list unmute` / `list block` / `list unblock` | apply a moderation list wholesale | +| `feed save` / `feed unsave` / `feed pin` / `feed unpin` | choose the saved feeds and home tabs | +| `notifications seen` | mark notifications as seen | +| `chat send` | send a direct message | +| `chat react` / `chat unreact` | react to a message, or take the reaction back | +| `chat group create` / `chat group add` / `chat group remove` / `chat group rename` | make and change group chats | +| `chat mark-read` / `chat delete` / `chat accept` / `chat mute` / `chat unmute` / `chat leave` | manage a conversation | +| `record create` / `record put` / `record delete` | write raw records in your repository | +| `upload` | upload a file as a blob, for records that embed one | +| `report` | report a post or an account to the moderators | +| `xrpc ` | calls any XRPC method: queries (GET) read, procedures (POST) can write — check the method before calling it | + +**Writes only on this machine** — sessions, config, the shell. Nothing on +the network changes: + +| command | what it does | +|---|---| +| `login` / `logout` / `account remove` | sign in (the session lives in this machine's credential store), sign out, forget an account | +| `account default` | show, or change, the account commands act as | +| `account refresh` | renew the session's tokens now | +| `config set` / `config unset` / `config forget` | change settings, or forget what `--new` has shown | +| `skill install` | write the skill into `.claude/skills/atcli/` | +| `completion --install` / `--uninstall` | edit the shell's startup file (it asks first) | + ## Signing in First check: `atcli whoami --json`. Exit 0 means a session exists and works. diff --git a/skills/atcli/reference.md b/skills/atcli/reference.md index 217bc46..58437de 100644 --- a/skills/atcli/reference.md +++ b/skills/atcli/reference.md @@ -2,21 +2,18 @@ -Every command also takes the global options: +## Which commands write -- `-h, --help`: show help for the command and stop -- `--json`: print the result as JSON (same as --format json) -- `--markdown`: print the result as Markdown (same as --format markdown) -- `--html`: print the result as an HTML page (same as --format html) -- `--format `: how to print the result (default: the format setting, else text) (one of: text, json, markdown, html) -- `-a, --account `: act as this signed-in account instead of the default -- `--color`: colour human-readable output (default: when stdout is a terminal) -- `-v, --verbose`: log every request to stderr +Every command reads unless it is listed here. + +These write to the network: they change your account, or publish something other people can see. An agent should have its user's ok before running one, unless the user asked for that exact action: ### `atcli login` sign in to an account, with OAuth (the default) or a password. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + ``` atcli login [options] [] ``` @@ -36,6 +33,8 @@ atcli login [options] [] sign an account out, ending its session and forgetting its tokens. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + ``` atcli logout [options] [] ``` @@ -75,6 +74,8 @@ atcli account list show or set the default account. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + Also: `use`, `switch`. ``` @@ -87,6 +88,8 @@ atcli account default [] renew an account's tokens now, rather than when they next run out. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + ``` atcli account refresh [] ``` @@ -97,6 +100,8 @@ atcli account refresh [] sign an account out, ending its session and forgetting its tokens. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + Also: `rm`. ``` @@ -271,6 +276,8 @@ atcli feed saved save feeds (or lists) to your feeds. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli feed save ... ``` @@ -281,6 +288,8 @@ atcli feed save ... remove feeds from your saved feeds. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli feed unsave ... ``` @@ -291,6 +300,8 @@ atcli feed unsave ... pin feeds to your home tabs (saving them if need be). +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli feed pin ... ``` @@ -301,6 +312,8 @@ atcli feed pin ... unpin feeds, keeping them saved. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli feed unpin ... ``` @@ -492,6 +505,8 @@ atcli notifications count mark notifications seen. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `read`. ``` @@ -504,6 +519,8 @@ atcli notifications seen [options] write a post, or a thread. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `skeet`. ``` @@ -537,6 +554,8 @@ atcli post [options] [...] reply to a post. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli reply [options] [...] ``` @@ -567,6 +586,8 @@ atcli reply [options] [...] quote a post. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli quote [options] [...] ``` @@ -598,6 +619,8 @@ atcli quote [options] [...] delete posts, or any other records of yours by at:// URI. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `rm`. ``` @@ -610,6 +633,8 @@ atcli delete ... like posts. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli like ... ``` @@ -620,6 +645,8 @@ atcli like ... take likes back. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli unlike ... ``` @@ -630,6 +657,8 @@ atcli unlike ... repost posts. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli repost ... ``` @@ -640,6 +669,8 @@ atcli repost ... take reposts back. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli unrepost ... ``` @@ -674,6 +705,8 @@ atcli bookmark list [options] bookmark posts. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli bookmark add ... ``` @@ -684,6 +717,8 @@ atcli bookmark add ... remove bookmarks. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `rm`. ``` @@ -696,6 +731,8 @@ atcli bookmark remove ... change who may reply to or quote one of your posts, or hide replies. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli gate [options] ``` @@ -725,6 +762,8 @@ atcli profile show [...] change your display name, description, avatar, banner and the rest. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `edit`, `set`. ``` @@ -744,6 +783,8 @@ atcli profile update [options] pin one of your posts to the top of your profile. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli profile pin ``` @@ -754,6 +795,8 @@ atcli profile pin unpin the pinned post. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli profile unpin ``` @@ -762,6 +805,8 @@ atcli profile unpin follow accounts. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli follow ... ``` @@ -772,6 +817,8 @@ atcli follow ... unfollow accounts. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli unfollow ... ``` @@ -810,6 +857,8 @@ atcli follows [options] [] block accounts. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli block ... ``` @@ -820,6 +869,8 @@ atcli block ... unblock accounts. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli unblock ... ``` @@ -843,6 +894,8 @@ atcli blocks [options] mute accounts, threads or words. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli mute [options] ... ``` @@ -858,6 +911,8 @@ atcli mute [options] ... unmute accounts, threads or words. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli unmute [options] ... ``` @@ -947,6 +1002,8 @@ atcli list show [options] make a new list. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list create [options] ``` @@ -959,6 +1016,8 @@ atcli list create [options] rename a list, or change its description. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list edit [options] ``` @@ -971,6 +1030,8 @@ atcli list edit [options] delete a list, and its membership records. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `rm`. ``` @@ -983,6 +1044,8 @@ atcli list delete add accounts to a list. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list add ... ``` @@ -994,6 +1057,8 @@ atcli list add ... take accounts off a list. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list remove ... ``` @@ -1020,6 +1085,8 @@ atcli list feed [options] mute every account on a moderation list. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list mute ``` @@ -1030,6 +1097,8 @@ atcli list mute unmute a moderation list. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list unmute ``` @@ -1040,6 +1109,8 @@ atcli list unmute block every account on a moderation list. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list block ``` @@ -1050,6 +1121,8 @@ atcli list block stop blocking a moderation list. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli list unblock ``` @@ -1135,6 +1208,8 @@ atcli chat show [options] send a message, starting the conversation if need be. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `message`, `dm`. ``` @@ -1171,6 +1246,8 @@ atcli chat watch [options] mark a conversation read (or --all of them). +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat mark-read [options] [] ``` @@ -1190,6 +1267,8 @@ atcli chat unread delete a message, for you (the others still see it). +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `rm`. ``` @@ -1203,6 +1282,8 @@ atcli chat delete react to a message with an emoji. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat react ``` @@ -1215,6 +1296,8 @@ atcli chat react take a reaction back. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat unreact ``` @@ -1227,6 +1310,8 @@ atcli chat unreact accept a message request. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat accept ``` @@ -1237,6 +1322,8 @@ atcli chat accept mute a conversation. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat mute ``` @@ -1247,6 +1334,8 @@ atcli chat mute unmute a conversation. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat unmute ``` @@ -1257,6 +1346,8 @@ atcli chat unmute leave a conversation (it is gone for you). +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat leave ``` @@ -1271,6 +1362,8 @@ group conversations: create them, add and remove members, rename. start a group conversation. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat group create ... ``` @@ -1282,6 +1375,8 @@ atcli chat group create ... add members to a group. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat group add ... ``` @@ -1293,6 +1388,8 @@ atcli chat group add ... remove members from a group. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat group remove ... ``` @@ -1304,6 +1401,8 @@ atcli chat group remove ... rename a group. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli chat group rename ``` @@ -1375,6 +1474,8 @@ atcli record collections [] create a record in your repository. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli record create [options] [] ``` @@ -1388,6 +1489,8 @@ atcli record create [options] [] create or replace a record at a given key. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli record put [options] [] ``` @@ -1402,6 +1505,8 @@ atcli record put [options] [] delete records from your repository. +**Writes to the network.** It changes your account, or publishes something other people can see. + Also: `rm`. ``` @@ -1414,6 +1519,10 @@ atcli record delete ... call any XRPC method, on any service. +**Writes to the network.** It changes your account, or publishes something other people can see. + +Whether it writes depends on the method: queries (GET) read, procedures (POST) write. + Also: `api`, `call`. ``` @@ -1433,6 +1542,8 @@ atcli xrpc [options] upload a file as a blob, for records that embed one. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli upload [options] ``` @@ -1444,6 +1555,8 @@ atcli upload [options] report a post or an account to the moderators. +**Writes to the network.** It changes your account, or publishes something other people can see. + ``` atcli report [options] ``` @@ -1457,6 +1570,8 @@ atcli report [options] Tab completion for bash, zsh, fish and PowerShell. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + Also: `completions`. ``` @@ -1493,6 +1608,8 @@ atcli skill show [options] install the skill for Claude Code (or any agent reading Agent Skills). +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + ``` atcli skill install [options] ``` @@ -1533,6 +1650,8 @@ atcli config get change a setting. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + ``` atcli config set ``` @@ -1544,6 +1663,8 @@ atcli config set go back to the default for a setting. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + Also: `reset`. ``` @@ -1566,6 +1687,8 @@ atcli config client-metadata [] forget what --new has shown, and where watches got to, for this account. +**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network. + ``` atcli config forget [options] ``` @@ -1598,3 +1721,22 @@ which atcli this is, and where it keeps things. ``` atcli version ``` + +- `atcli feed save`, `atcli feed unsave`, `atcli feed pin`, `atcli feed unpin`, `atcli notifications seen`, `atcli post`, `atcli reply`, `atcli quote`, `atcli delete`, `atcli like`, `atcli unlike`, `atcli repost`, `atcli unrepost`, `atcli bookmark add`, `atcli bookmark remove`, `atcli gate`, `atcli profile update`, `atcli profile pin`, `atcli profile unpin`, `atcli follow`, `atcli unfollow`, `atcli block`, `atcli unblock`, `atcli mute`, `atcli unmute`, `atcli list create`, `atcli list edit`, `atcli list delete`, `atcli list add`, `atcli list remove`, `atcli list mute`, `atcli list unmute`, `atcli list block`, `atcli list unblock`, `atcli chat send`, `atcli chat mark-read`, `atcli chat delete`, `atcli chat react`, `atcli chat unreact`, `atcli chat accept`, `atcli chat mute`, `atcli chat unmute`, `atcli chat leave`, `atcli chat group create`, `atcli chat group add`, `atcli chat group remove`, `atcli chat group rename`, `atcli record create`, `atcli record put`, `atcli record delete`, `atcli xrpc`, `atcli upload`, `atcli report`. + +These write only on this machine — sessions, config, the shell's startup file. Nothing on the network changes: + +- `atcli login`, `atcli logout`, `atcli account default`, `atcli account refresh`, `atcli account remove`, `atcli completion`, `atcli skill install`, `atcli config set`, `atcli config unset`, `atcli config forget`. + +And `atcli xrpc` does either, depending on the method: queries (GET) read, procedures (POST) write. + +Every command also takes the global options: + +- `-h, --help`: show help for the command and stop +- `--json`: print the result as JSON (same as --format json) +- `--markdown`: print the result as Markdown (same as --format markdown) +- `--html`: print the result as an HTML page (same as --format html) +- `--format `: how to print the result (default: the format setting, else text) (one of: text, json, markdown, html) +- `-a, --account `: act as this signed-in account instead of the default +- `--color`: colour human-readable output (default: when stdout is a terminal) +- `-v, --verbose`: log every request to stderr diff --git a/src/command.ts b/src/command.ts index b71dce4..fa9caca 100644 --- a/src/command.ts +++ b/src/command.ts @@ -60,6 +60,15 @@ export interface CommandSpec { default?: string; /** Not listed in help, nor offered by Tab. */ hidden?: boolean; + /** + * What the command writes, set on every runnable command: `network` when + * it changes state on the network (the account, or what other people can + * see), `local` when it only writes this machine's state (sessions, + * config, the shell's startup file), `false` when it only reads. The + * reference generator refuses to run while any runnable command has none, + * so the classification cannot silently fall behind the commands. + */ + writes?: false | 'network' | 'local'; run?: (ctx: Context) => Promise; } diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts index b2977b2..8ad219a 100644 --- a/src/commands/accounts.ts +++ b/src/commands/accounts.ts @@ -19,6 +19,7 @@ const login: CommandSpec = { name: 'login', group: GROUP, summary: 'sign in to an account, with OAuth (the default) or a password', + writes: 'local', description: ` Signs in and keeps the session in the system credential store (Secret Service or KWallet on Linux, the Keychain on macOS, Credential Manager on Windows). @@ -133,6 +134,7 @@ const logoutCommand: CommandSpec = { name: 'logout', group: GROUP, summary: 'sign an account out, ending its session and forgetting its tokens', + writes: 'local', args: [{ name: 'account', description: 'handle or DID (default: the --account, else the default account)', complete: 'account' }], options: [{ name: 'all', type: 'boolean', description: 'sign out of every account' }], examples: ['atcli logout', 'atcli logout bob.example.com', 'atcli logout --all'], @@ -158,6 +160,7 @@ const whoami: CommandSpec = { name: 'whoami', group: GROUP, summary: 'show the account commands act as, and check its session', + writes: false, options: [{ name: 'offline', type: 'boolean', description: 'only say which account, without asking its PDS whether the session works' }], examples: ['atcli whoami', 'atcli whoami --account bob.example.com --json'], run: async (ctx) => { @@ -201,6 +204,7 @@ const accountList: CommandSpec = { name: 'list', aliases: ['ls'], summary: 'list the signed-in accounts', + writes: false, run: async () => { const config = readConfig(); const accounts = Object.values(config.accounts).map((account) => publicAccount(account, config)); @@ -214,6 +218,7 @@ const accountDefault: CommandSpec = { name: 'default', aliases: ['use', 'switch'], summary: 'show or set the default account', + writes: 'local', description: 'With an account, makes it the one commands act as when --account is not given. Without, says which it is.', args: [{ name: 'account', description: 'handle or DID of a signed-in account', complete: 'account' }], examples: ['atcli account default', 'atcli account default bob.example.com'], @@ -235,6 +240,7 @@ const accountDefault: CommandSpec = { const accountRefresh: CommandSpec = { name: 'refresh', summary: "renew an account's tokens now, rather than when they next run out", + writes: 'local', args: [{ name: 'account', description: 'handle or DID (default: the default account)', complete: 'account' }], run: async (ctx) => { const session = await Session.load(ctx.arg('account') ?? ctx.str('account')); diff --git a/src/commands/chat.ts b/src/commands/chat.ts index c732ab3..d08a8f6 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -48,6 +48,7 @@ const chatList: CommandSpec = { name: 'list', aliases: ['ls'], summary: 'your conversations, most recent first', + writes: false, options: [ ...DATED_PAGING, { name: 'unread', type: 'boolean', description: 'only conversations with unread messages' }, @@ -72,6 +73,7 @@ const chatShow: CommandSpec = { name: 'show', aliases: ['read', 'messages'], summary: 'the messages of a conversation, oldest first', + writes: false, args: [CONVO_ARG], options: [ ...DATED_PAGING, @@ -96,6 +98,7 @@ const chatSend: CommandSpec = { name: 'send', aliases: ['message', 'dm'], summary: 'send a message, starting the conversation if need be', + writes: 'network', args: [CONVO_ARG, { name: 'text', variadic: true, description: "the text (or --file; '-' reads it from stdin)" }], options: [ { name: 'file', short: 'f', type: 'string', value: 'path', description: "read the text from a file ('-' for stdin)", complete: 'file' }, @@ -122,6 +125,7 @@ function convoAction(name: string, summary: string, nsid: string, done: string, name, aliases, summary, + writes: 'network', args: [CONVO_ARG], run: async (ctx) => { const convo = await convoOf(ctx, ctx.need('convo')); @@ -135,6 +139,7 @@ function reaction(name: string, summary: string, nsid: string): CommandSpec { return { name, summary, + writes: 'network', args: [CONVO_ARG, { name: 'message', required: true, description: 'the message id' }, { name: 'emoji', required: true, description: 'the reaction, one emoji' }], run: async (ctx) => { const convo = await convoOf(ctx, ctx.need('convo')); @@ -151,6 +156,7 @@ const chatGroup: CommandSpec = { { name: 'create', summary: 'start a group conversation', + writes: 'network', args: [{ name: 'name', required: true, description: 'its name' }, { name: 'members', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], run: async (ctx) => { const members = await Promise.all(ctx.rest('members').map(actorDid)); @@ -161,6 +167,7 @@ const chatGroup: CommandSpec = { { name: 'add', summary: 'add members to a group', + writes: 'network', args: [{ name: 'convo', required: true, description: 'the group conversation id' }, { name: 'members', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], run: async (ctx) => { const members = await Promise.all(ctx.rest('members').map(actorDid)); @@ -171,6 +178,7 @@ const chatGroup: CommandSpec = { { name: 'remove', summary: 'remove members from a group', + writes: 'network', args: [{ name: 'convo', required: true, description: 'the group conversation id' }, { name: 'members', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], run: async (ctx) => { const members = await Promise.all(ctx.rest('members').map(actorDid)); @@ -181,6 +189,7 @@ const chatGroup: CommandSpec = { { name: 'rename', summary: 'rename a group', + writes: 'network', args: [{ name: 'convo', required: true, description: 'the group conversation id' }, { name: 'name', required: true, description: 'the new name' }], run: async (ctx) => { const response = await ctx.client.chat('chat.bsky.group.editGroup', undefined, { convoId: ctx.need('convo'), name: ctx.need('name') }); @@ -208,6 +217,7 @@ const chatWatch: CommandSpec = { name: 'watch', aliases: ['follow', 'tail'], summary: 'print messages (and reactions, and the rest) as they arrive', + writes: false, description: ` Keeps running and prints what happens in your conversations as it happens: new messages, reactions, deleted messages, conversations started, accepted @@ -314,6 +324,7 @@ export const chatCommand: CommandSpec = { { name: 'mark-read', summary: 'mark a conversation read (or --all of them)', + writes: 'network', args: [{ ...CONVO_ARG, required: false }], options: [{ name: 'all', type: 'boolean', description: 'every conversation' }], run: async (ctx) => { @@ -330,6 +341,7 @@ export const chatCommand: CommandSpec = { { name: 'unread', summary: 'how many conversations have unread messages', + writes: false, run: async (ctx) => { const response = await ctx.client.chat('chat.bsky.convo.getUnreadCounts', {}); return result(response, `${response['unreadAcceptedConvos'] ?? 0} conversations, ${response['unreadRequestConvos'] ?? 0} requests`); @@ -339,6 +351,7 @@ export const chatCommand: CommandSpec = { name: 'delete', aliases: ['rm'], summary: 'delete a message, for you (the others still see it)', + writes: 'network', args: [CONVO_ARG, { name: 'message', required: true, description: 'the message id' }], run: async (ctx) => { const convo = await convoOf(ctx, ctx.need('convo')); diff --git a/src/commands/graph.ts b/src/commands/graph.ts index 0c46d3a..8f9255e 100644 --- a/src/commands/graph.ts +++ b/src/commands/graph.ts @@ -34,6 +34,7 @@ async function selfOr(ctx: Context, name = 'actor'): Promise { const profileShow: CommandSpec = { name: 'show', summary: 'show a profile (yours by default)', + writes: false, args: [{ name: 'actor', variadic: true, description: 'handles or DIDs (default: you)', complete: 'account' }], examples: ['atcli profile show bsky.app', 'atcli profile show alice.bsky.social bob.example.com --json'], run: async (ctx) => { @@ -76,6 +77,7 @@ const profileUpdate: CommandSpec = { name: 'update', aliases: ['edit', 'set'], summary: 'change your display name, description, avatar, banner and the rest', + writes: 'network', description: 'Only what is given changes. An empty value (--description "") clears a field.', options: [ { name: 'name', type: 'string', value: 'text', description: 'display name (up to 64 characters)' }, @@ -111,6 +113,7 @@ const profileUpdate: CommandSpec = { const profilePin: CommandSpec = { name: 'pin', summary: 'pin one of your posts to the top of your profile', + writes: 'network', args: [{ name: 'post', required: true, description: 'your post' }], run: async (ctx) => { const view = await getPost(ctx.client, ctx.need('post')); @@ -124,6 +127,7 @@ const profilePin: CommandSpec = { const profileUnpin: CommandSpec = { name: 'unpin', summary: 'unpin the pinned post', + writes: 'network', run: async (ctx) => { await editProfile(ctx, (record) => { delete record['pinnedPost']; }); return result({ pinnedPost: null }, `${green('✓')} unpinned`); @@ -145,6 +149,7 @@ function relationCommand(name: string, summary: string, collection: string, view name, group: PEOPLE, summary, + writes: 'network', args: [{ name: 'actor', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], run: async (ctx) => { await ctx.client.session(summary); @@ -174,6 +179,7 @@ function actorList(name: string, summary: string, nsid: string, key: string, opt aliases: options.aliases, group: PEOPLE, summary, + writes: false, args: options.self ? [] : [{ name: 'actor', description: 'handle or DID (default: you)', complete: 'account' }], options: PAGING, run: async (ctx) => { @@ -200,6 +206,7 @@ const mute: CommandSpec = { name: 'mute', group: PEOPLE, summary: 'mute accounts, threads or words', + writes: 'network', description: ` Muted accounts disappear from your feeds and notifications, privately: they are not told. --thread mutes notifications from a thread instead, and --word @@ -254,6 +261,7 @@ const unmute: CommandSpec = { name: 'unmute', group: PEOPLE, summary: 'unmute accounts, threads or words', + writes: 'network', args: [{ name: 'target', required: true, variadic: true, description: 'accounts (or, with --thread, posts; with --word, words)', complete: 'account' }], options: [ { name: 'thread', type: 'boolean', description: 'the targets are posts: unmute their threads' }, @@ -293,6 +301,7 @@ const mutes: CommandSpec = { name: 'mutes', group: PEOPLE, summary: 'the accounts (or words, or lists) you have muted', + writes: false, options: [ ...PAGING, { name: 'words', type: 'boolean', description: 'list muted words instead' }, @@ -313,6 +322,7 @@ const blocks: CommandSpec = { name: 'blocks', group: PEOPLE, summary: 'the accounts (or lists) you block', + writes: false, options: [...PAGING, { name: 'lists', type: 'boolean', description: 'list the moderation lists you block instead' }], run: async (ctx) => { if (ctx.flag('lists')) return await listListing(ctx, 'app.bsky.graph.getListBlocks'); @@ -325,6 +335,7 @@ const relationship: CommandSpec = { aliases: ['rel'], group: PEOPLE, summary: 'how accounts relate to you (or to another account): follows, blocks, lists', + writes: false, args: [{ name: 'others', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], options: [{ name: 'of', type: 'string', value: 'actor', description: 'whose relationships (default: you)', complete: 'account' }], run: async (ctx) => { @@ -351,6 +362,7 @@ const suggestions: CommandSpec = { aliases: ['suggest'], group: PEOPLE, summary: 'accounts to follow: in general, or like a given account', + writes: false, args: [{ name: 'actor', description: 'suggest accounts like this one', complete: 'account' }], options: PAGING, run: async (ctx) => { @@ -408,6 +420,7 @@ const list: CommandSpec = { name: 'mine', aliases: ['ls', 'by'], summary: "the lists someone has made (yours by default)", + writes: false, args: [{ name: 'actor', description: 'handle or DID (default: you)', complete: 'account' }], options: [...PAGING, { name: 'purpose', type: 'string', choices: ['curate', 'mod', 'reference'], value: 'purpose', description: 'only lists of this kind' }], run: async (ctx) => { @@ -419,6 +432,7 @@ const list: CommandSpec = { { name: 'show', summary: "a list's members", + writes: false, args: [{ name: 'list', required: true, description: LIST_ARG }], options: PAGING, run: async (ctx) => { @@ -429,6 +443,7 @@ const list: CommandSpec = { { name: 'create', summary: 'make a new list', + writes: 'network', args: [{ name: 'name', required: true, description: 'its name' }], options: [ { name: 'purpose', type: 'string', choices: ['curate', 'mod'], value: 'purpose', description: 'curate (a list to follow as a feed; the default) or mod (a list to mute or block)' }, @@ -450,6 +465,7 @@ const list: CommandSpec = { { name: 'edit', summary: 'rename a list, or change its description', + writes: 'network', args: [{ name: 'list', required: true, description: `one of your lists: ${LIST_ARG}` }], options: [ { name: 'name', type: 'string', value: 'text', description: 'its new name' }, @@ -473,6 +489,7 @@ const list: CommandSpec = { name: 'delete', aliases: ['rm'], summary: 'delete a list, and its membership records', + writes: 'network', args: [{ name: 'list', required: true, description: `one of your lists: ${LIST_ARG}` }], run: async (ctx) => { const uri = await ownList(ctx, ctx.need('list')); @@ -489,6 +506,7 @@ const list: CommandSpec = { { name: 'add', summary: 'add accounts to a list', + writes: 'network', args: [ { name: 'list', required: true, description: `one of your lists: ${LIST_ARG}` }, { name: 'actor', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }, @@ -506,6 +524,7 @@ const list: CommandSpec = { { name: 'remove', summary: 'take accounts off a list', + writes: 'network', args: [ { name: 'list', required: true, description: `one of your lists: ${LIST_ARG}` }, { name: 'actor', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }, @@ -528,6 +547,7 @@ const list: CommandSpec = { { name: 'feed', summary: 'posts from the members of a list', + writes: false, args: [{ name: 'list', required: true, description: LIST_ARG }], options: DATED_PAGING, run: async (ctx) => { @@ -542,6 +562,7 @@ const list: CommandSpec = { { name: 'mute', summary: 'mute every account on a moderation list', + writes: 'network', args: [{ name: 'list', required: true, description: LIST_ARG }], run: async (ctx) => { const uri = (await resolveFeed(ctx.client, ctx.need('list'), { kinds: ['list'], quiet: ctx.json })).uri; @@ -552,6 +573,7 @@ const list: CommandSpec = { { name: 'unmute', summary: 'unmute a moderation list', + writes: 'network', args: [{ name: 'list', required: true, description: LIST_ARG }], run: async (ctx) => { const uri = (await resolveFeed(ctx.client, ctx.need('list'), { kinds: ['list'], quiet: ctx.json })).uri; @@ -562,6 +584,7 @@ const list: CommandSpec = { { name: 'block', summary: 'block every account on a moderation list', + writes: 'network', args: [{ name: 'list', required: true, description: LIST_ARG }], run: async (ctx) => { const uri = (await resolveFeed(ctx.client, ctx.need('list'), { kinds: ['list'], quiet: ctx.json })).uri; @@ -572,6 +595,7 @@ const list: CommandSpec = { { name: 'unblock', summary: 'stop blocking a moderation list', + writes: 'network', args: [{ name: 'list', required: true, description: LIST_ARG }], run: async (ctx) => { const uri = (await resolveFeed(ctx.client, ctx.need('list'), { kinds: ['list'], quiet: ctx.json })).uri; @@ -603,6 +627,7 @@ const starterPack: CommandSpec = { { name: 'show', summary: 'a starter pack, its accounts and feeds', + writes: false, args: [{ name: 'pack', required: true, description: 'at:// URI or bsky.app/starter-pack link' }], run: async (ctx) => { const response = await ctx.client.appview<{ starterPack: Json }>('app.bsky.graph.getStarterPack', { starterPack: await starterPackUri(ctx.need('pack')) }); @@ -619,6 +644,7 @@ const starterPack: CommandSpec = { name: 'list', aliases: ['ls', 'by'], summary: "the starter packs someone has made (yours by default)", + writes: false, args: [{ name: 'actor', description: 'handle or DID (default: you)', complete: 'account' }], options: PAGING, run: async (ctx) => { diff --git a/src/commands/posts.ts b/src/commands/posts.ts index 2707f53..5bd36e6 100644 --- a/src/commands/posts.ts +++ b/src/commands/posts.ts @@ -228,6 +228,7 @@ const post: CommandSpec = { aliases: ['skeet'], group: WRITE, summary: 'write a post, or a thread', + writes: 'network', description: ` Mentions (@handle), links and #hashtags in the text are detected and linked, as the app does. The first link gets a preview card unless something else is @@ -251,6 +252,7 @@ const reply: CommandSpec = { name: 'reply', group: WRITE, summary: 'reply to a post', + writes: 'network', args: [ { name: 'post', required: true, description: 'the post to reply to' }, { name: 'text', variadic: true, description: "the text (or --file; '-' reads it from stdin)" }, @@ -264,6 +266,7 @@ const quote: CommandSpec = { name: 'quote', group: WRITE, summary: 'quote a post', + writes: 'network', args: [ { name: 'post', required: true, description: 'the post to quote' }, { name: 'text', variadic: true, description: "the text (or --file; '-' reads it from stdin)" }, @@ -280,6 +283,7 @@ const gateCommand: CommandSpec = { name: 'gate', group: WRITE, summary: 'change who may reply to or quote one of your posts, or hide replies', + writes: 'network', description: 'Replaces the reply rules of a thread you started. Without --reply-gate, everybody may reply.', args: [{ name: 'post', required: true, description: 'your post' }], options: [ @@ -316,6 +320,7 @@ const deleteCommand: CommandSpec = { aliases: ['rm'], group: WRITE, summary: 'delete posts, or any other records of yours by at:// URI', + writes: 'network', args: [{ name: 'post', required: true, variadic: true, description: 'posts (URI, link, handle/rkey) or at:// URIs of any record' }], examples: ['atcli delete https://bsky.app/profile/me.bsky.social/post/3k…', 'atcli delete at://did:plc:…/app.bsky.graph.follow/3k…'], run: async (ctx) => { @@ -336,6 +341,7 @@ function toggle(name: string, summary: string, collection: string, state: 'like' name, group: WRITE, summary, + writes: 'network', args: [{ name: 'post', required: true, variadic: true, description: 'the post(s)' }], run: async (ctx) => { await ctx.client.session(summary); @@ -370,6 +376,7 @@ const bookmark: CommandSpec = { name: 'list', aliases: ['ls'], summary: 'your bookmarks, newest first', + writes: false, options: DATED_PAGING, run: async (ctx) => { const dated = (each: { createdAt: string }) => each.createdAt; @@ -383,6 +390,7 @@ const bookmark: CommandSpec = { { name: 'add', summary: 'bookmark posts', + writes: 'network', args: [{ name: 'post', required: true, variadic: true, description: 'the post(s)' }], run: async (ctx) => { const added: string[] = []; @@ -398,6 +406,7 @@ const bookmark: CommandSpec = { name: 'remove', aliases: ['rm'], summary: 'remove bookmarks', + writes: 'network', args: [{ name: 'post', required: true, variadic: true, description: 'the post(s)' }], run: async (ctx) => { const removed: string[] = []; @@ -444,6 +453,7 @@ const unrollCommand: CommandSpec = { aliases: ['read-thread'], group: READ, summary: 'read the whole thread a post belongs to, as one piece', + writes: false, description: ` Give it any post of a thread someone wrote as replies to themselves: it finds the start, follows the author's own replies to the end, and prints the lot as @@ -465,6 +475,7 @@ const thread: CommandSpec = { name: 'thread', group: READ, summary: 'show a post with its parents and replies (or --unroll the whole thread)', + writes: false, args: [{ name: 'post', required: true, description: 'the post' }], options: [ { name: 'depth', short: 'd', type: 'number', value: 'n', description: 'how many levels of replies (default: 6)' }, @@ -492,6 +503,7 @@ const show: CommandSpec = { aliases: ['get'], group: READ, summary: 'show posts', + writes: false, args: [{ name: 'post', required: true, variadic: true, description: 'the post(s)' }], run: async (ctx) => { const uris = await Promise.all(ctx.rest('post').map(postUri)); @@ -505,6 +517,7 @@ function interactions(name: string, summary: string, nsid: string, key: string, name, group: READ, summary, + writes: false, args: [{ name: 'post', required: true, description: 'the post' }], options: [...(dateOf ? DATED_PAGING : PAGING), ...extra], run: async (ctx) => { diff --git a/src/commands/raw.ts b/src/commands/raw.ts index 6dd228f..1f29e59 100644 --- a/src/commands/raw.ts +++ b/src/commands/raw.ts @@ -25,6 +25,7 @@ const resolve: CommandSpec = { aliases: ['whois'], group: RAW, summary: 'resolve a handle to its DID, or a DID to its document, handle and PDS', + writes: false, args: [{ name: 'identity', required: true, variadic: true, description: 'handles or DIDs', complete: 'account' }], options: [{ name: 'document', type: 'boolean', description: 'include the whole DID document' }], examples: ['atcli resolve bsky.app', 'atcli resolve did:plc:z72i7hdynmk6r22z27h6tvur --document --json'], @@ -72,6 +73,7 @@ needs a signed-in account; reading works for anyone's repository.`, { name: 'get', summary: 'read one record', + writes: false, args: [{ name: 'uri', required: true, description: 'at:// URI, or a collection with --rkey' }], options: [ { name: 'rkey', type: 'string', value: 'key', description: 'the record key, when is a collection' }, @@ -88,6 +90,7 @@ needs a signed-in account; reading works for anyone's repository.`, name: 'list', aliases: ['ls'], summary: 'list the records of a collection', + writes: false, args: [{ name: 'collection', required: true, description: 'the collection NSID, e.g. app.bsky.feed.post' }], options: [ ...PAGING, @@ -106,6 +109,7 @@ needs a signed-in account; reading works for anyone's repository.`, name: 'collections', aliases: ['describe'], summary: "the collections in a repository, and who it belongs to", + writes: false, args: [{ name: 'actor', description: 'whose (default: yours)', complete: 'account' }], run: async (ctx) => { const repo = ctx.arg('actor') ? await actorDid(ctx.arg('actor')!) : (await ctx.client.session()).did; @@ -117,6 +121,7 @@ needs a signed-in account; reading works for anyone's repository.`, { name: 'create', summary: 'create a record in your repository', + writes: 'network', args: [{ name: 'collection', required: true, description: 'the collection NSID' }, { name: 'record', description: "the record as JSON, '@file', or '-' for stdin" }], options: [ { name: 'rkey', type: 'string', value: 'key', description: 'the record key (default: a new TID)' }, @@ -136,6 +141,7 @@ needs a signed-in account; reading works for anyone's repository.`, { name: 'put', summary: 'create or replace a record at a given key', + writes: 'network', args: [{ name: 'uri', required: true, description: 'at:// URI of your record, or the collection with --rkey' }, { name: 'record', description: "the record as JSON, '@file', or '-' for stdin" }], options: [ { name: 'rkey', type: 'string', value: 'key', description: 'the record key, when is a collection' }, @@ -158,6 +164,7 @@ needs a signed-in account; reading works for anyone's repository.`, name: 'delete', aliases: ['rm'], summary: 'delete records from your repository', + writes: 'network', args: [{ name: 'uri', required: true, variadic: true, description: 'at:// URIs' }], run: async (ctx) => { for (const uri of ctx.rest('uri')) await ctx.client.deleteRecord(uri); @@ -182,6 +189,7 @@ const xrpcCommand: CommandSpec = { aliases: ['api', 'call'], group: RAW, summary: 'call any XRPC method, on any service', + writes: 'network', description: ` Queries are GET with --param; procedures are POST with --data (or --post). The service is chosen from the method: app.bsky.* goes to the app view @@ -241,6 +249,7 @@ const upload: CommandSpec = { name: 'upload', group: RAW, summary: 'upload a file as a blob, for records that embed one', + writes: 'network', args: [{ name: 'file', required: true, description: 'the file', complete: 'file' }], options: [{ name: 'type', type: 'string', value: 'mime', description: 'its content type (default: sniffed from its bytes)' }], run: async (ctx) => { @@ -264,6 +273,7 @@ const report: CommandSpec = { name: 'report', group: RAW, summary: 'report a post or an account to the moderators', + writes: 'network', args: [{ name: 'subject', required: true, description: 'a post, or an account (handle or DID)' }], options: [ { name: 'reason', short: 'r', type: 'string', value: 'reason', description: `why: ${Object.keys(REPORT_REASONS).join(', ')}, or a full reason NSID (default: other)` }, diff --git a/src/commands/reading.ts b/src/commands/reading.ts index a7f9c93..9f32bea 100644 --- a/src/commands/reading.ts +++ b/src/commands/reading.ts @@ -46,6 +46,7 @@ const timeline: CommandSpec = { aliases: ['tl', 'home'], group: READ, summary: 'your home timeline: posts from the accounts you follow', + writes: false, options: DATED_PAGING, examples: ['atcli timeline', 'atcli timeline --since 2h', 'atcli timeline --since 2026-10-15T08:00:00Z --all --json'], run: async (ctx) => feedResult(await feedPages(ctx, 'timeline', 'app.bsky.feed.getTimeline', {}, { signedIn: true })), @@ -58,6 +59,7 @@ const authorPosts: CommandSpec = { aliases: ['author'], group: READ, summary: "someone's posts and reposts (yours by default)", + writes: false, args: [{ name: 'actor', description: 'handle or DID (default: you)', complete: 'account' }], options: [ ...DATED_PAGING, @@ -89,6 +91,7 @@ const feedShow: CommandSpec = { name: 'show', aliases: ['view', 'read'], summary: 'read a feed by name or address: a custom feed, a list, or your timeline', + writes: false, description: ` is what you would call it. A name ("mutuals", "JS/TS Wien") or the last part of a feed's address is looked for among the feeds and lists you have @@ -122,6 +125,7 @@ taken as it is.`, const feedInfo: CommandSpec = { name: 'info', summary: 'describe a custom feed', + writes: false, args: [{ name: 'feed', required: true, description: FEED_ARG }], run: async (ctx) => { const target = await resolveFeed(ctx.client, ctx.need('feed'), { kinds: ['feed'], quiet: ctx.json }); @@ -134,6 +138,7 @@ const feedList: CommandSpec = { name: 'list', aliases: ['ls', 'by'], summary: "the custom feeds someone has made (yours by default)", + writes: false, args: [{ name: 'actor', description: 'handle or DID (default: you)', complete: 'account' }], options: PAGING, run: async (ctx) => { @@ -149,6 +154,7 @@ const feedList: CommandSpec = { const feedSaved: CommandSpec = { name: 'saved', summary: 'the feeds you have saved and pinned, in order', + writes: false, run: async (ctx) => { const items = await savedFeeds(ctx); const feeds = items.filter((item) => item.type === 'feed').map((item) => item.value); @@ -176,6 +182,7 @@ function saveCommand(name: string, summary: string, change: (items: SavedFeed[], return { name, summary, + writes: 'network', args: [{ name: 'feed', required: true, variadic: true, description: `feeds or lists, each ${FEED_ARG.replace(/^a feed or list: /, '')}` }], run: async (ctx) => { const targets: Array<{ type: 'feed' | 'list'; value: string }> = []; @@ -193,6 +200,7 @@ function saveCommand(name: string, summary: string, change: (items: SavedFeed[], const feedSuggested: CommandSpec = { name: 'suggested', summary: 'feeds Bluesky suggests', + writes: false, options: PAGING, run: async (ctx) => { const page = await paginate(ctx.paging(), async (cursor, limit) => { @@ -207,6 +215,7 @@ const feedLiked: CommandSpec = { name: 'liked', aliases: ['likes'], summary: 'posts you have liked', + writes: false, options: DATED_PAGING, run: async (ctx) => { const session = await ctx.client.session('your likes'); @@ -240,6 +249,7 @@ const feed: CommandSpec = { const searchPosts: CommandSpec = { name: 'posts', summary: 'search posts', + writes: false, args: [{ name: 'query', required: true, variadic: true, description: 'what to look for (Lucene-style: "exact phrase", from:handle, …)' }], options: [ ...PAGING, @@ -283,6 +293,7 @@ const searchUsers: CommandSpec = { name: 'users', aliases: ['people', 'actors'], summary: 'search accounts by name, handle and description', + writes: false, args: [{ name: 'query', required: true, variadic: true, description: 'what to look for' }], options: [...PAGING, { name: 'typeahead', type: 'boolean', description: 'match handle and name prefixes, as the app does while you type' }], run: async (ctx) => { @@ -302,6 +313,7 @@ const searchUsers: CommandSpec = { const searchFeeds: CommandSpec = { name: 'feeds', summary: 'search custom feeds', + writes: false, args: [{ name: 'query', variadic: true, description: 'what to look for (nothing: the most popular feeds)' }], options: PAGING, run: async (ctx) => { @@ -318,6 +330,7 @@ const searchStarterPacks: CommandSpec = { name: 'starter-packs', aliases: ['packs'], summary: 'search starter packs', + writes: false, args: [{ name: 'query', required: true, variadic: true, description: 'what to look for' }], options: PAGING, run: async (ctx) => { @@ -351,6 +364,7 @@ const trending: CommandSpec = { aliases: ['trends'], group: READ, summary: 'what is being talked about right now', + writes: false, options: [{ name: 'limit', short: 'n', type: 'number', value: 'n', description: 'how many topics (default: 10)' }], run: async (ctx) => { const response = await ctx.client.appview<{ trends: Json[] }>('app.bsky.unspecced.getTrends', { limit: ctx.num('limit') ?? 10 }); @@ -372,6 +386,7 @@ const notificationsList: CommandSpec = { name: 'list', aliases: ['ls'], summary: 'your notifications, newest first', + writes: false, options: [ ...DATED_PAGING, { name: 'reason', type: 'list', choices: REASONS, value: 'reason', description: `only these kinds: ${REASONS.join(', ')}` }, @@ -408,6 +423,7 @@ const notificationsWatch: CommandSpec = { name: 'watch', aliases: ['follow', 'tail'], summary: 'print notifications as they arrive', + writes: false, description: ` Keeps running and prints each new notification as it comes: likes, reposts, follows, mentions, replies, quotes. With --json, each is one line of JSON @@ -509,6 +525,7 @@ const notifications: CommandSpec = { { name: 'count', summary: 'how many notifications are unread', + writes: false, run: async (ctx) => { const response = await ctx.client.appview<{ count: number }>('app.bsky.notification.getUnreadCount', {}, { signedIn: true }); return result(response, String(response.count)); @@ -518,6 +535,7 @@ const notifications: CommandSpec = { name: 'seen', aliases: ['read'], summary: 'mark notifications seen', + writes: 'network', options: [{ name: 'at', type: 'string', value: 'time', description: 'mark those up to this time (ISO 8601; default: now)' }], run: async (ctx) => { const at = ctx.str('at'); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index f0fb183..fb81e53 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -21,6 +21,7 @@ const completion: CommandSpec = { aliases: ['completions'], group: SETUP, summary: 'Tab completion for bash, zsh, fish and PowerShell', + writes: 'local', description: ` Prints the script that wires a shell's Tab key to atcli, for the shell given or the one you are in. --install adds a line loading it to the shell's @@ -93,6 +94,7 @@ const skill: CommandSpec = { name: 'show', aliases: ['print', 'export'], summary: 'print the whole skill as one document, to give any agent', + writes: false, description: ` Prints the skill and its complete command reference as a single Markdown document: what to put in an agent's instructions (an AGENTS.md, a system @@ -113,6 +115,7 @@ guide alone; with --json, "files" holds each file of the skill folder.`, { name: 'install', summary: 'install the skill for Claude Code (or any agent reading Agent Skills)', + writes: 'local', description: ` Writes the skill to .claude/skills/atcli/ in the current project, or with --global to ~/.claude/skills/atcli/ for every project. Files that are already @@ -155,6 +158,7 @@ export function helpCommand(root: CommandSpec): CommandSpec { name: 'help', group: SETUP, summary: "help on a command: 'atcli help post' is 'atcli post --help'", + writes: false, args: [{ name: 'command', variadic: true, description: 'the command' }], run: async (ctx) => { const path = [root]; @@ -173,6 +177,7 @@ export function versionCommand(version: () => string): CommandSpec { name: 'version', group: SETUP, summary: 'which atcli this is, and where it keeps things', + writes: false, run: async () => { const data = { version: version(), @@ -218,6 +223,7 @@ the command line wins over a setting: --format over format, --lang over lang. name: 'list', aliases: ['ls'], summary: 'every setting, its value, and what it does', + writes: false, run: async () => { const settings = readConfig().settings ?? {}; const rows = KEYS.map((key) => ({ key, value: settings[key] ?? null, default: SETTINGS[key].fallback, description: SETTINGS[key].description })); @@ -227,6 +233,7 @@ the command line wins over a setting: --format over format, --lang over lang. { name: 'get', summary: "one setting's value (its default when not set)", + writes: false, args: [{ name: 'key', required: true, description: 'the setting', complete: KEYS }], run: async (ctx) => { const key = keyOf(ctx.arg('key'), ctx.path); @@ -237,6 +244,7 @@ the command line wins over a setting: --format over format, --lang over lang. { name: 'set', summary: 'change a setting', + writes: 'local', args: [ { name: 'key', required: true, description: 'the setting', complete: KEYS }, { name: 'value', required: true, description: 'its new value' }, @@ -256,6 +264,7 @@ the command line wins over a setting: --format over format, --lang over lang. name: 'unset', aliases: ['reset'], summary: 'go back to the default for a setting', + writes: 'local', args: [{ name: 'key', required: true, description: 'the setting', complete: KEYS }], run: async (ctx) => { const key = keyOf(ctx.arg('key'), ctx.path); @@ -270,6 +279,7 @@ the command line wins over a setting: --format over format, --lang over lang. { name: 'client-metadata', summary: 'the OAuth client document to host yourself, for the oauth-client setting', + writes: false, description: ` Prints the client metadata document for an OAuth client of your own: host it at exactly that URL, served as application/json with status 200 (no redirect), @@ -293,6 +303,7 @@ keep using it, so keep it there for as long as they should last.`, { name: 'forget', summary: 'forget what --new has shown, and where watches got to, for this account', + writes: 'local', description: ` --new remembers, for each listing, the newest item it showed, and a watch with --resume where it got to. This forgets that for the account in use, so the @@ -314,6 +325,7 @@ next --new shows the newest again and the next --resume watch starts from now.`, { name: 'path', summary: 'where the config file is', + writes: false, run: async () => { const file = PATH.join(configDir(), 'config.json'); return { data: { path: file }, text: file, raw: true }; diff --git a/src/reference.ts b/src/reference.ts index 3aeb38c..6f999d9 100644 --- a/src/reference.ts +++ b/src/reference.ts @@ -3,7 +3,9 @@ import { usageLine } from './help.ts'; import { root } from './commands/index.ts'; // The command reference as Markdown, for the agent skill: generated from the -// same tree the parser uses. +// same tree the parser uses. Every runnable command declares whether it +// writes (`writes` on its spec); this generator refuses to run while one +// does not, so the classification cannot silently fall behind the commands. function flag(option: OptionSpec): string { const value = option.type === 'boolean' ? '' : ` <${option.value ?? 'value'}>`; @@ -18,15 +20,25 @@ function describe(option: OptionSpec): string { return `${option.description}${extra.length ? ` (${extra.join('; ')})` : ''}`.replace(/\|/g, '\\|'); } +function writesLine(command: CommandSpec): string | undefined { + if (command.writes === 'network') return '**Writes to the network.** It changes your account, or publishes something other people can see.'; + if (command.writes === 'local') return "**Writes only on this machine.** It changes local state — sessions, config, the shell's startup file — and nothing on the network."; + return undefined; +} + export function reference(): string { + const network: string[] = []; + const local: string[] = []; const lines: string[] = [ '# atcli command reference', '', '', '', - 'Every command also takes the global options:', + '## Which commands write', '', - ...GLOBAL_OPTIONS.map((option) => `- \`${flag(option)}\`: ${describe(option)}`), + 'Every command reads unless it is listed here.', + '', + 'These write to the network: they change your account, or publish something other people can see. An agent should have its user\'s ok before running one, unless the user asked for that exact action:', '', ]; const visit = (path: CommandSpec[]) => { @@ -34,7 +46,15 @@ export function reference(): string { if (command.hidden) return; if (path.length > 1) { const name = ['atcli', ...path.slice(1).map((each) => each.name)].join(' '); + if (command.run && command.writes === undefined) { + throw new Error(`reference: ${name} does not say whether it writes — set writes: false, 'local' or 'network' on its spec`); + } + if (command.run && command.writes === 'network') network.push(`\`${name}\``); + if (command.run && command.writes === 'local') local.push(`\`${name}\``); lines.push(`${'#'.repeat(Math.min(path.length + 1, 4))} \`${name}\``, '', `${command.summary}.`, ''); + const writes = writesLine(command); + if (writes) lines.push(writes, ''); + if (name === 'atcli xrpc') lines.push('Whether it writes depends on the method: queries (GET) read, procedures (POST) write.', ''); if (command.aliases?.length) lines.push(`Also: ${command.aliases.map((alias) => `\`${alias}\``).join(', ')}.`, ''); if (command.default) lines.push(`Without a subcommand, runs \`${name} ${command.default}\`.`, ''); if (command.run) { @@ -47,5 +67,19 @@ export function reference(): string { for (const sub of command.subcommands ?? []) visit([...path, sub]); }; visit([root]); + lines.push( + network.length ? `- ${network.join(', ')}.` : '- (none).', + '', + 'These write only on this machine — sessions, config, the shell\'s startup file. Nothing on the network changes:', + '', + local.length ? `- ${local.join(', ')}.` : '- (none).', + '', + 'And `atcli xrpc` does either, depending on the method: queries (GET) read, procedures (POST) write.', + '', + 'Every command also takes the global options:', + '', + ...GLOBAL_OPTIONS.map((option) => `- \`${flag(option)}\`: ${describe(option)}`), + '', + ); return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`; }