npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vintecc/sprintforge

v0.3.0

Published

SprintForge command line interface: sprint planning and Sprint Accelerator from the terminal.

Readme

sprintforge

The SprintForge command line interface — sprint planning, the Sprint Accelerator and agentic repository setup from the terminal.

npm install -g @vintecc/sprintforge
sprintforge --help

Command 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, Claude Code, interactive
sprintforge init --root=../app             # another checkout (also: sprintforge init ../app)
sprintforge init -n --finish=pr            # non-interactive; open the draft pull request at the end
sprintforge init -n --resume=start-over    # non-interactive; restart an interrupted run

init is mostly automatic: it derives its choices from the repository, writes the fixed files itself, and hands Claude Code only the part that needs judgement. There are no presets and no fine-tuning dialogue. The CLI asks at most four questions:

| Question | When | |---|---| | Q1, the branch | always (interactive) | | The agents (multiselect: Claude Code, GitHub Copilot, Gemini CLI) | always (interactive); --agents answers it. Starts from the answer recorded last time | | Q2, the language servers | only when a detected language has a language-server row and claude plugin exists | | Q3, the finish | always (interactive) |

At the end of its session Claude asks which recommendations to apply (AI Compass entries and the documentation structure, at most four questions grouped by kind, each a multi-pick). A resume question appears only when the previous run was interrupted. Everything else is printed with its evidence.

| Flag | Meaning | |---|---| | --root=<dir> (or a bare directory) | the repository to set up; default the current directory | | --agent=<id> | the agent that merges, writes the rulebook prose and applies the recommendations; claude-code is the only adapter today | | --dod=<ids> | the Definition of Done items, e.g. implementation,tests,docs. Without it a run keeps what it derived or recorded: the interactive question is switched off for now | | --agents=<list> | the AI agents your team uses, e.g. claude,copilot,gemini; the answer to the agents question. Claude Code is always included (it runs init and the night shift). Without the flag, a non-interactive run reuses the recorded answer, else Claude Code alone | | -n, --non-interactive | ask nothing (see below) | | --finish=pr\|commit\|leave | the finish without asking; a choice not offered (for example pr on a protected branch) becomes leave | | --resume=resume\|start-over | the answer to the resume question | | --preset, --tool-folders | deprecated. Accepted with a "no longer has an effect" notice, for one minor release, then removed | | --seeds=<…>, --no-seeds | deprecated. init writes no personas or skills any more, it recommends them. Accepted with a "no longer has an effect" notice, removed in a later release |

