Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ jobs:
echo ">> publishing $name@$ver"
npm publish "$dir" --access public --provenance
}
# A name being tried for the first time can be refused by npm's
# typosquatting check, which runs only on a real publish — no local
# check predicts it. That refusal must not take the release with it, so
# an optional launcher is attempted last and allowed to fail.
publish_optional() {
if publish "$1"; then
return 0
fi
echo "::warning::optional launcher $1 was not published; the release stands on the required ones"
}

# The platform packages must land before any launcher that optionally
# depends on them: a launcher on the registry ahead of its binaries is a
# broken install for whoever hits that window.
Expand All @@ -196,6 +207,10 @@ jobs:
for d in npm/dist/launchers/*/; do
publish "${d%/}"
done
for d in npm/dist/launchers-optional/*/; do
[ -d "$d" ] || continue
publish_optional "${d%/}"
done

# Tag the released commit and create the GitHub Release — only after npm
# publishing succeeds, so a failed publish never leaves a dangling tag/release.
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ Tests live beside the code and lean on a few package-local helpers rather than a
- **A version is immutable.** Re-dispatching an already-released version from a *different* commit is refused, because publishing is idempotent and the run would otherwise go green having shipped nothing.
- **Publishing is idempotent.** Already-published packages are skipped, so a run that died after `npm-publish` can be resumed by re-dispatching the same commit.
- **Adding a platform touches three places** that must agree: `TARGETS` in `npm/scripts/build-packages.mjs`, `PLATFORMS` in the `Makefile`, and the `build` matrix in `release.yml`.
- **The launcher is published under two names**, listed in `LAUNCHERS` in the same script: `scc-cli` is the documented install, and `@protonspy/scc` stays published so earlier installs keep receiving versions. Both ship the same shim and put the same `scc` command on PATH — npm resolves the package name and installs the `bin` name, and those never had to match. Launchers are emitted under `npm/dist/launchers/` rather than beside the platform packages so that publish order stays structural: `dist/scc-*/` first, `dist/launchers/*/` second. A launcher that reached the registry ahead of the binaries in its `optionalDependencies` is a broken install for anyone in that window.
- **The launcher is published under every name in `LAUNCHERS`**, from one source, in two tiers. A `required` name is already ours — `@protonspy/scc` is the documented install, and a failure there fails the release. A non-required name is one being tried for the first time: attempted last, allowed to fail with a warning. Both tiers are directories (`npm/dist/launchers/`, `npm/dist/launchers-optional/`) so publish order *and* failure policy are visible in the layout, after `dist/scc-*/` — a launcher reaching the registry ahead of the binaries in its `optionalDependencies` is a broken install for anyone in that window.

The optional tier exists because **npm's typosquatting similarity check runs only on a real publish**: `npm view` returning 404 means unregistered, not publishable, and `npm publish --dry-run` never reaches the check. v0.9.0 died on `403 — Package name too similar to existing package cp-cli` for a name both of those had called free, and because that name published first, `set -e` took the working launcher with it. Corollary, and it is the load-bearing half: **only a required name may appear in documentation**, the embedded `entry.md` included. Promote a name into the docs in the release *after* the one that proved it publishes.
- Actions are pinned by commit SHA. Keep them pinned.

