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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,18 @@ npx @protonspy/scc rtk --check # CI: exit 2 when the block is missing

The install is the part that is still a decision — cargo, a Rust toolchain, minutes
of build — so a bare `init` asks before running it and takes silence for no.
Nothing is written when the binary is absent: guidance naming a command the machine
cannot run is worse than no guidance. `scc launch` does the same thing at the top of
a session, for the workspaces wired before this or scaffolded with `--no-rtk`.
The block is written either way: it travels with the repository, not with this
machine, and RTK passes a command it has no filter for through unchanged. `scc
update` adds it to an entry file that lacks one, and keeps one already there;
`--no-rtk` skips it on both. `scc launch` does the same thing at the top of a
session.

The block sits between RTK's own `<!-- rtk-instructions -->` markers, which is what
makes `rtk init` and `scc rtk` converge on one copy instead of two. `scc rtk`
replaces what is there with the block this scc ships — same guidance, roughly a
fifth of the bytes, in a file preloaded into every request — and says so when the
one it replaced claimed a newer version; `--keep` leaves it alone. `init` and
`launch` always keep, because replacing somebody's block is a trade-off to make
one it replaced claimed a newer version; `--keep` leaves it alone. `init`,
`update` and `launch` always keep, because replacing somebody's block is a trade-off to make
deliberately rather than as a side effect. Everything outside the markers is
untouched either way, and `--no-install` writes the block without touching cargo.

Expand Down
48 changes: 24 additions & 24 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ func mustCwd() string {
}

