From 17d4b34a89f9e8f5e28a5ffd99eee6d884e1b8f1 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Fri, 26 Jun 2026 15:42:27 -0700 Subject: [PATCH] first pass at documentation and tooling --- .gitignore | 3 + README.md | 70 +++++++ docs/agent-guide.md | 183 +++++++++++++++++ docs/capability-spec.md | 106 ++++++++++ docs/consumers.md | 128 ++++++++++++ docs/overview.md | 140 +++++++++++++ docs/producers.md | 155 ++++++++++++++ lexicons/README.md | 35 ++++ lexicons/dev/at-intent/capability.json | 114 +++++++++++ lexicons/dev/at-intent/usage.json | 38 ++++ resolver/README.md | 62 ++++++ resolver/cli.mjs | 121 +++++++++++ resolver/fixtures/demo.mjs | 87 ++++++++ resolver/resolver.mjs | 189 ++++++++++++++++++ tools/README.md | 65 ++++++ tools/examples/all.capabilities.json | 42 ++++ tools/examples/passive-reader.capability.json | 11 + tools/examples/save-service.capability.json | 16 ++ tools/examples/subscribe.capability.json | 19 ++ tools/lib/atproto.mjs | 94 +++++++++ tools/lib/capability.mjs | 100 +++++++++ tools/lib/cli.mjs | 42 ++++ tools/lib/nsid.mjs | 12 ++ tools/lib/spec.mjs | 19 ++ tools/lib/usage.mjs | 29 +++ tools/package.json | 19 ++ tools/write-capability-interactive.mjs | 160 +++++++++++++++ tools/write-capability.mjs | 124 ++++++++++++ tools/write-usage.mjs | 102 ++++++++++ 29 files changed, 2285 insertions(+) create mode 100644 .gitignore create mode 100644 docs/agent-guide.md create mode 100644 docs/capability-spec.md create mode 100644 docs/consumers.md create mode 100644 docs/overview.md create mode 100644 docs/producers.md create mode 100644 lexicons/README.md create mode 100644 lexicons/dev/at-intent/capability.json create mode 100644 lexicons/dev/at-intent/usage.json create mode 100644 resolver/README.md create mode 100755 resolver/cli.mjs create mode 100644 resolver/fixtures/demo.mjs create mode 100644 resolver/resolver.mjs create mode 100644 tools/README.md create mode 100644 tools/examples/all.capabilities.json create mode 100644 tools/examples/passive-reader.capability.json create mode 100644 tools/examples/save-service.capability.json create mode 100644 tools/examples/subscribe.capability.json create mode 100644 tools/lib/atproto.mjs create mode 100644 tools/lib/capability.mjs create mode 100644 tools/lib/cli.mjs create mode 100644 tools/lib/nsid.mjs create mode 100644 tools/lib/spec.mjs create mode 100644 tools/lib/usage.mjs create mode 100644 tools/package.json create mode 100755 tools/write-capability-interactive.mjs create mode 100755 tools/write-capability.mjs create mode 100755 tools/write-usage.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48a6a28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.impeccable/ +*.log diff --git a/README.md b/README.md index 859ed9e..953eb50 100644 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ # AT Intents + +A standard, registry-free way for atproto apps to declare what they can **do** — +so other apps (and agents) can discover those capabilities from a user's own repo +and route to them. No central directory: an app you use leaves a small record +behind, and that footprint *is* the discovery surface. + +``` + ┌─────────────────────────── the user's repo ───────────────────────────┐ +producers │ dev.at-intent.usage ──"I use Skyreader"──► resolve app DID ─┐ │ consumers +publish │ dev.at-intent.usage ──"I use Sill"────────────────────────┐ │ │ discover + route + └─────────────────────────────────────────────────────────────┼─┼────────┘ + ▼ ▼ + ┌─ Skyreader repo ─┐ ┌─ Sill repo ─┐ + │ capability ×4 │ │ capability │ + │ (verb,subject,…) │ │ (passive) │ + └──────────────────┘ └─────────────┘ +``` + +Every capability is a **`(verb, subject, handler)`** triple plus a **`delivery`** +method (`passive` / `pds` / `service`) that says how the action actually reaches +the app. The same record doubles as an **agent tool definition** — discovered +from the user's data at runtime, scoped by their existing grants. + +## Start here + +| You are… | Read | +|---|---| +| understanding the system | [`docs/overview.md`](./docs/overview.md) | +| an app that **does things** (save/subscribe/share/annotate) | [`docs/producers.md`](./docs/producers.md) | +| an app that **discovers + routes** (menus, "open with", agents) | [`docs/consumers.md`](./docs/consumers.md) | +| an **AI agent** asked to author records for a repo | [`docs/agent-guide.md`](./docs/agent-guide.md) | +| writing a capability record | [`docs/capability-spec.md`](./docs/capability-spec.md) | + +## Try it in 30 seconds + +```bash +# See the discovery loop run offline (no network, no auth): +node resolver/cli.mjs --demo +node resolver/cli.mjs --demo --feed https://blog.example/rss # who can subscribe? + +# Validate + preview a capability record without writing it (zero deps): +cd tools +node write-capability.mjs examples/subscribe.capability.json --dry-run --identifier myapp.com +``` + +## What's in this repo + +``` +lexicons/ dev.at-intent.capability + dev.at-intent.usage (the two records) +tools/ write capability/usage records — from a JSON file or interactively +resolver/ the consumer-side discovery loop (lib + CLI + offline fixtures) +docs/ overview, producer/consumer guides, agent guide, spec reference +``` + +## Status + +Exploration / draft. The namespace `dev.at-intent.*` is a working placeholder +(likely long-term home: `community.lexicon.*`); it's defined in one constant +([`tools/lib/nsid.mjs`](./tools/lib/nsid.mjs)) plus the lexicon `id` fields, so +re-homing is mechanical. No app publishes these records on the live network yet — +the resolver fixtures stand in for that so the full loop runs today. + +## Prior art & context + +Descends from Web / Android / Apple App Intents — but replaces their handler +*registry* with the user's repo *footprint*, which is what W3C Web Intents +(~2012) lacked. Builds on atproto OAuth permission sets +([proposal 0011](https://github.com/bluesky-social/proposals/blob/main/0011-auth-scopes/README.md)), +lexicon resolution, and lexicon embeds. See [`docs/overview.md`](./docs/overview.md) +for the full picture. diff --git a/docs/agent-guide.md b/docs/agent-guide.md new file mode 100644 index 0000000..c633a6d --- /dev/null +++ b/docs/agent-guide.md @@ -0,0 +1,183 @@ +# Agent guide — author AT Intents capability records for a repo + +**Audience: an AI coding agent** (Claude Code or similar) working inside an app's +repository. A developer has said something like: + +> "Let's make an AT Intents capability record — see +> https://github.com/<...>/at-intents for context." + +This document is your instruction set. Your job: **scan the repo, figure out what +the app does, write capability record file(s) as JSON, and tell the developer the exact CLI +command to publish them.** You do not publish records yourself (that needs the +app's credentials) and you do not need to run anything that writes. + +If you can fetch additional context, read [`overview.md`](./overview.md) and +[`capability-spec.md`](./capability-spec.md) in this repo. The essentials are +inlined below so you can work from this file alone. + +## The model in 60 seconds + +AT Intents lets an app declare what it can *do* so other apps and agents can +discover it from a user's repo. Each capability is a **`(verb, subject, handler)` +triple** plus a **`delivery`** method, stored as a `dev.at-intent.capability` +record in the app's own repo. + +- **verb** — `share` `save` `subscribe` `annotate` `open` (open set; pick the + closest, or coin one and flag it to the developer). +- **subject** — what it acts on, a *union* of shapes: `uri` (a bare web/feed + URL), `at-uri` (a record, optionally `of` a lexicon type), `did` (an account). +- **delivery** — `passive` (you read a record already in the user's repo), `pds` + (the consumer writes a record into the user's repo that you read), or `service` + (the consumer must call your HTTP endpoint; a PDS write won't reach you). **This + is the field most likely to be wrong — get it right.** + +## Your procedure + +### Step 1 — confirm scope with the developer + +Briefly state what you're about to do and ask one question if anything is +ambiguous: *"I'll scan the repo for user-facing actions and draft AT Intents +capability specs. Should I cover all of them or a specific feature?"* Then +proceed. + +### Step 2 — scan the repo for what the app does + +You are looking for **user-facing actions** and the **data each one touches**. +Gather evidence; don't guess. High-signal places to look: + +**App identity (for `name` / `icon` / the app DID):** +- `package.json` name/description, `README*`, site ``/`og:` tags, + `public/`/`static/` for an icon, `manifest.json`, marketing copy. +- The app's atproto identity: a `did:web:<domain>`, a configured handle, OAuth + `client-metadata.json`, or `.well-known/atproto-did`. The capability records + live in *this* app's repo, so the DID is the app's own. + +**Lexicons the app defines or writes (for `subject` / `produces`):** +- `lexicons/**/*.json` (files with `"lexicon": 1`) — record types this app owns. +- atproto write calls: grep for `com.atproto.repo.createRecord`, + `putRecord`, `applyWrites`, `.create(`, `agent.com.atproto.repo`, or an SDK + wrapper. The `collection` / `$type` argument is a `produces` NSID. +- Record types it *reads* but doesn't own (e.g. + `community.lexicon.bookmarks.bookmark`, `app.bsky.feed.post`) → candidate + `passive` subjects. + +**Endpoints / backends (to decide `delivery`):** +- XRPC routes or HTTP handlers (`/xrpc/<nsid>`, an API router, server routes, + serverless functions) — a `service` `endpoint`. +- A non-PDS canonical store (a database — D1, Postgres, KV; an external API) that + user actions write to. **If the action's source of truth is a backend, not the + user's PDS, it's `delivery: service`.** + +**The actions themselves (for `verb` / `subject`):** +- UI: buttons/menus labeled save, subscribe/follow, share/post, highlight/annotate, + open/view. Share targets, intent handlers, "add to…" flows. +- Functions/routes named `save`, `subscribe`, `share`, `post`, `bookmark`, + `highlight`, `follow`, `import`. + +### Step 3 — map each action to a capability + +For every distinct user-facing action, decide the triple + delivery using this +table: + +| Question | Field | How to answer from evidence | +|---|---|---| +| What's the action? | `verb` | save / subscribe / share / annotate / open — closest match. | +| What can you act on? | `subject[]` | A raw URL → `{kind: uri}`. A record you accept → `{kind: at-uri, of: <nsid>}`. An account → `{kind: did, as: account}`. List every accepted shape. | +| Where does the result live? | `delivery` | In the **user's PDS** as a record you write/read → `pds`. In **your backend** → `service`. You only **read** an existing repo record → `passive`. | +| (pds) what record? | `produces` | The NSID(s) from the createRecord calls. | +| (pds) what permission? | `scope` | `include:<your.permission.set.nsid>` — ask the developer if unknown; leave a clear `TODO`. | +| (service) what URL? | `endpoint` | The XRPC/HTTP route that performs it. | +| Extra inputs? | `input[]` | A note, title, target collection — name + kind. | + +**The `delivery` decision is the one that silently breaks if wrong.** A "save" +whose data lands in your own database is `service`, *not* `pds` — a PDS write +would never reach your backend, so a consumer would think it saved when it +didn't. When the evidence is ambiguous, write your best guess and call it out in +the summary for the developer to confirm. + +Write `description` for a planner *and* a human: what it does, when to use it, +what it won't do (≤300 graphemes). This text drives both UI pickers and agent +tool-calling, so it matters. + +### Step 4 — write the record file(s) + +Create one JSON file in the repo holding the capability record(s) — a single +object, or `{ "capabilities": [ ... ] }` for several. It's the literal +`dev.at-intent.capability` shape; `$type` and `createdAt` are filled in by the +writer, so you can omit them. Recommended path: `at-intent/capabilities.json` (or +alongside the app's lexicons). Any `_`-prefixed field (e.g. `_comment`) is +ignored, so use it for review notes. + +```json +{ + "_comment": "Drafted by an agent from repo analysis. Review delivery/scope/endpoint, then publish with the CLI below.", + "capabilities": [ + { + "name": "<app display name>", + "icon": "<https icon url, if found>", + "description": "<what it does, when to use it, what it won't do>", + "verb": "<save|subscribe|share|annotate|open>", + "subject": [ + { "kind": "<uri|at-uri|did>" } + ], + "delivery": "<passive|pds|service>", + + "_delivery_note": "Keep ONLY the fields for the delivery you chose:", + "_passive": "you read an existing repo record — no produces/scope/endpoint", + "_pds": "consumer writes a record you read — set produces + scope", + "produces": ["<nsid you write — pds only>"], + "scope": "include:<your.permission.set> TODO confirm — pds only", + "_service": "consumer calls your backend — set endpoint instead", + "endpoint": "<https url / xrpc route> TODO confirm — service only" + } + ] +} +``` + +Fill it from your evidence and **delete the `_*` hint fields and any +delivery-fields that don't apply**. Mark every value you guessed with a `TODO` +note (a `_todo` field, or call it out in your summary). + +### Step 5 — tell the developer how to publish + +Do **not** write records yourself. Give the developer this, adjusting the file +path and identifier (the tooling is zero-dependency Node 18+ — no install): + +```bash +cd tools + +# 1) Dry-run — validates and prints the exact write, writes nothing: +node write-capability.mjs <path>/capabilities.json --dry-run --identifier <app-handle> + +# 2) Publish to the APP's repo (use an app password, never the main password): +PDS_IDENTIFIER=<app-handle> PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \ + node write-capability.mjs <path>/capabilities.json +``` + +Also mention the interactive builder as an alternative: +`node write-capability-interactive.mjs`. + +### Step 6 — summarize for review + +End with a short report: + +1. **Capabilities drafted** — one line each: `verb` + `subject` + `delivery`. +2. **Assumptions & TODOs** — every value you guessed, especially `delivery`, + `scope`, and `endpoint`, with where the evidence was thin. +3. **Usage records** — remind the developer that discovery also requires writing + a `dev.at-intent.usage` record into each *user's* repo when they use the app + (see [producers.md](./producers.md)); that's app code, not a one-time publish. +4. **Next command** — the dry-run line from Step 5. + +## Guardrails + +- **Don't invent NSIDs, endpoints, scopes, or a DID.** If the repo doesn't show + it, leave a `TODO` and ask. A wrong `produces`/`endpoint` is worse than a blank + one. +- **A capability may only describe its own app's records/endpoints** — never + another app's collections. (This is also a security rule: descriptors are + untrusted input to consumer agents.) +- **Don't publish anything.** Your deliverable is JSON record files + the command. The + human runs the write, ideally `--dry-run` first. +- **Prefer fewer, correct capabilities** over many speculative ones. One verb you + verified beats five you inferred. diff --git a/docs/capability-spec.md b/docs/capability-spec.md new file mode 100644 index 0000000..d530618 --- /dev/null +++ b/docs/capability-spec.md @@ -0,0 +1,106 @@ +# Capability record reference + +A capability is a `dev.at-intent.capability` record — plain JSON, the shape +defined by the [lexicon](../lexicons/dev/at-intent/capability.json). Write the +JSON directly. `$type` and `createdAt` are optional: the +[`write-capability.mjs`](../tools/write-capability.mjs) tool fills them in, +validates against the lexicon **and** the cross-field `delivery` rules, then +publishes it. (You could also `POST` it yourself via +`com.atproto.repo.createRecord` — the tool just adds validation, the dry-run +preview, and app-password auth.) + +A file may hold: + +- a single capability object, **or** +- a top-level array, **or** +- `{ "capabilities": [ ... ] }` to publish several at once. + +Any field starting with `_` (e.g. `_comment`) is ignored — handy for notes, since +JSON has no comments. + +## Fields + +| Field | Required | Type | Notes | +|---|---|---|---| +| `name` | ✅ | string ≤64 graphemes | Display name of the handler (e.g. `Skyreader`). | +| `verb` | ✅ | string | Known: `share` `save` `subscribe` `annotate` `open`. Open set — unknown verbs validate with a warning; consumers that don't know a verb skip it. | +| `subject` | ✅ | array (≥1) of [subject specs](#subject-spec) | A union: matches if **any** entry fits. | +| `delivery` | ✅ | `passive` \| `pds` \| `service` | How the action reaches you. See [delivery rules](#delivery-rules). | +| `description` | — | string ≤300 graphemes | Strongly recommended. Written for **both** a human picker and an agent planner: what it does, when to use it, what it won't do. | +| `icon` | — | uri | Handler icon; http(s) URL. | +| `input` | — | array of [input fields](#input-field) | Named typed inputs beyond the subject. | +| `produces` | — | array of NSIDs | Record type(s) you write. Required-ish for `pds`; empty for `passive`/`service`. | +| `endpoint` | — | uri | **Required for `delivery: service`** — the URL/XRPC that performs the action. | +| `scope` | — | string | **For `delivery: pds`** — an `include:<nsid>` permission set the consumer must request. | +| `$type` | auto | NSID | Set to `dev.at-intent.capability` by the writer; you may include it. | +| `createdAt` | auto | datetime | Added by the writer if omitted. | + +### Subject spec + +```json +"subject": [ + { "kind": "uri" }, + { "kind": "at-uri", "of": "community.lexicon.bookmarks.bookmark" }, + { "kind": "did", "as": "account" } +] +``` + +| Field | Required | Notes | +|---|---|---| +| `kind` | ✅ | `uri` \| `at-uri` \| `did` \| `string` \| `blob`. `uri` is a first-class bare URL the bookmark-centric model couldn't express. | +| `of` | — | For `kind: at-uri` only — the lexicon NSID the AT-URI must resolve to. Omit to accept any record type. | +| `as` | — | `record` (default) \| `collection` \| `account`. Distinguishes "act on this one record" from "subscribe to this whole account/collection." | + +### Input field + +```json +"input": [ + { "name": "category", "kind": "string", "description": "Optional folder.", "required": false } +] +``` + +| Field | Required | Notes | +|---|---|---| +| `name` | ✅ | The input's name. | +| `kind` | ✅ | `string` \| `uri` \| `at-uri` \| `did` \| `integer` \| `boolean` \| `blob`. | +| `of` | — | For `kind: at-uri` — the lexicon type the value must resolve to. | +| `required` | — | Defaults to false. | +| `description` | — | What the input means (also feeds agent planners). | + +## Delivery rules + +The validator enforces these cross-field rules (the JSON Schema alone can't): + +- **`service`** — `endpoint` is **required** (error if missing). `produces` and + `scope` are unexpected (warning). +- **`pds`** — `produces` and `scope` are expected (warning if missing). `endpoint` + is ignored (warning). +- **`passive`** — `produces`, `scope`, and `endpoint` are all unexpected + (warning); a passive capability reads what's already there and writes nothing. + +Errors block a write. Warnings don't — pass `--force` to write despite them, or +`--dry-run` to inspect first. + +## Full example + +```json +{ + "name": "Skyreader", + "icon": "https://skyreader.app/icon.png", + "description": "Subscribe to a feed, a standard.site publication, or an account so its new items appear in your Skyreader reading list.", + "verb": "subscribe", + "subject": [ + { "kind": "uri" }, + { "kind": "at-uri", "of": "site.standard.publication" }, + { "kind": "did", "as": "account" } + ], + "input": [ + { "name": "category", "kind": "string", "description": "Optional folder to file the subscription under." } + ], + "produces": ["app.skyreader.feed.subscription"], + "delivery": "pds", + "scope": "include:app.skyreader.subscribe" +} +``` + +More: one file per delivery type in [`tools/examples/`](../tools/examples/). diff --git a/docs/consumers.md b/docs/consumers.md new file mode 100644 index 0000000..4484f26 --- /dev/null +++ b/docs/consumers.md @@ -0,0 +1,128 @@ +# Consumer guide — discover and route to capabilities + +You're a **consumer** if you want to offer a user actions powered by the *other* +apps they use: a "send this out" menu, an "open with" picker, an agent that can +act across the atmosphere. You build none of those integrations by hand — you +read them out of the user's repo. + +New to the model? Read the [overview](./overview.md) first. + +## The loop you implement + +``` +1. RESOLVE Scan the user's repo for dev.at-intent.usage records → app DIDs. +2. DISCOVER Resolve each app DID → its dev.at-intent.capability records. +3. MATCH Filter to (verb, subject) you care about → candidate handlers. +4. AUTHORIZE (pds only) Aggregate include:<nsid> scopes; request their union. +5. ACT Route by delivery: passive | pds | service. +``` + +Steps 1–3 are **read-only and need no auth**. The [`resolver/`](../resolver/) in +this repo implements all of them; you can embed `resolver.mjs` (Node + browser, +zero deps) directly or treat it as a reference. + +## Quick start with the reference resolver + +```bash +node resolver/cli.mjs --demo # full action graph, offline +node resolver/cli.mjs --demo --feed https://blog/rss # who can subscribe to this feed? +node resolver/cli.mjs --demo --scopes # scopes you'd need to enable everything +node resolver/cli.mjs alice.bsky.social # live: real repo scan +``` + +In code: + +```js +import { resolveActionGraph, matchActions, requiredScopes } from "./resolver/resolver.mjs"; + +const graph = await resolveActionGraph("alice.bsky.social"); + +// "Which of Alice's apps can subscribe to this feed URL?" +const handlers = matchActions(graph, { verb: "subscribe", subject: { kind: "uri" } }); + +// What OAuth scopes must I request to enable them? +const scopes = requiredScopes(handlers); // ["include:app.myreader.subscribe", ...] +``` + +## Matching: the rules + +Matching is on `(verb, subject)`. A query subject matches a capability if **any** +of the capability's subject entries fits: + +- `kind` must be equal (`uri` ≠ `at-uri`). +- For `at-uri`, if both sides name an `of` type, they must match. A capability + with `at-uri` and no `of` accepts any record type. +- `as` (record / collection / account) must match (default `record`). + +So a bare feed `uri` matches a reader's subscribe but **not** a bookmark app +whose subject is an `at-uri` — the right handlers surface and the wrong ones +don't, with zero per-app code. That codeless second-handler property is the whole +point: a new app lights up in your UI just by publishing records. + +## Routing by `delivery` + +Once the user picks a handler, route by its `delivery`: + +| `delivery` | What you do | Auth needed | +|---|---|---| +| `passive` | Nothing — the handler already reads the primitive in the repo. Just show it as available. | none | +| `pds` | Write the capability's `produces` record(s) into the user's repo. | the capability's `scope` | +| `service` | `POST` to the capability's `endpoint`. | the endpoint's own auth | + +> **Don't assume `pds` for everything.** A `service` capability will not work if +> you only write to the PDS; you must call its endpoint. A `passive` capability +> needs no write at all. Honoring `delivery` is what keeps "looks like it worked" +> and "actually worked" the same thing. + +## OAuth scope aggregation + +`passive` and the entire discover/display path need **no new scope** — ship that +first as a complete, zero-scope feature. + +For `pds` capabilities: + +1. Collect the `include:<nsid>` scope of each handler the user might invoke + (`requiredScopes(actions)`). +2. Request their **union** at OAuth time — but only trigger incremental re-auth + on **first encounter** of an app whose scope you don't already hold. A fresh + grant on a newly discovered collection is normal OAuth, not a failure. +3. Never widen to `repo:*` for convenience. Scope is the real containment + boundary — keep it least-privilege and per-discovered-app. (See + [atproto proposal 0011](https://github.com/bluesky-social/proposals/blob/main/0011-auth-scopes/README.md).) + +## Staleness, caching, performance + +- **Staleness.** Usage records are write-once; nothing removes one when a person + stops using an app. Age handlers out by `lastSeenAt` + a TTL (resolver default + 180 days; `--stale-days`, `--include-stale`). +- **Caching.** Capability records change rarely — cache them by app DID with a + long TTL. Only the usage scan must be fresh. (The resolver accepts a `cache` + Map in `opts`.) +- **Latency.** A repo scan plus N capability resolutions across N PDSes per login + has a budget. Cache aggressively; parallelize the per-app resolution. +- **Browser CORS.** Live `listRecords` from a web origin can hit CORS on some + PDSes; a tiny read proxy fixes it. The Node path has no such limit. + +## Agent consumers + +A capability descriptor *is* a tool definition (see the +[overview](./overview.md#why-the-same-record-is-also-an-agent-tool)). An agent +consumer runs the same loop, then exposes matched capabilities as tools. Extra +duties unique to agents: + +- Treat `description` and `input` as **untrusted** input to your planner + (prompt-injection hardening). +- **Verify the handler's authority before acting**, not just before displaying — + a spoofed usage record could otherwise redirect work to an attacker's handler. +- Gate any write the user didn't directly request behind a dry-run/confirm step, + and do per-step scope checks rather than one broad up-front grant. + +## Checklist + +- [ ] Scan usage records; dedupe by `app`; drop stale by `lastSeenAt` + TTL. +- [ ] Resolve + cache capabilities by app DID. +- [ ] Match on `(verb, subject)` — don't hardcode an app list. +- [ ] Route by `delivery` (passive / pds / service) — handle all three. +- [ ] Aggregate `include:` scopes; request the union; incremental re-auth only on + first encounter; never `repo:*`. +- [ ] (Agents) untrusted-input hardening + verify authority before acting. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..cf2dfdf --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,140 @@ +# AT Intents — the system + +> A standard, registry-free way for atproto apps to declare what they can *do* — +> so other apps (and agents) can discover those capabilities from a user's own +> repo and route to them. No central directory: an app you use leaves a small +> record behind, and that footprint *is* the discovery surface. + +This document describes the model. If you're implementing it, jump to +[Producers](./producers.md) (you publish capabilities) or +[Consumers](./consumers.md) (you discover and route to them). If an agent is +authoring records for you, point it at [the agent guide](./agent-guide.md). + +## The problem + +When an app wants to be a destination for "share / save / subscribe to this," it +gets hand-wired into every other app, one integration at a time. W3C Web Intents +(~2012) tried to fix this with a global handler registry and died on discovery: +nobody picks a handler from a list of strangers, and nobody registers handlers +because nothing consumes them. + +AT Intents sidesteps both. Discovery is not "what could you install" — it's +**"what's already in your repo."** You see an app as a destination *because you +already use it* and it left a record. Discovery as a footprint, not a registry, +is the load-bearing idea. + +## The triple + +Every capability is a `(verb, subject, handler)` triple: + +- **verb** — `share`, `save`, `subscribe`, `annotate`, `open`, … (an open set + with a governed core vocabulary). The verb is *not* always "share" — subscribe, + save, and annotate are first-class. +- **subject** — what the verb acts on, as a *union* of accepted shapes. Crucially + includes a **bare `uri`** (an RSS feed has no atproto type) and + account/collection subjects, not just record AT-URIs. +- **handler** — the app: the DID whose repo holds the capability records and + where the action lands. + +## `delivery` — the field that makes capabilities honest + +`delivery` is how an action actually reaches the handler. It's the difference +between a capability that works and one that silently doesn't. + +| `delivery` | Meaning | `produces` | Cost to consumer | +|---|---|---|---| +| `passive` | Handler **reads a primitive already in the repo**; consumer writes nothing. | empty | zero | +| `pds` | Consumer **writes the produced records** to the user's repo; handler reads them. | the record(s) | a write + scope | +| `service` | Consumer **calls `endpoint`**; a PDS write alone won't reach the handler. | empty | an API call | + +Why it matters: a "Save to X" capability can *look* free and silently not work, +because a record written to the user's PDS never reaches an app whose canonical +store is its own backend. `delivery: service` makes that case explicit instead of +a silent failure. `scope` (an `include:<nsid>` permission set) is only needed for +`delivery: pds`. + +## Two records, two homes + +- **`dev.at-intent.capability`** — authored once by the app, in the *app's* repo, + resolved from the app's DID. One record per capability. See the + [lexicon](../lexicons/dev/at-intent/capability.json). +- **`dev.at-intent.usage`** — written by the app into the *user's* repo when they + use it. Lightweight: which app, optional per-user config, a `lastSeenAt` for + staleness. **This is the discovery footprint.** See the + [lexicon](../lexicons/dev/at-intent/usage.json). + +This mirrors how atproto already separates per-user identity from lexicon +authority, and keeps the per-user record small and stable rather than duplicating +a capability blob into every repo. + +## The discovery loop + +A consumer assembles a user's **action graph** without any hardcoded app list: + +``` +1. RESOLVE Scan the user's repo for dev.at-intent.usage records → app DIDs. +2. DISCOVER Resolve each app DID → its dev.at-intent.capability records (cache by DID). +3. MATCH Now you have a typed action set: {(verb, subject) → handler, delivery, scope}. + Answer "which of my apps can <verb> this <subject>?" +4. AUTHORIZE (pds only) Aggregate the include:<nsid> scopes of chosen handlers; request their union. +5. ACT passive → nothing; pds → write the produced record(s); service → call the endpoint. +``` + +Steps 1–3 are read-only and need no auth. The [resolver](../resolver/) implements +exactly this loop; run `node resolver/cli.mjs --demo` to see it offline. + +## Why the same record is also an agent tool + +A capability descriptor maps almost field-for-field onto an LLM tool definition: + +| An agent tool spec needs… | A capability descriptor has… | +|---|---| +| a name / id | `name` + NSID + app DID | +| a natural-language description | `description` (write it for a planner) | +| a typed input schema | `subject` + `input` | +| an output / effect | `produces` | +| a permission requirement | `scope` (the app's published permission set) | +| a way to be invoked | the `delivery` route | + +So the tool set is **discovered from the user's own data at runtime** and scoped +by their existing grants — unlike static MCP servers that hardcode a fixed API. + +## OAuth scope: the central constraint + +atproto OAuth grants are requested at login and enumerate collections up front, +so a dynamically discovered app's collections aren't in the grant. The `delivery` +split resolves this: + +- `passive` and discovery/display need **no new scope** — a complete feature with + zero scope changes. +- `pds` capabilities each advertise an `include:<nsid>` **permission set** + ([atproto proposal 0011](https://github.com/bluesky-social/proposals/blob/main/0011-auth-scopes/README.md)). + The consumer aggregates the sets of discovered apps and triggers incremental + re-auth **only** on first encounter of a new app that needs scope it doesn't + hold. A blanket `repo:*` is rejected. + +## Trust & safety + +- A **capability record** resolves from the app's own authority — as trustworthy + as the app. Display fields are safe because they come from the real authority. +- A **usage record** is self-asserted by whatever app wrote it. A malicious app + the user already authed could plant one and spoof the *association* ("this user + uses X"); the display still resolves from X's real authority, so the blast + radius is a misleading suggestion, not a hijacked write. Only an already-authed + app can plant one at all. +- For **agent** consumers, treat `description` / `input` as untrusted planner + input (prompt-injection hardening), verify the handler's authority before + acting (not just before displaying), and enforce that a capability may only + name its own app's collections. + +## Naming & namespace + +The names `dev.at-intent.*` are this repo's working namespace (authority +`at-intent.dev`). They're a placeholder so the design can iterate; the likely +long-term home is `community.lexicon.*`. The NSIDs live in one place +([`tools/lib/nsid.mjs`](../tools/lib/nsid.mjs) and the lexicon `id` fields) so +re-homing is a mechanical change. + +"Intents" deliberately echoes the Web / Android / Apple App Intents lineage this +descends from. (Note: Bluesky's separate "User Intents" work uses "intent" for +data-reuse consent — a name collision to flag, not to rename around.) diff --git a/docs/producers.md b/docs/producers.md new file mode 100644 index 0000000..c5c18c7 --- /dev/null +++ b/docs/producers.md @@ -0,0 +1,155 @@ +# Producer guide — publish your app's capabilities + +You're a **producer** if your app *does things*: saves, subscribes, shares, +annotates, opens. This guide takes you from "my app has features" to "other apps +and agents can discover and route to those features from a user's repo." + +New to the model? Read the [overview](./overview.md) first. Want an agent to do +the analysis for you? See the [agent guide](./agent-guide.md). + +There are two jobs: + +1. **Publish capability records** (once, in your app's repo) — describe what you + can do. +2. **Write usage records** (per user, in *their* repo) — leave the footprint that + lets consumers discover you at all. + +--- + +## 1. Publish your capabilities + +### Step 1 — enumerate your write/read surfaces + +List everything a user can *do* through your app. For each, you'll fill in the +triple plus `delivery`. Ask: + +- **verb** — is it `share`, `save`, `subscribe`, `annotate`, `open`, or something + new? Don't force everything into "share." +- **subject** — what does it act on? A bare URL (`uri`)? A record of a given type + (`at-uri` + `of`)? An account (`did`)? List *all* accepted shapes — it's a + union. +- **delivery** — the key decision, below. + +### Step 2 — choose `delivery` for each surface + +This is the field that decides whether a capability actually works. Pick by +asking *where the canonical data lives*: + +| If… | `delivery` | You must… | +|---|---|---| +| your app just **reads** a primitive the user already has in their repo | `passive` | nothing else — no `produces`, no `scope` | +| your app's data lives **in the user's PDS** as a record you read | `pds` | list `produces` (the NSID you write) and a `scope` | +| your app's canonical store is **your own backend** (a PDS write won't reach you) | `service` | give an `endpoint` | + +> The classic mistake: marking a backend-canonical save as `pds` or `passive`. A +> record written to the user's repo never reaches your backend, so the capability +> looks free and silently fails. Use `service`. + +### Step 3 — write the record as JSON + +A capability is just a JSON record matching the +[lexicon](../lexicons/dev/at-intent/capability.json) — `$type` and `createdAt` +are filled in for you. Full field reference: [capability-spec.md](./capability-spec.md). + +```json +{ + "name": "My Reader", + "icon": "https://myreader.app/icon.png", + "description": "Subscribe to a feed or account so new items appear in your reading list. Use to follow a source over time; not for saving a single article.", + "verb": "subscribe", + "subject": [ + { "kind": "uri" }, + { "kind": "did", "as": "account" } + ], + "produces": ["app.myreader.subscription"], + "delivery": "pds", + "scope": "include:app.myreader.subscribe" +} +``` + +Write `description` as if a planner will read it: what it does, when to use it, +what it won't do. Tool-calling quality is dominated by description quality, and +the same text feeds a human picker. + +See [`tools/examples/`](../tools/examples/) for one record per delivery type and a +multi-capability file. + +### Step 4 — validate and write + +You'll need an **app password** for your app's account (on Bluesky: Settings → +Privacy and security → App passwords — never your main password). The tooling is +zero-dependency Node 18+ — no install. + +```bash +cd tools + +# Always dry-run first: validates and prints the exact write, but writes nothing. +node write-capability.mjs examples/subscribe.capability.json --dry-run \ + --identifier myreader.app + +# Write for real (capability records go in YOUR app's repo): +PDS_IDENTIFIER=myreader.app PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \ + node write-capability.mjs my-subscribe.capability.json +``` + +Prefer to be walked through it? `node write-capability-interactive.mjs` prompts +for each field, saves a reusable JSON record, shows a dry-run, and writes on +confirmation. + +Publish your whole surface at once with a `{ "capabilities": [...] }` file — see +[`examples/all.capabilities.json`](../tools/examples/all.capabilities.json). + +### Step 5 — make the lexicon resolvable (optional, for live discovery) + +Capability *records* live in your repo and resolve today. For the *lexicon* NSID +to resolve on the open network, the authority domain (here `at-intent.dev`) must +publish the lexicon per atproto lexicon resolution. Until then, consumers fetch +records directly by collection NSID from your PDS — which the resolver already +does. Nothing blocks you from publishing records now. + +--- + +## 2. Write usage records (the footprint) + +A capability nobody can discover is invisible. When a person uses your app, write +a `dev.at-intent.usage` record **into their repo** so consumers find you: + +```json +{ + "$type": "dev.at-intent.usage", + "app": "did:web:myreader.app", + "config": { "defaultCategory": "tech" }, + "lastSeenAt": "2026-06-26T12:00:00Z", + "createdAt": "2026-03-01T00:00:00Z" +} +``` + +- You already have write access to the user's repo (they authed your app), so you + can create this during normal use. +- **Dedupe by `app`** — one usage record per handler is enough. +- **Bump `lastSeenAt`** when they use the app again, so consumers can age out + stale handlers (default TTL ~180 days). +- `config` is your private per-user blob (a default collection, a category) — + shape is yours. + +For manual/testing creation (writes into the *user's* repo, so auth as the user): + +```bash +node write-usage.mjs --app did:web:myreader.app \ + --config '{"defaultCategory":"tech"}' --dry-run --identifier alice.bsky.social +``` + +--- + +## Checklist + +- [ ] Every user-facing action mapped to a `(verb, subject)` pair. +- [ ] `delivery` chosen by where the canonical data lives (no backend-canonical + surface marked `pds`/`passive`). +- [ ] `pds` capabilities declare `produces` **and** a `scope`. +- [ ] `service` capabilities declare an `endpoint`. +- [ ] `description` written for a planner (what / when / won't). +- [ ] Capability records written to your app's repo (verify with the + [resolver](../resolver/): `node resolver/cli.mjs <your-app-handle>`). +- [ ] A usage record written into each user's repo on use, deduped by `app`, + `lastSeenAt` bumped. diff --git a/lexicons/README.md b/lexicons/README.md new file mode 100644 index 0000000..dbbce7b --- /dev/null +++ b/lexicons/README.md @@ -0,0 +1,35 @@ +# Lexicons + +The protocol nucleus: two lexicons built around the `(verb, subject, handler)` +triple, with a `delivery` field that decides whether a capability is actionable +at all. + +- [`dev/at-intent/capability.json`](./dev/at-intent/capability.json) — the + per-app descriptor. Authored once by the app, stored in the app's own authority + repo, one record per capability. +- [`dev/at-intent/usage.json`](./dev/at-intent/usage.json) — the per-user + discovery signal, written into the user's repo by the app. + +See [`../docs/overview.md`](../docs/overview.md) for the model and +[`../docs/capability-spec.md`](../docs/capability-spec.md) for a field-by-field +reference. + +## Namespace + +These use `dev.at-intent.*` (authority domain `at-intent.dev`) as a working +namespace; the likely long-term home is `community.lexicon.*`. The NSIDs are +defined in exactly two places — these files' `id` fields and +[`../tools/lib/nsid.mjs`](../tools/lib/nsid.mjs) — so re-homing is mechanical. + +## `delivery` — the one design move worth knowing + +A "Save to X" capability can *look* like a zero-cost reader and silently not work, +because some apps' canonical store is their own backend, not the PDS. A record +written to the user's repo never reaches them. So `delivery` is not an +afterthought — it decides whether a capability is actionable: + +| `delivery` | Meaning | `produces` | scope | +|---|---|---|---| +| `passive` | Handler reads a primitive already in the repo; consumer writes nothing. | empty | none | +| `pds` | Consumer writes the produced record(s); handler reads them. | the record(s) | `include:<nsid>` | +| `service` | Consumer must call `endpoint`; a PDS write won't reach the handler. | empty | none | diff --git a/lexicons/dev/at-intent/capability.json b/lexicons/dev/at-intent/capability.json new file mode 100644 index 0000000..624edf8 --- /dev/null +++ b/lexicons/dev/at-intent/capability.json @@ -0,0 +1,114 @@ +{ + "lexicon": 1, + "id": "dev.at-intent.capability", + "description": "A single capability an app (the handler) declares: a (verb, subject, handler) triple, plus optional typed inputs, the records it produces, and how the action is delivered. Authored once by the app developer and stored in the app's own authority repo; consumers discover an app via a dev.at-intent.usage record and resolve these from the app's DID. v1 consumers may read any subset of fields and ignore the rest.", + "defs": { + "main": { + "type": "record", + "description": "One capability. An app publishes one record per capability (e.g. a reader might publish four: save, subscribe, share, annotate).", + "key": "tid", + "record": { + "type": "object", + "required": ["name", "verb", "subject", "delivery"], + "properties": { + "name": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640, + "description": "Display name of the handler for this capability (e.g. 'Skyreader', 'Skyreader Linkblog')." + }, + "icon": { + "type": "string", + "format": "uri", + "description": "Optional handler icon URL. Consumers should constrain rendering (see docs: icon/display trust)." + }, + "description": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000, + "description": "Natural-language description of what this capability does. Written to serve BOTH a human picker and an agent planner — say what it does, when to use it, what it won't do." + }, + "verb": { + "type": "string", + "knownValues": ["share", "save", "subscribe", "annotate", "open"], + "maxLength": 128, + "description": "The action this capability performs. Open set; knownValues are the vocabulary defined so far. A consumer that doesn't know a verb should ignore the capability rather than guess." + }, + "subject": { + "type": "array", + "items": {"type": "ref", "ref": "#subjectSpec"}, + "minLength": 1, + "description": "The accepted subject(s) — a union. The capability matches a shared item if that item satisfies ANY entry (e.g. subscribe accepts a feed URL OR a publication AT-URI OR an account DID)." + }, + "input": { + "type": "array", + "items": {"type": "ref", "ref": "#inputField"}, + "description": "Additional named, typed inputs the handler accepts beyond the subject (a note, a title, a target collection). Kept deliberately small; complex inputs (e.g. span selectors) are deferred — see docs." + }, + "produces": { + "type": "array", + "items": {"type": "string", "format": "nsid"}, + "description": "Record type(s) written when the capability runs. Empty/omitted for delivery=passive (handler reads an existing primitive) and delivery=service (handler ingests out-of-band)." + }, + "delivery": { + "type": "string", + "knownValues": ["passive", "pds", "service"], + "description": "How the action reaches the handler. 'passive': the handler reads a primitive ALREADY in the user's repo; the consumer performs no extra write (zero-cost). 'pds': the consumer writes the 'produces' records into the user's repo and the handler reads them (enrichment). 'service': the consumer must call 'endpoint'; a PDS write alone would NOT reach the handler (e.g. an app whose canonical store is its own backend)." + }, + "endpoint": { + "type": "string", + "format": "uri", + "description": "Required for delivery=service: the HTTP/XRPC endpoint that performs the action." + }, + "scope": { + "type": "string", + "description": "For capabilities that write (delivery=pds): the OAuth permission set the handler requires, as an 'include:<nsid>' scope string per atproto auth-scopes (proposal 0011). The consumer aggregates these across discovered apps and requests their union. Omit for delivery=passive." + }, + "createdAt": {"type": "string", "format": "datetime"} + } + } + }, + "subjectSpec": { + "type": "object", + "description": "One accepted subject shape.", + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "knownValues": ["uri", "at-uri", "did", "string", "blob"], + "description": "The value type of the subject. 'uri' is a bare web/feed URL with NO atproto type (e.g. an RSS feed) — a first-class shareable the bookmark-centric model couldn't express. 'at-uri' points at a record; 'did' is an account." + }, + "of": { + "type": "string", + "format": "nsid", + "description": "For kind=at-uri: the lexicon type the AT-URI must resolve to (e.g. community.lexicon.bookmarks.bookmark, site.standard.publication)." + }, + "as": { + "type": "string", + "knownValues": ["record", "collection", "account"], + "default": "record", + "description": "The semantic role of the subject: a single record, an entire collection, or an account. Lets 'subscribe to this account/collection' be distinct from 'act on this one record'." + } + } + }, + "inputField": { + "type": "object", + "description": "A named, typed input beyond the subject.", + "required": ["name", "kind"], + "properties": { + "name": {"type": "string", "maxLength": 128}, + "kind": { + "type": "string", + "knownValues": ["string", "uri", "at-uri", "did", "integer", "boolean", "blob"] + }, + "of": { + "type": "string", + "format": "nsid", + "description": "For kind=at-uri: the lexicon type the value must resolve to." + }, + "required": {"type": "boolean", "default": false}, + "description": {"type": "string", "maxGraphemes": 300, "maxLength": 3000} + } + } + } +} diff --git a/lexicons/dev/at-intent/usage.json b/lexicons/dev/at-intent/usage.json new file mode 100644 index 0000000..81ecfc8 --- /dev/null +++ b/lexicons/dev/at-intent/usage.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "dev.at-intent.usage", + "description": "A per-user discovery signal, written into the user's OWN repo by an app when the person uses it: 'I use this handler.' Consumers scan for these on login and resolve each app's dev.at-intent.capability records. Self-asserted; only an app the user has already granted write access can create one (see docs: trust/spoofing).", + "defs": { + "main": { + "type": "record", + "description": "One usage signal per handler the user uses. Consumers should dedupe by 'app'.", + "key": "tid", + "record": { + "type": "object", + "required": ["app", "createdAt"], + "properties": { + "app": { + "type": "string", + "format": "did", + "description": "The handler the user uses. Resolves (via atproto lexicon resolution) to that app's capability records." + }, + "capability": { + "type": "string", + "format": "at-uri", + "description": "Optional: a specific capability record this usage refers to. Omit to mean 'all of the app's capabilities'." + }, + "config": { + "type": "unknown", + "description": "Optional per-user configuration consumed by the handler (e.g. a default collection AT-URI, a default category). Shape is handler-defined." + }, + "lastSeenAt": { + "type": "string", + "format": "datetime", + "description": "Bumped by the app each time the person uses it, so consumers can age out stale handlers (see docs: revocation/staleness). Absence means 'never refreshed since createdAt'." + }, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/resolver/README.md b/resolver/README.md new file mode 100644 index 0000000..47ed234 --- /dev/null +++ b/resolver/README.md @@ -0,0 +1,62 @@ +# Resolver — the consumer side + +A read-only, zero-auth, zero-dependency resolver that discovers a user's +**action graph** from their repo footprint, and a CLI over it. This is the +reference implementation a [consumer](../docs/consumers.md) embeds. + +``` +resolver.mjs core lib (Node 18+ and browser; fetch-based) +cli.mjs prints the action graph / answers "which app can do X?" +fixtures/ demo records (not yet on the network) for the offline loop +``` + +## The loop + +``` +actor (handle/DID) + -> scan repo for dev.at-intent.usage records (discovery) + -> resolve each app DID -> its dev.at-intent.capability records + -> assemble (verb, subject, handler) action graph (the triple) +matchActions(graph, {verb, subject}) -> "which handler can do X?" +requiredScopes(actions) -> the include:<nsid> scopes to request +``` + +## CLI + +```bash +node cli.mjs --demo # full action graph (offline fixtures) +node cli.mjs --demo --feed https://blog/rss # "which of my apps subscribe to a feed?" +node cli.mjs --demo --scopes # scopes to request to enable everything +node cli.mjs --demo --json # raw graph +node cli.mjs alice.bsky.social # live: real handle, real repo scan +``` + +Live mode does real atproto plumbing — handle→DID, DID doc→PDS, +`com.atproto.repo.listRecords` for usage/capability records. Until apps publish +these records it finds **nothing** against live handles — which is itself proof +the plumbing is real, not mocked. The fixtures stand in for the network so the +full loop runs offline today. + +## Library + +```js +import { resolveActionGraph, matchActions, requiredScopes } from "./resolver.mjs"; + +const graph = await resolveActionGraph("alice.bsky.social", { cache: new Map() }); +const handlers = matchActions(graph, { verb: "subscribe", subject: { kind: "uri" } }); +const scopes = requiredScopes(handlers); +``` + +`opts`: `fixtures` (offline records), `cache` (Map, persist with a long TTL), +`staleDays` (default 180), `includeStale`, `fetchImpl`, `now`. + +## Caveats + +- **No real records exist yet** — everything non-demo resolves to empty until an + app publishes `dev.at-intent.*` records. +- **Browser live mode can hit CORS** — `listRecords` against arbitrary PDSes may + be blocked from a web origin; a tiny read proxy fixes it. The Node path and + demo mode have no such limit. +- **Routing is read-only here** — `pds`/`service` capabilities show what they + *would* do; performing the write/call is the consumer's job (see + [consumers.md](../docs/consumers.md)). diff --git a/resolver/cli.mjs b/resolver/cli.mjs new file mode 100755 index 0000000..1f95730 --- /dev/null +++ b/resolver/cli.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// AT Intents resolver CLI — the consumer side of the loop. +// +// node cli.mjs <handle|did> # print the user's action graph +// node cli.mjs <handle> --verb subscribe # filter to one verb +// node cli.mjs <handle> --feed <url> # "which of my apps can subscribe to this?" +// node cli.mjs --demo [...] # run fully offline against fixtures +// node cli.mjs <handle> --json # raw graph as JSON +// +// Flags: --verb <v> --feed <uri> --subject-type <nsid> --include-stale +// --stale-days <n> --scopes --json --demo + +import { resolveActionGraph, matchActions, requiredScopes } from "./resolver.mjs"; +import demo, { DEMO_ACTOR } from "./fixtures/demo.mjs"; + +function parseArgs(argv) { + const a = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (t === "--demo") a.demo = true; + else if (t === "--json") a.json = true; + else if (t === "--scopes") a.scopes = true; + else if (t === "--include-stale") a.includeStale = true; + else if (t === "--verb") a.verb = argv[++i]; + else if (t === "--feed") a.feed = argv[++i]; + else if (t === "--subject-type") a.subjectType = argv[++i]; + else if (t === "--stale-days") a.staleDays = Number(argv[++i]); + else a._.push(t); + } + return a; +} + +const C = { + dim: (s) => `\x1b[2m${s}\x1b[0m`, + bold: (s) => `\x1b[1m${s}\x1b[0m`, + cyan: (s) => `\x1b[36m${s}\x1b[0m`, + green: (s) => `\x1b[32m${s}\x1b[0m`, + yellow: (s) => `\x1b[33m${s}\x1b[0m`, +}; + +const deliveryNote = { + passive: C.green("passive") + C.dim(" — reads a primitive already in your repo (zero-cost)"), + pds: C.cyan("pds") + C.dim(" — would write a record to your repo (needs OAuth scope)"), + service: C.yellow("service") + C.dim(" — would call the app's endpoint"), +}; + +function subjectStr(s) { + if (s.kind === "at-uri") return `at-uri<${s.of || "*"}>${s.as && s.as !== "record" ? " as " + s.as : ""}`; + return s.as && s.as !== "record" ? `${s.kind} as ${s.as}` : s.kind; +} + +function printGraph(graph) { + console.log(`\n${C.bold("Action graph")} for ${C.cyan(graph.actor)} ${C.dim(graph.did)}`); + if (!graph.handlers.length) { + console.log(C.dim("\n No usage records found — nothing in this repo's footprint yet.\n")); + return; + } + for (const h of graph.handlers) { + const tag = h.error ? C.yellow(" [unresolved]") : h.stale ? C.dim(" [stale]") : ""; + console.log(`\n ${C.bold(h.app)}${tag}`); + if (h.error) { console.log(` ${C.yellow(h.error)}`); continue; } + for (const cap of h.capabilities) { + const subj = (cap.subject || []).map(subjectStr).join(" | "); + console.log(` ${C.green(cap.verb.padEnd(10))} ${subj}`); + console.log(` ${C.dim(cap.description || cap.name || "")}`); + console.log(` delivery: ${deliveryNote[cap.delivery] || cap.delivery}` + + (cap.produces?.length ? C.dim(` produces: ${cap.produces.join(", ")}`) : "")); + } + } + console.log(); +} + +function printMatches(graph, query) { + const matches = matchActions(graph, query); + const what = query.subject ? `${query.verb || "any verb"} on ${subjectStr(query.subject)}` : query.verb; + console.log(`\n${C.bold("Handlers that can")} ${C.green(what)} ${C.dim("(from your apps)")}`); + if (!matches.length) { console.log(C.dim("\n None of your apps can do this.\n")); return; } + for (const m of matches) { + console.log(`\n ${C.cyan(m.name || m.handler)} ${C.dim(m.handler)}`); + console.log(` ${m.description || ""}`); + console.log(` route: ${deliveryNote[m.delivery] || m.delivery}` + + (m.delivery === "service" && m.endpoint ? C.dim(` -> ${m.endpoint}`) : "")); + } + const scopes = requiredScopes(matches); + if (scopes.length) console.log(`\n ${C.dim("scopes to request:")} ${scopes.join(" ")}`); + console.log(); +} + +async function main() { + const a = parseArgs(process.argv.slice(2)); + const actor = a.demo ? DEMO_ACTOR : a._[0]; + if (!actor) { + console.error("usage: node cli.mjs <handle|did> [--verb v] [--feed url] [--demo] [--json]"); + process.exit(1); + } + + const opts = { staleDays: a.staleDays, includeStale: a.includeStale }; + if (a.demo) opts.fixtures = demo; + + let graph; + try { + graph = await resolveActionGraph(actor, opts); + } catch (err) { + console.error(C.yellow(`\nresolve failed: ${err?.message || err}\n`)); + process.exit(1); + } + + if (a.json) { console.log(JSON.stringify(graph, null, 2)); return; } + if (a.scopes) { console.log(requiredScopes(graph.actions).join(" ") || C.dim("(no pds-delivery scopes)")); return; } + + if (a.feed || a.verb || a.subjectType) { + const query = { verb: a.verb, includeStale: a.includeStale }; + if (a.feed) { query.verb = a.verb || "subscribe"; query.subject = { kind: "uri" }; } + else if (a.subjectType) query.subject = { kind: "at-uri", of: a.subjectType }; + printMatches(graph, query); + } else { + printGraph(graph); + } +} + +main(); diff --git a/resolver/fixtures/demo.mjs b/resolver/fixtures/demo.mjs new file mode 100644 index 0000000..80e0fe6 --- /dev/null +++ b/resolver/fixtures/demo.mjs @@ -0,0 +1,87 @@ +// Demo fixtures — records that don't exist on the network yet. A user +// (DEMO_ACTOR) who uses two apps, plus those apps' published capabilities. +// Lets the resolver run the full loop offline. Mirrors the example records under +// ../../tools/examples/. + +export const DEMO_ACTOR = "did:plc:demouser000000000000000"; +const READER = "did:web:skyreader.app"; +const SILL = "did:web:sill.social"; + +const readerCaps = [ + { + $type: "dev.at-intent.capability", + name: "Skyreader", + icon: "https://skyreader.app/icon.png", + description: + "Subscribe to a feed, a standard.site publication, or an account so its new items appear in your Skyreader reading list.", + verb: "subscribe", + subject: [ + { kind: "uri" }, + { kind: "at-uri", of: "site.standard.publication" }, + { kind: "did", as: "account" }, + ], + input: [ + { name: "category", kind: "string", description: "Optional folder to file the subscription under." }, + { name: "tags", kind: "string" }, + ], + produces: ["app.skyreader.feed.subscription"], + delivery: "pds", + scope: "include:app.skyreader.subscribe", + createdAt: "2026-06-26T00:00:00Z", + }, + { + $type: "dev.at-intent.capability", + name: "Skyreader Linkblog", + icon: "https://skyreader.app/icon.png", + description: "Post a link to your standard.site linkblog with an optional note.", + verb: "share", + subject: [{ kind: "uri" }], + input: [ + { name: "title", kind: "string" }, + { name: "note", kind: "string" }, + ], + produces: ["site.standard.document"], + delivery: "pds", + scope: "include:app.skyreader.linkblog", + createdAt: "2026-06-26T00:00:00Z", + }, + { + $type: "dev.at-intent.capability", + name: "Skyreader", + icon: "https://skyreader.app/icon.png", + description: "Save an article to your Skyreader reading list (read-it-later).", + verb: "save", + subject: [{ kind: "uri" }], + input: [{ name: "title", kind: "string" }], + delivery: "service", + endpoint: "https://skyreader.app/xrpc/app.skyreader.feed.save", + createdAt: "2026-06-26T00:00:00Z", + }, +]; + +const sillCaps = [ + { + $type: "dev.at-intent.capability", + name: "Sill", + icon: "https://sill.social/icon.png", + description: + "Your saved bookmarks, surfaced for daily review. Sill reads the community bookmark already in your repo — nothing is written.", + verb: "save", + subject: [{ kind: "at-uri", of: "community.lexicon.bookmarks.bookmark" }], + delivery: "passive", + createdAt: "2026-06-26T00:00:00Z", + }, +]; + +export default { + usage: { + [DEMO_ACTOR]: [ + { $type: "dev.at-intent.usage", app: READER, lastSeenAt: "2026-06-20T12:00:00Z", createdAt: "2026-03-01T00:00:00Z", config: { defaultCategory: "tech" } }, + { $type: "dev.at-intent.usage", app: SILL, lastSeenAt: "2026-06-24T09:00:00Z", createdAt: "2026-02-10T00:00:00Z" }, + ], + }, + capabilities: { + [READER]: readerCaps, + [SILL]: sillCaps, + }, +}; diff --git a/resolver/resolver.mjs b/resolver/resolver.mjs new file mode 100644 index 0000000..e4138d2 --- /dev/null +++ b/resolver/resolver.mjs @@ -0,0 +1,189 @@ +// AT Intents resolver — discover a user's "action graph" from their repo +// footprint. Read-only, no auth. Runs in Node 18+ and the browser (uses +// globalThis.fetch). This is the core a CONSUMER app embeds. +// +// The loop: +// actor (handle/DID) +// -> scan repo for dev.at-intent.usage records (discovery) +// -> resolve each app DID -> its dev.at-intent.capability records +// -> assemble (verb, subject, handler) action graph (the triple) +// matchActions(graph, {verb, subject}) answers "which handler can do X?" + +export const USAGE_NSID = "dev.at-intent.usage"; +export const CAPABILITY_NSID = "dev.at-intent.capability"; + +const PLC_DIRECTORY = "https://plc.directory"; +const HANDLE_RESOLVER = "https://public.api.bsky.app"; +const DEFAULT_STALE_DAYS = 180; + +const getFetch = (opts) => opts.fetchImpl || globalThis.fetch; + +// --- identity / repo plumbing ------------------------------------------------- + +export async function resolveDid(actor, opts = {}) { + if (actor.startsWith("did:")) return actor; + const handle = actor.replace(/^@/, ""); + const url = `${HANDLE_RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; + const res = await getFetch(opts)(url); + if (!res.ok) throw new Error(`resolveHandle ${handle}: HTTP ${res.status}`); + const json = await res.json(); + if (!json.did) throw new Error(`no DID for handle ${handle}`); + return json.did; +} + +export async function resolveDidDoc(did, opts = {}) { + const f = getFetch(opts); + if (did.startsWith("did:plc:")) { + const res = await f(`${PLC_DIRECTORY}/${did}`); + if (!res.ok) throw new Error(`plc ${did}: HTTP ${res.status}`); + return res.json(); + } + if (did.startsWith("did:web:")) { + const host = decodeURIComponent(did.slice("did:web:".length)).replace(/:/g, "/"); + const res = await f(`https://${host}/.well-known/did.json`); + if (!res.ok) throw new Error(`did:web ${did}: HTTP ${res.status}`); + return res.json(); + } + throw new Error(`unsupported DID method: ${did}`); +} + +export function pdsFromDidDoc(doc) { + const svc = (doc.service || []).find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + if (!svc?.serviceEndpoint) throw new Error("no #atproto_pds service in DID doc"); + return svc.serviceEndpoint; +} + +export async function resolvePds(did, opts = {}) { + return pdsFromDidDoc(await resolveDidDoc(did, opts)); +} + +export async function listRecords(pds, did, collection, opts = {}) { + const f = getFetch(opts); + const out = []; + let cursor; + do { + const u = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`); + u.searchParams.set("repo", did); + u.searchParams.set("collection", collection); + u.searchParams.set("limit", "100"); + if (cursor) u.searchParams.set("cursor", cursor); + const res = await f(u); + // A repo with no records in this collection answers 400; treat as empty. + if (res.status === 400) return out; + if (!res.ok) throw new Error(`listRecords ${collection}@${did}: HTTP ${res.status}`); + const json = await res.json(); + for (const r of json.records || []) out.push(r); + cursor = json.cursor; + } while (cursor); + return out; +} + +// --- capability resolution (cached by app DID) -------------------------------- + +export async function resolveCapabilities(appDid, opts = {}) { + const { fixtures, cache } = opts; + if (fixtures?.capabilities && appDid in fixtures.capabilities) { + return fixtures.capabilities[appDid]; + } + if (cache?.has(appDid)) return cache.get(appDid); + const pds = await resolvePds(appDid, opts); + const caps = (await listRecords(pds, appDid, CAPABILITY_NSID, opts)).map((r) => r.value); + cache?.set(appDid, caps); + return caps; +} + +// --- staleness ---------------------------------------------------------------- + +export function isStale(usage, opts = {}) { + const days = opts.staleDays ?? DEFAULT_STALE_DAYS; + if (days == null) return false; + const ts = usage.lastSeenAt || usage.createdAt; + if (!ts) return false; + const now = opts.now ? new Date(opts.now) : new Date(); + return now - new Date(ts) > days * 86_400_000; +} + +// --- the action graph --------------------------------------------------------- + +export async function resolveActionGraph(actor, opts = {}) { + const { fixtures } = opts; + const did = await resolveDid(actor, opts); + + let usageRecords; + if (fixtures?.usage && did in fixtures.usage) { + usageRecords = fixtures.usage[did]; + } else { + const pds = await resolvePds(did, opts); + usageRecords = (await listRecords(pds, did, USAGE_NSID, opts)).map((r) => r.value); + } + + // dedupe by app, keeping the freshest signal + const byApp = new Map(); + const freshness = (u) => u.lastSeenAt || u.createdAt || ""; + for (const u of usageRecords) { + const prev = byApp.get(u.app); + if (!prev || freshness(u) > freshness(prev)) byApp.set(u.app, u); + } + + // resolve each app's capabilities — per-app failure degrades gracefully + const handlers = []; + for (const [appDid, usage] of byApp) { + try { + const capabilities = await resolveCapabilities(appDid, opts); + handlers.push({ app: appDid, usage, capabilities, stale: isStale(usage, opts) }); + } catch (err) { + handlers.push({ app: appDid, usage, capabilities: [], error: String(err?.message || err) }); + } + } + + // flatten to a queryable action list + const actions = []; + for (const h of handlers) { + for (const cap of h.capabilities) { + actions.push({ + verb: cap.verb, + subject: cap.subject || [], + handler: h.app, + name: cap.name, + delivery: cap.delivery, + endpoint: cap.endpoint, + produces: cap.produces || [], + scope: cap.scope, + description: cap.description, + stale: h.stale, + }); + } + } + + return { actor, did, handlers, actions }; +} + +// --- matching: "which handler can do (verb, subject)?" ------------------------ + +export function subjectMatches(spec, query) { + if (spec.kind !== query.kind) return false; + if (spec.kind === "at-uri" && spec.of && query.of && spec.of !== query.of) return false; + return (spec.as || "record") === (query.as || "record"); +} + +export function matchActions(graph, { verb, subject, includeStale = false } = {}) { + return graph.actions.filter((a) => { + if (a.stale && !includeStale) return false; + if (verb && a.verb !== verb) return false; + if (subject && !(a.subject || []).some((s) => subjectMatches(s, subject))) return false; + return true; + }); +} + +// --- scope aggregation: what a consumer must request to enable these ---------- + +// Union of include:<nsid> permission sets across pds-delivery actions. +export function requiredScopes(actions) { + const set = new Set(); + for (const a of actions) { + if (a.delivery === "pds" && a.scope) set.add(a.scope); + } + return [...set]; +} diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..74fc5ee --- /dev/null +++ b/tools/README.md @@ -0,0 +1,65 @@ +# Tools + +CLI tooling to author AT Intents records. **Node 18+, zero dependencies** — no +install. + +## Write a capability record + +From a JSON file — the literal `dev.at-intent.capability` shape, with `$type` / +`createdAt` optional (see [capability-spec.md](../docs/capability-spec.md)): + +```bash +# Validate + preview, write nothing: +node write-capability.mjs examples/subscribe.capability.json --dry-run --identifier myapp.com + +# Publish to the APP's repo (capability records live in the app's own repo): +PDS_IDENTIFIER=myapp.com PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \ + node write-capability.mjs examples/subscribe.capability.json +``` + +A file may hold one record, a top-level array, or `{ "capabilities": [...] }`. +Interactively (prompts for each field, saves a reusable JSON record, dry-runs, +then writes on confirmation): + +```bash +node write-capability-interactive.mjs --out my.capability.json +``` + +Flags: `--dry-run` `--json` `--force` (write despite warnings) `--identifier` +`--password` `--pds` `--rkey`. + +## Write a usage record + +The per-user discovery footprint — normally written by the app for the user; this +is for manual/testing use (auth as the **user**, since it lands in their repo): + +```bash +node write-usage.mjs --app did:web:myapp.com \ + --config '{"defaultCategory":"tech"}' --dry-run --identifier alice.bsky.social +``` + +## Auth + +Writes use an **app password** via `com.atproto.server.createSession` against the +account's own PDS (resolved from the handle/DID). Provide credentials with +`--identifier`/`--password` or the `PDS_IDENTIFIER` / `PDS_APP_PASSWORD` +environment variables. Get an app password from your PDS settings — **never use +your main account password.** `--dry-run` needs no credentials. + +## Layout + +``` +lib/ + nsid.mjs the two NSIDs + authority (single source of truth) + atproto.mjs resolve handle/DID/PDS; createSession; createRecord (zero-dep) + spec.mjs load a JSON record file; normalize to a capability list + capability.mjs validate + build a capability record (incl. delivery rules) + usage.mjs validate + build a usage record + cli.mjs colors, validation reporting, dry-run printing, auth-from-args +write-capability.mjs from a JSON record file +write-capability-interactive.mjs prompt-driven +write-usage.mjs usage footprint +examples/ one record per delivery type + a multi-cap file +``` + +The consumer-side resolver lives in [`../resolver/`](../resolver/). diff --git a/tools/examples/all.capabilities.json b/tools/examples/all.capabilities.json new file mode 100644 index 0000000..c30aa26 --- /dev/null +++ b/tools/examples/all.capabilities.json @@ -0,0 +1,42 @@ +{ + "_comment": "A multi-capability file: publish an app's whole surface in one run with `node write-capability.mjs examples/all.capabilities.json`. Use a `capabilities` array (or a bare top-level JSON array).", + "capabilities": [ + { + "name": "Skyreader", + "icon": "https://skyreader.app/icon.png", + "description": "Subscribe to a feed, publication, or account so new items appear in your Skyreader reading list.", + "verb": "subscribe", + "subject": [ + { "kind": "uri" }, + { "kind": "at-uri", "of": "site.standard.publication" }, + { "kind": "did", "as": "account" } + ], + "produces": ["app.skyreader.feed.subscription"], + "delivery": "pds", + "scope": "include:app.skyreader.subscribe" + }, + { + "name": "Skyreader Linkblog", + "icon": "https://skyreader.app/icon.png", + "description": "Post a link to your standard.site linkblog with an optional note.", + "verb": "share", + "subject": [ { "kind": "uri" } ], + "input": [ + { "name": "title", "kind": "string" }, + { "name": "note", "kind": "string" } + ], + "produces": ["site.standard.document"], + "delivery": "pds", + "scope": "include:app.skyreader.linkblog" + }, + { + "name": "Skyreader", + "icon": "https://skyreader.app/icon.png", + "description": "Save an article to your Skyreader reading list (read-it-later).", + "verb": "save", + "subject": [ { "kind": "uri" } ], + "delivery": "service", + "endpoint": "https://skyreader.app/xrpc/app.skyreader.feed.save" + } + ] +} diff --git a/tools/examples/passive-reader.capability.json b/tools/examples/passive-reader.capability.json new file mode 100644 index 0000000..73d3c65 --- /dev/null +++ b/tools/examples/passive-reader.capability.json @@ -0,0 +1,11 @@ +{ + "_comment": "A `passive` capability — the zero-cost case. The app reads a primitive ALREADY in the user's repo (the community bookmark). No write, no scope: discovery alone surfaces it as a destination. (_comment is ignored by the writer.)", + "name": "Sill", + "icon": "https://sill.social/icon.png", + "description": "Your saved bookmarks, surfaced for daily review. Sill reads the community bookmark already in your repo — nothing is written.", + "verb": "save", + "subject": [ + { "kind": "at-uri", "of": "community.lexicon.bookmarks.bookmark" } + ], + "delivery": "passive" +} diff --git a/tools/examples/save-service.capability.json b/tools/examples/save-service.capability.json new file mode 100644 index 0000000..e234908 --- /dev/null +++ b/tools/examples/save-service.capability.json @@ -0,0 +1,16 @@ +{ + "_comment": "A `save` capability with delivery=service. The app's canonical store is its own backend, NOT the PDS — so a record written to the user's repo would never reach it. It declares an endpoint and produces nothing on the PDS. This is the case the bookmark-centric model got silently wrong. (_comment is ignored by the writer.)", + "name": "Skyreader", + "icon": "https://skyreader.app/icon.png", + "description": "Save an article to your Skyreader reading list (read-it-later). Delivered to Skyreader's backend; nothing is written to your repo.", + "verb": "save", + "subject": [ + { "kind": "uri" } + ], + "input": [ + { "name": "title", "kind": "string" }, + { "name": "author", "kind": "string" } + ], + "delivery": "service", + "endpoint": "https://skyreader.app/xrpc/app.skyreader.feed.save" +} diff --git a/tools/examples/subscribe.capability.json b/tools/examples/subscribe.capability.json new file mode 100644 index 0000000..fb74e63 --- /dev/null +++ b/tools/examples/subscribe.capability.json @@ -0,0 +1,19 @@ +{ + "_comment": "A `subscribe` capability with delivery=pds. The consumer writes a `produces` record into the user's repo; the app reads it. Exercises a union subject: a bare feed URL (no atproto type), a publication AT-URI, and an account DID — matches if ANY entry fits. The _comment field is ignored by the writer.", + "name": "Skyreader", + "icon": "https://skyreader.app/icon.png", + "description": "Subscribe to a feed, a standard.site publication, or an account so its new items appear in your Skyreader reading list. Use for following a source over time; not for saving a single article (that's `save`).", + "verb": "subscribe", + "subject": [ + { "kind": "uri" }, + { "kind": "at-uri", "of": "site.standard.publication" }, + { "kind": "did", "as": "account" } + ], + "input": [ + { "name": "category", "kind": "string", "description": "Optional folder to file the subscription under." }, + { "name": "tags", "kind": "string" } + ], + "produces": ["app.skyreader.feed.subscription"], + "delivery": "pds", + "scope": "include:app.skyreader.subscribe" +} diff --git a/tools/lib/atproto.mjs b/tools/lib/atproto.mjs new file mode 100644 index 0000000..6187e49 --- /dev/null +++ b/tools/lib/atproto.mjs @@ -0,0 +1,94 @@ +// Minimal, zero-dependency atproto plumbing for the write CLIs. +// +// Reads use public endpoints (handle->DID, DID doc->PDS). Writes use an app +// password: createSession against the user's own PDS, then createRecord with +// the returned access token. No SDK — these are plain XRPC calls over fetch. +// +// Get an app password at: <your PDS>/settings/app-passwords (on Bluesky: +// Settings -> Privacy and security -> App passwords). NEVER use your main +// account password here. + +const PLC_DIRECTORY = "https://plc.directory"; +const HANDLE_RESOLVER = "https://public.api.bsky.app"; + +// --- identity / PDS resolution ------------------------------------------------ + +export async function resolveDid(actor) { + if (actor.startsWith("did:")) return actor; + const handle = actor.replace(/^@/, ""); + const url = `${HANDLE_RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`resolveHandle ${handle}: HTTP ${res.status}`); + const json = await res.json(); + if (!json.did) throw new Error(`no DID for handle ${handle}`); + return json.did; +} + +export async function resolveDidDoc(did) { + if (did.startsWith("did:plc:")) { + const res = await fetch(`${PLC_DIRECTORY}/${did}`); + if (!res.ok) throw new Error(`plc ${did}: HTTP ${res.status}`); + return res.json(); + } + if (did.startsWith("did:web:")) { + const host = decodeURIComponent(did.slice("did:web:".length)).replace(/:/g, "/"); + const res = await fetch(`https://${host}/.well-known/did.json`); + if (!res.ok) throw new Error(`did:web ${did}: HTTP ${res.status}`); + return res.json(); + } + throw new Error(`unsupported DID method: ${did}`); +} + +export function pdsFromDidDoc(doc) { + const svc = (doc.service || []).find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + if (!svc?.serviceEndpoint) throw new Error("no #atproto_pds service in DID doc"); + return svc.serviceEndpoint; +} + +export async function resolvePds(didOrHandle) { + const did = await resolveDid(didOrHandle); + return pdsFromDidDoc(await resolveDidDoc(did)); +} + +// --- authenticated session (app password) ------------------------------------- + +// Returns { pds, did, accessJwt, handle }. If `pds` is given we skip resolution +// (useful when a handle's DID doc is unreachable but you know the host). +export async function createSession({ identifier, password, pds }) { + if (!identifier) throw new Error("missing identifier (handle or DID)"); + if (!password) throw new Error("missing app password"); + const host = pds || (await resolvePds(identifier)); + const res = await fetch(`${host}/xrpc/com.atproto.server.createSession`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier, password }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`createSession failed: HTTP ${res.status} ${body}`); + } + const json = await res.json(); + return { pds: host, did: json.did, accessJwt: json.accessJwt, handle: json.handle }; +} + +// Create a record in the session's repo. `rkey` optional (server assigns a TID +// when omitted). Returns { uri, cid }. +export async function createRecord(session, { collection, record, rkey }) { + const body = { repo: session.did, collection, record }; + if (rkey) body.rkey = rkey; + const res = await fetch(`${session.pds}/xrpc/com.atproto.repo.createRecord`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${session.accessJwt}`, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`createRecord ${collection} failed: HTTP ${res.status} ${text}`); + } + return res.json(); +} diff --git a/tools/lib/capability.mjs b/tools/lib/capability.mjs new file mode 100644 index 0000000..62933f5 --- /dev/null +++ b/tools/lib/capability.mjs @@ -0,0 +1,100 @@ +// Validate + build a dev.at-intent.capability record from a parsed JSON object. +// +// Validation mirrors the lexicon (../../lexicons/dev/at-intent/capability.json) +// plus the cross-field `delivery` rules that the JSON Schema can't express. +// Errors block a write; warnings are advisory (descriptor quality, open-set +// verbs, scope hygiene). Pass --force to write despite warnings. + +import { CAPABILITY_NSID } from "./nsid.mjs"; + +const VERBS = ["share", "save", "subscribe", "annotate", "open"]; +const SUBJECT_KINDS = ["uri", "at-uri", "did", "string", "blob"]; +const SUBJECT_AS = ["record", "collection", "account"]; +const INPUT_KINDS = ["string", "uri", "at-uri", "did", "integer", "boolean", "blob"]; +const DELIVERY = ["passive", "pds", "service"]; + +const RECORD_FIELDS = [ + "name", "icon", "description", "verb", "subject", + "input", "produces", "delivery", "endpoint", "scope", "createdAt", +]; + +// graphemes ~ code points; good enough for the maxGraphemes checks here. +const len = (s) => [...String(s)].length; + +export function validateCapability(cap) { + const errors = []; + const warnings = []; + + if (!cap || typeof cap !== "object") { + return { errors: ["capability is not an object"], warnings }; + } + + // --- name --- + if (!cap.name || typeof cap.name !== "string") errors.push("name: required string"); + else if (len(cap.name) > 64) errors.push("name: max 64 graphemes"); + + // --- verb --- + if (!cap.verb || typeof cap.verb !== "string") errors.push("verb: required string"); + else if (!VERBS.includes(cap.verb)) + warnings.push(`verb: "${cap.verb}" is outside the known set (${VERBS.join(", ")}); consumers that don't know it will skip this capability`); + + // --- subject --- + if (!Array.isArray(cap.subject) || cap.subject.length === 0) { + errors.push("subject: required non-empty array"); + } else { + cap.subject.forEach((s, i) => { + if (!s || typeof s !== "object") { errors.push(`subject[${i}]: not an object`); return; } + if (!SUBJECT_KINDS.includes(s.kind)) errors.push(`subject[${i}].kind: must be one of ${SUBJECT_KINDS.join(", ")}`); + if (s.as && !SUBJECT_AS.includes(s.as)) errors.push(`subject[${i}].as: must be one of ${SUBJECT_AS.join(", ")}`); + if (s.kind === "at-uri" && !s.of) warnings.push(`subject[${i}]: kind=at-uri without "of" matches any record type — set "of" to a lexicon NSID to be specific`); + if (s.of && s.kind !== "at-uri") warnings.push(`subject[${i}]: "of" is only meaningful for kind=at-uri`); + }); + } + + // --- input --- + if (cap.input != null) { + if (!Array.isArray(cap.input)) errors.push("input: must be an array"); + else cap.input.forEach((f, i) => { + if (!f || typeof f !== "object") { errors.push(`input[${i}]: not an object`); return; } + if (!f.name) errors.push(`input[${i}].name: required`); + if (!INPUT_KINDS.includes(f.kind)) errors.push(`input[${i}].kind: must be one of ${INPUT_KINDS.join(", ")}`); + }); + } + + // --- produces --- + if (cap.produces != null && !Array.isArray(cap.produces)) errors.push("produces: must be an array of NSIDs"); + + // --- delivery (cross-field rules) --- + if (!DELIVERY.includes(cap.delivery)) { + errors.push(`delivery: required, must be one of ${DELIVERY.join(", ")}`); + } else if (cap.delivery === "service") { + if (!cap.endpoint) errors.push("delivery=service requires an endpoint (the URL that performs the action)"); + if (cap.produces?.length) warnings.push("delivery=service usually produces nothing on the PDS; the handler ingests out-of-band"); + if (cap.scope) warnings.push("delivery=service does not need a PDS scope"); + } else if (cap.delivery === "pds") { + if (!cap.produces?.length) warnings.push("delivery=pds normally lists the record type(s) it writes in `produces`"); + if (!cap.scope) warnings.push("delivery=pds normally declares a `scope` (include:<nsid> permission set) so consumers can request it"); + if (cap.endpoint) warnings.push("delivery=pds ignores `endpoint`"); + } else if (cap.delivery === "passive") { + if (cap.produces?.length) warnings.push("delivery=passive writes nothing; drop `produces`"); + if (cap.scope) warnings.push("delivery=passive needs no scope; drop it (zero-cost discovery)"); + if (cap.endpoint) warnings.push("delivery=passive ignores `endpoint`"); + } + + // --- description quality (matters for both human picker and agent planner) --- + if (!cap.description) warnings.push("description: missing — agents and humans both rely on it; say what it does, when to use it, what it won't do"); + else if (len(cap.description) > 300) errors.push("description: max 300 graphemes"); + + if (cap.icon && !/^https?:\/\//.test(cap.icon)) warnings.push("icon: should be an http(s) URL"); + + return { errors, warnings }; +} + +export function buildCapabilityRecord(cap, now) { + const record = { $type: CAPABILITY_NSID }; + for (const f of RECORD_FIELDS) { + if (cap[f] !== undefined) record[f] = cap[f]; + } + if (!record.createdAt) record.createdAt = now || new Date().toISOString(); + return record; +} diff --git a/tools/lib/cli.mjs b/tools/lib/cli.mjs new file mode 100644 index 0000000..1cb9cba --- /dev/null +++ b/tools/lib/cli.mjs @@ -0,0 +1,42 @@ +// Shared CLI helpers: colors, validation reporting, dry-run printing, and +// pulling auth out of flags/env. + +export const C = { + dim: (s) => `\x1b[2m${s}\x1b[0m`, + bold: (s) => `\x1b[1m${s}\x1b[0m`, + cyan: (s) => `\x1b[36m${s}\x1b[0m`, + green: (s) => `\x1b[32m${s}\x1b[0m`, + yellow: (s) => `\x1b[33m${s}\x1b[0m`, + red: (s) => `\x1b[31m${s}\x1b[0m`, +}; + +// Print errors/warnings for one record. Returns true if it has blocking errors. +export function report(label, { errors, warnings }) { + if (errors.length) { + console.error(`\n${C.red("✗")} ${C.bold(label)} — ${errors.length} error(s):`); + for (const e of errors) console.error(` ${C.red("•")} ${e}`); + } + if (warnings.length) { + console.error(`\n${C.yellow("!")} ${C.bold(label)} — ${warnings.length} warning(s):`); + for (const w of warnings) console.error(` ${C.yellow("•")} ${w}`); + } + if (!errors.length && !warnings.length) console.error(`${C.green("✓")} ${label} — looks good`); + return errors.length > 0; +} + +// Show exactly what a write would do, without doing it. +export function printDryRun({ pds, did, collection, record }) { + console.log(`\n${C.dim("# dry-run — no record written")}`); + console.log(`${C.bold("POST")} ${pds || "<your-pds>"}/xrpc/com.atproto.repo.createRecord`); + console.log(C.dim(`# as repo: ${did || "<your-did>"} collection: ${collection}`)); + console.log(JSON.stringify({ repo: did || "<your-did>", collection, record }, null, 2)); +} + +// identifier/password from flags first, then env. (PDS_IDENTIFIER / PDS_APP_PASSWORD) +export function authFromArgs(a) { + return { + identifier: a.identifier || process.env.PDS_IDENTIFIER || process.env.PDS_HANDLE, + password: a.password || process.env.PDS_APP_PASSWORD, + pds: a.pds || process.env.PDS_URL, + }; +} diff --git a/tools/lib/nsid.mjs b/tools/lib/nsid.mjs new file mode 100644 index 0000000..4964b8a --- /dev/null +++ b/tools/lib/nsid.mjs @@ -0,0 +1,12 @@ +// The canonical NSIDs for the AT Intents records. Change these two constants +// (and the lexicon `id` fields under ../../lexicons/) to re-home the namespace. +// +// Authority domain: at-intent.dev. atproto resolves a record's lexicon by +// reversing the NSID authority into a domain, resolving that to a DID, and +// reading the lexicon from there — so these names only resolve on the live +// network once at-intent.dev publishes the lexicons. The *records themselves* +// live in each app's repo under these collection NSIDs. + +export const AUTHORITY = "at-intent.dev"; +export const CAPABILITY_NSID = "dev.at-intent.capability"; +export const USAGE_NSID = "dev.at-intent.usage"; diff --git a/tools/lib/spec.mjs b/tools/lib/spec.mjs new file mode 100644 index 0000000..1eb7c64 --- /dev/null +++ b/tools/lib/spec.mjs @@ -0,0 +1,19 @@ +// Load capability record(s) from a JSON file. +// +// The file is just the capability record as JSON — the same shape as the lexicon +// (../../lexicons/dev/at-intent/capability.json). `$type` and `createdAt` are +// optional; the writer fills them in. A file may hold a single record object, a +// top-level array, or `{ "capabilities": [ ... ] }` to publish several at once. + +import { readFile } from "node:fs/promises"; + +export async function loadRecordFile(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +export function capabilitiesFrom(parsed) { + if (Array.isArray(parsed)) return parsed; + if (parsed && Array.isArray(parsed.capabilities)) return parsed.capabilities; + if (parsed && typeof parsed === "object") return [parsed]; + throw new Error("file is not a capability object, an array, or { capabilities: [...] }"); +} diff --git a/tools/lib/usage.mjs b/tools/lib/usage.mjs new file mode 100644 index 0000000..d4b33b8 --- /dev/null +++ b/tools/lib/usage.mjs @@ -0,0 +1,29 @@ +// Validate + build a dev.at-intent.usage record. Written into a USER's repo by +// an app when the person uses it — the discovery footprint a consumer scans. + +import { USAGE_NSID } from "./nsid.mjs"; + +const RECORD_FIELDS = ["app", "capability", "config", "lastSeenAt", "createdAt"]; + +export function validateUsage(u) { + const errors = []; + const warnings = []; + if (!u || typeof u !== "object") return { errors: ["usage is not an object"], warnings }; + + if (!u.app || typeof u.app !== "string") errors.push("app: required DID of the handler"); + else if (!u.app.startsWith("did:")) errors.push("app: must be a DID (did:plc:... or did:web:...)"); + + if (u.capability && !u.capability.startsWith("at://")) errors.push("capability: must be an AT-URI"); + if (u.lastSeenAt && Number.isNaN(Date.parse(u.lastSeenAt))) errors.push("lastSeenAt: not a valid datetime"); + + return { errors, warnings }; +} + +export function buildUsageRecord(u, now) { + const record = { $type: USAGE_NSID }; + for (const f of RECORD_FIELDS) { + if (u[f] !== undefined) record[f] = u[f]; + } + if (!record.createdAt) record.createdAt = now || new Date().toISOString(); + return record; +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..9f6c0bc --- /dev/null +++ b/tools/package.json @@ -0,0 +1,19 @@ +{ + "name": "at-intent-tools", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "CLI tooling for the AT Intents proposal: write capability/usage records, resolve a repo's action graph. Zero dependencies.", + "bin": { + "at-intent-capability": "./write-capability.mjs", + "at-intent-capability-interactive": "./write-capability-interactive.mjs", + "at-intent-usage": "./write-usage.mjs", + "at-intent-resolve": "../resolver/cli.mjs" + }, + "scripts": { + "capability": "node write-capability.mjs", + "interactive": "node write-capability-interactive.mjs", + "usage": "node write-usage.mjs", + "resolve": "node ../resolver/cli.mjs" + } +} diff --git a/tools/write-capability-interactive.mjs b/tools/write-capability-interactive.mjs new file mode 100755 index 0000000..7401896 --- /dev/null +++ b/tools/write-capability-interactive.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// Build a dev.at-intent.capability record by answering prompts. Writes a JSON +// record file you can keep, then validates, prints a dry-run, and (with +// confirmation) writes the record. +// +// node write-capability-interactive.mjs [--out record.json] +// +// Auth (only needed if you choose to write): --identifier / --password, or +// PDS_IDENTIFIER / PDS_APP_PASSWORD env vars. + +import { writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { validateCapability, buildCapabilityRecord } from "./lib/capability.mjs"; +import { CAPABILITY_NSID } from "./lib/nsid.mjs"; +import { createSession, createRecord } from "./lib/atproto.mjs"; +import { C, report, printDryRun, authFromArgs } from "./lib/cli.mjs"; + +const rl = createInterface({ input: stdin, output: stdout }); +const ask = async (q, dflt) => { + const ans = (await rl.question(dflt ? `${q} ${C.dim(`[${dflt}]`)} ` : `${q} `)).trim(); + return ans || dflt || ""; +}; +const askYN = async (q, dflt = false) => { + const ans = (await ask(`${q} ${C.dim(dflt ? "(Y/n)" : "(y/N)")}`)).toLowerCase(); + if (!ans) return dflt; + return ans.startsWith("y"); +}; + +function parseArgs(argv) { + const a = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (t === "--out") a.out = argv[++i]; + else if (t === "--identifier") a.identifier = argv[++i]; + else if (t === "--password") a.password = argv[++i]; + else if (t === "--pds") a.pds = argv[++i]; + else a._.push(t); + } + return a; +} + +async function collectSubjects() { + console.log(C.dim("\nSubjects — what this capability accepts (a union; matches if ANY entry fits).")); + console.log(C.dim("kinds: uri (a bare web/feed URL) | at-uri (a record) | did (an account) | string | blob")); + const subjects = []; + do { + const kind = await ask(` subject kind ${C.dim("(uri/at-uri/did/string/blob)")}:`, "uri"); + const s = { kind }; + if (kind === "at-uri") { + const of = await ask(" of (lexicon NSID, e.g. site.standard.publication):"); + if (of) s.of = of; + } + if (kind === "did" || kind === "at-uri") { + const as = await ask(` as ${C.dim("(record/collection/account)")}:`, "record"); + if (as && as !== "record") s.as = as; + } + subjects.push(s); + } while (await askYN(" add another subject?")); + return subjects; +} + +async function collectInputs() { + if (!(await askYN("\nAdd named inputs beyond the subject (a note, title, target collection)?"))) return []; + console.log(C.dim("kinds: string | uri | at-uri | did | integer | boolean | blob")); + const inputs = []; + do { + const name = await ask(" input name:"); + if (!name) break; + const kind = await ask(" kind:", "string"); + const field = { name, kind }; + const desc = await ask(" description (optional):"); + if (desc) field.description = desc; + if (await askYN(" required?")) field.required = true; + inputs.push(field); + } while (await askYN(" add another input?")); + return inputs; +} + +async function main() { + const a = parseArgs(process.argv.slice(2)); + console.log(C.bold("\nAT Intents — interactive capability builder\n")); + console.log(C.dim("Answer the prompts to describe one capability your app provides.\n")); + + const cap = {}; + cap.name = await ask("Display name (e.g. 'Skyreader'):"); + const desc = await ask("Description (what it does, when to use it, what it won't do):"); + if (desc) cap.description = desc; + const icon = await ask("Icon URL (optional):"); + if (icon) cap.icon = icon; + + cap.verb = await ask(`Verb ${C.dim("(share/save/subscribe/annotate/open, or your own)")}:`, "share"); + cap.subject = await collectSubjects(); + + console.log(C.dim("\nDelivery — how the action actually reaches your app:")); + console.log(C.dim(" passive your app READS a record already in the user's repo (zero-cost)")); + console.log(C.dim(" pds the consumer WRITES record(s) to the user's repo; your app reads them")); + console.log(C.dim(" service the consumer must CALL your endpoint; a PDS write won't reach you")); + cap.delivery = await ask("delivery (passive/pds/service):", "pds"); + + if (cap.delivery === "service") { + cap.endpoint = await ask(" endpoint URL (the API/XRPC that performs the action):"); + } + if (cap.delivery === "pds") { + const produces = await ask(" produces — record NSID(s) you write, comma-separated:"); + if (produces) cap.produces = produces.split(",").map((s) => s.trim()).filter(Boolean); + cap.scope = await ask(" scope (include:<nsid> permission set):"); + } + + const inputs = await collectInputs(); + if (inputs.length) cap.input = inputs; + + // validate + const v = validateCapability(cap); + report(`${cap.name} [${cap.verb}]`, v); + + // build the record (adds $type + createdAt) + const record = buildCapabilityRecord(cap); + + // offer to save the record JSON — directly re-runnable with write-capability.mjs + const out = a.out || (await ask("\nSave this record to a JSON file? (path, or blank to skip):")); + if (out) { + await writeFile(out, JSON.stringify(record, null, 2) + "\n"); + console.log(`${C.green("✓ saved")} ${out}`); + } + + // dry-run preview + const { identifier, password, pds } = authFromArgs(a); + printDryRun({ pds, did: identifier, collection: CAPABILITY_NSID, record }); + + if (v.errors.length) { + console.log(C.red("\nErrors above must be fixed before this can be written. Edit the JSON file and use write-capability.mjs.\n")); + rl.close(); + return; + } + + if (!(await askYN("\nWrite this record to the PDS now?"))) { + console.log(C.dim("\nNothing written. Run `node write-capability.mjs <record.json>` later to write it.\n")); + rl.close(); + return; + } + + rl.close(); + let session; + try { + session = await createSession({ identifier, password, pds }); + } catch (err) { + console.error(C.red(`\nauth failed: ${err.message}`)); + console.error(C.dim("Set --identifier/--password or PDS_IDENTIFIER/PDS_APP_PASSWORD and retry with write-capability.mjs.\n")); + return; + } + try { + const res = await createRecord(session, { collection: CAPABILITY_NSID, record }); + console.log(`${C.green("\n✓ wrote")} ${C.cyan(res.uri)}\n`); + } catch (err) { + console.error(C.red(`\nwrite failed: ${err.message}\n`)); + } +} + +main(); diff --git a/tools/write-capability.mjs b/tools/write-capability.mjs new file mode 100755 index 0000000..ea7d030 --- /dev/null +++ b/tools/write-capability.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +// Write dev.at-intent.capability record(s) from a JSON file. +// +// node write-capability.mjs <record.json> [options] +// +// The file is a capability record (the dev.at-intent.capability shape); `$type` +// and `createdAt` are optional and filled in. It may be one record, an array, or +// { "capabilities": [ ... ] }. +// +// Options: +// --dry-run validate and print the createRecord call; write nothing +// --json print the built record(s) as JSON and exit +// --identifier <h> handle or DID to auth as (env: PDS_IDENTIFIER) +// --password <pw> app password (env: PDS_APP_PASSWORD) +// --pds <url> PDS endpoint override (env: PDS_URL) +// --rkey <key> record key for a single capability (default: server TID) +// --force write even if there are warnings (errors always block) +// +// Capability records are authored by the APP, in the APP's own repo — so +// --identifier is the app's account. Example: +// PDS_IDENTIFIER=myapp.com PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \ +// node write-capability.mjs examples/subscribe.capability.json + +import { loadRecordFile, capabilitiesFrom } from "./lib/spec.mjs"; +import { validateCapability, buildCapabilityRecord } from "./lib/capability.mjs"; +import { CAPABILITY_NSID } from "./lib/nsid.mjs"; +import { createSession, createRecord } from "./lib/atproto.mjs"; +import { C, report, printDryRun, authFromArgs } from "./lib/cli.mjs"; + +function parseArgs(argv) { + const a = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (t === "--dry-run") a.dryRun = true; + else if (t === "--json") a.json = true; + else if (t === "--force") a.force = true; + else if (t === "--identifier") a.identifier = argv[++i]; + else if (t === "--password") a.password = argv[++i]; + else if (t === "--pds") a.pds = argv[++i]; + else if (t === "--rkey") a.rkey = argv[++i]; + else a._.push(t); + } + return a; +} + +async function main() { + const a = parseArgs(process.argv.slice(2)); + const file = a._[0]; + if (!file) { + console.error("usage: node write-capability.mjs <record.json> [--dry-run] [--identifier h] [--password pw]"); + process.exit(1); + } + + let caps; + try { + caps = capabilitiesFrom(await loadRecordFile(file)); + } catch (err) { + console.error(C.red(`\ncould not load ${file}: ${err.message}\n`)); + process.exit(1); + } + if (a.rkey && caps.length > 1) { + console.error(C.red("--rkey only works with a single-capability file")); + process.exit(1); + } + + // validate all, build records + const now = new Date().toISOString(); + let blocked = false; + let warned = false; + const records = []; + caps.forEach((cap, i) => { + const label = `${cap.name || "(unnamed)"} [${cap.verb || "?"}]`; + const v = validateCapability(cap); + if (report(label, v)) blocked = true; + if (v.warnings.length) warned = true; + records.push(buildCapabilityRecord(cap, now)); + }); + + if (blocked) { + console.error(C.red("\nFix the errors above before writing.\n")); + process.exit(1); + } + + if (a.json) { + console.log(JSON.stringify(records.length === 1 ? records[0] : records, null, 2)); + return; + } + + if (a.dryRun) { + const { identifier, pds } = authFromArgs(a); + for (const record of records) { + printDryRun({ pds, did: identifier, collection: CAPABILITY_NSID, record }); + } + console.log(C.dim(`\n${records.length} record(s) validated. Re-run without --dry-run to write.\n`)); + return; + } + + if (warned && !a.force) { + console.error(C.yellow("\nThere are warnings. Re-run with --force to write anyway, or --dry-run to inspect.\n")); + process.exit(1); + } + + const { identifier, password, pds } = authFromArgs(a); + let session; + try { + session = await createSession({ identifier, password, pds }); + } catch (err) { + console.error(C.red(`\nauth failed: ${err.message}\n`)); + process.exit(1); + } + console.error(C.dim(`\nauthed as ${session.handle || session.did} @ ${session.pds}`)); + + for (const record of records) { + try { + const res = await createRecord(session, { collection: CAPABILITY_NSID, record, rkey: a.rkey }); + console.log(`${C.green("✓ wrote")} ${record.name} ${C.dim(record.verb)} ${C.cyan(res.uri)}`); + } catch (err) { + console.error(C.red(`✗ ${record.name}: ${err.message}`)); + } + } + console.log(); +} + +main(); diff --git a/tools/write-usage.mjs b/tools/write-usage.mjs new file mode 100755 index 0000000..59aab26 --- /dev/null +++ b/tools/write-usage.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +// Write a dev.at-intent.usage record into a USER's repo — the discovery +// footprint a consumer scans. In production an app writes this for the user +// when they start using it; this script is for manual/testing use. +// +// node write-usage.mjs --app <did> [options] +// +// Options: +// --app <did> the handler the user uses (required) +// --capability <at-uri> a specific capability record (optional) +// --config <json> per-user config blob, e.g. '{"defaultCategory":"tech"}' +// --last-seen <iso> lastSeenAt datetime (default: now) +// --dry-run validate and print; write nothing +// --json print the built record and exit +// --identifier <h> USER handle/DID to auth as (env: PDS_IDENTIFIER) +// --password <pw> app password (env: PDS_APP_PASSWORD) +// --pds <url> PDS override (env: PDS_URL) +// --force write even with warnings +// +// Note: the auth identity is the USER (the record lands in their repo), not the +// app — unlike write-capability.mjs. + +import { validateUsage, buildUsageRecord } from "./lib/usage.mjs"; +import { USAGE_NSID } from "./lib/nsid.mjs"; +import { createSession, createRecord } from "./lib/atproto.mjs"; +import { C, report, printDryRun, authFromArgs } from "./lib/cli.mjs"; + +function parseArgs(argv) { + const a = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (t === "--app") a.app = argv[++i]; + else if (t === "--capability") a.capability = argv[++i]; + else if (t === "--config") a.config = argv[++i]; + else if (t === "--last-seen") a.lastSeen = argv[++i]; + else if (t === "--dry-run") a.dryRun = true; + else if (t === "--json") a.json = true; + else if (t === "--force") a.force = true; + else if (t === "--identifier") a.identifier = argv[++i]; + else if (t === "--password") a.password = argv[++i]; + else if (t === "--pds") a.pds = argv[++i]; + else a._.push(t); + } + return a; +} + +async function main() { + const a = parseArgs(process.argv.slice(2)); + if (!a.app) { + console.error("usage: node write-usage.mjs --app <did> [--config '{...}'] [--dry-run]"); + process.exit(1); + } + + const u = { app: a.app }; + if (a.capability) u.capability = a.capability; + if (a.lastSeen) u.lastSeenAt = a.lastSeen; + else u.lastSeenAt = new Date().toISOString(); + if (a.config) { + try { u.config = JSON.parse(a.config); } + catch { console.error(C.red("--config must be valid JSON")); process.exit(1); } + } + + const v = validateUsage(u); + if (report(`usage -> ${u.app}`, v)) { + console.error(C.red("\nFix the errors above.\n")); + process.exit(1); + } + + const record = buildUsageRecord(u); + + if (a.json) { console.log(JSON.stringify(record, null, 2)); return; } + + if (a.dryRun) { + const { identifier, pds } = authFromArgs(a); + printDryRun({ pds, did: identifier, collection: USAGE_NSID, record }); + console.log(C.dim("\nRe-run without --dry-run to write.\n")); + return; + } + + if (v.warnings.length && !a.force) { + console.error(C.yellow("\nWarnings present. Re-run with --force or --dry-run.\n")); + process.exit(1); + } + + const { identifier, password, pds } = authFromArgs(a); + let session; + try { + session = await createSession({ identifier, password, pds }); + } catch (err) { + console.error(C.red(`\nauth failed: ${err.message}\n`)); + process.exit(1); + } + try { + const res = await createRecord(session, { collection: USAGE_NSID, record }); + console.log(`${C.green("\n✓ wrote usage")} ${C.cyan(res.uri)}\n`); + } catch (err) { + console.error(C.red(`\nwrite failed: ${err.message}\n`)); + process.exit(1); + } +} + +main(); -- 2.51.2