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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,37 @@ jobs:
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with:
version: v2.12.2

web:
name: view frontend
# internal/view/dist/ is the built frontend `scc view` embeds, committed so a
# `go build` needs no node. This job rebuilds it from web/ and fails when the
# result differs from what is committed — the only thing that keeps the two
# from drifting apart.
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '22'

- name: install
run: npm ci

- name: test
run: npm test

- name: build (typecheck included)
run: npm run build

- name: dist is current
run: |
if [ -n "$(git status --porcelain -- ../internal/view/dist)" ]; then
echo "::error::internal/view/dist/ is stale — run \`npm run build\` in web/ and commit the result"
git status --porcelain -- ../internal/view/dist
exit 1
fi
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ npm/dist/
# CodeGraph's symbol index. Not an scc artifact: it is CodeGraph's schema on
# CodeGraph's schedule, rebuilt by `scc launch`, and it is machine-specific.
/.codegraph/

# The view frontend's packages. Its build output, internal/view/dist/, is committed.
/web/node_modules/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Installed globally (`npm i -g @protonspy/scc`) the same commands are just `scc i
| `validate` | Every applicable validator, one exit code, one JSON document. `--pr` also reads the pull request open on this branch; `--checks` also runs this project's build, format, lint and test commands. |
| `check\|check set\|check skip` | The delivery gate: this project's own build, format, lint and test commands, run in that order and judged. A gate the project does not have is skipped once and then stays quiet. |
| `hooks install\|check\|remove` | Hooks that run the validators without anybody remembering to — git's (`scc validate` before a commit, the message checked as it is written, `scc validate --pr --checks` before a push) and the harness's own (findings, drift and undelivered work handed back to the agent, and a prompt that names an artifact routed through the map). `init` writes both; anything scc did not write is left alone. |
| `view` | Serves the docs, plans and specs as a read-only web page on `127.0.0.1:7777` (`--port 0` for a free one): wiki, ADRs, codewiki next to the source lines it cites, glossary, stack, notes, and every plan and spec with its progress. Runs until Ctrl+C. |
| `rtk` | Wires in [RTK](https://github.com/rtk-ai/rtk) after the fact: installs it if missing, then splices its usage block into the entry file. |
| `graph build\|sync\|query\|explore\|scope` | The workspace's symbol graph, via [CodeGraph](https://github.com/colbymchenry/codegraph). `scope` narrows it to certain trees — one graph per directory, since CodeGraph indexes a single root at a time. |
| `launch` | Starts the harness with the workspace's symbol graph and RTK block current, **inside a sandbox by default** — ai-jail on Linux/macOS, a dev container on Windows. |
Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0002-narrowed-from-csdd.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ When something from `csdd` is wanted here, the *decision* is ported and not the
the three platforms. The reversal does not weaken the other three — it is the
editor-free `devcontainer up` / `devcontainer exec` path, not the VS Code flow, so
the surface is still arguments in and a process out.
- **A second item was reversed by its own record.** The embedded web dashboard is
now `scc view`, under `adr:0003-local-read-only-viewer`: read-only,
loopback-only, and reading through the same parsers as the CLI. The TUI and the
MCP server stay decided against.
66 changes: 66 additions & 0 deletions docs/adr/0003-local-read-only-viewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
status: accepted
---

# A local, read-only viewer, built with React

## Context

`adr:0002-narrowed-from-csdd` decided against an embedded web dashboard, among
other surfaces built for a person rather than for an agent, and said that decision
is reopened by a new record rather than by a pull request. This is that record.

The addressing commands — `scc map`, `scc notes find`, `scc graph` — exist so an
agent never reads a whole file. A person reading the record end to end is the one
reader they were never built for: the wiki, the ADRs, the codewiki next to the
lines it cites, every plan and spec with its progress. The user asked for that as
`scc view`, a page served by the binary.

Two questions follow. Whether a web surface may exist at all, given 0002; and how a
React frontend ships inside a binary whose module is stdlib-only
(`adr:0001-stdlib-only-dependencies`).

## Decision

**`scc view` exists, and is held to three limits** that keep it inside what 0002
was protecting:

- **Read-only.** GET and HEAD only; nothing on disk changes. Every write stays a
command with an exit code, so the agent-facing surface is still the only one that
does anything.
- **Loopback-only.** The listener binds `127.0.0.1`, and a `Host` header that does
not name loopback is refused, which closes DNS rebinding. There is no
authentication because there is no remote access to authenticate.
- **The same parsers.** Every route reads through `internal/artifact`, so the page
shows what `scc map` would say and is not a second reading of the files.

**The frontend is React, built by Vite from `web/` into `internal/view/dist/`,
which is committed and embedded with `go:embed`.** The Go module stays
stdlib-only: `web/` is build machinery outside the module, as `npm/` already is,
and a `go build` needs no node. CI rebuilds `dist/` and fails on a diff.

Rejected: React vendored without a build step (no JSX, no TypeScript, hand-managed
updates), and React loaded from a CDN (needs network, and puts a third-party script
in a page that can read the workspace). Rendering Markdown in Go was rejected
because the stdlib has no renderer and `mdscan` is a scanner.

## Consequences

- The binary grows by the bundle — about 400 KB, 120 KB gzipped.
- A change to `web/` needs node ≥ 22.12 and a committed rebuild; a change to Go
alone does not. The CI job is what makes a forgotten rebuild loud.
- The frontend's packages are npm dependencies with their own supply chain. They
run in the developer's build and in the browser, never in the Go binary's
process, and `package-lock.json` pins them; `docs/stack.md` lists each.
- Markdown from the repository is untrusted input to the page: raw HTML is shown as
text, and a Content-Security-Policy keeps scripts and connections on the
viewer's own origin, so a hostile page in a cloned repository cannot read
through the source route and send it anywhere.
- The source route can read any text file in the workspace up to 1 MiB whose real
path has no dot-named segment, not only the files a codewiki page cites. The dot
rule is applied after symlinks are followed and Windows short names expanded, so
neither a committed link to `.env` nor `GIT~1` reaches past it.
- There is no authentication: any account on the machine can reach the loopback
port. Accepted, because the readable set is the checkout and the record, not
credentials. If a shared host ever makes that unacceptable, the fix is a per-run
token in the URL, not a wider bind.
21 changes: 20 additions & 1 deletion docs/codewiki/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ cmd/scc/main.go os.Exit(cli.Run(os.Args[1:]))
|
internal/cli the whole command surface
|
scaffold · validate · hooks write / check / gate
scaffold · validate · hooks · view write / check / gate / serve
| \
assets · manifest ears · mdscan · artifact · notes templates, hashes, grammars
|
Expand Down Expand Up @@ -156,6 +156,25 @@ workspace safe by construction. `Changed` is the only one that reads content, an
compares against the *working* tree rather than `HEAD`, because at the end of a turn
the agent has usually written code and not yet committed it.

## The viewer is one more reader, not a second parser

[internal/view/page.go:41-69]() · [internal/view/page.go:98-115]()

`scc view` serves the record to a person, which `adr:0002-narrowed-from-csdd`
ruled out until `adr:0003-local-read-only-viewer` reopened it on three conditions.
The one visible here: every route reads through `artifact.Load` and
`artifact.Within`, so the page can never disagree with `scc map` about what a file
says or where the workspace ends. `resolve` is the single containment check both
the page and the source route pass through.

[internal/view/view.go:68-100]()

The other two conditions are the middleware: GET and HEAD only, and a `Host`
header that names loopback by name. A domain that merely resolves to `127.0.0.1`
is refused on purpose — that is what a DNS-rebinding page does. The React frontend
is built from `web/` into `internal/view/dist/` and embedded, so none of it is in
the Go module.

## Hooks are events, not scripts

[internal/hooks/harness.go:42-60]() · [internal/hooks/harness.go:127-140]()
Expand Down
21 changes: 21 additions & 0 deletions docs/stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ nobody recorded a reason, this file says so rather than inventing one.
assembles the publish tree. Cost: a node dependency for people who only wanted a
Go binary — which is why direct download from the GitHub Release stays supported.

## The view frontend — `web/`

Build machinery outside the Go module, as `npm/` is: its output,
`internal/view/dist/`, is committed and embedded, so `go build` needs no node. Why a
React page exists at all is `adr:0003-local-read-only-viewer`. Every version is
pinned exactly in `web/package.json` and locked in `web/package-lock.json`.

- **React 19 and react-dom** — the page `scc view` serves. Chosen by the user over
a build-free alternative; the component model is what keeps three views and a
sidebar small.
- **react-markdown 10 and remark-gfm 4** — render the record's Markdown, tables and
task lists included. Chosen over an HTML-emitting renderer because it escapes
raw HTML by default, and repository Markdown is untrusted input to the page.
- **Vite 8 and @vitejs/plugin-react** — the bundler that writes `dist/`; `npm run
dev` proxies `/api` to a running `scc view` for hot reload.
- **TypeScript 7** — typechecks `web/src` as the first half of `npm run build`.
- **Vitest 5** — runs `web/src/links.test.ts`, the one module with logic worth a
test: where every link in a page goes.
- **Node.js ≥ 22.12** — what Vite 8 and Vitest 5 require; needed only to change
`web/`. CI's `web` job uses it to rebuild `dist/` and fail on a diff.

## Integrated binaries — driven, never vendored

Each is a third-party CLI scc composes a command line for and starts. None is a Go
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ func Run(args []string) int {
return runCheck(args[1:])
case "hooks":
return runHooks(args[1:])
case "view":
return runView(args[1:])
default:
render.Err(fmt.Sprintf("unknown command %q", args[0]))
fmt.Fprintf(os.Stderr, "run `%s help` for the available commands\n", prog())
Expand Down Expand Up @@ -132,6 +134,7 @@ Commands:
validate Run every applicable validator; exit 2 on findings
check The delivery gate — build, format, lint, test: run | set | skip | show
hooks Git hooks that run the validators — install | check | remove
view Serve docs, plans and specs as a read-only web page on 127.0.0.1
version Print the version
help Show this help

Expand All @@ -146,5 +149,6 @@ Exit codes:
2 validation findings

"%s launch" is the one exception: it returns whatever the agent it started returned.
`, render.Bold(prog()), prog(), prog(), prog())
"%s view" runs until interrupted, and an interrupt is a 0.
`, render.Bold(prog()), prog(), prog(), prog(), prog())
}
95 changes: 95 additions & 0 deletions internal/cli/view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package cli

import (
"context"
"flag"
"fmt"
"net"
"os"
"os/signal"
"strconv"

"github.com/protonspy/spec-claude-code/internal/render"
"github.com/protonspy/spec-claude-code/internal/view"
)

// defaultViewPort is fixed rather than 0 so the URL survives a restart and a tab
// left open reconnects; --port 0 asks the system for a free one instead.
const defaultViewPort = 7777

// viewContext is what stops the server: an interrupt, in the real binary. A test
// swaps it for a context it cancels itself, since it cannot signal its own process.
var viewContext = func() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), os.Interrupt)
}

// runView serves the workspace read-only on loopback until interrupted. It is the
// one command that does not return on its own, and an interrupt is its normal end,
// so that exits 0.
func runView(args []string) int {
fs := flag.NewFlagSet("view", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.Usage = viewUsage
root := addRoot(fs)
port := fs.Int("port", defaultViewPort, "port to listen on, on 127.0.0.1 only; 0 picks a free one")
jsonOut := addJSON(fs)
rest, err := parseFlags(fs, helpWord(args))
if err != nil {
return exitFor(err)
}
if !noPositionals(rest, "view") {
return ExitError
}
if *port < 0 || *port > 65535 {
render.Err(fmt.Sprintf("--port %d is not a port", *port))
return ExitError
}
target, ok := resolveRoot(*root)
if !ok || !requireWorkspace(target) {
return ExitError
}

addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(*port))
ln, err := net.Listen("tcp", addr)
if err != nil {
render.Err(fmt.Sprintf("could not listen on %s: %v", addr, err))
render.Detail(" another process holds the port — pass --port <n>, or --port 0 for a free one")
return ExitError
}
url := "http://" + ln.Addr().String() + "/"

ctx, stop := viewContext()
defer stop()
if *jsonOut {
if code := emitJSON(struct {
URL string `json:"url"`
Root string `json:"root"`
}{url, target}); code != ExitOK {
_ = ln.Close()
return code
}
} else {
render.OK("serving " + target)
render.Info(url)
render.Detail(" read-only, loopback only — Ctrl+C to stop")
}
if err := view.Serve(ctx, ln, target); err != nil {
render.Err(fmt.Sprintf("server stopped: %v", err))
return ExitError
}
return ExitOK
}

func viewUsage() {
fmt.Fprintf(os.Stderr, `Usage: %s view [--port N] [--root DIR] [--json]

