The command line
sluiceway is a command you run on your own machine. It does two kinds of things:
initandcheckread the files of your clone and nothing else: no credentials, no token, no network. Start with init explains them.login,status,stack,preview,tick,rescanandsettingstalk to the Sluiceway app atconsole.sluiceway.devwith a personal token you make there. They let you, or a coding agent you give the token to, see your stacks, read a stack’s preview, tick, ask for a scan and change settings from a terminal.
scan, resolve, apply and settle run only in the workflow: they need the run’s identity and the workflow token, and the command line stops at them with a sentence.
Install it
With Node 22 or newer, run it without installing:
npx sluiceway --helpOr bunx sluiceway with Bun. To keep it on your path:
npm i -g sluicewayThe npm package sluiceway is released with the action, from the same tag and with the same version. npx sluiceway@<version> runs the one of a release you reviewed.
A binary, with no Node
Every release also carries the command line as one file per platform, with no Node needed:
| Platform | File |
|---|---|
| Linux, x64 | sluiceway-linux-x64 |
| Linux, arm64 | sluiceway-linux-arm64 |
| macOS, Intel | sluiceway-darwin-x64 |
| macOS, Apple silicon | sluiceway-darwin-arm64 |
| Windows, x64 | sluiceway-windows-x64.exe |
Download it from the releases, or from the newest one by its fixed address, and check it against SHA256SUMS from the same release:
curl -fsSLO https://github.com/sluiceway/sluiceway/releases/latest/download/sluiceway-linux-x64curl -fsSLO https://github.com/sluiceway/sluiceway/releases/latest/download/SHA256SUMSsha256sum --check --ignore-missing SHA256SUMSchmod +x sluiceway-linux-x64sudo mv sluiceway-linux-x64 /usr/local/bin/sluicewayOn macOS, use shasum -a 256 --check --ignore-missing SHA256SUMS. The macOS binaries are not signed: a file downloaded with a browser is stopped by Gatekeeper until you run xattr -d com.apple.quarantine sluiceway-darwin-arm64. A file downloaded with curl is not.
Sign in to the app
Make a token in the app on your own page, Tokens (https://console.sluiceway.dev/settings/tokens). A token is for one org and works for 30, 90 or 365 days, and the app shows it once. Then:
sluiceway loginIt asks for the token and does not show what you type. A script pipes it in instead: sluiceway login < token.txt. The token is never read from an environment variable.
login checks the token with the app and keeps it only when it works. A token starts with sluiceway_; anything else, such as a GitHub token pasted by mistake, is refused before it is sent anywhere. It is kept in:
- macOS: the keychain, under the service
sluiceway. - Linux: the Secret Service keyring (GNOME Keyring, KWallet) through
secret-tool, when there is one. - Otherwise, and on Windows:
sluiceway/tokens.jsonunder your config directory ($XDG_CONFIG_HOMEor~/.config, and%APPDATA%on Windows), readable by you alone.
The app used to answer at app.sluiceway.dev. A token you kept for that address still signs you in, and the next sluiceway login keeps it under console.sluiceway.dev and takes the old entry out. --app https://app.sluiceway.dev still works while the old address answers.
sluiceway logout takes the token out of every place it is kept. It still works until it expires: revoke it on the Tokens page to stop it at once.
Everything you do with the token counts as you, by the same rules as the app’s pages and the dashboard: a tick is judged by the repo’s tick rule, and the app’s audit log says it came “via the command line”.
The commands
A repo is its name, infra, or org/infra when the org is the token’s. A stack is its stack id, such as apps/api:prod.
| Command | What it does |
|---|---|
sluiceway status |
The org’s stacks by state, grouped as the app’s org view groups them: Needs you (pending, drifted, preview failed), In flight (deploying and queued), In sync. |
sluiceway status <repo> |
The same for one repo, with its last scan and its dashboard. |
sluiceway stack <repo> <stack id> |
The stack’s row: its state, the counts in the dashboard’s words, what it deletes or replaces, drift, the preview page on GitHub, the run of a deploy and the last deploy. |
sluiceway preview <repo> <stack id> |
Every change of the stack’s preview: what a deploy would create, update, replace or delete, with each resource’s type and name and every property path whole, the values your repo’s dashboard.showValues shows, the drift, and the policies’ failures and warnings. It is the stack’s preview page on GitHub, read through the app, with its link and when the scan wrote it. |
sluiceway tick <repo> <stack id> |
Ticks the stack, as you. A stack that deletes or replaces anything needs --yes. |
sluiceway rescan <repo> |
Asks GitHub for a full scan, as Rescan in the app does. |
sluiceway settings <repo> |
The keys of sluiceway.yaml a settings pull request may change, with the values the file gives them. |
sluiceway settings <repo> set <key>=<value> ... |
Opens a pull request that sets them, and prints its link. A person merges it. |
Reading a stack’s preview
preview prints what the stack’s preview page on GitHub holds, the page its row links, in the words of the dashboard’s details but with every path whole:
apps/api:prod in acme/infra: 1 update, 1 replacePreview page: https://github.com/acme/infra/runs/48213301, of 0a1b2c3, written at 2026-09-26T08:00:00.000Z
Changes REPLACE aws:rds/instance:Instance main · forced by engineVersion · also changes tags.team update aws:lambda/function:Function api · memorySize 128 → 256, timeout nothing → 30 create + import aws:sqs/queue:Queue jobs
Outside the code changed aws:ec2/securityGroup:SecurityGroup web · ingress[0].cidrBlocks[0]The app reads the page from GitHub when you ask, hands it on, and keeps none of it: no resource name, type, property path or value is stored, logged or cached by the app. The page is written by the scan when the workflow has checks: write, and the app reads it with its Checks permission, which each org accepts on GitHub. A stack with nothing waiting and no drift has no preview, and ends as not found (4); an org that has not accepted the permission yet ends as failed (1), and says so. A page GitHub cut at its size limit says how many changes are not on it; the scan’s job log lists every one.
What a tick does, and what it does not
tick reads the row first, and stops when the stack deletes or replaces anything unless you add --yes. Then it asks the app, which judges the tick by the same rule a tick on the dashboard gets and opens the deployment record. The command prints the app’s answer, waits until the app shows the record, for at most a minute, and ends there:
The tick of network:prod is asked: the deployment record is open.Deployment record 4242: waiting to start.The workflow deploys it through a fresh preview and the hash check, and the dashboard says how it went: https://github.com/acme/infra/issues/7It does not wait for the deploy, and it never says a deploy went out: your workflow deploys the stack on your own runner, and the dashboard row says how it ended. Follow it there, or with sluiceway stack.
Setting a key
Each change is key=value. A value is read as JSON when it is JSON, and as text when it is not:
sluiceway settings infra set dashboard.redact=true tickers=adminsluiceway settings infra set 'dashboard.sections=["pending","deploying"]'sluiceway settings infra set 'stacks[apps/api:prod].deploy=on-merge'sluiceway settings infra set drift.enabled=null # back to the defaultTo set text that reads as JSON, such as the title true, quote it as JSON: 'dashboard.title="true"'. The app says which keys a pull request may change, and refuses any other, or a value of the wrong kind, naming each one before anything is written.
Each command, by example
The examples are for an org, acme, with one repo, infra, and four stacks: two pending, one queued and one in sync. Each command is shown with what it prints, and with --json, the app’s answer as it came.
sluiceway login
Asks for the token, checks it with the app, and says who it signs you in as and where it keeps the token.
sluiceway loginSigned in to https://console.sluiceway.dev as alice, for acme, with the token laptop, which works until 2026-12-25T00:00:00Z.The token is kept in the macOS keychain.sluiceway login --json < token.txt{ "app": "https://console.sluiceway.dev", "login": "alice", "org": "acme", "token": { "name": "laptop", "expiresAt": "2026-12-25T00:00:00Z" }, "kept": "the macOS keychain"}sluiceway status
The org’s stacks by state, the way the app’s org view groups them. With a repo, the same for that repo, with its last scan and its dashboard.
sluiceway statusacme: 2 pending, 1 deploying, 1 in sync, 4 stacksLast scan: 0a1b2c3 of acme/infra at 2026-09-26T08:00:00Z
Needs you acme/infra apps/api:prod pending, deletes or replaces 1 update, 1 replace · from #14 by bob acme/infra network:prod pending 1 update · from #12 by aliceIn flight acme/infra db:prod queued ticked by carolIn sync acme/infra site:prodsluiceway status infra --json{ "org": { "login": "acme", "kind": "organization", "page": "https://console.sluiceway.dev/acme" }, "repo": { "name": "acme/infra", "page": "https://console.sluiceway.dev/acme/infra", "dashboard": "https://github.com/acme/infra/issues/7", "stacks": 4, "counts": { "pending": 2, "deploying": 1, "drifted": 0, "failed": 0, "in-sync": 1 }, "recordWriter": null }, "scan": { "sha": "0a1b2c3d4e5f", "at": "2026-09-26T08:00:00Z", "run": null }, "counts": { "pending": 2, "deploying": 1, "drifted": 0, "failed": 0, "in-sync": 1 }, "total": 4, "stacks": [ { "stack": "site:prod", "repo": "acme/infra", "state": "in-sync", "word": "in sync", "destroys": false, "line": "", "at": null }, { "stack": "network:prod", "repo": "acme/infra", "state": "pending", "word": "pending", "destroys": false, "line": "1 update · from #12 by alice", "at": null }, { "stack": "apps/api:prod", "repo": "acme/infra", "state": "pending", "word": "pending", "destroys": true, "line": "1 update, 1 replace · from #14 by bob", "at": null }, { "stack": "db:prod", "repo": "acme/infra", "state": "deploying", "word": "queued", "destroys": false, "line": "ticked by carol", "at": null } ]}sluiceway stack
One stack’s row: its state, the counts in the dashboard’s words, what it deletes or replaces, drift, the preview page on GitHub, the run of a deploy and the last deploy. A line with nothing to say is left out.
sluiceway stack infra network:prodnetwork:prod in acme/infra: pending1 update · from #12 by aliceChanges: 1 updatePreview: sluiceway / network:prod, on https://github.com/acme/infra/commit/0a1b2c3d/checksDashboard: https://github.com/acme/infra/issues/7sluiceway stack infra network:prod --json{ "stack": "network:prod", "repo": "acme/infra", "state": "pending", "word": "pending", "line": "1 update · from #12 by alice", "counts": { "creates": 0, "updates": 1, "replaces": 0, "deletes": 0, "tracking": 0, "changed": 0, "gone": 0 }, "changes": "1 update", "destroys": null, "drift": null, "ticked": false, "failed": false, "dashboard": "https://github.com/acme/infra/issues/7", "preview": { "name": "sluiceway / network:prod", "url": "https://github.com/acme/infra/commit/0a1b2c3d/checks" }, "run": null, "lastDeploy": null, "at": "2026-09-26T08:00:00Z"}sluiceway preview
Every change of the stack’s preview, read from its preview page on GitHub through the app: the policies, the changes with each path whole and the values dashboard.showValues shows, and the drift. A destroy is in capitals. With --json, the app’s answer as it came.
sluiceway preview infra apps/api:prodapps/api:prod in acme/infra: 1 update, 1 replacePreview page: https://github.com/acme/infra/runs/48213301, of 0a1b2c3, written at 2026-09-26T08:00:00.000Z
Policies warning tags · the queue has no team tag
Changes REPLACE aws:rds/instance:Instance main · forced by engineVersion · also changes tags.team update aws:lambda/function:Function api · memorySize 128 → 256, timeout nothing → 30 create + import aws:sqs/queue:Queue jobs
Outside the code changed aws:ec2/securityGroup:SecurityGroup web · ingress[0].cidrBlocks[0]sluiceway preview infra apps/api:prod --json{ "stack": "apps/api:prod", "repo": "acme/infra", "page": { "name": "sluiceway / apps/api:prod", "url": "https://github.com/acme/infra/runs/48213301", "sha": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567", "at": "2026-09-26T08:00:00.000Z" }, "title": "apps/api:prod: 1 update, 1 replace", "policies": [ { "result": "warning", "namespace": "tags", "message": "the queue has no team tag" } ], "changes": [ { "action": "replace", "tracking": null, "type": "aws:rds/instance:Instance", "name": "main", "properties": [ "engineVersion", "tags.team" ], "forcedBy": [ "engineVersion" ], "values": [] }, { "action": "update", "tracking": null, "type": "aws:lambda/function:Function", "name": "api", "properties": [ "memorySize", "timeout" ], "forcedBy": [], "values": [ { "path": "memorySize", "old": "128", "new": "256" }, { "path": "timeout", "old": null, "new": "30" } ] }, { "action": "create", "tracking": "import", "type": "aws:sqs/queue:Queue", "name": "jobs", "properties": [], "forcedBy": [], "values": [] } ], "drift": [ { "action": "changed", "type": "aws:ec2/securityGroup:SecurityGroup", "name": "web", "properties": [ "ingress[0].cidrBlocks[0]" ] } ], "unlisted": 0, "unread": 0}sluiceway tick
Ticks the stack as you and stops once the app shows the deployment record. With --json the answer is two of the app’s: tick, what the tick came to, and deploy, the record as the app showed it. The audit log’s line for it says via the command line.
sluiceway tick infra network:prodThe tick of network:prod is asked: the deployment record is open.Deployment record 4242: waiting to start.The workflow deploys it through a fresh preview and the hash check, and the dashboard says how it went: https://github.com/acme/infra/issues/7sluiceway tick infra network:prod --json{ "tick": { "outcome": "asked", "sentence": "The tick of network:prod is asked: the deployment record is open.", "deployment": { "id": 4242, "status": "https://console.sluiceway.dev/api/v1/orgs/acme/repos/infra/deployments/4242" }, "dashboard": "https://github.com/acme/infra/issues/7" }, "deploy": { "deployment": 4242, "stack": "network:prod", "repo": "acme/infra", "who": "ticked by alice via the command line", "via": "via the command line", "result": "waiting to start", "state": "deploying", "sha": "0a1b2c3d4e5f", "run": "https://github.com/acme/infra/actions/runs/99", "at": "2026-09-26T08:05:00Z", "flagged": false, "approvedBy": null }}sluiceway tick infra apps/api:prod --json# exit 5{ "error": "apps/api:prod deletes or replaces resources (1 replace). Run the tick again with --yes to deploy that.", "code": "needs-yes", "exit": 5}sluiceway rescan
Asks GitHub for a full scan of the repo, as Rescan in the app does. It deploys nothing.
sluiceway rescan infraGitHub was asked for a full scan of acme/infra.Dashboard: https://github.com/acme/infra/issues/7sluiceway rescan infra --json{ "repo": "acme/infra", "outcome": "asked", "sentence": "GitHub was asked for a full scan of acme/infra.", "dashboard": "https://github.com/acme/infra/issues/7"}sluiceway settings
Without set, the keys a settings pull request may change and the values the file gives them. With set, the pull request it opened. A person merges it.
sluiceway settings infrasluiceway.yaml of acme/infra at 0a1b2c3: https://github.com/acme/infra/blob/0a1b2c3d4e5f/sluiceway.yaml dashboard.redact = false tickers = "write" drift.enabled = falsesluiceway settings infra --json{ "repo": "acme/infra", "file": "sluiceway.yaml", "sha": "0a1b2c3d4e5f", "url": "https://github.com/acme/infra/blob/0a1b2c3d4e5f/sluiceway.yaml", "text": "dashboard:\n redact: false\n", "problem": null, "keys": { "dashboard.redact": false, "tickers": "write", "drift.enabled": false }, "pullRequest": { "title": "Sluiceway: dashboard settings", "branch": "sluiceway/dashboard", "base": "main" }}sluiceway settings infra set dashboard.redact=trueOpened pull request #31 on sluiceway.yaml. dashboard.redact: false → truehttps://github.com/acme/infra/pull/31sluiceway settings infra set dashboard.redact=true --json{ "outcome": "opened", "pullRequest": 31, "url": "https://github.com/acme/infra/pull/31", "sentence": "Opened pull request #31 on sluiceway.yaml.", "changes": [ "dashboard.redact: false → true" ], "problems": []}sluiceway logout
Takes the token out of every place it is kept, and asks the app nothing. With --json, removed names those places.
sluiceway logoutSigned out of https://console.sluiceway.dev: the token is gone from the macOS keychain.It still works until it expires. Revoke it on https://console.sluiceway.dev/settings/tokens to stop it now.When it fails
Without --json the reason goes to stderr. With --json it is one document on stdout, and its exit is the exit code. The table below says what each one means.
sluiceway stack infra nope:prod --json# exit 4{ "error": "Not found.", "code": "not-found", "exit": 4}sluiceway status --json# exit 3{ "error": "Not signed in to https://console.sluiceway.dev. Make a token on https://console.sluiceway.dev/settings/tokens, then run sluiceway login.", "code": "not-signed-in", "exit": 3}For agents and scripts
Every command that talks to the app takes --json. It prints one JSON document on stdout: the app’s answer as it came, for a tick { "tick": ..., "deploy": ... }, and on a failure { "error", "code", "exit" }. The answers are the app’s API, version 1 (1.1.0 or later for preview), described at https://console.sluiceway.dev/api/v1/openapi.json.
The exit code says how it ended:
| Code | Meaning | What to do |
|---|---|---|
| 0 | Done | |
| 1 | Failed: the app could not be reached, a tick or pull request failed, a deployment record failed, the app may not read a preview page yet | Read the words, or the dashboard |
| 2 | Not understood: the command line, or a token that is not one | Fix the command |
| 3 | Not signed in, or the token does not work (revoked, expired, the person left the org, the app left it) | sluiceway login with a new token |
| 4 | Not found, or not in the token’s org, or a stack with no preview | Check the repo and the stack id |
| 5 | Refused: the tick rule, a tick that happens on GitHub, changes the app refused, a pull request already open, a destroy without --yes |
Read the reason; do not retry as it is |
| 6 | Try again later: the rate limit (the message says in how many seconds), GitHub did not answer, the record not shown yet | Wait and run it again |
The app allows 120 reads and 10 writes a minute per token.
Where it connects
init and check make no network call. The app’s commands make HTTPS calls to the app alone, with the token you gave, and never to GitHub: the app does what GitHub needs, as you, by its own rules. The token goes nowhere else, and a redirect is refused. --app <address> names another app than https://console.sluiceway.dev, such as one for a test; it must be https, or http to this machine only. The one other process the command line starts is the keychain’s own command, security or secret-tool, which gets the token on stdin.