With --json alone init refuses (usage, exit 2) instead of hanging on a prompt; add -n to run it from a script. Non-interactive means: missing requirements stop the run, nobody is signed in, the run continues only on a clean, unprotected branch, nothing is installed (no self-update, no language servers), Claude runs headless (--print) and applies none of the recommendations (the result file lists them), and the finish defaults to leave. No flag makes a non-interactive run install anything.

  1. Requirements. git, gh (installed and logged in to github.com; only the exit status of gh auth status --hostname github.com is read) and claude on PATH. Each missing one is printed with its fix, and init waits: Enter re-checks, q (or the end of input) quits.

  2. Sign-in. Before any question about the repository, a cheap authenticated call (/api/me) checks the login. Without a valid one init runs the sprintforge login device flow itself and carries on; a 401 logs in again and retries once, and never starts a second device login. A rejected SPRINTFORGE_TOKEN is reported instead (unset or replace it): a login cannot fix it.

  3. Branch. It shows the branch, the working-tree state and the dirty files and asks you to be on a clean branch made for this run. It never creates or switches a branch; "no" stops with nothing written and nothing downloaded. A protected branch (main, master, develop, trunk) is named, and pr is then not offered at the end. Dirty paths the last init run left uncommitted are marked; they do not count against a non-interactive run's clean-tree rule.

  4. Fetch and scan. The pack is fetched over HTTPS (plain HTTP only for localhost) into a temporary directory removed on success, failure and Ctrl+C. Path traversal, links, non-text files and archives over the size and file-count caps are refused (every SprintForge call, not only the pack, is HTTPS-only outside localhost); a 402 is printed as the server wrote it. This CLI reads pack format 2; a pack in another format is refused before anything is written, with a message naming the side to upgrade (an older pack: the SprintForge server; a newer one: the CLI). The scan prints the languages, the frontend and end-to-end detection, the existing agent files, and every derived choice with its evidence (Visual check on react in web/package.json). Languages come from the pack's language folders: each descriptor names its detection markers and a built-in command reader (src/init/readers/) that reads the build, test, lint and format commands from the manifests; a descriptor's static hint fills a gap and is marked (hint). Language servers (src/init/lsp.mjs): when a detected language has a row (dotnet csharp-lsp + dotnet tool install --global csharp-ls, node typescript-lsp + npm install -g typescript-language-server typescript, python pyright-lsp + pipx install pyright, go gopls-lsp + go install golang.org/x/tools/gopls@latest, rust rust-analyzer-lsp + rustup component add rust-analyzer, java jdtls-lsp with instructions only) and claude plugin exists, an interactive run shows the exact commands and asks Install? [y/N]. Plugins install with claude plugin install <plugin>@claude-plugins-official --scope local. A server already on PATH is skipped; one installed in a tools folder PATH does not reach (~/.dotnet/tools) gets the PATH hint instead of a reinstall. The installs run only after validation passed, as argument vectors without a shell or sudo and without the GitHub, Anthropic and SprintForge tokens; each is re-checked, a failure is reported with the command to run by hand and never fails init, and the result file records lspInstall per language. Non-interactive runs never ask or install. On Windows nothing is spawned (npm and pip are .cmd shims): the commands are printed, and a claude that is itself a .cmd shim is reported with a request to install the native binary.

  5. The CLI writes the fixed files, after printing the plan (create, refresh, merge, skip per path), and nothing else:

    | File | What it is | |---|---| | AGENTS.md | four managed blocks (commands, Definition of Done, standard slices, docs map) inside prose Claude fills. An agents-index block an older CLI wrote is left as it is | | CLAUDE.md, GEMINI.md | a thin pointer file for each chosen agent that reads one (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI); nothing for an agent you did not choose | | .agents/agent-tools.json | the chosen agents, which sync and doctor keep following | | .github/workflows/sprintforge-night-shift.yml | the same generator as night-shift setup (see Setting up the night shift) | | .github/workflows/claude-pr-review.yml | the review workflow SprintForge dispatches; written when absent and refreshed while it is still the generated copy. A review workflow the team wrote is kept | | .github/sprintforge-runner.lock | the runner bundle pin, when SprintForge names the current bundle; otherwise a to-do says to run night-shift setup | | docs/decisions/NNNN-agentic-setup.md | the result file |

    init writes no personas, skills, pull request template, issue forms, CODEOWNERS, .agents/README.md or .agents/THIRD_PARTY_NOTICES.md, and moves nothing out of .claude/ or .github/agents. Those come from the AI Compass: the CLI works out which Toolkit entries fit this repository (src/init/compass.mjs; an entry the repository already has is left out) and stages a copy of each one the pack ships in the pack's temporary directory (_recommended/<target>), with this repository's commands and paths filled in. Nothing is copied into the repository unless you pick it in step 6.

    A file that already exists with other content (a CLAUDE.md with real rules, a workflow you edited) is never overwritten: its versions are staged in the pack's temporary directory for Claude to merge. A legacy .agents/AGENTS.md with rules is staged the same way: Claude moves its rules into the root AGENTS.md and leaves it as a one-line pointer. A path with someone's uncommitted changes that init would write stops the run and is listed; the last run's own uncommitted output does not (the result file, a file still at the hash it recorded, and, after an interrupted session, every path its plan named), so a resume, or a re-run after leave or a failed check, needs no commit first. .claude/settings.json is never touched.

  6. Claude's part. In order: it shows a short plan and waits for your yes (interactive runs only), merges every staged file, fixes the toolchain steps inside the workflow's workspace region (and may edit its plugins region), folds your own text under AGENTS.md headings the CLI's blocks now share, fills the prose of AGENTS.md, and records its notes in the result file. Last, it applies the recommendations you pick. It asks, grouped by kind (personas, skills, templates and other entries, docs structure), which ones to apply; each question takes several, all or none. For each pick it copies the staged copy to its target: .agents/agents/<role>.agent.md, .agents/skills/<name>/SKILL.md, .github/pull_request_template.md (its Definition of Done checklist set from AGENTS.md) or .github/ISSUE_TEMPLATE/<kind>.yml (with a note that filing an issue queues nothing). For the docs structure it creates the missing folders of the fixed set, each with a short README.md. It never overwrites an existing file. An entry the pack does not ship (/verify-criteria, the review-this-diff prompt, the plan-then-build workflow, the red-team agent, a project .mcp.json) gets its Compass link instead. It records every recommendation, applied or not, under Agent notes as ### Applied recommendations, plus the line Docs layout: <keep|per-service|global>. A non-interactive run applies none. Claude never commits or pushes.

  7. Validation. The CLI checks the tree, not the agent's word: the rulebook and its blocks, the Definition of Done block against what was derived or recorded and the standard slices block, the workflow (byte-identical outside its two regions, declared toolchain versions, no install of the Playwright CLI or Graphify and no run of either, in any form, and every other install pinned exactly, the plugins step's shape), .claude/settings.json unchanged, no docs/ folder outside the fixed set (features, decisions, developers) appearing during the run, and status: complete. Leftover tailoring markers in AGENTS.md are reported, not failed. Files Claude applied from the recommendations are checked for three things only: none was written through a symbolic link (a target behind one is not even offered, and becomes a to-do), an issue form sets no queue label and carries the note that filing it queues nothing, and a file from the pack comes with .agents/THIRD_PARTY_NOTICES.md (the licences of its text). When Claude applied a persona or skill under .agents/, init writes the wrapper of that source only, so Claude Code (and Copilot) see it, and never over a hand-written file (that one becomes a to-do); sprintforge sync stays the command for everything else. The doctor agent-setup checks do not run inside init: run sprintforge doctor yourself. Accepted language-server installs run last.

  8. Finish. The diffstat of the run's own files, the checks, the to-dos and the recommendations, then pr (default), commit or leave. The recommendations, also written to the result file: the AI Compass entries (a table of kind, entry, why here and the link <SprintForge URL>/ai-compass?tab=toolkit&entry=<id>), the documentation structure (the recommended layout, keep, per-service or global, and the fixed folder set with the missing folders; shown only while a folder is missing; the pull request template is not recommended when the repository has one, a .github/PULL_REQUEST_TEMPLATE/ folder included), tools (Playwright CLI, Graphify), repository settings worth adding (today one: at least two code owners on .github/** plus a ruleset requiring code-owner review, shown when the last CODEOWNERS line that matches the night-shift workflow, its runner lock or CODEOWNERS itself names fewer than two owners), plugin notes, and what to remove (GEMINI.md when Gemini CLI is not one of your agents). The default finish is leave when a check failed. On a protected branch pr is not offered and commit is the default. Only the paths the run wrote are staged and committed (files Claude created during the session, the recommendations you applied included; they are listed in the result file); your other changes are never included. Non-interactive: --finish, default leave; --finish=pr after a failed check commits without pushing. With -n --json, Claude's headless output goes to stderr, so stdout holds exactly one JSON document.