Serve the workspace's docs, plans and specs as a read-only web page on
127.0.0.1, until interrupted: the wiki, ADRs, codewiki with the source lines it
cites, glossary, stack, notes, every plan and every spec.

Flags:
--port N port to listen on (default %d; 0 picks a free one)
--root DIR %s
--json print {"url", "root"} on stdout once listening
`, prog(), defaultViewPort, rootFlagHelp)
}
76 changes: 76 additions & 0 deletions internal/cli/view_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cli

import (
"context"
"net"
"strconv"
"strings"
"testing"
)

// stopAtOnce swaps the interrupt for a context that is already cancelled, so
// runView binds, prints its URL, and returns the way an interrupt makes it return.
func stopAtOnce(t *testing.T) {
t.Helper()
orig := viewContext
viewContext = func() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx, cancel
}
t.Cleanup(func() { viewContext = orig })
}

func TestViewPrintsALoopbackURLAndExitsZeroOnInterrupt(t *testing.T) {
stopAtOnce(t)
root := initWorkspace(t)
stdout, stderr, code := run(t, "view", "--port", "0", "--root", root, "--json")
if code != ExitOK {
t.Fatalf("exit = %d, want 0 (stderr: %s)", code, stderr)
}
var got struct{ URL, Root string }
decode(t, stdout, &got)
if !strings.HasPrefix(got.URL, "http://127.0.0.1:") || strings.HasSuffix(got.URL, ":0/") {
t.Errorf("url = %q, want a bound 127.0.0.1 port", got.URL)
}
}

func TestViewRefusesOutsideAWorkspace(t *testing.T) {
stopAtOnce(t)
_, stderr, code := run(t, "view", "--port", "0", "--root", t.TempDir())
if code != ExitError || !strings.Contains(stderr, "not an scc workspace") {
t.Errorf("exit = %d, stderr %q; want 1 naming the missing workspace", code, stderr)
}
}

func TestViewReportsAPortInUse(t *testing.T) {
stopAtOnce(t)
root := initWorkspace(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port)

_, stderr, code := run(t, "view", "--port", port, "--root", root)
if code != ExitError || !strings.Contains(stderr, "127.0.0.1:"+port) {
t.Errorf("exit = %d, stderr %q; want 1 naming 127.0.0.1:%s", code, stderr, port)
}
}

func TestViewRejectsABadPort(t *testing.T) {
for _, p := range []string{"-1", "65536"} {
if _, _, code := run(t, "view", "--port", p); code != ExitError {
t.Errorf("--port %s exit = %d, want 1", p, code)
}
}
}

func TestViewHelpIsZero(t *testing.T) {
for _, args := range [][]string{{"view", "--help"}, {"view", "help"}} {
if _, stderr, code := run(t, args...); code != ExitOK || !strings.Contains(stderr, "view [--port N]") {
t.Errorf("%v exit = %d, stderr %q", args, code, stderr)
}
}
}
39 changes: 39 additions & 0 deletions internal/view/dist/assets/index-BcFXmEw6.js

Large diffs are not rendered by default.

Loading
Loading