// initRTK puts RTK's usage block in the entry file the agent is about to load,
// and makes sure the binary that block names is actually there.
// and offers to build the binary that block names when it is missing.
//
// Setup is where this belongs. The block is what makes RTK work at all — an agent
// that never read it never types the prefix — so a workspace scaffolded without it
Expand All @@ -214,46 +214,46 @@ func mustCwd() string {
// and it means a workspace is wired whether the first session starts through
// `scc launch` or by typing `claude`.
//
// What stays a decision is the install, and the two halves are settled in that
// order. cargo is a Rust toolchain and minutes of build, so a bare `init` asks
// before running it and takes silence for no; `--rtk` is that consent given in
// advance, and `--no-rtk` declines the whole step. Nothing is written when the
// binary is absent, because guidance naming a command the machine cannot run is
// worse than no guidance: an agent that tries the prefix, watches it fail, and
// then reads the same file's rules learns to discount all of them.
// The block is written whether or not the binary is there. It travels with the
// repository rather than with this machine, so the next clone, the CI image, or
// this machine after a `cargo install` reads a file already wired — and RTK passes
// a command through unchanged when it has no filter for it. `--no-rtk` is the one
// way to scaffold without it.
//
// It degrades rather than fails. A missing cargo, a declined prompt, or a failed
// build all end in a scaffolded workspace and one line saying what is not wired —
// except under --rtk, where the user asked for this by name and a failure is an
// answer they need in the exit code.
// What stays a decision is the install. cargo is a Rust toolchain and minutes of
// build, so a bare `init` asks before running it and takes silence for no; `--rtk`
// is that consent given in advance. A missing cargo, a declined prompt, or a failed
// build all end in a scaffolded workspace carrying the block and one line saying
// the binary is not there — except under --rtk, where the user asked for the build
// by name and a failure is an answer they need in the exit code.
func initRTK(root string, yes, disabled, quiet bool) (*rtkReport, int) {
if disabled {
return nil, ExitOK
}
failed := false
noInstall := false
if _, ok := rtk.Path(); !ok {
if reason := rtkInstallOK(rtkAsk{yes: yes, quiet: quiet}); reason != "" {
// --rtk named this step, so not getting it is an error and is said as
// --rtk named the build, so not getting it is an error and is said as
// one — on stderr, which is also the only stream left when the caller is
// emitting JSON on stdout. Without the flag it is a status line: the
// workspace is scaffolded and complete, and RTK is the part that is not
// wired yet.
// emitting JSON on stdout. The block is still written below: the build
// is what failed, not the workspace.
if yes {
render.Err("--rtk: " + reason)
render.Detail(" " + rtk.InstallCmd())
return nil, ExitError
failed = true
}
if !quiet {
render.Info("no RTK block: " + reason)
render.Detail(fmt.Sprintf(" wire it in later with: %s rtk", prog()))
}
return nil, ExitOK
noInstall = true
}
}
// keep, because a re-run of `init` is not the moment to replace a block
// somebody put there themselves — `scc rtk` is where that trade-off is made
// deliberately, and it is the same call `scc launch` makes for the same reason.
report, code := applyRTK(root, rtkOptions{keep: true, quiet: quiet})
if !yes {
report, code := applyRTK(root, rtkOptions{keep: true, noInstall: noInstall, quiet: quiet})
switch {
case failed:
return report, ExitError
case !yes:
return report, ExitOK
}
return report, code
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func TestInitSeedsTheKnowledgeBaseWithoutManagingIt(t *testing.T) {
// rewrite documents the user authored.
func TestUpdateIgnoresTheSeededKnowledgeBase(t *testing.T) {
root := initWorkspace(t)
stdout, stderr, code := run(t, "update", "--root", root, "--json")
stdout, stderr, code := run(t, "update", "--root", root, "--json", "--no-rtk")
if code != ExitOK {
t.Fatalf("update: %d (%s)", code, stderr)
}
Expand Down
33 changes: 19 additions & 14 deletions internal/cli/rtk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,24 +348,24 @@ func TestInitSplicesTheBlockWhenRTKIsAlreadyThere(t *testing.T) {
}
}

// Nothing is written when the binary is absent and nobody can be asked about
// building it. Guidance naming a command the machine cannot run is worse than no
// guidance: the agent tries the prefix, watches it fail, and discounts the rest of
// the file with it.
func TestInitWritesNoBlockWithoutTheBinary(t *testing.T) {
// The block is written when the binary is absent and nobody can be asked about
// building it. It travels with the repository, not with this machine: the next
// clone, the CI image, or this machine after a `cargo install` reads an entry file
// already wired.
func TestInitWritesTheBlockWithoutTheBinary(t *testing.T) {
isolatedPath(t)
root := t.TempDir()
stdout, stderr, code := run(t, "init", "--claude", "--root", root)
if code != ExitOK {
t.Fatalf("exit = %d, want a scaffolded workspace anyway (stderr: %s)", code, stderr)
}
if got := readEntry(t, root, paths.Claude.EntryFile); strings.Contains(got, "rtk-instructions") {
t.Error("init wrote a block naming a binary that is not there")
if got := readEntry(t, root, paths.Claude.EntryFile); !strings.Contains(got, "<!-- rtk-instructions") {
t.Errorf("init left no block in %s without rtk on PATH", paths.Claude.EntryFile)
}
// Said once, and it names the way to wire it in later: a step that was skipped
// in silence is one nobody knows to take.
if !strings.Contains(stdout+stderr, "rtk") {
t.Errorf("init never said why RTK was skipped: %q", stdout+stderr)
// Said once, and it names how to get the binary: a block whose command is not
// on this machine is one nobody knows to install.
if !strings.Contains(stdout+stderr, rtk.InstallCmd()) {
t.Errorf("init never said rtk is missing: %q", stdout+stderr)
}
}

Expand Down Expand Up @@ -446,16 +446,21 @@ func TestInitJSONCarriesTheRTKReport(t *testing.T) {
}
}

// --rtk named the step, so a machine that cannot take it says so in the exit code
// --rtk named the build, so a machine that cannot take it says so in the exit code
// rather than scaffolding and moving on. Without the flag the same situation is a
// status line: the workspace is finished, and RTK is the part that is not wired.
// status line: the workspace is finished, and the binary is the part that is missing.
func TestInitWithRTKFailsWhenItCannotBeBuilt(t *testing.T) {
isolatedPath(t)
_, stderr, code := run(t, "init", "--claude", "--rtk", "--root", t.TempDir())
root := t.TempDir()
_, stderr, code := run(t, "init", "--claude", "--rtk", "--root", root)
if code != ExitError {
t.Fatalf("exit = %d, want %d with neither rtk nor cargo on PATH", code, ExitError)
}
if !strings.Contains(stderr, "cargo") {
t.Errorf("stderr does not say what is missing: %q", stderr)
}
// The build failed, not the workspace: the block is there all the same.
if got := readEntry(t, root, paths.Claude.EntryFile); !strings.Contains(got, "<!-- rtk-instructions") {
t.Errorf("init --rtk left no block in %s when the build failed", paths.Claude.EntryFile)
}
}
110 changes: 100 additions & 10 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import (

"github.com/protonspy/spec-claude-code/internal/assets"
"github.com/protonspy/spec-claude-code/internal/hooks"
"github.com/protonspy/spec-claude-code/internal/mdblock"
"github.com/protonspy/spec-claude-code/internal/paths"
"github.com/protonspy/spec-claude-code/internal/render"
"github.com/protonspy/spec-claude-code/internal/rtk"
"github.com/protonspy/spec-claude-code/internal/scaffold"
"github.com/protonspy/spec-claude-code/internal/workspace"
)
Expand All @@ -36,6 +38,7 @@ func runUpdate(args []string) int {
yes := fs.Bool("yes", false, "apply without asking (required when stdin is not a terminal)")
force := fs.Bool("force", false, "also overwrite files you have edited, naming each one")
dryRun := fs.Bool("dry-run", false, "report the plan and write nothing")
noRTK := fs.Bool("no-rtk", false, "leave the entry files without RTK's usage block")
picks := map[string]*bool{}
for _, h := range paths.Harnesses() {
picks[h.ID] = fs.Bool(h.ID, false, "update only the "+h.ID+" tree")
Expand Down Expand Up @@ -69,19 +72,28 @@ func runUpdate(args []string) int {
}
plans = append(plans, plan)
}
var rtkMissing []string
if !*noRTK {
if rtkMissing, err = missingRTKBlocks(target, harnesses); err != nil {
render.Err(fmt.Sprintf("update failed: %v", err))
return ExitError
}
}

if *jsonOut && *dryRun {
return emitJSON(struct {
Plans []*scaffold.UpdatePlan `json:"plans"`
}{plans})
RTK []string `json:"rtk,omitempty"`
}{plans, rtkMissing})
}
if !*jsonOut {
reportPlans(harnesses, plans, *force)
reportRTK(rtkMissing)
nudgeHooks(target)
}

pending := 0
writes := false
pending := len(rtkMissing)
writes := len(rtkMissing) > 0
for _, p := range plans {
pending += len(p.Pending())
writes = writes || p.Writes(*force)
Expand All @@ -91,21 +103,21 @@ func runUpdate(args []string) int {
// one output shape, whatever it decided to do.
if pending == 0 {
if *jsonOut {
return emitResults(nil)
return emitResults(nil, nil)
}
render.OK(fmt.Sprintf("already on template version %s — nothing to do", assets.Version))
return ExitOK
}
if *dryRun {
if *jsonOut {
return emitResults(nil)
return emitResults(nil, nil)
}
render.Info("dry run: nothing was written")
return ExitOK
}
if !writes {
if *jsonOut {
return emitResults(nil)
return emitResults(nil, nil)
}
render.Info("nothing to apply — the differences are all in files scc will not touch")
return ExitOK
Expand Down Expand Up @@ -141,9 +153,14 @@ func runUpdate(args []string) int {
}
results = append(results, res)
}
rtkFiles, err := addRTKBlocks(target, rtkMissing, *jsonOut)
if err != nil {
render.Err(fmt.Sprintf("update failed: %v", err))
return ExitError
}

if *jsonOut {
return emitResults(results)
return emitResults(results, rtkFiles)
}
applied := 0
for _, res := range results {
Expand All @@ -153,6 +170,9 @@ func runUpdate(args []string) int {
}
}
render.OK(fmt.Sprintf("%d file(s) updated to template version %s", applied, assets.Version))
for _, file := range rtkFiles {
render.OK(fmt.Sprintf("%s — RTK block %s", file.Path, file.Action))
}
for _, res := range results {
for _, it := range res.Kept {
if it.Action == scaffold.UpConflict {
Expand All @@ -167,14 +187,84 @@ func runUpdate(args []string) int {
}

// emitResults writes the one document `update --json` ever emits. Never null: a
// caller iterating `.results` must not have to special-case the no-op run.
func emitResults(results []*scaffold.UpdateResult) int {
// caller iterating `.results` must not have to special-case the no-op run. "rtk"
// appears only on the runs that added the block, so its absence is the answer to
// "was an entry file edited".
func emitResults(results []*scaffold.UpdateResult, rtkFiles []blockFile) int {
if results == nil {
results = []*scaffold.UpdateResult{}
}
return emitJSON(struct {
Results []*scaffold.UpdateResult `json:"results"`
}{results})
RTK []blockFile `json:"rtk,omitempty"`
}{results, rtkFiles})
}

// missingRTKBlocks lists the entry files of the targeted trees that exist and carry
// no RTK block — the half of an update that is not a template file.
//
// Only a missing block is planned. One already there is kept whatever version it
// claims: it may be `rtk init`'s own, and replacing it is a trade-off `scc rtk`
// makes deliberately, not one an update makes as a side effect. An entry file that
// is not on disk is left to `init`, which owns bringing it into existence.
func missingRTKBlocks(root string, harnesses []paths.Harness) ([]string, error) {
block, err := assets.RTKBlock()
if err != nil {
return nil, err
}
var out []string
seen := map[string]bool{}
for _, h := range harnesses {
// Codex and opencode share AGENTS.md: planning it twice would add it twice.
if seen[h.EntryFile] {
continue
}
seen[h.EntryFile] = true
file, err := spliceEntryBlock(root, h.EntryFile, rtk.Markers, block, true, true)
if err != nil {
return nil, err
}
if file.Action == string(mdblock.Added) {
out = append(out, h.EntryFile)
}
}
return out, nil
}

// reportRTK adds the entry files about to gain the block to the summary the user
// is agreeing to: an edit to a file outside the managed trees is still an edit.
func reportRTK(entries []string) {
if len(entries) == 0 {
return
}
render.Info(fmt.Sprintf("RTK usage block — add (%d)", len(entries)))
for _, entry := range entries {
fmt.Println(" " + entry)
}
fmt.Println()
}

// addRTKBlocks writes the block into each entry file missingRTKBlocks planned.
// keep, so a block that appeared between the plan and the write is left alone.
func addRTKBlocks(root string, entries []string, quiet bool) ([]blockFile, error) {
if len(entries) == 0 {
return nil, nil
}
block, err := assets.RTKBlock()
if err != nil {
return nil, err
}
var out []blockFile
for _, entry := range entries {
file, err := spliceEntry(root, entry, block, rtkOptions{keep: true, quiet: quiet})
if err != nil {
return nil, fmt.Errorf("%s: %w", entry, err)
}
if file.Action == string(mdblock.Added) {
out = append(out, file)
}
}
return out, nil
}

// updateTargets resolves which trees to update: the ones named by flag, or every
Expand Down
Loading
Loading