Windows. On win32 the CLI never spawns npm, pip or any .cmd/.bat shim and never uses shell: true: the self-update and the language-server installs print their commands for you to run. Node refuses to spawn .cmd files directly, and a shell would open command injection.

| error.code | Meaning | |---|---| | usage | an unknown or malformed flag, or --json without -n | | setup_problem | a requirement is missing, the branch was not confirmed (or is protected or dirty in a non-interactive run), a path init would write has uncommitted changes (listed under data.conflicts), the pack was refused (format, caps, traversal), the Claude session ended early, or a check failed | | not_signed_in | a non-interactive run without a valid login; run sprintforge login first | | not_entitled | the server refused the pack (402); its sentence is printed | | update_failed | the self-update could not install or verify the new version; the manual command is printed |

See troubleshooting for each refusal and its fix.

Running it again

The result file docs/decisions/NNNN-agentic-setup.md is the state. Its files hashes decide per file: unchanged since the last run is refreshed, edited is merged by Claude (with the last run's version, found in git history by its hash, as the merge base), missing is recreated. The Definition of Done block in AGENTS.md as it is on disk is the record: edit it and a re-run keeps it, and an item that newly applies becomes a to-do. The Definition of Done question is switched off for now (ASK_DOD in src/init/dod-selection.mjs): a run writes what the scan derived or what AGENTS.md records, and --dod sets the list. Nothing is ever added silently. A run that left status: in progress asks resume (default) or start over.

Recommendations already applied are not offered again: an entry the repository already has (a persona, skill, template or issue form at its target path or in the usual agent folders) is left out of the list, so a re-run only names what is still missing. A file you applied is yours from then on: init does not refresh it, so a pull request template's checklist does not follow a later change to the Definition of Done; update it yourself.

A result file an older init wrote is read back as before, but its Migrations and Skill decisions sections are not carried over: this init moves nothing and seeds nothing, so neither has anything left to decide. A "Personas and skills" block an older init wrote into AGENTS.md is no longer kept current; the recommendations name it under what is worth removing.

Agent setup: sync and doctor

Your personas live in .agents/agents/<role>.agent.md (.github/agents/<role>.agent.md is still read for a persona .agents/agents does not have) and your skills in .agents/skills/<name>/SKILL.md. You write them yourself or copy them from the AI Compass Toolkit (init applies the ones you pick from its recommendations, and writes the wrappers of the ones it applied). sync generates what each chosen agent tool expects from them, so you edit one file and every tool follows: a thin wrapper per persona and skill in .claude/ for Claude Code, and, when GitHub Copilot is one of your agents, a full marked copy of each persona in .github/agents. The agents are the ones init recorded in .agents/agent-tools.json; a repository without that file is a Claude Code one.

sprintforge sync                  # write the files for the agents this repository is set up for
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 drift

A wrapper carries the source's name and description, plus tools, model, disable-model-invocation and user-invocable when the source sets them: hiding a skill at its source hides it in Claude Code too. tools and model reach persona wrappers only, the other two skill wrappers only, and only when Claude Code understands the value; a Copilot-style model: GPT-4.1 or tools: ['codebase'] is left out and doctor notes it.

Three 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-review skill is published to Claude Code as sprintforge-code-review;
  • a source with unreadable frontmatter fails the run before anything is written.

doctor reports on the agent setup: the rulebook (AGENTS.md, or the older .agents/AGENTS.md; both at once is a non-failing note to merge them), 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. Next to it, .github/sprintforge-runner.lock must hold a valid sha256= pin, or the check fails (the workflow runs only a pinned bundle). It also checks the review workflow: .github/workflows/claude-pr-review.yml must exist and declare every input SprintForge sends, because SprintForge dispatches it by that name for every pull request review and the review loop, and a missing file or input means nothing is reviewed (the fix is night-shift setup). A copy the team maintains itself is fine; only these two facts are checked. doctor also scans every *.yml / *.yaml in .github/workflows, whatever its name, and fails on each file that still runs unpinned SprintForge code: the bundle route in any form other than /api/runner/bundle/<64 lowercase hex sha256>.tar.gz (or a shell variable holding the pin, as the generated workflow does) — so /api/runner/bundle.tar.gz, .zip, /latest or a query string — any fetch from /api/runner/scripts/, and toJson(secrets) in a workflow that downloads either. The failure names the file and spells out the risk: unverified code from SprintForge running with every repository secret, on routes that now answer 410 Gone, so the workflow no longer runs until night-shift setup is re-run. Matching is case-insensitive like GitHub expressions (ToJSON( secrets ) counts), and a workflow that runs only its own checked-out scripts is not affected. Such a workflow that also maps a personal access token (NIGHT_SHIFT_PAT, GH_PAT, PAT) gets a note. Without --check, and signed in, doctor also asks SprintForge for the current bundle and adds a note — never a failure — when the pin is older: it keeps working until SprintForge retires it, so merge the runner-update pull request or re-run night-shift setup in your own time. Also without --check (signed in or not), it reads with your gh login whether the SprintForge GitHub App is a bypass actor on the default branch's rulesets, or may skip its branch protection's required reviews: the night shift's publish job pushes as that App with a token SprintForge issues it, and the token would inherit the bypass. A bypass is a WARNING note, never a failure; what your login cannot read (bypass lists need an admin) is said so.

doctor also checks that the SprintForge you talk to can dispatch this repository's night shift (the dispatch trust check, security finding L23). A deployment that dispatches as its own GitHub App, not capture-apps-sprintforge[bot], must have that App's bot login in the repository's (or organisation's) NIGHT_SHIFT_TRUSTED_ACTORS variable, and one that overrides NightShiftRuns:AllowedWorkflowFiles must list sprintforge-night-shift.yml; either mismatch is a failure, naming the fix. The deployment's side is read from the githubAppBotLogin and allowedWorkflowFiles fields of its anonymous /api/cli/metadata document, the variable with your gh login; whatever cannot be read (a deployment that does not publish a field, a login without access to the variables) is a note, never a pass. With --check, which stays offline, it only notes a workflow that does not pass the variable on (regenerate it with night-shift setup --force).

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:

  1. --team <name> wins whenever it is given, and is just this once — it is not remembered.
  2. Otherwise the CLI reads the git remote of the current directory (git remote get-url, falling back to gh repo view) and asks GET /api/cli/teams/resolve?repository=owner/repo. The answer only ever contains teams you are a member of.
  3. One match is used without saying anything.
  4. 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'.
  5. 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 #95

