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
41 changes: 31 additions & 10 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,37 @@
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind,consistency=delegated",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

// --- Add language runtimes / tools here (optional) -------------------------
// Devcontainer features layer on top of the image without editing the Dockerfile.
// Examples (uncomment what you need):
// "features": {
// "ghcr.io/devcontainers/features/python:1": { "version": "3.13" },
// "ghcr.io/devcontainers/features/go:1": {},
// "ghcr.io/devcontainers/features/rust:1": {}
// },
// --- Language runtimes / tools --------------------------------------------
// Devcontainer features layer on top of the base image without editing the
// Dockerfile. Go is this repo's primary toolchain: pin it to the version CI
// builds/tests with (.github/workflows/ci.yml) plus the golangci-lint the lint
// job runs. "1.25" resolves to the latest 1.25.x patch. (Node comes from the
// base node:24 image, so it needs no feature here.)
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "1.25",
"golangciLintVersion": "2.12.2"
}
},

"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"eamodio.gitlens"
"eamodio.gitlens",
"golang.go"
],
"settings": {
"terminal.integrated.defaultProfile.linux": "zsh"
"terminal.integrated.defaultProfile.linux": "zsh",
"go.useLanguageServer": true,
// Keep files gofmt-clean and imports tidy — the CI lint job gates on
// `gofmt -l`, so match it on save.
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
}
}
},
Expand All @@ -65,6 +79,13 @@
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
},

// Runs once at create, while egress is still wide open (before postStartCommand
// programs the firewall): warm the module cache, install the local csdd binary
// onto PATH (${GOPATH}/bin) so `csdd` works in the sandbox, and grab
// govulncheck (matching CI's lint job). The web dashboard stays a placeholder
// until `make web-build`. Best-effort: a hiccup here must never block creation.
"postCreateCommand": "go mod download && go install golang.org/x/vuln/cmd/govulncheck@latest || true",

// Program the egress firewall on every start (needs the caps above). Wait for
// it before handing over the container so nothing escapes the default-deny.
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
Expand Down
5 changes: 4 additions & 1 deletion .devcontainer/init-firewall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ for domain in \
"statsig.com" \
"marketplace.visualstudio.com" \
"vscode.blob.core.windows.net" \
"update.code.visualstudio.com"; do
"update.code.visualstudio.com" \
"proxy.golang.org" \
"sum.golang.org" \
"vuln.go.dev"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ csdd spec generate my-feature --artifact requirements # → validate → appro

