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
2 changes: 2 additions & 0 deletions .cards/backlog.jsonl

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ node --check internal/httpapi/templates/assets/*.js
node --test "tests/js/*.test.cjs"
```

For changes to `cards init`, the installed skill, or the MCP handshake, also run
the deterministic end-to-end smoke — no API key, no network:

```bash
scripts/smoke-adoption.sh
```

The agentic half — whether a real agent can set a board up from the installed
skill alone — needs a model and is not part of the gate:

```bash
CARDS_AGENT_CMD='claude -p' go test -tags smoke ./internal/smoke/
CARDS_AGENT_CMD='claude -p' CARDS_SMOKE_RUNS=5 go test -tags smoke -v ./internal/smoke/
```

It asserts over the workspace the agent leaves behind rather than its
transcript, and reports a per-check pass rate across runs. Without
`CARDS_AGENT_CMD` it skips.

For UI/template/CSS work, at minimum run:

```bash
Expand Down
67 changes: 63 additions & 4 deletions cmd/cards/init.go
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
// Command cards — init subcommand. Scaffolds a fresh workspace (starter
// definitions + a welcome board) either locally under ./.cards or globally at
// the personal workspace location.
// the personal workspace location, and installs the agent skill beside it.
package main

import (
"flag"
"fmt"
"os"
"path/filepath"

"github.com/somebox/cards/internal/agentguide"
)

func initCmd(args []string) error {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
global := fs.Bool("global", false, "initialize the personal workspace (~/.cards or $CARDS_HOME)")
quiet := fs.Bool("quiet", false, "suppress post-init instructions (like import/export summaries)")
noSkill := fs.Bool("no-skill", false, "do not install the cards agent skill into .claude/skills/")
if err := fs.Parse(args); err != nil {
return err
}

var dir string
// dir is where the workspace goes; harnessRoot is where the agent skill
// goes. They are deliberately different roots — see below.
var dir, harnessRoot string
if *global {
h, err := globalHome()
if err != nil {
return err
}
dir = h
// NOT globalHome(): that honors $CARDS_HOME, which relocates the board.
// It does not relocate the user's harness directory, so deriving the
// skill path from it would scatter .claude/skills/ next to whichever
// workspace happened to be configured.
home, err := os.UserHomeDir()
if err != nil {
return err
}
harnessRoot = home
} else {
target := "."
if fs.NArg() > 0 {
Expand All @@ -41,20 +56,50 @@ func initCmd(args []string) error {
return fmt.Errorf("%s is already a workspace (it has definitions/workspace.json) — nothing to init; use it with: cards --workspace %s", abs, abs)
}
dir = filepath.Join(abs, ".cards")
// Beside .cards, never inside it: the skill belongs to the project's
// harness, not to the board's data.
harnessRoot = abs
}

created, err := initWorkspace(dir)
if err != nil {
return fmt.Errorf("initialize workspace: %w", err)
}

// Installing the skill is independent of whether the workspace was created.
// An established project already has a workspace and no skill — the common
// case, and the one with no other install path.
// A skill failure must not swallow the workspace result: by this point the
// workspace is already scaffolded, and the user needs to be told both facts.
// The error is still returned, so scripts see a non-zero exit.
skillPath, skillCreated := "", false
var skillErr error
if !*noSkill {
skillPath, skillCreated, skillErr = agentguide.InstallSkill(harnessRoot)
}

wrapSkillErr := func(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("install agent skill: %w", err)
}
if *quiet {
return nil
return wrapSkillErr(skillErr)
}
if !created {
fmt.Printf("workspace already initialized at %s\n", dir)
} else {
fmt.Printf("initialized workspace at %s\n", dir)
}
reportSkill(skillPath, skillCreated, *noSkill, skillErr)
if skillErr != nil {
return wrapSkillErr(skillErr)
}
if !created {
return nil
}
fmt.Printf("initialized workspace at %s\n\n", dir)
fmt.Println()
fmt.Println("Next:")
if *global {
fmt.Println(" cards # serve it (zero-config)")
Expand All @@ -64,3 +109,17 @@ func initCmd(args []string) error {
fmt.Println(" open http://127.0.0.1:8787/ui/boards/welcome")
return nil
}

// reportSkill says what happened to the agent skill. An existing skill is
// reported, not silently skipped: the user needs to know why their install did
// not take effect.
func reportSkill(path string, created, skipped bool, err error) {
switch {
case skipped, err != nil:
return // the error itself carries the detail and the remedy
case created:
fmt.Printf("installed the cards agent skill at %s\n", path)
default:
fmt.Printf("cards skill already exists at %s; not overwritten\n", path)
}
}
148 changes: 148 additions & 0 deletions cmd/cards/initskill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/somebox/cards/internal/agentguide"
)

func skillDir(root string) string {
return filepath.Join(root, filepath.FromSlash(agentguide.SkillDirName))
}

func TestInitCmd_InstallsSkillBesideWorkspace(t *testing.T) {
root := t.TempDir()
if err := initCmd([]string{"--quiet", root}); err != nil {
t.Fatalf("init: %v", err)
}
for _, rel := range []string{"SKILL.md", "references/cli-reference.md"} {
if _, err := os.Stat(filepath.Join(skillDir(root), rel)); err != nil {
t.Errorf("skill file %s not installed: %v", rel, err)
}
}
// Beside .cards, never inside it — the skill belongs to the harness, not
// to the board's data directory.
if _, err := os.Stat(filepath.Join(root, ".cards", ".claude")); err == nil {
t.Error("skill was installed inside .cards/")
}
}

// The case with no other install path: a project that already has a board.
func TestInitCmd_InstallsSkillWhenWorkspaceAlreadyExists(t *testing.T) {
root := t.TempDir()
if err := initCmd([]string{"--quiet", "--no-skill", root}); err != nil {
t.Fatalf("init: %v", err)
}
if _, err := os.Stat(skillDir(root)); err == nil {
t.Fatal("--no-skill still installed the skill")
}
// Workspace now exists, so initWorkspace reports created=false. The skill
// must still land.
if err := initCmd([]string{"--quiet", root}); err != nil {
t.Fatalf("re-init: %v", err)
}
if _, err := os.Stat(filepath.Join(skillDir(root), "SKILL.md")); err != nil {
t.Errorf("skill not installed into an existing workspace: %v", err)
}
}

func TestInitCmd_SkillIsNeverClobbered(t *testing.T) {
root := t.TempDir()
if err := initCmd([]string{"--quiet", root}); err != nil {
t.Fatalf("init: %v", err)
}
marker := filepath.Join(skillDir(root), "SKILL.md")
if err := os.WriteFile(marker, []byte("locally edited"), 0o644); err != nil {
t.Fatal(err)
}
if err := initCmd([]string{"--quiet", root}); err != nil {
t.Fatalf("re-init: %v", err)
}
got, err := os.ReadFile(marker)
if err != nil {
t.Fatal(err)
}
if string(got) != "locally edited" {
t.Error("re-init overwrote a locally edited skill")
}
}

// $CARDS_HOME relocates the board, not the user's harness directory. Deriving
// the skill path from globalHome() would scatter .claude/skills/ next to
// whichever workspace happened to be configured.
func TestInitCmd_GlobalSkillFollowsHomeNotCardsHome(t *testing.T) {
cardsHome := t.TempDir()
userHome := t.TempDir()
t.Setenv("CARDS_HOME", cardsHome)
t.Setenv("HOME", userHome)

if err := initCmd([]string{"--quiet", "--global"}); err != nil {
t.Fatalf("init --global: %v", err)
}
if _, err := os.Stat(filepath.Join(skillDir(userHome), "SKILL.md")); err != nil {
t.Errorf("skill not installed under the user home: %v", err)
}
if _, err := os.Stat(skillDir(cardsHome)); err == nil {
t.Error("skill was installed under CARDS_HOME instead of the user home")
}
}

func TestMCPPrintInstructionsMatchesTheHandshake(t *testing.T) {
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
runErr := mcpCmd([]string{"--print-instructions"})
_ = w.Close()
os.Stdout = old
if runErr != nil {
t.Fatalf("mcp --print-instructions: %v", runErr)
}
printed, err := io.ReadAll(r)
_ = r.Close()
if err != nil {
t.Fatalf("read stdout: %v", err)
}
if got, want := string(printed), agentguide.MCPInstructions(); got != want {
t.Errorf("printed instructions differ from the served handshake\ngot %d bytes, want %d", len(got), len(want))
}
}

// Debris from an interrupted install must fail loudly, but must not swallow the
// workspace result — by that point the workspace is already scaffolded, and the
// user needs both facts.
func TestInitCmd_ReportsWorkspaceEvenWhenSkillInstallFails(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(skillDir(root), "references"), 0o755); err != nil {
t.Fatal(err)
}

old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
initErr := initCmd([]string{root})
_ = w.Close()
os.Stdout = old
out, _ := io.ReadAll(r)
_ = r.Close()

if !errors.Is(initErr, agentguide.ErrIncompleteSkill) {
t.Fatalf("got %v, want ErrIncompleteSkill", initErr)
}
if !strings.Contains(string(out), "initialized workspace at") {
t.Errorf("workspace result was swallowed by the skill failure:\n%s", out)
}
if !isWorkspaceDir(filepath.Join(root, ".cards")) {
t.Error("workspace was not scaffolded")
}
}
4 changes: 2 additions & 2 deletions cmd/cards/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,11 @@ Commands:
workspace show
boards show [board_id]

init Scaffold a new workspace (./.cards or, with --global, ~/.cards)
init Scaffold a new workspace + install the agent skill (--no-skill to skip)
serve Run the HTTP + web UI server
export Dump all card data as JSONL (local; --workspace <dir>)
import Load a JSONL export into the workspace DB (local; --workspace <dir>)
mcp Run the stdio MCP server (--workspace <dir>)
mcp Run the stdio MCP server (--workspace <dir>; --print-instructions)
run-extensions Run the hook supervisor (--workspace <dir>)
do <id> Invoke a run extension (--param k=v)
extensions List/show declared extensions
Expand Down
11 changes: 10 additions & 1 deletion cmd/cards/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os"
"time"

"github.com/somebox/cards/internal/agentguide"
"github.com/somebox/cards/internal/config"
"github.com/somebox/cards/internal/httpapi"
"github.com/somebox/cards/internal/mcp"
Expand Down Expand Up @@ -192,9 +193,17 @@ func serveCmd(args []string) error {
func mcpCmd(args []string) error {
fs := flag.NewFlagSet("mcp", flag.ContinueOnError)
workspace := fs.String("workspace", "", "workspace directory (contains definitions/)")
printInstructions := fs.Bool("print-instructions", false, "print the agent instructions served in the MCP handshake, then exit")
if err := fs.Parse(args); err != nil {
return err
}
// Lives on `mcp` rather than as its own verb: this text IS the handshake, and
// it needs no workspace — a harness can be wired from an installed binary
// with no checkout and no server running.
if *printInstructions {
fmt.Print(agentguide.MCPInstructions())
return nil
}
abs, autoInit, err := resolveWorkspaceDir(*workspace)
if err != nil {
return err
Expand All @@ -214,6 +223,6 @@ func mcpCmd(args []string) error {
if actor == "" {
actor = result.Workspace.Settings.DefaultUser
}
srv := mcp.New(svc, result.Workspace, result.CardTypes, result.Boards, actor)
srv := mcp.New(svc, result.Workspace, result.CardTypes, result.Boards, actor, mcp.WithVersion(shortVersion()))
return srv.Serve()
}
Loading
Loading