This file is the entry point for AI coding agents and contributors working on sctl.
It is the single source of truth relative to
CLAUDE.md—CLAUDE.mdonly contains@AGENTS.mdand re-imports this file; don't split guidance between the two. Everything beyond engineering principles and the architecture quick-map is owned by the docs linked below (ownership table indocs/README.md) — cross-link them, don't copy their content here.
Before writing any code, read
docs/development.md— build and test commands, static analysis, environment variables, the version floor, branches, CI, and releases.
Before changing package structure, adding a package, or altering dependency direction, read
docs/architecture.md— process model, directory layout, per-package responsibilities, dependency direction.
Before changing the envelope, RPC methods, limits, or handshake, read
docs/protocol.md—internal/pkg/protocol/protocol.jsonis the schema authority; generated files are updated withmake protocol-generate, while the doc owns temporal semantics.
Before touching authentication, keys, enrollment, or auditing, read
docs/threat-model.md— security boundaries, attack surface, accepted trade-offs, and the inventory of credentials on disk.
Before claiming a change "actually works", read
docs/verification.md— how to write one-shot verification scripts undere2e/scratch/, what counts as evidence, and which side effects to inspect for surfaces you cannot drive.
Before adding, rewriting, or reviewing any documentation, read
docs/doc-maintenance.md— ownership rules and truth discipline (if you can'tgit grepit on this branch, don't write it down).
sctl is ScriptCat's local control tool: a bridge daemon, an MCP server, and script management commands, shipped as a single cross-platform binary. Go, built on the cago framework and cobra.
sctl mcp / CLI verbs ──/control/* HTTP──▶ sctl serve (daemon) ──WS──▶ ScriptCat extension (approval authority)
└─WS──▶ sctl Browser extension (1+ paired instances)
internal/client/ internal/daemon/ internal/pkg/ (shared by both sides)
The authority always lives on the extension side for ScriptCat's write and source-disclosure gates: the daemon
approves no write and discloses no source on its own — it forwards the request and blocks until a human
decides in the browser. Browser control (sctl browsers, tabs, windows, groups, reading-list, bookmarks,
history, browsing-data, recent, downloads, cookies, extensions) and page automation (sctl page) are the
deliberate exception: they have no human gate by design, so any control-token holder can drive a paired
sctl Browser instance and the pages in it immediately. Only the L2 browser-data operations — deleting bookmarks and
uninstalling an extension — are gated: the sctl Browser extension holds them until a human approves in its own
approval window. See docs/threat-model.md. Full process model and package responsibilities are
in docs/architecture.md.
These are non-negotiable, regardless of what the owning docs say about mechanics. All of them are held by review today; the one exception is called out in the item itself.
-
Reproduce before you fix. Reproduce a reported bug yourself and capture the failing evidence, then pin it down with a failing test, and only then change code. The order is not negotiable: a fix that starts from an assumption often repairs a problem that never existed while burying the real cause deeper. If it doesn't reproduce, say so plainly and stop — "it's obviously wrong" is not an exception, and neither is a one-line change. How to reproduce and what counts as evidence are in
docs/verification.md. -
Tests first. Write the failing test before the implementation. Test names state behavior ("list returns exit code 3 when the extension is not connected"), not implementation detail ("calls dispatch"). When a test fails, fix the code, not the test. Delete meaningless tests outright — tautologies, tests that only assert a mock, pure pass-throughs — but confirm against the source, one by one, that each really protects nothing before deleting it.
-
Fix root causes, not symptoms. No
//nolintto paper over a diagnostic, no swallowed errors, no empty branch added just to silence something. A//nolintmust name the specific rule and give a reason — that much is mechanically enforced bynolintlintin.golangci.yaml— but whether the reason is a genuine exception rather than "make it pass" is still something only review can judge. -
Validate at boundaries, don't second-guess internally. Everywhere untrusted data enters — WS envelopes,
/control/*requests, payloads from the extension, command-line arguments — must be validated. Between trusted internal layers, add noif x == nilfallbacks, no swallowed errors, no "just in case" runtime shims. An assumption you cannot hold should panic or return an error rather than be masked by a branch that never fires — that branch only makes the bug that does fire harder to find. -
Layer by process role; dependencies point one way.
client/(request side) anddaemon/(guard side) do not depend on each other and communicate across processes only through/control/*.internal/pkg/is the shared layer and may only be depended upon from above.bridgeknows nothing about the HTTP control plane. The single exception is the guard side referencingclient/controlone-way. Once a shortcut breaks the direction, the two process roles are welded together at compile time and separating them again means a rewrite. The current dependency graph and the reason for the exception are indocs/architecture.md. -
Sensitive files hit disk in exactly one place. The long-term key and control token both go through
internal/pkg/fsutil.WriteFileAtomic. A non-atomic write leaves truncated content behind on a crash, and a bareos.WriteFilealso loses the 0600 permission bits. The inventory of credentials on disk is indocs/threat-model.md. -
stdout belongs to the CLI alone. stdout carries
sctl mcp's JSON-RPC channel; a single byte written there by the daemon side or a library corrupts MCP frames. Diagnostics always go throughinternal/pkg/logging, which writes to stderr plus the log files under the data directory — never stdout. -
Extend through the existing extension points. A new RPC method lands in
internal/pkg/protocol/protocol.jsonfirst. A new verb reuses thedispatch/dispatchBlockingskeleton ininternal/cli/dispatch.goinstead of re-implementing connection, cancellation, and exit-code mapping. A new/control/*handler is registered incontrolapi.Handler.Register, on the mux thatinternal/daemon/component.goassembles. Inject dependencies through constructors and depend on narrow interfaces rather than concrete types —controlapi.Bridgeis the template for that pattern. Never branch on a type string in shared code. -
Reuse before you rebuild.
git grepfor an existing helper before writing a new one. Extract a shared implementation the second time the same logic appears — one concept, one implementation, so a single fix lands everywhere. But don't pre-abstract for hypothetical needs: three repeated lines beat a premature generic helper. -
Stay in scope. A bug fix touches only the files that bug requires. Correcting a stale comment or an incorrect doc line right under your cursor is in scope; drive-by refactors and rename sweeps are not.
-
Comments explain "why"; no dead code. A comment states a constraint or reason the code cannot express — why 0600, why the symlink must be resolved first here. Comments that restate the steps get deleted. Likewise: no dead code, no commented-out blocks, no
// removedmarkers — git remembers. -
Documentation doesn't claim what isn't there. Before asserting that a file, function, or flag exists, verify it on the current branch with
git grep/git ls-files. The discipline and the per-claim verification table are indocs/doc-maintenance.md.
go build ./... && go vet ./... && go test ./... -race && golangci-lint run ./...Never claim "fixed" or "passing" without real output as evidence — see
docs/verification.md.