| Command | What it does |
|---|---|
| `csdd init [--with-baseline]` | bootstrap the workspace (steering · skills · agents · commands · hooks · docs) |
| `csdd init [--with-baseline] [--hooks] [--prepush]` | bootstrap the workspace (steering · skills · agents · commands · docs); the Claude Code hooks and the pre-push gate are opt-in via `--hooks`/`--prepush` (or `--include hooks,pre-push`) |
| `csdd update [--dry-run]` · `clean` · `destroy` | upgrade managed artifacts (keeping your edits as `.old`) · sweep `.old` backups · tear the workspace back down |
| `csdd copy <kind>/<name>` | cherry-pick one shipped artifact (skill · agent · rule · command · hook · template · steering) — the complement of `init --exclude` |
| `/csdd-setup-init` · `/csdd-setup-update` | adapt / refresh the workflow for your stack — Claude Code slash commands |
Expand Down Expand Up @@ -487,7 +487,7 @@ CLAUDE.md # entry point + steering imports + knowledge-base workflow
.claude/agents/*.md # sub-agents (implementer, code-reviewer, …)
.claude/skills/<n>/ # skill bundles
.claude/commands/ # slash commands (/csdd-setup-init, /csdd-commit, /prd, /csdd-graph-query, …)
.claude/hooks/ # deterministic automation (format-after-edit, pre-push test gate, …)
.claude/hooks/ # deterministic automation (format-after-edit, …) — opt-in via `init --hooks`
.csdd/ # csdd's operational state (the workspace marker + manifest.json) — the csdd analog of .git/
specs/<feature>/ # SDD contracts
docs/ # knowledge base: plans/ · adr/ · wiki/ · graph/ · raw/ · glossary.md · stack.md · product/
Expand Down
103 changes: 103 additions & 0 deletions internal/cli/cli_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,109 @@ func TestInitScaffoldsClaudeCodeArtifacts(t *testing.T) {
}
}

// TestInitDefaultOmitsOptInComponents asserts a bare `csdd init` leaves out the
// opt-in components (Claude Code hooks + the pre-push gate) and that the default
// settings.json therefore carries no hook wiring.
func TestInitDefaultOmitsOptInComponents(t *testing.T) {
dir := t.TempDir()
if code, _, errOut := run(t, "init", "--root", dir); code != 0 {
t.Fatalf("init failed (code=%d): %s", code, errOut)
}
for _, p := range []string{
".claude/hooks",
".claude/hooks/block-destructive.sh",
".githooks/pre-push",
} {
if _, err := os.Stat(filepath.Join(dir, p)); err == nil {
t.Errorf("default init should NOT scaffold opt-in component %s", p)
}
}
var parsed map[string]any
raw := readFile(t, filepath.Join(dir, ".claude", "settings.json"))
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("default settings.json is not valid JSON: %v", err)
}
if _, ok := parsed["hooks"]; ok {
t.Errorf("default settings.json must not wire hooks:\n%s", raw)
}
if _, ok := parsed["permissions"]; !ok {
t.Errorf("default settings.json should keep the permissions block:\n%s", raw)
}
}

// TestInitOptInHooksAndPrePush covers the --include spelling (with the "prepush"
// alias) scaffolding the hook scripts and pre-push gate as executables and
// wiring the hooks into settings.json, which must stay valid JSON.
func TestInitOptInHooksAndPrePush(t *testing.T) {
dir := t.TempDir()
code, out, errOut := run(t, "init", "--root", dir, "--include", "hooks,prepush")
if code != 0 {
t.Fatalf("init --include failed (code=%d): %s", code, errOut)
}
if !strings.Contains(out, "included (opt-in): hooks, pre-push") {
t.Errorf("expected an opt-in summary, got:\n%s", out)
}
for _, exe := range []string{
".claude/hooks/block-destructive.sh",
".claude/hooks/format-after-edit.sh",
".claude/hooks/test-before-stop.sh",
".githooks/pre-push",
} {
info, err := os.Stat(filepath.Join(dir, exe))
if err != nil {
t.Errorf("--include should have scaffolded %s: %v", exe, err)
continue
}
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
t.Errorf("%s must be executable, mode=%v", exe, info.Mode())
}
}
var parsed map[string]any
raw := readFile(t, filepath.Join(dir, ".claude", "settings.json"))
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("merged settings.json is not valid JSON: %v", err)
}
if _, ok := parsed["hooks"]; !ok {
t.Errorf("opted-in settings.json must wire the hooks:\n%s", raw)
}
if _, ok := parsed["permissions"]; !ok {
t.Errorf("merged settings.json dropped the permissions block:\n%s", raw)
}
if !strings.Contains(raw, "block-destructive.sh") {
t.Errorf("opted-in settings.json must reference the hook scripts:\n%s", raw)
}
}

// TestInitHooksBooleanFlag asserts the --hooks convenience flag scaffolds the
// hooks (and wires settings.json) without dragging in the pre-push gate.
func TestInitHooksBooleanFlag(t *testing.T) {
dir := t.TempDir()
if code, _, errOut := run(t, "init", "--root", dir, "--hooks"); code != 0 {
t.Fatalf("init --hooks failed (code=%d): %s", code, errOut)
}
if _, err := os.Stat(filepath.Join(dir, ".claude/hooks/block-destructive.sh")); err != nil {
t.Errorf("--hooks should scaffold the hook scripts: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".githooks/pre-push")); err == nil {
t.Error("--hooks alone must NOT scaffold the pre-push gate")
}
}

// TestInitRejectsExcludedOptInKeys makes sure the now opt-in keys are no longer
// accepted by --exclude (they are off by default; --include turns them on).
func TestInitRejectsExcludedOptInKeys(t *testing.T) {
for _, key := range []string{"hooks", "pre-push"} {
dir := t.TempDir()
code, _, errOut := run(t, "init", "--root", dir, "--exclude", key)
if code == 0 {
t.Errorf("--exclude %s should be rejected now that it is opt-in", key)
}
if !strings.Contains(errOut, "unknown --exclude component") {
t.Errorf("--exclude %s: expected an unknown-component error, got: %s", key, errOut)
}
}
}

// TestInitWithBaselineImportsSteering asserts CLAUDE.md imports the baseline
// steering files as always-on @-references.
func TestInitWithBaselineImportsSteering(t *testing.T) {
Expand Down
13 changes: 7 additions & 6 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ func run(t *testing.T, args ...string) (int, string, string) {
func freshWorkspace(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if code, _, errOut := run(t, "init", "--root", dir, "--with-baseline"); code != 0 {
// A complete workspace for tests exercises every tree, so opt into the
// hooks and pre-push components that a bare `csdd init` now leaves out.
if code, _, errOut := run(t, "init", "--root", dir, "--with-baseline", "--hooks", "--prepush"); code != 0 {
t.Fatalf("init failed (code=%d): %s", code, errOut)
}
return dir
Expand Down Expand Up @@ -186,16 +188,15 @@ func TestInitExcludeAgentsSkills(t *testing.T) {
t.Errorf("--exclude should have skipped %s", p)
}
}
// Still present: the rest of the full workspace.
// Still present: the rest of the full workspace. (hooks and pre-push are
// opt-in, so they are absent here — see TestInitDefaultOmitsOptInComponents.)
for _, p := range []string{
"CLAUDE.md",
".mcp.json",
".claude/settings.json",
".claude/rules/ears-format.md",
".claude/templates/specs/requirements.md",
".claude/commands",
".claude/hooks",
".githooks/pre-push",
"specs",
} {
if _, err := os.Stat(filepath.Join(dir, p)); err != nil {
Expand All @@ -208,10 +209,10 @@ func TestInitExcludeAgentsSkills(t *testing.T) {
// comma lists, and that both forms compose.
func TestInitExcludeRepeatableFlag(t *testing.T) {
dir := t.TempDir()
if code, _, errOut := run(t, "init", "--root", dir, "--exclude", "agents", "--exclude", "hooks,commands"); code != 0 {
if code, _, errOut := run(t, "init", "--root", dir, "--exclude", "agents", "--exclude", "rules,commands"); code != 0 {
t.Fatalf("init failed (code=%d): %s", code, errOut)
}
for _, p := range []string{".claude/agents", ".claude/hooks", ".claude/commands"} {
for _, p := range []string{".claude/agents", ".claude/rules", ".claude/commands"} {
if _, err := os.Stat(filepath.Join(dir, p)); err == nil {
t.Errorf("repeated/comma --exclude should have skipped %s", p)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import (
func bareWorkspace(t *testing.T) string {
t.Helper()
dir := t.TempDir()
// hooks are opt-in (absent unless --include hooks), so they need no exclude.
if code, _, errOut := run(t, "init", "--root", dir,
"--exclude", "skills,agents,rules,commands,hooks,templates,steering"); code != 0 {
"--exclude", "skills,agents,rules,commands,templates,steering"); code != 0 {
t.Fatalf("init --exclude failed (code=%d): %s", code, errOut)
}
return dir
Expand Down
Loading
Loading