## Commits
Expand Down
13 changes: 10 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,29 @@ npm-build: require-version ## Assemble npm/dist/ from the artifacts (VERSION=vX.
npm-dry-run: ## Dry-run publish every assembled package
@set -euo pipefail; \
if [ ! -f npm/dist/launchers/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make npm-build VERSION=vX.Y.Z first" >&2; exit 1; fi; \
for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/; do \
for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/ npm/dist/launchers-optional/*/; do \
[ -f "$$d/package.json" ] || continue; \
echo "== $$d"; npm publish "$$d" --access public --dry-run; done
@echo "note: --dry-run does not exercise npm's name-similarity check; only a real publish does"

.PHONY: npm-publish
npm-publish: ## Publish the assembled packages, skips already-published; OTP=123456 if 2FA
@set -euo pipefail; \
if [ ! -f npm/dist/launchers/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make dist VERSION=vX.Y.Z && make npm-build VERSION=vX.Y.Z" >&2; exit 1; fi; \
otp=; if [ -n "$(OTP)" ]; then otp="--otp=$(OTP)"; fi; \
for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/; do \
for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/ npm/dist/launchers-optional/*/; do \
[ -f "$$d/package.json" ] || continue; \
name=$$(cd "$$d" && node -p "require('./package.json').name"); \
ver=$$(cd "$$d" && node -p "require('./package.json').version"); \
if npm view "$$name@$$ver" version >/dev/null 2>&1; then \
echo "skip $$name@$$ver (already published)"; continue; fi; \
echo ">> publishing $$name@$$ver"; npm publish "$$d" --access public $$otp; \
echo ">> publishing $$name@$$ver"; \
case "$$d" in \
npm/dist/launchers-optional/*) \
npm publish "$$d" --access public $$otp \
|| echo "!! optional launcher $$name was not published; the release stands on the required ones" ;; \
*) npm publish "$$d" --access public $$otp ;; \
esac; \
done

.PHONY: release
Expand Down
42 changes: 20 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@ AI agents.
No install — run it straight from npm inside the repo you want to govern:

```bash
npx scc-cli init # asks which harness, then scaffolds the rules, agents, and layout
npx scc-cli init --codex # or name it: --claude (default), --codex, --opencode
npx scc-cli spec new user-auth # specs/user-auth/: requirements.md, design.md, tasks.md
npx scc-cli plan new checkout-revamp # plans/checkout-revamp.md
npx scc-cli validate # every check; exit 2 means it found something
npx scc-cli update # show what a newer scc would change, then confirm
npx @protonspy/scc init # asks which harness, then scaffolds the rules, agents, and layout
npx @protonspy/scc init --codex # or name it: --claude (default), --codex, --opencode
npx @protonspy/scc spec new user-auth # specs/user-auth/: requirements.md, design.md, tasks.md
npx @protonspy/scc plan new checkout-revamp # plans/checkout-revamp.md
npx @protonspy/scc validate # every check; exit 2 means it found something
npx @protonspy/scc update # show what a newer scc would change, then confirm
```

Installed globally (`npm i -g scc-cli`) the same commands are just `scc init`,
`scc spec new user-auth`, and so on — the package is `scc-cli`, the command is `scc`.
Installed globally (`npm i -g @protonspy/scc`) the same commands are just `scc init`,
`scc spec new user-auth`, and so on.

| Command | What it does |
|---|---|
Expand All @@ -43,9 +43,9 @@ installs it with cargo when it is not on PATH, and puts its usage block into
`CLAUDE.md`/`AGENTS.md` so the agent knows to prefix commands with it:

```bash
npx scc-cli init --rtk # scaffold, then wire RTK in
npx scc-cli rtk # wire it into a workspace that already exists
npx scc-cli rtk --check # CI: exit 2 when the block is missing
npx @protonspy/scc init --rtk # scaffold, then wire RTK in
npx @protonspy/scc rtk # wire it into a workspace that already exists
npx @protonspy/scc rtk --check # CI: exit 2 when the block is missing
```

The block sits between RTK's own `<!-- rtk-instructions -->` markers, and scc inserts
Expand Down Expand Up @@ -89,21 +89,19 @@ accountability, and a checker that was confidently incomplete would be worse tha

## Install

Published on npm as [`scc-cli`](https://www.npmjs.com/package/scc-cli) — the launcher
pulls the right prebuilt binary for your platform as an optional dependency, so there
is no toolchain to set up.
Published on npm as [`@protonspy/scc`](https://www.npmjs.com/package/@protonspy/scc) —
the launcher pulls the right prebuilt binary for your platform as an optional
dependency, so there is no toolchain to set up.

```bash
npx scc-cli help # no install; pins nothing, always the latest
npx scc-cli@0.0.1 help # pin a version (CI)
npm i -g scc-cli # then: scc help
npx @protonspy/scc help # no install; pins nothing, always the latest
npx @protonspy/scc@0.0.1 help # pin a version (CI)
npm i -g @protonspy/scc # then: scc help
```

The package is `scc-cli`; the command it installs is `scc`. Without `-g` it lands in
`node_modules/.bin`, which npm scripts see and your shell does not — reach it there as
`npx scc`. The same package is also published as `@protonspy/scc` for installs that
predate the shorter name; use one or the other, not both, since they claim the same
command.
The package is `@protonspy/scc`; the command it installs is `scc`. Without `-g` it
lands in `node_modules/.bin`, which npm scripts see and your shell does not — reach it
there as `npx scc`.

Or from source (Go 1.25+):

Expand Down
2 changes: 1 addition & 1 deletion internal/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import (
// 8: the entry file names *when* to read each rule instead of tabulating all nine
// as equals — five read on their own trigger, four looked up by name — and the two
// review agents are tightened in the same pass.
// 9: the npx fallback names the unscoped `scc-cli` package; the entry file gives
// 9: the entry file gives
// each rule its own trigger line instead of running four of them together in a
// sentence — project.md above all, since a build command nobody read is guessed —
// and it stops telling a harness that preloads the rules to go and read them.
Expand Down
2 changes: 1 addition & 1 deletion internal/assets/templates/entry.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ docs/ knowledge base — wiki, adr, codewiki, glossary, stack

## Checking your work

`scc validate` — or `npx scc-cli validate` if not installed (`@<version>` pins for CI).
`scc validate` — or `npx @protonspy/scc validate` if not installed (`@<version>` pins for CI).
`scc update` brings a newer scc's rules and agents in: it shows the plan, then asks.

Exit `0` ok · `1` could not run · `2` ran and found something. A finding is an answer, not a crash.
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,14 @@ func TestUnknownFlagIsAUsageError(t *testing.T) {
}

// SCC_PROG lets the npm launcher make help text echo the spelling the user typed
// (`npx scc-cli`) instead of a bare binary name they may not have.
// (`npx @protonspy/scc`) instead of a bare binary name they may not have.
func TestProgHonorsEnvOverride(t *testing.T) {
t.Setenv("SCC_PROG", "npx scc-cli")
if got := prog(); got != "npx scc-cli" {
t.Setenv("SCC_PROG", "npx @protonspy/scc")
if got := prog(); got != "npx @protonspy/scc" {
t.Errorf("prog() = %q, want the override", got)
}
_, stderr, _ := run(t, "help")
if !strings.Contains(stderr, "npx scc-cli") {
if !strings.Contains(stderr, "npx @protonspy/scc") {
t.Errorf("usage did not use SCC_PROG: %q", stderr)
}
}
Expand Down
61 changes: 39 additions & 22 deletions npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ How `scc` reaches npm. Nothing here is built from source at install time.
| `scc/` | The launcher source: a Node shim, no binary. Its `package.json` is a template — `name`, `version`, and `optionalDependencies` are all generated. |
| `scripts/build-packages.mjs` | Assembles `npm/dist/` from the release artifacts. |
| `dist/scc-<platform>/` | Generated. One per target, carrying the native binary. |
| `dist/launchers/<name>/` | Generated. The same shim, once per published launcher name. |
| `dist/launchers/<name>/` | Generated. The same shim, under a name that is already ours. |
| `dist/launchers-optional/<name>/` | Generated. The same shim, under a name being tried — allowed to fail. |

## How the install works

Expand All @@ -21,27 +22,43 @@ matching the host, and `bin/scc.js` resolves that package's binary and execs it.
No postinstall script and no network access at install time — the binary is
already there or the install failed.

## Two launcher names

The launcher is published twice, from one source, listed in `LAUNCHERS` in
`build-packages.mjs`:

| Name | Role |
|---|---|
| `scc-cli` | The documented install. Unscoped, so `npm i -g scc-cli` is the whole line. |
| `@protonspy/scc` | Kept published so earlier installs keep receiving versions. |

Both put the same `scc` command on PATH: npm resolves the *package* name and
puts the *bin* name on PATH, and those never had to match. The bare `scc` on npm
has belonged to an unrelated project since 2013, which is why neither name is it.

Installing both globally is the one thing to avoid — they compete for the same
command name, and npm resolves that by letting the last one win.

The split is also why launchers live under `dist/launchers/` instead of beside
the platform packages: publishing walks `dist/scc-*/` first and `dist/launchers/*/`
second, and a launcher that reached the registry ahead of the binaries it depends
on would be a broken install for anyone who hit that window.
## Launcher names, required and optional

The launcher is published from one source under every name in `LAUNCHERS` in
`build-packages.mjs`. Each name is `required` or not, and that flag is the whole
difference:

| Tier | Meaning | On failure |
|---|---|---|
| `required: true` | The name is already ours. `@protonspy/scc` is the documented install. | The release fails. |
| `required: false` | A name being tried for the first time. | Warn and carry on. |

All of them put the same `scc` command on PATH: npm resolves the *package* name
and installs the *bin* name, and those never had to match. The bare `scc` on npm
has belonged to an unrelated project since 2013, which is why no name here is it.

**Why the optional tier exists.** npm applies a typosquatting similarity check
that runs *only on a real publish*. `npm view <name>` returning 404 says the name
is unregistered, not that it is publishable, and `npm publish --dry-run` never
reaches the check either. v0.9.0 died on `403 — Package name too similar to
existing package cp-cli` for a name both of those had called free; because that
name published first, `set -e` took the working launcher down with it and left
six orphaned platform packages on the registry.

So an unproven name is attempted last and allowed to fail, and **only a required
name may appear in documentation** — including the `entry.md` embedded in six
binaries. Pointing an install line at a package that might be refused is the same
bug somewhere more expensive. Promote a name to `required`, and into the docs, in
the release *after* the one that proved it publishes.

Installing two launchers globally is the one thing to avoid: they compete for the
same command name, and npm resolves that by letting the last one win.

The tiers are also directories, so publish order and failure policy are both
visible in the layout: `dist/scc-*/`, then `dist/launchers/`, then
`dist/launchers-optional/`. A launcher that reached the registry ahead of the
binaries in its `optionalDependencies` would be a broken install for anyone who
hit that window.

## Releasing

Expand Down
10 changes: 2 additions & 8 deletions npm/scc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,14 @@ workflow into a mechanically validated contract for humans and AI agents. Works
with Claude Code, Codex, and opencode.

```bash
npm i -g scc-cli # then, anywhere: scc help
npx scc-cli help # no install
npm i -g @protonspy/scc # then, anywhere: scc help
npx @protonspy/scc help # no install
```

The command is `scc` either way. Installing without `-g` puts it in
`node_modules/.bin`, which is on PATH for npm scripts but not for your shell —
there, reach it as `npx scc`.

> Published under two names: **`scc-cli`**, which is the one to use, and
> `@protonspy/scc`, kept so earlier installs keep receiving versions. Same
> package, same `scc` command — install one, not both, since they compete for
> the same command name. The bare `scc` on npm belongs to an unrelated project
> from 2013.

This package is a thin launcher. The native binary ships in a per-platform
optional dependency (`@protonspy/scc-linux-x64`, `…-darwin-arm64`, …); npm
installs only the one matching your machine. There is no postinstall step and no
Expand Down
18 changes: 9 additions & 9 deletions npm/scc/bin/scc.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ try {
process.exit(1);
}

// When invoked via `npx scc-cli` (which is `npm exec` under the hood), echo that
// exact spelling in the binary's help/usage output. A global install runs this
// same launcher as the bare `scc` command — there npm is not in the picture
// (npm_command is unset), so the binary keeps its default name. An explicit
// SCC_PROG always wins.
// When invoked via `npx @protonspy/scc` (which is `npm exec` under the hood),
// echo that exact spelling in the binary's help/usage output. A global install
// runs this same launcher as the bare `scc` command — there npm is not in the
// picture (npm_command is unset), so the binary keeps its default name. An
// explicit SCC_PROG always wins.
//
// The name is read from this package's own package.json rather than written in,
// because one shim is published under two names (`scc-cli` and `@protonspy/scc`)
// and a hardcoded spelling would be wrong for whichever one the user did not
// type — telling them to re-run a command under a package they never installed.
// because one shim is published under more than one name and a hardcoded
// spelling would be wrong for whoever installed under the other — telling them
// to re-run a command under a package they never installed.
const env = { ...process.env };
if (!env.SCC_PROG) {
const argv1 = process.argv[1] || "";
Expand All @@ -57,7 +57,7 @@ if (!env.SCC_PROG) {
argv1.includes("/_npx/") ||
argv1.includes("\\_npx\\");
if (viaNpx) {
let self = "scc-cli";
let self = "@protonspy/scc";
try {
self = require("../package.json").name || self;
} catch {
Expand Down
Loading
Loading