- C 97%
- C++ 2%
- TypeScript 1%
- Python <1%
- JavaScript <1%
- 2 more languages <1%
ordner — the suite's protocols, from the shell #
ordner speaks the ordner suite's protocols from one command: mail (JMAP),
webdav (files), calendar (JMAP) and contacts (JMAP). Built as a
bundle, following the dns architecture — one command tree driving help,
parsing, Tab completion and the agent skill, so they cannot drift apart.
Status: all four protocols implemented, not yet run against a live
server. Each plugin is tested against an in-process fake built from the
suite's own server source (~/Code/ordner.fyi); the first runs against a real
install, with real app passwords, are still to come. See §Protocols for what
each one covers and what is still open.
Install #
Install bundle if you don't yet have it. Then click the link to install ordner: nzip:emma.ordner.fyi/ordner
Or, from a terminal:
bundle install @emma.ordner.fyi/ordner # verified, put on your PATH, kept current by `bundle update`
Why #
Ad-hoc scripts were the weakest link: one composed a probe with a redacted
credential placeholder copied from a transcript, sent malformed
Authorization headers, and produced a false outage report. A tool that owns
its credentials removes that failure class structurally — passwords live in
the platform keychain, nothing on the command line is ever a token, and the
protocol quirks (sort keys, reference resolution, empty filters) are encoded
once.
Shape #
ordner mail search | get | send | move | mailboxes | check
ordner webdav ls | get | put | mkdir | rm | check
ordner calendar list | create | update | rm | calendars | check
ordner contacts search | get | create | update | rm | books | check
ordner <protocol> account list | add | edit | rm | default | password
ordner credential list | add | edit | rm | password
ordner config show | path | check
ordner shell [bash | zsh | fish | powershell]
ordner skill [--dir <dir>]
ordner version
--account <name>(-a) on every protocol command picks the account to act as; without it, the protocol's default account.--jsonprints one envelope —{ok: true, data}or{ok: false, error: {code, message}}— and the exit code says the same:0ok,1error,2usage,3not supported by the plugin,4config or keychain. Under--jsonevery failure is the envelope, with the plugin's own code (unauthorized,unreachable,not_found, …) where it has one.checkper protocol authenticates and makes a cheap read: the standing canary that says the configuration and the service are both alive. A failure is an error like any other, with its code.- Status and diagnostics go to stderr, results to stdout.
Accounts #
Every protocol has its own accounts — any number of them, each with a name — and one of them is the default. The name and url go to the config file; the sign-in is either the account's own user and password, or a credential it shares with other accounts. Passwords go to the platform keychain.
The quick way onto one suite install: one app password with the scopes you
need (or all), stored once as a credential, then one account per protocol
that signs in with it — one prompt in all, and each account still tries the
password against its server before it is added:
ordner credential add lab --user emma@ordner.fyi # asks for the app password, once
ordner mail account add lab --url https://lab.ordner.fyi --credential lab
ordner webdav account add lab --url https://lab.ordner.fyi --credential lab
ordner calendar account add lab --url https://lab.ordner.fyi --credential lab
ordner contacts account add lab --url https://lab.ordner.fyi --credential lab
An account with a sign-in of its own, and more than one account per protocol:
ordner mail account add home --url https://mail.example.org/jmap/api --user emma@example.org
ordner mail account list
ordner mail account default home
ordner mail search --account lab --unread
Credentials #
| command | does |
|---|---|
credential list |
name, user, whether the password is stored, and the accounts using each |
credential add <name> --user |
asks for the password once and stores it in the keychain |
credential edit <name> --user |
changes the user every account using it signs in as |
credential password <name> |
tries the new password against every account using it; only if all accept does it replace the stored one |
credential rm <name> |
removes it and its keychain entry — refused while an account uses it |
account add … --credential <name> and account edit <name> --credential <name> put an account on a credential (moving onto one drops the account's
own user and stored password); such an account's password is set with
credential password, not account password.
| command | does |
|---|---|
account list |
the protocol's accounts: name, user, url, whether a password is stored; * marks the default |
account add <name> --url --user [--default] |
asks for the app password (once, no echo), tries it against the server, then saves the account and stores the password in the keychain; the first account becomes the default |
account add <name> --url --credential <cred> |
signs in with a credential: no prompt; tried against the server first |
account edit <name> [--url] [--user] [--credential] [--default] |
changes the url, the user, or the credential it signs in with |
account rm <name> |
removes the account and its keychain entry |
account default [<name>] |
shows the default, or makes <name> the default |
account password [<name>] |
tries and stores (or replaces) the password; without a name, the default account's |
A password is never an argument. It is typed at a prompt that does not
echo, or piped in with --password-stdin (one line) — e.g. from a password
manager: pass show ordner/mail | ordner mail account password work --password-stdin. Without a terminal and without --password-stdin,
account add saves the account without a password and says so; account password refuses.
The password is asked for once: it is an app password the suite already
minted, so there is nothing to confirm by typing it twice. Instead it is
tried before it is kept — add and password run the protocol's
check with it, and a refused password (unauthorized, with the scope it
needs) saves nothing. --no-verify stores it untried, e.g. when the server
is not reachable from here.
A command uses --account <name> if given, else the protocol's default, else
the only account there is. Several accounts and no default is a config error
(exit 4) that says how to pick one.
The keychain #
| platform | keychain | through |
|---|---|---|
| Linux | Secret Service — GNOME Keyring, KeePassXC, KWallet on Plasma 6 | secret-tool (libsecret) |
| Linux | KWallet | kwallet-query, and dbus-send to delete |
| macOS | login keychain | security |
| Windows | Credential Manager | PowerShell (CredRead/CredWrite) |
Each backend is the platform's own tool, so the bundle carries no native
modules. On Linux, secret-tool is used when it is installed, else
kwallet-query; ORDNER_CLI_KEYCHAIN (secret-service, kwallet, macos,
windows) picks one explicitly, and ORDNER_CLI_KWALLET names the wallet
(default kdewallet). Entries are filed under the service ordner-cli, one
per protocol account (mail/work). The secret reaches the tool on stdin,
never in its argv.
Config #
$ORDNER_CLI_CONFIG, else ~/.config/ordner-cli/config.json (macOS:
~/Library/Application Support/ordner-cli/, Windows: %APPDATA%\ordner-cli\).
ordner config path says which. The account and credential commands
write it — private, atomically — and it holds no secrets:
{
"protocols": {
"mail": {
"default": "lab",
"accounts": {
"lab": { "url": "https://lab.ordner.fyi", "credential": "lab" },
"home": { "url": "https://mail.example.org/jmap/api", "user": "emma@example.org" }
}
},
"webdav": {
"accounts": {
"lab": { "url": "https://lab.ordner.fyi", "credential": "lab" }
}
}
},
"credentials": {
"lab": { "user": "emma@ordner.fyi" }
}
}
An account has either a user (its password in the keychain as
mail/home) or a credential (the password in the keychain once, as
credential/lab).
For a machine with no keychain (CI, a headless server), an account or a
credential in the file may name where its password comes from instead: "password": {"env": NAME}, {"file": path} or {"command": "…"}. That source wins over the
keychain. A password itself is never read from the file.
ordner config show lists every protocol's accounts, the credentials and
who uses them, the defaults, the keychain in use and whether each password is stored — never the values.
ordner config check exits 4 and names what is missing: mail.account (no
account), mail.default (several, none default), mail.work.password,
credential.lab.password (once, however many accounts share it),
mail.work.credential (it names a credential that is not there).
The single-account shape of 0.1.0 ("mail": {"url", "user", "password"})
is refused with directions; add the account again with account add.
Protocols #
Mail #
JMAP (RFC 8620/8621) against the suite's aggregated session, in
packages/cli/src/protocols/mail/.
ordner mail mailboxes # paths, roles, counts, ids
ordner mail search --mailbox inbox --unread --from adam
ordner mail search --text invoice --after 2026-10-01 --limit 50 --position 50
ordner mail get <id> # --json adds the HTML body
ordner mail move <id> archive # a path, a role or an id
ordner mail send --to pip@ordner.fyi --bcc audit@ordner.fyi --subject Report --attach q3.pdf report.txt
ordner mail copy <id> Projects/2026 # one more mailbox; stays where it is
ordner mail mark <id> --read --flagged # --unread, --unflagged
ordner mail rm <id> # to Trash; --destroy for good
ordner mail draft --subject Plan --attach plan.pdf note.txt # stored, not sent
ordner mail send --draft <id> # send a stored draft as it is
ordner mail attachment <id> <part|name> --out ~/Downloads
- Url: the origin (
https://lab.ordner.fyi, found at/.well-known/jmap), the session URL, or the API URL (…/jmap/api, whose session is beside it). - Password: an app password with the
imapscope (orall). It also sends: an app password holds every right of its owner once it opens the door,mail:send-asincluded, sosmtpis not needed — and ansmtp-only password opens nothing over JMAP. A wrong or revoked password (401), ansmtp-only one (403insufficient_scope) and one scoped only to other data (a session with no mail account) are config errors, exit 4, that say which scope to mint. - search is newest first;
--mailboxtakes a path, role or id; dates areYYYY-MM-DD(UTC midnight) or ISO 8601;--unreadisnotKeyword $seen;--jsonreturns{messages, position, total}for paging with--position. - send is the suite's own flow in one request:
Email/setcreates the message in Drafts,EmailSubmission/setsubmits it by creation id with a stated envelope (so Bcc is a recipient, never a header). The server moves it to the Outbox, holds it for its undo window (sendAtin the answer), then files it in Sent. A refused submission destroys the draft.--frompicks one of the account's identities (aliases, send-as grants). Each--attachfile is uploaded first (RFC 8620 §6.1,uploadUrl, typed by its extension) and referenced byblobIdfrom the Email'sattachments; a refused upload (e.g. overmaxSizeUpload) creates nothing.--draft <id>instead submits a stored draft as it is — its From picks the identity, its To/Cc/Bcc the envelope — and a refusal leaves that draft alone. - draft builds the same Email (attachments included) in Drafts with
$draft, and does not submit it; recipients are optional. - move replaces the message's mailboxes: in the target, out of every other.
- copy adds one mailbox (
mailboxIds/<id>: true, as the suite's IMAP COPY does). JMAP keeps one Email in several mailboxes, so the id does not change and flags,moveandrmact on it everywhere;Email/copyis only between accounts. - mark patches
keywords/$seenandkeywords/$flagged(set or cleared), so other keywords survive, and prints the state afterwards. - rm does what the webmail's Delete does: the message moves to Trash, out of
every other mailbox.
--destroydestroys it (Email/set destroy: every mailbox, for good); a message already in Trash alone needs--destroy. - attachment finds the part by id (
mail get --json) or unique file name and downloads its part handle, which the server serves decoded.--outtakes a file or a directory; without it the bytes go to stdout, or with--jsoninto the envelope as base64. - A JMAP method error or refused object is
mail_error, exit 1, with the method, the JMAP error type and the server's description.
Files (WebDAV) #
ordner webdav speaks RFC 4918 to the suite's webdav service
(protocols/webdav/): PROPFIND (Depth 0 and 1), GET, HEAD, PUT, MKCOL,
DELETE, COPY, MOVE, and RFC 3744's ACL for sharing. The password is an app password with the webdav scope, sent as
HTTP Basic; a 401 means a wrong password or one scoped for another service
— the server cannot tell them apart, so the error says both and how to store
a new one.
ordner webdav account add files --url https://lab.ordner.fyi --user emma@ordner.fyi
ordner webdav ls Documents
ordner webdav put Documents/ report.md # keeps its name; refuses to replace
ordner webdav put Documents/report.md report.md --force
ordner webdav get Documents/report.md -o report.md
ordner webdav mkdir -p Photos/2026/Juni
ordner webdav rm -r Photos/old
ordner webdav copy Documents/report.md Archive/ # server-side; refuses to replace
ordner webdav move Documents/draft.md Documents/final.md --force
ordner webdav share Projects/Garden --to <account-id> --access read-write
ordner webdav quota
The account url names where the files root is:
| url | means |
|---|---|
https://lab.ordner.fyi (the origin) |
discover: PROPFIND /dav/ for DAV:current-user-principal (RFC 5397), and /dav/principals/users/<id>/ becomes the home /dav/files/<id>/ |
…/dav/ or …/dav/files/ |
the same discovery, at that mount |
anything else, e.g. …/dav/files/<id>/ |
the files root itself, used as given (any WebDAV server's collection URL works) |
Discovery costs one PROPFIND per command; the principal id is opaque (not the
login), which is why giving the origin is the easy way. Every path a command
takes is relative to the files root (/ and '' are the root; .. is
refused), and ls prints paths the other commands take back.
| command | does |
|---|---|
ls [path] |
a folder's members, sorted (a file lists as itself); --json adds size, ISO date, etag, content type |
get <path> [-o file] |
streams to stdout, or to <file>.part renamed when complete |
put <path> <file | -> |
streams a file (with its Content-Length) or stdin; a path ending in / keeps the file's name; refuses to replace an existing file unless --force (If-None-Match: *, so the server decides a race); --type sets the Content-Type, else it comes from the extension |
mkdir [-p] <path> |
MKCOL; -p creates the missing parents and tolerates an existing folder |
rm [-r] <path> |
DELETE; a folder only with -r (WebDAV deletes a folder with everything in it); never the root; a 207 (members left behind, e.g. locked) is an error that names them |
copy [-f] [--shallow] <from> <to> (cp) |
server-side COPY: a <to> ending in / is a folder the source goes into under its own name, else the new path itself; refuses to replace unless --force (Overwrite: F, so the server decides a race — and with --force a folder at <to> is replaced whole); a folder copies with its members (Depth: infinity), alone with --shallow; --json says created (false: something was replaced) |
move [-f] <from> <to> (mv) |
server-side MOVE, the same rules; on ordner.fyi a moved shared folder keeps its shares and link (they anchor on the folder's id, not its path) |
share <folder> --to <id> [--access read|read-write|upload-only] |
grants access with an ACL request and prints the share's link (below) |
unshare <folder> --to <id> |
takes that one grant back; the others stay |
quota |
RFC 4331 quota-used-bytes / quota-available-bytes of the root; no available means no limit (the suite's default); exit 3 when the server reports none |
check |
discovery + a Depth 0 PROPFIND of the root, with the quota when the server reports it |
Sharing is the suite's model, spoken over plain RFC 3744 (ordner.fyi
docs/areas/files.md §6): a share is a grant on a folder (never a file,
never the files root), held in a share record anchored on the folder's id.
ACL on a folder is how an app password reaches it — the webdav scope
carries drive:share; the JMAP shareWith property and the web app edit the
same record. An ACL body replaces the folder's settable rules only, so
share and unshare read DAV:acl first and send every other
non-protected, non-inherited grant back as it was, in order (a rule's order is
part of it, RFC 3744 §5.5). The first grant mints the share and its opaque
link, read back from the suite's {https://ns.ordner.fyi/svc/dav}share-link
property (https://…/dav/shares/<id>/, which a browser opens after sign-in).
--tois the account id (the<id>of its/dav/files/<id>/, or of a group), sent as/dav/principals/users/<id>/; a principal URL is taken as is;publicisDAV:all, anyone holding the link. A login name is not an id: the server refuses it (unknown_principal).--access:read(DAV:read, the default),read-write(DAV:read+DAV:write; a sharee cannot delete or rename the shared folder itself), orupload-only(DAV:bindalone: a drop box — adding files, never listing or reading any).- Grants WebDAV cannot express are protected and left alone: a delegation of
the whole drive, and rules with an expiry, a password or an emailed code.
Changing one is refused (
protected) — those are made in the files web app. - A server that reports no
DAV:acl, or answers ACL with 405, does not share over WebDAV:share/unshareexit 3 (not_implemented).
Under --json, a refusal is {ok: false, error: {code, message}} with exit
1; the codes are unauthorized (401), forbidden (403), not_found,
exists (412 on put, copy and move; 405 on mkdir), conflict (409: the
parent folder is missing), precondition_failed (other 412s), locked (423),
insufficient_storage (507), partial (207 on delete, copy or move),
is_collection, not_collection (share on a file), unknown_principal,
protected, not_shared (unshare of a grant that is not there, or is
inherited from a folder above), invalid_path, discovery, network,
protocol and http.
Not covered: locks, properties, versions and trash, password/email/expiring
links, and shares mounted from other people's homes (/dav/files/shares/,
/dav/files/<owner>/…) — their hrefs lie outside the files root and ls
shows them as absolute paths.
Calendar #
JMAP for Calendars over the suite's one JMAP session; events are JSCalendar
(RFC 8984). The account url is the suite's origin
(https://lab.ordner.fyi, discovered through /.well-known/jmap) and the
password an app password with the calendar scope, sent as HTTP Basic. A
refused credential says so (HTTP 401, with the account password command to
fix it); a password of another scope — the session then has no calendar —
is named as that, not as a wrong password.
ordner calendar calendars # ids, names, the default (*)
ordner calendar list [--after <when>] [--before <when>] [--calendar <c>] [--tz <zone>]
ordner calendar create --title <t> --start <when> [--end <when> | --duration 1h30m] [--location] [--description] [--calendar <c>] [--tz <zone>]
ordner calendar update <id> [--title] [--start] [--end | --duration] [--location] [--description] [--calendar <c>] [--tz <zone>] [--occurrence <recurrence-id>]
ordner calendar rm <id> [--occurrence <recurrence-id>]
ordner calendar show <id> [--occurrence <recurrence-id>] [--tz <zone>]
ordner calendar find <text> [--after <when>] [--before <when>] [--calendar <c>] [--tz <zone>]
ordner calendar export [--after <when>] [--before <when>] [--calendar <c>] [-o <file>]
ordner calendar import <file | -> [--calendar <c>] [--update]
ordner calendar check
- Time is never guessed. A time with an offset (
2026-10-13T09:00+02:00,…Z) is that moment; a local one (2026-10-13T09:00,2026-10-13 09:00) is read in--tz, else this machine's zone, and refused (exit 2) when the zone's clocks skip or repeat it; a bare date (2026-10-13) is an all-day event, its end date exclusive. Listings show times in--tz, else this machine's zone: the table names the zone in its header,--jsongives RFC 3339 with the offset (2026-10-13T03:00:00-04:00) and each event's owntimeZonebeside it (nullfor all-day and floating). - An event lives in a zone.
createstores the local start in--tz(else this machine's zone) with a duration, as JSCalendar does, so a weekly 09:00 stays 09:00 across daylight-saving changes.--endis turned into that duration; neither means one hour, or one day for a date.updatekeeps the event's zone unless--tzis given (then the moment is kept and re-expressed there), and a new--startkeeps the duration. - Recurrences are expanded here. The server matches a series whose
occurrences overlap the window but answers the series, not occurrences
(there is no
expandRecurrences);listexpands them with the suite's own recurrence engine (vendor/ical), applying overrides and exclusions. Every occurrence carries the seriesidand itsrecurrenceId;updateandrmact on the whole series, or with--occurrence <recurrence-id>on that one occurrence (an override, or an exclusion). - Calendars.
creategoes to the default calendar — the home'scalendar— unless--calendarnames another by id or name.update --calendarmoves an event, and since an event's id is its location, the answer carries the new id. The task list's tasks are not listed. showprints one event whole — its first start, its rules as RFC 5545 RRULEs, which occurrences were removed and which changed, its UID — or with--occurrencethat occurrence as it stands, its change applied.findsearches what the server searches:CalendarEvent/query'stextcondition, a case-insensitive substring of the title (not the notes or the place; a recurring event by its series title). With--afterand--beforeit lists the matching events' occurrences in that window aslistdoes; otherwise one line per event.exportwrites one VCALENDAR: each event exactly as the server stores that projection (the suite's ownapplyJSCalendar), merged the way the suite's own exports merge (VERSION, PRODID, each VTIMEZONE once, then the events). JMAP carries a zone's name only, so the VTIMEZONEs are written here from the IANA rules, as yearly rules from the first year an event uses up to the rules still in force, which stay open. A recurring event keeps its rule, its EXDATEs and its RECURRENCE-ID instances;--after/--beforechoose the events with an occurrence in the window and export them whole.importreads every VCALENDAR in the file and makes one event per UID — the master and its RECURRENCE-ID instances become one JSCalendar event withrecurrenceOverrides, EXDATEs become exclusions — through the suite's owntoJSCalendar, which is what the server'sCalendarEvent/parseruns (there is no iCalendar import route over JMAP; a blob upload per event would buy nothing). Windows and Mozilla zone names become their IANA name, since the server keeps the name and not the file's VTIMEZONE. A UID the account already holds, in any calendar, is skipped and listed (so a file imported twice adds nothing);--updatereplaces those events with the file's version where they are, emptying what the file no longer has (notes, place, rules, exceptions). What does not survive the mapping (RDATE, EXRULE, attachments, an unknown zone) is reported per event; VTODO and VJOURNAL are counted, not imported. Exits 1 when the server refused an event.
Contacts #
JMAP for Contacts (RFC 9610) over JSContact cards (RFC 9553), as the suite's
contacts service serves it behind the origin's JMAP router. The account's url
is the origin (https://lab.ordner.fyi, meaning its /.well-known/jmap) or
the API url (…/jmap/api); its password is an app password with the
contacts scope, sent as HTTP Basic.
ordner contacts account add me --url https://lab.ordner.fyi --user emma@ordner.fyi
ordner contacts check
ordner contacts search emma # NAME EMAIL TELEPHONE ID
ordner contacts create --name "Emma Ordner" --email work:emma@ordner.fyi --tel work,mobile:+49301234567
ordner contacts update <id> --add-email private:emma@example.org --org ordner
ordner contacts get <id> --json # the mapped fields plus the whole card
ordner contacts rm <id>
ordner contacts books # * marks the default book
| flag | JSContact |
|---|---|
--name |
name.full (FN). Without it the server derives the full name from the components |
--given, --surname |
the given / surname entries of name.components (N) |
--org |
organizations — the first one's name |
--email [tags:]address |
an entry of emails; tags are contexts: work, private (or home) |
--tel [tags:]number |
an entry of phones; tags are contexts and features: mobile (or cell), voice, fax, text, pager, textphone, video |
--address-book |
addressBookIds, by id or by name; new cards go to the default book |
search [<query>]is the server'stextfilter: a case-insensitive substring of the full or structured name, a nickname, an email address, a telephone number as stored (spacing matters), the organisation or a category — in name order. Without a query it lists the cards;--limit(default 50) caps it, and stderr says when there are more.updatechanges only what it names. It reads the card and sends only the members it speaks for, each rebuilt from the stored one, so addresses, notes, photos, labels and vCard parameters survive.--email/--telreplace the set (an address already there keeps its details unless tags are given),--add-…/--rm-…edit it, and an empty value clears (--org "").--address-bookmoves the card, which gives it a new id (printed).- Errors. A wrong or revoked password is a 401 (
unauthorized); a password without the contacts scope reaches a session that has no contacts (no_contacts) — both say to store a contacts-scoped app password withordner contacts account password. A change the server refuses isrefused, with its JMAP SetError type, description and properties; an unknown id isnot_found. Under--jsoneach is the error envelope, exit 1. - Only the account's own address books (the session's primary contacts account) are reached; books shared from other people's accounts are not yet.
Plugins — the boundary #
Every protocol sits behind a port interface in
packages/cli/src/protocols/:
interface MailPort {
search(filter): Promise<MailSummary[]>;
get(id): Promise<Message>;
send(draft: MailDraft): Promise<{ id }>;
move(id, mailbox): Promise<void>;
check(): Promise<CheckResult>;
}
FilesPort, CalendarPort and ContactsPort follow the same shape.
Account management stays in the app. The app owns the config file and
the keychain: it picks the account (--account, the default, the only
one), resolves its password, and hands the plugin one Account when it
builds the port:
interface Account { protocol; name; url; user; password }
interface Plugins {
mail(account: Account): MailPort;
webdav(account: Account): FilesPort;
calendar(account: Account): CalendarPort;
contacts(account: Account): ContactsPort;
}
A plugin never reads the config, never opens the keychain and never chooses an account; it gets what it needs to connect, and nothing else.
The registry (protocols/registry.ts) holds the Plugins table, one
factory per protocol (protocols/<protocol>/). Replacing an implementation
adds a file and changes its line there — nothing above the boundary moves.
A plugin throws errors with a string code, which --json carries; an
operation a plugin cannot do throws NotImplementedError (exit 3).
The JMAP plugins share protocols/jmap.ts and the vendored suite client;
see packages/cli/src/vendor/README.md for what is vendored from the suite
and every local change.
The skill #
ordner skill [--dir <dir>] writes SKILL.md — generated from the same
command definitions that parse the arguments, so it is always accurate —
plus Tab-completion scripts for bash, zsh, fish and PowerShell. A checked-in
copy ships in packages/cli/skills/ordner-cli/ and a test pins it against
regeneration: docs cannot drift from code.
Develop #
npm install
npm run ordner -- mail --help # from source
npm test # node:test, including the shipped-skill pin
npm run typecheck
npm run pack && npm run smoke # build the bundle, run it from the archive
The tests never touch a real keychain: they put a fake secret-tool on
PATH that keeps secrets as files in a scratch directory and logs its argv,
so a test can check that no password ever reached a command line.
Node ≥ 26.10 (the sources are erasable-syntax TypeScript: node runs them as they are, from disk and from inside an archive — no build step, what is attested is what is read).
CI #
.tangled/workflows/ci.yml: typecheck, test, pack, smoke on every push and
PR to main. release.yml runs on v* tags and builds a dry run — the
publish flow (tangled release files vs knot LFS) is the operator's decision,
marked TODO in the workflow.