Claude: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.

watch ls also reports label watch: the labels the team watches (set on the Watch card, for example aikido) and the open issues that wear one. Each labelled issue shows its trust state, which is trusted (a team member with write wrote it or labelled it), awaiting approval (anybody else: Aikido, another bot, an outside author), approved or not checked yet (no sweep has looked at it). A closing ! line says how many wait for approval, which is done in Watch & Maintain, not from the terminal. Under --json, data.watchedLabels is the label list, data.labelledIssues is { id, number, title, url, repository, labels, trust, authorLogin, labelledByLogin, eligiblePayers, skipReason, isRunning } and data.awaitingApproval is the count the navigation badge shows. A server that predates label watch sends none of it, which reads as no labels and nothing waiting.

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 question

This 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 workflows (night shift and review), replace a different SPRINTFORGE_API_URL variable
sprintforge night-shift setup --json                    # { "data": { "workflow": …, "runnerLock": …, "reviewWorkflow": …, "apiUrl": …, "secrets": …, "permissions": … } }

night-shift setup configures a repository for autonomous night-shift runs and automatic pull request review:

  1. Workflow permissions. Checks gh api repos/{owner}/{repo}/actions/permissions/workflow for can_approve_pull_request_reviews and default_workflow_permissions: write. Sets them if needed (or prints the required admin action if lacking permissions).

  2. 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's address, as a repository variable. Each SprintForge deployment has its own API URL, and it is not a secret. Setup sets the repository variable SPRINTFORGE_API_URL (gh variable set SPRINTFORGE_API_URL --repo <owner/repo> --body <url>) to the URL the CLI is using — SPRINTFORGE_API_URL from your environment, else the CLI's default — when you may (a repository admin). It never replaces a different value without --force: it prints a note and the command instead. A plain-http (local) URL is never published. --json reports it under apiUrl: { variable, url, state, existing, command }, state one of created, updated, unchanged, kept (a different value was there), failed (not allowed; command is what an admin runs) or skipped (not https). The generated workflow reads vars.SPRINTFORGE_API_URL, then a secret of that name (older setups), then the URL setup was run against.

  3. Workflow file. Generates .github/workflows/sprintforge-night-shift.yml, which opens with a comment block on protecting it (security finding M22): give .github/** at least two code owners in CODEOWNERS (for example /.github/ @your-org/owner-one @your-org/owner-two) and add a ruleset on the default branch that requires a pull request with code-owner review — the workflow runs with your developers' Claude seat secrets, and one owner could approve a change that hands them out. sprintforge init checks the CODEOWNERS half and recommends it when the last matching line for a .github/ path names fewer than two owners. The file has three jobs (the two-job split): prepare (no LLM; reads private companion repositories and foreign issues with GH_ISSUE_TOKEN), session (the Claude sessions and builds; read-only permissions plus id-token: write, only the payer's seat secret — no App key, no GH_ISSUE_TOKEN; every GitHub write is recorded for the next job) and publish (no LLM; contents/pull-requests/issues/actions: write; checks what the session recorded and every commit, then pushes, opens the PRs and labels). prepare and publish use the sprintforge-publish environment: put NIGHT_SHIFT_APP_PRIVATE_KEY and GH_ISSUE_TOKEN there, not at repository level, which every job — the session job too — receives. session uses the sprintforge environment, which holds the seat secrets (see Connecting a Claude seat). Every action is pinned to a commit SHA (<owner>/<repo>@<sha> # vX.Y.Z) and the Claude Code CLI to one exact version, both from src/night-shift/action-pins.mjs. Each job that runs runner code fetches and verifies the pinned bundle itself. It is the same generator sprintforge init uses: the file is CLI-owned except two marked regions, both before any step that writes git credentials or resolves the Claude token:

    • # sprintforge:begin workspace: one setup step per detected language, with the version the manifests declare (global.json/TargetFramework, engines/.nvmrc, requires-python, go.mod, the Java build file), and nothing else: no third-party tool is installed there. Graphify and (with a frontend) the Playwright CLI are recommended in init's result file, each with why it helps, and never written into the workflow, because the job holds the payer's seat token and a default install would put unpinned third-party code in front of it. The packages live in src/tools.mjs at latest, a trade-off the product owner accepted on 2026-10-11 for the commands a developer runs by hand, on their own machine. Validation refuses any install of either tool in the region, in any form, the line an earlier release wrote included: remove that step (Claude does so during init) and install the tool on your own machine. Until it is gone, night-shift setup refuses to keep the region (workflow_regions_invalid). Any other tool install is accepted only at an exact version.
    • # sprintforge:begin plugins, right after the Claude Code CLI install: one claude plugin marketplace add <owner>/<repo>#<ref> per GitHub marketplace in .claude/settings.json's extraKnownMarketplaces and one claude plugin install <plugin>@<marketplace> per enabled plugin from them. Its step reads only the PLUGIN_MARKETPLACE_ACCESS_TOKEN secret (Contents: Read on the marketplace repositories), through the gh credential helper set in its own env:. .claude/settings.json is read, never written.

    Over an existing workflow that has both regions, setup keeps them and regenerates the rest; a workflow that only differs inside its regions is left untouched. The kept regions must pass the same checks as in init (see workflow_regions_invalid below).

  4. Runner lock. Writes .github/sprintforge-runner.lock: a comment header and one sha256=<64 lowercase hex> line, the runner bundle SprintForge serves now (GET /api/cli/runner-bundle). Commit it with the workflow. The workflow downloads exactly that bundle (/api/runner/bundle/<sha256>.tar.gz, with the run's OIDC token), checks its SHA-256 before unpacking it, and fails closed otherwise. It never hands the bundle toJson(secrets): one step with no secret decides who pays, the next gets only that developer's own Claude secrets. Re-running setup moves the pin to the current bundle; so will the pull request SprintForge opens when it publishes a new runner. An older pin keeps working until SprintForge retires it (by default 180 days after it was superseded). When SprintForge cannot be asked, setup writes nothing.

  5. Review workflow. Writes .github/workflows/claude-pr-review.yml, the workflow SprintForge dispatches by this exact name for every pull request review: the Claude Review button, the automatic review of a pull request, and the review loop that follows each night-shift pull request. Without it nothing is reviewed in the repository. It is the same kind of workflow as the night shift's: it runs the runner bundle .github/sprintforge-runner.lock pins (fetched with the run's OIDC token and SHA-256-verified before anything runs), uses the payer's own Claude seat from the sprintforge environment, and is split in three jobs so no job holds both an LLM and a write credential:

    • acknowledge (no LLM): the eyes reaction, and clearing the Claude:review label.
    • review (the session): read-only permissions plus id-token: write. It checks the pull request out and reviews it, but builds, tests and installs nothing from it (security finding N1). The verified bundle is moved out of the workspace before the runner fetches the pull request's head, so a pull request that adds a .sprintforge-runner/ of its own can never replace the runner.
    • publish (no LLM, never checks the pull request out): validates the review result and posts it with the job's own GITHUB_TOKEN (github-actions[bot], the identity the review loop trusts).

    Its first step in each of its three jobs checks the inputs it reads (pr_number is digits only; the review and publish jobs also hold timeout_minutes to 5-90 because the job stops at 135 minutes, effort to one of five levels, and the session id and resume_run_id to their shapes), and the night shift's prepare job checks its numeric inputs the same way (job_timeout_minutes 60-720, budget_percentage 0-100, and so on), and a session transcript is restored only from an earlier review of the same pull request by this workflow, dispatched on the default branch: the run is read from the Actions API first. It declares every input SprintForge may send (a dispatch with an undeclared input answers 422), and the whole file is the CLI's (a review builds nothing, so there are no team regions). A repository may maintain its own review workflow: setup creates the file when it is missing, updates a copy that still opens with the generated first line (# SprintForge Claude PR Review — generated by …, so a later release's hardening reaches repositories that already ran setup; a CRLF checkout of the current text counts as current), keeps a file the team wrote (reviewWorkflow.action is kept; the summary says so) and replaces that only with --force. --json reports reviewWorkflow: { path, action }, action one of created, unchanged, updated, kept, replaced. sprintforge init writes it the same way (creates it, refreshes a generated copy, keeps a team-written one), as does night-shift setup; sprintforge doctor fails without it.

| error.code | Meaning | |---|---| | workflow_exists | .github/workflows/sprintforge-night-shift.yml already exists (use --force to overwrite) | | api_url_invalid | SPRINTFORGE_API_URL is not a plain http(s)://host[:port][/path] URL (a query, credentials or a quote), so it cannot be the workflow's fallback; nothing was written | | workflow_linked | the workflow or lock file, or a folder on its path, is a symbolic link; setup never writes through one | | runner_bundle_unavailable (or token_invalid, network) | SprintForge could not say which runner bundle to pin; nothing was written | | workflow_regions_invalid | the existing workflow's workspace or plugins region fails the checks init runs (an action that is not an official setup action, a secret, a $GITHUB_ENV write, an install of Graphify or the Playwright CLI, an undeclared marketplace, a branch as a pin, …), or its region markers are duplicated, nested or out of order; fix it, or --force keeps valid-looking regions and prints the problems (a file with broken markers is regenerated without them) | | 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                  # environment secret in the current repository
sprintforge night-shift seat connect --repo octo/app  # … in another repository

A 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>), as an environment secret of the repository's sprintforge environment — the environment the generated workflow's session job declares. Never a repository or organization secret: those reach any workflow somebody pushes on a branch, while an environment limited to main reaches only the session job SprintForge dispatches on it (security finding M22). Environment secrets are per repository, so run it once in each repository that runs the night shift.

Plan requirement: GitHub offers environments and environment secrets in a private or internal repository only on GitHub Pro (personal accounts), GitHub Team or GitHub Enterprise; on GitHub Free they exist for public repositories only. On a private repository of a GitHub Free account or organization, seat connect stops with environment_failed and says so. There is no repository- or organization-secret fallback: upgrade the owner's plan or make the repository public.

  1. Permission check before minting: checks that your account has admin rights on the repository before running claude setup-token. Without them it mints nothing, exits non-zero and prints the exact command for an administrator to run.
  2. The environment: when sprintforge does not exist yet, creates it with main as its only deployment branch: SprintForge dispatches every run on main, whatever the default branch is. An existing environment is never changed, but it is checked before any token is minted. One that lets every branch deploy (GitHub auto-creates it that way when a workflow names it first; Protected branches only in a repository with no branch protection rule is the same) is refused with environment_unrestricted; a named list with no branch in it is refused with environment_no_branches, since the session job could never deploy. One whose deployment branch list or branch protection rules cannot be read is refused with environment_unverifiable: nothing is stored on a list nobody could check. A named list wider than a single branch policy for exactly main (*, main*, or any tag policy, which lets a workflow run on a matching tag deploy), or any Protected branches only environment (repository rulesets are not read, so even a lone main rule cannot prove nothing else is protected), gets a note that those branches or tags can read the secret and how to limit it. --repo must be exactly <owner>/<repo>.
  3. Zero credential leakage: captures the token from claude setup-token and pipes it via stdin into gh secret set <name> --env sprintforge --repo <owner/repo>. The token is never printed, logged, written to disk, put in argv, or sent to SprintForge.
  4. --json shape: secretName, scope ("environment"), environment, target (the repository), created, and on success environmentCreated and branchRestriction (protected, custom or none). On a permission refusal, created is false and adminAction holds the command.
  5. Older flags: --repo-secret is accepted and changes nothing; --org-secret and --org are refused with a usage error explaining why.

| error.code | Meaning | |---|---| | permission_denied | environment secrets need admin rights on the repository; the admin's command is printed | | environment_failed | the sprintforge environment could not be read or created; nothing was minted. For a private or internal repository the hint names the plan it needs (GitHub Pro, Team or Enterprise; GitHub Free has environments on public repositories only), otherwise it says to check your admin rights | | environment_unrestricted | the existing sprintforge environment lets every branch deploy; an admin limits it to main, then run again | | environment_no_branches | the existing sprintforge environment allows no deployment branch, so the session job could never run; an admin adds main, then run again | | environment_unverifiable | the existing sprintforge environment is restricted, but its deployment branch list (or, for Protected branches only, the branch protection rules) could not be read; nothing was minted or stored. Check that your gh login can read them, then run again | | token_capture_failed | claude setup-token failed or token could not be parsed | | secret_set_failed | gh secret set refused the secret | | not_signed_in | gh is not authenticated; run gh auth login | | not_a_repository | cannot determine the target repository outside a repo; pass --repo | | usage | an unknown flag, --org-secret / --org, or a --repo that is not <owner>/<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 --disable

show 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 412

ls 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 schedule

follow 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 |

Runner pin notice

A repository that pins the night-shift runner (.github/sprintforge-runner.lock) is told when the pin is not the bundle SprintForge serves now, without running doctor: every command that talks to SprintForge prints one line on stderr before it runs, for example This repository's runner pin (78a3be7839f5…) is not the bundle SprintForge serves now (…). It keeps working until SprintForge retires it; move it with 'sprintforge night-shift setup' or by merging SprintForge's runner-pin pull request. It is a heads-up, never a failure, and it stays out of the way:

  • only inside a checkout that has a valid lock, and only while signed in; otherwise nothing is said;
  • SprintForge is asked at most once every six hours (the answer is kept in the CLI's own config, never in the repository), for at most two seconds, and any error is silence;
  • never with --json (stdout stays one document), and not for login, logout, init or night-shift setup, which sign in or write the pin themselves.

The CLI's own version has no such notice to add: the gate below already stops an outdated CLI and updates it.

Version gate and self-update

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.

The same document's latestVersion drives the self-update. It is accepted only as a strict x.y.z that is newer than this CLI and has the same major. Then, on an interactive terminal with a global npm install, the CLI runs npm install -g @vintecc/sprintforge@<version> --ignore-scripts (an argument vector, no shell, from a temp directory, with SprintForge, GitHub and Anthropic tokens removed from its environment). It checks that the installed binary reports that version and re-runs your command on it, with SPRINTFORGE_UPDATED_TO set so the re-run never updates again. Its exit code is the command's. An update also rescues a CLI below the minimum.

It prints that command instead of running it when any of these hold: no TTY, --json, CI, -n/--non-interactive, an npx run, a project-local install or source checkout, SPRINTFORGE_NO_UPDATE=1, a major bump, or Windows (the CLI never spawns npm's .cmd shim or a shell). Before installing it also asks npm root -g where the install would land: when that is not the running copy (two Node installs, say) or the prefix is not writable without elevated rights, it prints the command with the reason and carries on. A failed install or a binary that reports another version prints the manual command (or SPRINTFORGE_NO_UPDATE=1) and exits 1 with update_failed.

Configuration

| Variable | Default | Purpose | |---|---|---| | SPRINTFORGE_API_URL | https://sprintforge.apps.demo.captureplatform.com | the SprintForge API to talk to. Set it to your deployment's URL (e.g. in your shell profile): the default is only the CLI's own fallback. night-shift setup carries the URL in use into the repository's SPRINTFORGE_API_URL variable and the workflow's fallback | | SPRINTFORGE_CONFIG_DIR | $XDG_CONFIG_HOME/sprintforge | where credentials.json and config.json live | | SPRINTFORGE_NO_UPDATE | unset | 1 turns the self-update off; the update command is still printed |

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.mjs

The CLI has no runtime dependencies and uses only Node built-ins (Node >= 20.11).