sprintforge
v0.1.0
Published
SprintForge command line interface — sprint planning and Sprint Accelerator from the terminal.
Maintainers
Readme
sprintforge
The SprintForge command line interface — sprint planning, the Sprint Accelerator and agentic repository setup from the terminal.
npm install -g sprintforge
sprintforge --helpCommand surface
The grammar is noun → verb, like gh. sprintforge --help lists every command of the shipped
surface; the ones whose slice has not landed yet say so and point at the issue that adds them.
| Area | Commands |
|---|---|
| Identity | login, logout, whoami |
| Agentic repo setup | init, sync, doctor [--check] |
| Sprint Accelerator | queue add\|rm\|ls, watch add\|rm\|ls, night-shift run\|trigger\|schedule\|setup\|seat, trigger now, runs ls\|view\|follow |
| Telemetry | telemetry (reserved; usage telemetry is coming) |
--json
Every command takes --json and then prints exactly one JSON document on stdout — no progress
lines, no prompts:
{ "schemaVersion": 1, "ok": true, "command": "telemetry", "data": { "status": "coming" } }
{ "schemaVersion": 1, "ok": false, "command": "nope",
"error": { "code": "unknown_command", "message": "Unknown command 'nope'.", "hint": "Run 'sprintforge --help'." } }schemaVersion only changes when the envelope changes shape. error.code is stable;
error.message is for humans.
Global flags: --json, --help/-h, --version/-v, and --team <name> (see Team resolution).
| Exit code | Meaning | |---|---| | 0 | success | | 1 | the command ran and failed | | 2 | usage — unknown command, missing or unknown subcommand, unknown flag | | 3 | a known command whose slice has not landed yet | | 4 | this CLI is older than the minimum version SprintForge supports |
init — prepare a repository for agentic work
sprintforge init # this repository, the Standard preset, Claude Code
sprintforge init --preset=minimal # minimal | standard | full
sprintforge init --tool-folders=keep # answer the migrate/keep question up front
sprintforge init --root=../app # another checkout
sprintforge init --non-interactive # no prompts: previous answers, else defaultsinit is a conversation, so it needs a terminal: with --json alone it refuses instead of hanging
on a prompt, and points at --non-interactive. What happens, in order:
- Preflight. You must be signed in.
initshows the current branch and the state of the working tree and asks you to create a clean branch yourself — it never creates or switches a branch, and it stops right there if you answer no. It then checks that your agent CLI andghare available and scans the repository: stacks, deployable units, an existing rulebook, persona and skill folders, docs, templates, workflows and a previous decision record. - Selections. The preset — Minimal (rulebook, Definition of Done, templates, docs layout,
decision record), Standard (the default: Minimal plus feature-designer, one developer persona
per detected stack, reviewer, security-reviewer and the process skills) or Full (Standard plus
the design skills, tdd-engineer, handoff and parallel dispatching) — then a fine-tune list where
every catalogue item can be added with
+<item>or a line number and dropped with-<item>. The business and marketing personas are never in a preset; the fine-tune list is the only way to add one. If the repository keeps persona or skill content in a tool folder (.claude/agents/…), you choose migrate (move it to.github/agents/and.agents/skills/, wrappers generated from there) or keep (edited in place, recorded in.agents/kept-agent-files.jsonand from then on invisible tosyncand todoctor --check). Finally, each stack-specific skill the scan found a reason for is offered on its own line with that reason — "react is a dependency in web/package.json" — and is only written if you say yes. Two more multi-selects follow: the Definition of Done — the items a change is measured by, each then set to always or when applicable with its condition (docs=when: the change alters behaviour,security-gate=always, separated by;) — and the standard slices every feature breakdown gets. Both are written once intoAGENTS.md; the pull request template's checklist is generated from the Definition of Done, word for word, and the generated PRD-to-issues skill reads the slices. - The
sprintforgeskill. Before the session starts,initinstalls its own skill at.agents/skills/sprintforge/SKILL.mdand generates its wrapper for your agent tool (.claude/skills/sprintforge/SKILL.mdfor Claude Code). It is copied, never tailored: it lists every command, when to use each, that every call takes--json, and the one hard rule — never run a spending command without the user's explicit yes in the conversation. Afterwards, "put epic 95 on watch" in your agent runssprintforge watch add 95 --json, and "start a run" asks you first. A file you chose to keep at that path is left alone. - The agent session. Claude Code starts interactively with the init prompt, the unpacked pack
and your selections. It asks the docs-layout question — global, per service, co-located design
notes, or keep what exists — and writes
AGENTS.md, a thinCLAUDE.mdandGEMINI.md, the docs folders from the fixed set, the templates, and the decision record. - Validation. The CLI checks the tree, not the agent's word:
AGENTS.mdexists, the tool files point at it and stayed thin, the decision record isstatus: completeand carries every answer plus the pack and CLI version, and no documentation folder outsidefeatures/,decisions/anddevelopers/appeared. Every selected persona must name one of the build or test commands the CLI read out of the repository's own manifests and point at the docs tree — a persona that does not is a template, and the run fails.AGENTS.mdmust hold exactly the Definition of Done items that were selected, with their marks, and no item that was dropped; the issue forms for feature, bug and finding must exist with a required acceptance criteria field on the feature form; and the pull request template must carry the generated checklist and a verification matrix.syncand thedoctoragent-setup checks run on the result, with the kept files excluded. - Finish. A diffstat, then your choice: commit and open a draft pull request that links the decision record, commit only, or leave everything uncommitted. Nothing is pushed without that explicit yes.
The pack is downloaded into a temporary directory and removed when the run ends — after success, after a failure and after Ctrl+C.
Existing files are improved, never replaced: the agent edits the rulebook, persona, skill or template that is already there, so the change is an edit in the branch diff and the team's own wording survives.
Running it again
The decision record's frontmatter is the state — there is no lock file — and all three of these read it:
A re-run pre-fills every question with the previous answer, so pressing Enter through the dialogue reproduces the previous setup. Afterwards it prints only the answers that moved (
preset: minimal → standard), or says that nothing changed. A typed--preset=wins over the record; the record wins over the built-in default.An interrupted run leaves
status: in progress. The next run stops on one question before anything is downloaded: resume (the default — the agent continues that record, keeping its number) or start over (every question asked again, the record rewritten).--non-interactiveanswers every question the way a re-run would and never prompts: previous answer where the record has one, default where it does not, and the agent runs headless. It combines with--json. Two flags steer it:sprintforge init --non-interactive --finish=commit # pr | commit | leave (default: leave) sprintforge init --non-interactive --resume=start-overA scripted run cannot confirm a branch, so the branch question is answered from the state itself: a dirty tree or a protected branch (
main,master,develop,trunk) exits non-zero with the same message as ano, before the pack is downloaded. Nothing is pushed unless--finish=prwas typed, and any failed check still exits non-zero.
Agent setup: sync and doctor
Your personas live in .github/agents/<role>.agent.md and your skills in
.agents/skills/<name>/SKILL.md. sync generates the wrappers each agent tool expects from them, so
you edit one file and every tool follows:
sprintforge sync # write the wrappers for every supported tool
sprintforge sync --check # report drift, write nothing, exit non-zero if any
sprintforge sync --tool=claude # one tool only
sprintforge doctor --check # the CI gate: rulebook, frontmatter and driftThree things make this safe to re-run in a repository that also has hand-written agents and skills:
- every generated file carries a
GENERATED by …marker, and only marked files are ever deleted; - a skill whose name collides with a built-in command of the tool is renamed rather than shadowing
it — a
code-reviewskill is published to Claude Code assprintforge-code-review; - a source with unreadable frontmatter fails the run before anything is written.
doctor reports on the agent setup: the rulebook (.agents/AGENTS.md), the frontmatter of every
persona and skill, and wrapper drift. --check is the CI mode — local checks only, no login and no
network, no secret value ever read, and a non-zero exit on any failure. Without --check it also
names the groups it could not check without a login. When the repository has
.github/workflows/sprintforge-night-shift.yml, both modes also check that it grants
id-token: write: the runner signs in to SprintForge with a GitHub Actions OIDC token, and without
that permission the runner cannot authenticate to SprintForge.
Both take --root=<dir> (or a bare directory argument) to work on another tree, and --tool=<id> to
narrow to one agent tool. Value flags are attached with =, because the router reads a detached
value as a subcommand.
Team resolution
Most commands act for a team. The CLI never asks you to name one twice:
--team <name>wins whenever it is given, and is just this once — it is not remembered.- Otherwise the CLI reads the git remote of the current directory (
git remote get-url, falling back togh repo view) and asksGET /api/cli/teams/resolve?repository=owner/repo. The answer only ever contains teams you are a member of. - One match is used without saying anything.
- Several matches are asked once, and the answer is remembered in your own CLI config — keyed by repository, never written into the repository, so it is your choice and not your colleagues'.
- No match is explained rather than guessed: the repository is not linked to any of your teams.
sprintforge whoami shows what this resolves to for the current directory. With --json, nothing
is ever prompted: a repository matching two of your teams comes back as team_ambiguous and you
pass --team.
| error.code | Meaning |
|---|---|
| not_a_repository | this directory is not a GitHub repository checkout |
| team_not_linked | the repository is not linked to any team you are a member of |
| team_ambiguous | several of your teams work here and nothing could be asked (--json) |
| unknown_team | --team named a team you are not a member of in this repository |
The night-shift queue
sprintforge queue ls # what is queued for the resolved team
sprintforge queue add 123 # add the Claude:queue label through the API
sprintforge queue rm 123 # take it off again
sprintforge queue add 123 --repo octo/app # when two of the team's repositories have a #123
sprintforge queue ls --json # { "data": { "team": …, "issues": [ … ] } }The issue is addressed the way you think of it — by number. The CLI resolves the team first (see
Team resolution), asks GET /api/cli/issues/lookup which of that team's issues carry the number,
and then calls the same queue endpoints the web app uses. Nothing is guessed: a number that is in
two of the team's repositories is refused with the list, and you pick with --repo.
queue ls --json returns issues as { id, number, title, url, status, repository, assignees,
queueOrder }, in queue order; queue add and queue rm return the issue they acted on plus the
same list as it is afterwards.
| error.code | Meaning |
|---|---|
| issue_not_found | no repository the team tracks has that issue number |
| issue_ambiguous | several of the team's repositories do — pass --repo <owner/repo> |
| not_entitled | your SprintForge plan does not include this; the server's sentence is printed |
| forbidden | the server refused — for example you are not a member of that team |
| token_invalid | the CLI token expired or was revoked; run sprintforge login |
Entitlement is decided by the API and never by the CLI: a refusal is rendered as the one sentence the server wrote, never as a stack trace.
Watching an epic
sprintforge watch ls # what the resolved team is watching
sprintforge watch add 95 # put Claude:watch on the epic through the API
sprintforge watch rm 95 # take it off again
sprintforge watch add 95 --repo octo/app # when two of the team's repositories have a #95Claude:watch on a parent issue means "implement its sub-issues without me queueing them one at a
time". The issue is looked up by number exactly as queue does, and the label is written by the
GitHub App after the API has checked the issue is in one of the team's repositories.
Two answers come back as warnings, printed as ! … lines and carried in data.warnings under
--json: Watch is switched off for the team, and the epic has no open sub-issues. Neither fails the
command — the label is on, it simply has nothing to do yet. watch ls --json returns issues as
{ number, title, url, repository, pendingChildren, eligiblePayers } plus watchEnabled, and
watch add|rm return the issue they acted on with watched, subIssueCount and warnings.
The error.code values are the ones in the table above, plus watch_add_failed /
watch_remove_failed when GitHub itself refused the label.
Starting a run
sprintforge night-shift run # confirm, then run whatever is queued
sprintforge night-shift trigger now # trigger a run immediately
sprintforge trigger now # shorthand: trigger immediately
sprintforge night-shift run --issues 12,13 # only these issues
sprintforge night-shift run --model opus # override the model for this run
sprintforge night-shift run --yes # skip the questionThis one spends money, so it asks. Before anything is triggered the CLI reads the Sprint Accelerator status for the resolved team and shows who pays, what runs and what is left:
This starts a night-shift run and spends budget.
Payer octo-dev
Team Alpha
Model opus (override)
Budget 12.5% of the weekly quota (~1,250,000 tokens) left — Smart mode
Issues #12, #13
Start the run? [y/N]:Anything but y/yes triggers nothing and exits non-zero. The budget figures are the server's own,
so the number you confirm is the number the run is dispatched with.
--json without --yes is refused (confirmation_required, exit 2) and reads nothing: one JSON
document on stdout leaves no room for a prompt, and a prompt nobody answers must never be taken as a
yes. Scripts and agents pass --yes deliberately, after showing the spend to the person paying.
--model takes a label from the team's model table — an unknown one is refused with the list.
run --json --yes returns { triggered, payer, model, budget, issues, status, runUrl, triggeredAt }.
| error.code | Meaning |
|---|---|
| confirmation_required | --json without --yes; nothing was read and nothing was triggered |
| run_declined | the question was answered no; nothing was triggered |
| unknown_model | --model named a model the team's model table does not have |
| status_unavailable | the spend could not be read, so nothing was asked and nothing triggered |
| trigger_failed | the API refused the run; the server's sentence is printed |
Setting up the night shift
sprintforge night-shift setup # configure workflow and permissions
sprintforge night-shift setup --yes # non-interactive: accept prompts automatically
sprintforge night-shift setup --repo octo/app # target a specific repository
sprintforge night-shift setup --force # overwrite existing workflow if present
sprintforge night-shift setup --json # { "data": { "workflow": …, "secrets": …, "permissions": … } }night-shift setup configures a repository for autonomous night-shift runs:
- Workflow permissions. Checks
gh api repos/{owner}/{repo}/actions/permissions/workflowforcan_approve_pull_request_reviewsanddefault_workflow_permissions: write. Sets them if needed (or prints the required admin action if lacking permissions). - No runner secret. The runner authenticates every SprintForge call with a GitHub Actions OIDC token, so setup stores no SprintForge secret in the repository (
"secrets": {}under--json).SPRINTFORGE_API_URLis optional and only needed for a SprintForge other than the default. - Workflow file. Generates
.github/workflows/sprintforge-night-shift.ymlwithid-token: write,contents: write,pull-requests: write,issues: write, scheduled cron, and repository branch-locking concurrency.
| error.code | Meaning |
|---|---|
| workflow_exists | .github/workflows/sprintforge-night-shift.yml already exists (use --force to overwrite) |
| permissions_declined | actions permissions update declined |
| permissions_failed | actions permissions could not be updated (adminAction required) |
Connecting a Claude seat
sprintforge night-shift seat connect # prompts for repository or organization secret
sprintforge night-shift seat connect --repo-secret # store as repository secret in current repository
sprintforge night-shift seat connect --org-secret # store as organization secret in repo's organization
sprintforge night-shift seat connect --repo octo/app --repo-secret
sprintforge night-shift seat connect --org octo --org-secretA night-shift run or Claude Review spends an individual developer's subscription seat, billed to that
developer's secret. night-shift seat connect connects a personal subscription token to GitHub under
the exact secret name the workflow expects (CLAUDE_CODE_OAUTH_TOKEN_<GITHUB_LOGIN_UPPERCASE>):
- Interactive scope selection: Prompts every time whether to store the token as a repository
or organization secret. Non-interactive or script use passes
--repo-secretor--org-secret. - Permission check before minting: Verifies GitHub permissions before running
claude setup-token:- For organization secrets, checks that your account has the
adminrole in the organization. Without org admin rights, it exits non-zero, prints the exact secret name and thegh secret setcommand for an administrator to run, and does not create a repository secret (no silent fallback). - For repository secrets, checks that your account has admin rights on the repository.
- For organization secrets, checks that your account has the
- Zero credential leakage: Captures the token from
claude setup-tokenand pipes it via stdin intogh secret set. The token is never printed, logged, written to disk, put in argv, or sent to SprintForge. --jsonshape: Under--json, outputs a structured result withsecretName,scope,target, andcreated. On permission refusal,createdisfalseand includesadminActionwith the recommended command.
| error.code | Meaning |
|---|---|
| admin_required | organization secrets require org admin rights; admin command is printed |
| permission_denied | repository secrets require admin permissions on the repository |
| token_capture_failed | claude setup-token failed or token could not be parsed |
| not_signed_in | gh is not authenticated; run gh auth login |
| not_a_repository | cannot determine target repository/organization outside a repo |
Your night-shift schedule
A schedule is per developer per team — it names whose Claude seat gets spent — so these two commands only ever read and write your own, and there is no flag that points them at somebody else.
sprintforge night-shift schedule # same as 'show'
sprintforge night-shift schedule show
sprintforge night-shift schedule set --every day --at 20:00
sprintforge night-shift schedule set --every '2 weeks' --on mon,thu --at 21:00
sprintforge night-shift schedule set --timezone America/New_York
sprintforge night-shift schedule set --disableshow prints the recurrence, the time, the zone and the next run rendered in that zone — which
is the point of showing it at all, because it is how you confirm the zone is the one you meant:
Night shift for Alpha: every 2 weeks on Mon, Thu at 21:00
Time 21:00 Europe/Brussels
Next run Thu 8 Oct 2026, 21:00 Europe/Brussels
Last run Mon 5 Oct 2026, 21:00 Europe/Brussels — Succeeded| Flag | Takes |
|---|---|
| --every | day, daily, 2d, '2 days', week, 3w, '3 weeks' — --every 2 days unquoted works too |
| --on | weekdays of a weekly recurrence: mon,wed,fri, or the full names |
| --at | 20:00, 9:05 or 0900 |
| --timezone, --tz | an IANA zone, e.g. Europe/Brussels |
| --enable, --disable | switch the schedule on or off without touching the recurrence |
set is a read-modify-write: flags you leave out keep what is stored, so set --at 21:00 moves the
time and leaves the recurrence alone. Giving any of the flags above switches the schedule on unless
you also pass --disable.
The time zone. --timezone always wins. Otherwise a schedule that is already running keeps the
zone it was set with, and only a schedule that is not running yet picks up this machine's zone
(TZ, else the host setting) — so setting a schedule from a laptop abroad cannot quietly move a
standing 20:00 run into another zone.
Validity is the server's to judge: an unknown zone, a weekly recurrence with no weekday or an interval past the ceiling are refused by the API and its sentence is printed as written. The CLI keeps no second opinion, which is also why what you set here is exactly what the Sprint Accelerator settings screen shows.
schedule show|set --json returns { team, repository, schedule }, where schedule is
{ enabled, unit, interval, timeOfDay, weekdays, timeZoneId, description, nextRunAtUtc,
nextRunLocal, lastFiredAtUtc, lastStatus }; set adds updated: true.
| error.code | Meaning |
|---|---|
| usage | a flag was mistyped, a value is not a recurrence/weekday/time, or set was given nothing to set |
| schedule_unavailable | the current schedule could not be read; nothing was written |
| schedule_rejected | the API refused the schedule; its validation message is printed |
Following runs
sprintforge runs ls # the team's runs of the last 14 days
sprintforge runs ls --limit 10
sprintforge runs view <run-id> # status, phase, issue, pull request
sprintforge runs follow <run-id> # live output until the run ends
sprintforge runs follow <run-id> --since 412ls is one line per run — short id, status, phase, developer, issue — running ones first, then the
most recently dispatched; it reads the same endpoint the Runs tab reads, so the terminal and the web
app cannot disagree about what ran. view takes the full run id printed by runs ls --json.
Night-shift runs of Alpha, last 14 days (2):
aaaaaaaa Running Implementing octocat CapturePlatform/app#148 runs ls | view | follow
bbbbbbbb Succeeded Done hubot CapturePlatform/app#147 night-shift schedulefollow is the terminal equivalent of leaving the Runs tab's Output panel open. It opens the
live-output stream (Server-Sent Events) with your CLI token as Authorization: Bearer — never in
the query string — prints each line as it arrives, and exits 0 when the run ends.
A dropped connection is expected, not an error. The server ends a stream at its lifetime cap, a
laptop lid closes, a train enters a tunnel. follow remembers the sequence number of the last batch
it printed, reconnects with it as Last-Event-ID (and as afterSeq, for a proxy that strips the
header), and drops any batch at or below it — so a resume never shows a gap and never prints a line
twice. Reconnections are announced on stderr, so sprintforge runs follow <id> > run.log keeps
the log clean. After ten consecutive failed attempts it gives up with the exact command to pick up
where it stopped (--since <seq>).
runs ls|view --json returns { team, windowDays, runs } / { team, run }, where a run is
{ id, kind, status, phase, ended, developer, repository, issue, issues, runUrl, pullRequest,
loopId, dispatchedAt, startedAt, endedAt }. follow --json prints nothing while it waits and one
envelope when the run ends — { team, run, lines, batches, reconnects, lastSeq, ended } — so a
script can block on a run and then read how it went.
| error.code | Meaning |
|---|---|
| usage | view/follow was given something that is not a run id |
| runs_unavailable | the list could not be read |
| run_not_found | no such run in this team — another team's run reads the same way |
| run_stream_unavailable | the stream was refused (not a member, too many open streams); the server's sentence is printed |
| stream_lost | the connection could not be re-established; the hint resumes it with --since |
Version gate
Before the first command that talks to SprintForge, the CLI reads the anonymous
GET /api/cli/metadata and compares its own version with minimumVersion. An outdated CLI prints
an upgrade message and exits 4 without calling any other endpoint. Metadata that cannot be reached
never blocks a command — a network problem is not the same as being out of date.
Configuration
| Variable | Default | Purpose |
|---|---|---|
| SPRINTFORGE_API_URL | https://sprintforge.apps.demo.captureplatform.com | the SprintForge API to talk to |
| SPRINTFORGE_CONFIG_DIR | $XDG_CONFIG_HOME/sprintforge | where credentials.json and config.json live |
config.json holds preferences only — today, the team remembered per repository under teams. The
token is never in it.
Development
node bin/sprintforge.mjs --help
node --test test/*.test.mjsThe CLI has no runtime dependencies and uses only Node built-ins (Node >= 20.11).
