Imported from arcolabs/arcops (
AGENTS.md). Install upstream withnpx skills add arcolabs/arcops. Copyright stays with the author.
arcops (quay-cli) - Project Notes
arcops is the terminal interface to the Arcops agent-native GTM control plane
for founder-led PLG products. It gives a founder's coding agent deterministic
cross-channel facts, lifecycle semantics, experiment memory, and bounded actions
across acquisition, activation, revenue, retention, Search Console, Stripe, and
the customer inbox. Published to npm as @arcolab/arcops.
Naming history: previously traffic-source-cli (binary ts, backend traffic-source/) -> quay (binary quay, package @tritonix/quay) -> arcops (binary arcops, package @arcolab/arcops). The current repositories are arcolabs/arcops and arcolabs/arcops-server. The ts_ prefix identifies legacy compatibility tokens only. Zeabur project / CF Access app names may still reference old slugs - that's a separate, costly rename and stays deferred.
Machine-local harness policy supplies generic safety and execution guardrails. This AGENTS.md owns CLI engineering rules, including output layering, catalog-as-data, fetch timeouts, lockfile behavior, and ANSI discipline. Arcops product truth is owned by arcolabs/arco/docs/context/arcops/. Cross-session task state belongs in this repository's GitHub Issues, PRs, and CI—not in personal context or Buzz transcripts.
Stack
- TypeScript, target ES2022, module ESNext, strict mode
- Bun for build (
bun buildviabuild.ts) + test (bun test) + package manager (bun.lockis the source of truth) - Runtime is Node 20+ (binary has
#!/usr/bin/env node); we don't ship a Bun-only build - One runtime dep:
picocolors(stderr coloring). Dev deps:@types/bun,@types/node,typescript. Anything else needs justification.
Commands
bun run dev # bun --watch run src/main.ts -- (pass CLI args after --)
bun run build # bundles to dist/arcops.mjs with shebang + version inject
bun test # bun test
bun run typecheck # tsc --noEmit (must pass before commit)
bun link # symlinks dist/arcops.mjs to ~/.bun/bin/arcops for local use
Two-repo workflow
- Server repo:
arcolabs/arcops-server(TanStack Start, Zeabur). Endpoints, auth middleware, schema, and key verification live there. Production:https://arcops.cc(Tencent Tokyo;tritonix.cnis a legacy alias until external pointers migrate,ops.arco.videowas unbound 2026-07-16). - CLI repo: this one,
arcolabs/arcops. Distributed via npm (@arcolab/arcops);bun linkfor local dev. - Cross-repo work: split commits via
git -C <path>. A server change is live only after its reviewed commit reachesmainand the Zeabur deployment is verified; pushing a review branch does not deploy it. - New endpoint or scope change in server -> after deploy: re-test affected
arcopscommand end-to-end againsthttps://arcops.cc.
Directory map
src/main.ts- entrypoint. Awaitsdispatch(argv), exits with returned code.src/dispatch.ts- trie router. Walks argv tokens, longest-prefix match againstCOMMANDS[]. Intermediate nodes (arcops authwith no verb) print children, never error. Catch renders errors viaemitError(JSON envelope in pipe mode,✖ <msg>in TTY) and returns exit 1.src/commands/index.ts-COMMANDScatalog. All command registration happens here. Adding a command = appending to this array; do not switch on command name elsewhere.src/commands/*.ts- handlers, one file per noun. Each handler receives a flag/positional bag, never raw argv.src/api.ts-apiCall<T>+ typedapiGet/apiPost/apiDelete. All HTTP goes through here. Surfaces CF Access intercept and non-JSON responses askind: 'intercept'errors (contract item 5). Preserves the server's structurederror.code/detailonApiError(contract item 2).src/config.ts-~/.arcops/credentials.json(token + api) and~/.arcops/config.json.resolveAuth(flags)precedence: flag ->ARCOPS_APIenv (QUAY_API compat) -> file -> default (https://arcops.cc; retired defaults tritonix.cn / ops.arco.video normalize on read). Auto-migrates~/.quay/->~/.arcops/on first run (normalizes the retiredtritonix.cndefault), leaving the legacy dir as a backup.src/output.ts- TTY-awaredetectOutputFormat,printJson,printTable,info/warn/error/success(stderr only),emitError(agent-first error rendering).src/lib/site-resolve.ts- accepts numeric id or domain, fuzzy-matches, errors on ambiguity.src/lib/confirm.ts+src/lib/editor.ts-$EDITORbody input + terraform-style "type the site domain to confirm" gate for destructive sends. Both refuse to run when stdin is not a TTY (contract item 4).src/version.ts- readsprocess.env.CLI_VERSION(build-time injected) with'0.0.0-dev'fallback so dev builds are obviously dev.build.ts- single file. Bun.build todist/arcops.mjs, prepends shebang, chmod 755, definesprocess.env.CLI_VERSIONfrom package.json. Bun-only APIs (import.meta.dir,Bun.build) - that's why@types/bunis in tsconfig types.
Agent-first contract (KEH-90 S2)
- Failures exit non-zero: every command audited;
dispatchcatch returns 1, arg-validation exits 2.--attachis a repeatable flag (one file per occurrence); a comma-separated value is rejected explicitly rather than ENOENTing. - Structured error passthrough:
ApiErrorcarries the servercode/detail;emitErrorprints{"error":{code,message,detail?,status?}}on stderr in JSON mode,✖ <msg>in TTY. No bare 502 /undefined. - verify-after-send:
inbox send/reply/draft sendre-fetch the thread and confirm the server-returned outboundmessageIdexists. A response withdelivery.status=queuedis reported as queued, never sent; provider completion is a separate durable state. A missing outbound exits non-zero. - Non-interactive under pipes:
confirmByTypingandresolveBody's editor path refuse when!process.stdin.isTTY; pass--yes/ a body flag to run unattended. - Version / intercept detection: non-JSON or redirected (CF Access) responses throw
kind: 'intercept'with a "version mismatch / request intercepted" message. The CLI sendsx-arcops-cli-versionon every request; a server-sent version header would require a server-side change (out of bounds for S2) and is a follow-up.
Output discipline (TTY-aware)
Iron rule: stdout = data, stderr = everything else. The rules below are the repository-owned contract.
process.stdout.isTTY-> text; non-TTY (pipe / redirect) -> JSON.--output text|jsonflag overrides.- Spinners, progress, warnings, success ticks, command summaries ->
stderr(soarcops site ls | jqstill works). printJson(undefined)outputs literal"undefined"- destructure carefully and letapiCallthrow on empty/non-JSON responses (it does).- Color is on by default for TTY stderr only.
NO_COLORenv disables. We never color stdout.
Auth & tokens
- Org-scoped Better Auth API keys are the primary credential. Create and revoke them in Arcops Workspace settings; plaintext is shown once. The CLI never creates a key. Legacy
ts_…tokens remain accepted through the server's compatibility path but are no longer issued for new users. arcops auth login --token <api-key>saves the supplied key verbatim to~/.arcops/credentials.json(mode 0600). The CLI is prefix-agnostic, and sanity-checks the key against/api/sitesso invalid credentials fail fast.- Three scopes:
read(queries),write(mutations like archive),send(outbound email). Server enforces; CLI doesn't pre-filter. ARCOPS_APIenv overrides API URL (QUAY_APIread as one-version compat). There is no token env - credentials are file-only or--tokenflag, by design (avoid ambient secrets in shells).
Catalog-as-data dispatch
Adding a new command = one entry in src/commands/index.ts:
{ path: ['inbox', 'archive'], summary: '…', positional: ['site'], handler: inbox.archive }
The dispatcher consumes path tokens, then binds positionals by name. Never switch on command name in dispatch or output. New verbs = data, not code paths.
Fetch timeouts
apiCallalways setsAbortSignal.timeout(timeoutMs). Default 30s, override withARCOPS_TIMEOUT_MSenv (QUAY_TIMEOUT_MSread as compat). Streaming endpoints (none in v1) would skip.- Error message reports the actual timeout used, not the hardcoded default.
Gotchas
- Bun lockfile:
bun addcan hang on cold cache ("Resolving dependencies"). Workaround:npm install <pkg>, thenbun installmigratespackage-lock.json->bun.lock, then deletepackage-lock.json. Commitbun.lock, neverpackage-lock.json. - Build typecheck:
build.tsusesimport.meta.dirandBun.build- these need@types/bunintsconfig.jsontypes. Without it,tsc --noEmitfails. - CF Access intercepts (legacy domains only):
arcops.ccis a plain proxied zone with NO CF Access app - Bearer tokens always reach the server. Onlytritonix.cnstill sits behind CF Access (Bypass + Everyone on/api/*). IfapiCallever throws "Cloudflare Access intercepted the request to /api/...", the request went to a legacy domain whose bypass policy is missing/misconfigured, or a custom--apipointed somewhere gated. apiCalldoes not follow redirects:redirect: 'manual'is intentional. CF Access redirects to its OAuth page silently broke the CLI before this was added - fetch followed the redirect, got HTML 200, JSON.parse fell back to a string, destructuring{ sites }returnedundefined, commands silently printed"undefined"to stdout. Don't change to'follow'. (Contract item 5 now turns this into an explicit intercept error.)bun linkvsnpm link: both create global symlinks but in different prefixes. Ifwhich arcopsshows a path under.nvm/versions/node/.../bin/, an oldnpm linkis winning over ourbun link. Runnpm unlink -g @arcolab/arcops(or legacy@tritonix/quay/@traffic-source/cli/ stalets/quaybinaries) to clean up.- Server changes are not live until pushed: see the server repo's
CLAUDE.mdDeployment section. Local-onlybun run db:migrateagainst prod also needed for new tables. - Drafts response casing: the server's drafts endpoint returns Drizzle rows without a row mapper, so draft fields are camelCase (
bodyText,createdAt,authorUserId) - unlike threads/messages whichrowToThread/rowToMessageremap to snake_case.draft.createPOSTsbody_text(snake_case input) because the server readsreq.body.body_text.