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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ Response without New Zealand reference.

**sctx hook** - Reads agent hook input from stdin, returns matching context entries. This is the main integration point. Decisions are excluded from hook output.

**sctx context \<path\>** - Query context entries for a file or directory. Supports `--on <action>`, `--when <timing>`, and `--json`.
**sctx context \<path\>** - Query context entries for a file or directory. Supports `--on <action>`, `--when <timing>`, `--json`, and `--all` to dump every entry from every AGENTS.yaml.

**sctx decisions \<path\>** - Query decisions for a file or directory. Supports `--json`.
**sctx decisions \<path\>** - Query decisions for a file or directory. Supports `--json` and `--all`.

**sctx validate [\<dir\>]** - Checks all context files in a directory tree for schema errors and invalid globs.

Expand Down
129 changes: 116 additions & 13 deletions cmd/sctx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ const usage = `sctx — Structured Context CLI
Usage:
sctx hook Read agent hook input from stdin, return matching context
sctx context <path> [--on <action>] [--when <timing>] [--json]
sctx context --all [--on <action>] [--when <timing>] [--json]
Query context entries for a file or directory
sctx decisions <path> [--json] Query decisions for a file or directory
sctx decisions <path> [--json]
sctx decisions --all [--json] Query decisions for a file or directory
sctx validate [<dir>] Validate all context files in a directory tree
sctx init Create a starter AGENTS.yaml in the current directory
sctx claude enable Enable sctx hooks in Claude Code
Expand All @@ -37,6 +39,7 @@ var (
version = "dev"

errMissingPath = errors.New("missing required <path> argument")
errAllAndPath = errors.New("--all and <path> are mutually exclusive")
errOnNeedsValue = errors.New("--on requires a value")
errWhenNeedsValue = errors.New("--when requires a value")
errInvalidAction = errors.New("invalid --on value")
Expand Down Expand Up @@ -101,17 +104,16 @@ func cmdHook(in io.Reader, out, errOut io.Writer) error {
}

func cmdContext(args []string, out, errOut io.Writer) error {
if len(args) < 1 {
return errMissingPath
}

filePath := args[0]
action := core.ActionAll
timing := core.TimingAll
jsonOutput := false
allFlag := false
var filePath string

for i := 1; i < len(args); i++ {
for i := 0; i < len(args); i++ {
switch args[i] {
case "--all":
allFlag = true
case "--on":
if i+1 >= len(args) {
return errOnNeedsValue
Expand Down Expand Up @@ -140,9 +142,23 @@ func cmdContext(args []string, out, errOut io.Writer) error {
timing = core.Timing(v)
case "--json":
jsonOutput = true
default:
filePath = args[i]
}
}

if allFlag && filePath != "" {
return errAllAndPath
}

if allFlag {
return cmdContextAll(action, timing, jsonOutput, out, errOut)
}

if filePath == "" {
return errMissingPath
}

absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("resolving path: %w", err)
Expand Down Expand Up @@ -187,20 +203,67 @@ func cmdContext(args []string, out, errOut io.Writer) error {
return nil
}

func cmdDecisions(args []string, out, errOut io.Writer) error {
if len(args) < 1 {
return errMissingPath
func cmdContextAll(action core.Action, timing core.Timing, jsonOutput bool, out, errOut io.Writer) error {
result, warnings, err := core.ResolveAll(core.ResolveAllRequest{
Action: action,
Timing: timing,
})
if err != nil {
return err
}

for _, w := range warnings {
_, _ = fmt.Fprintln(errOut, w)
}

filePath := args[0]
if jsonOutput {
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(result.ContextEntries)
}

if len(result.ContextEntries) == 0 {
_, _ = fmt.Fprintln(out, "No matching context found.")
return nil
}

for _, entry := range result.ContextEntries {
_, _ = fmt.Fprintf(out, " - %s\n", entry.Content)
_, _ = fmt.Fprintf(out, " Match: %s\n", strings.Join(entry.Match, ", "))
_, _ = fmt.Fprintf(out, " (from %s)\n", entry.SourceFile)
}

return nil
}

func cmdDecisions(args []string, out, errOut io.Writer) error {
jsonOutput := false
allFlag := false
var filePath string

for i := 1; i < len(args); i++ {
if args[i] == "--json" {
for i := range args {
switch args[i] {
case "--all":
allFlag = true
case "--json":
jsonOutput = true
default:
filePath = args[i]
}
}

if allFlag && filePath != "" {
return errAllAndPath
}

if allFlag {
return cmdDecisionsAll(jsonOutput, out, errOut)
}

if filePath == "" {
return errMissingPath
}

absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("resolving path: %w", err)
Expand Down Expand Up @@ -253,6 +316,46 @@ func cmdDecisions(args []string, out, errOut io.Writer) error {
return nil
}

func cmdDecisionsAll(jsonOutput bool, out, errOut io.Writer) error {
result, warnings, err := core.ResolveAll(core.ResolveAllRequest{})
if err != nil {
return err
}

for _, w := range warnings {
_, _ = fmt.Fprintln(errOut, w)
}

if jsonOutput {
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(result.DecisionEntries)
}

if len(result.DecisionEntries) == 0 {
_, _ = fmt.Fprintln(out, "No matching decisions found.")
return nil
}

for _, entry := range result.DecisionEntries {
_, _ = fmt.Fprintf(out, " - %s\n", entry.Decision)
_, _ = fmt.Fprintf(out, " Rationale: %s\n", entry.Rationale)

for _, alt := range entry.Alternatives {
_, _ = fmt.Fprintf(out, " Considered %s, rejected: %s\n", alt.Option, alt.ReasonRejected)
}

if entry.RevisitWhen != "" {
_, _ = fmt.Fprintf(out, " Revisit when: %s\n", entry.RevisitWhen)
}

_, _ = fmt.Fprintf(out, " Match: %s\n", strings.Join(entry.Match, ", "))
_, _ = fmt.Fprintf(out, " (from %s)\n", entry.SourceFile)
}

return nil
}

func cmdValidate(args []string, out io.Writer) error {
dir := "."
if len(args) > 0 {
Expand Down
148 changes: 148 additions & 0 deletions cmd/sctx/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,154 @@ func TestCmdDecisions(t *testing.T) {
}
}

func setupAllFixture(t *testing.T) {
t.Helper()
tmp := t.TempDir()
t.Chdir(tmp)

writeTestFile(t, filepath.Join(tmp, "AGENTS.yaml"), `context:
- content: "root guidance"
match: ["**"]
on: read
when: before

decisions:
- decision: "use Go"
rationale: "fast"
match: ["**"]
`)
writeTestFile(t, filepath.Join(tmp, "sub", "AGENTS.yaml"), `context:
- content: "sub guidance"
match: ["*.go"]
on: edit
when: after

decisions:
- decision: "use Postgres"
rationale: "reliable"
match: ["*.sql"]
`)
}

func TestCmdContext_All(t *testing.T) {
setupAllFixture(t)

var out, errOut bytes.Buffer
if err := cmdContext([]string{"--all"}, &out, &errOut); err != nil {
t.Fatal(err)
}

got := out.String()
for _, want := range []string{"root guidance", "sub guidance", "Match:", "(from AGENTS.yaml)", "(from sub/AGENTS.yaml)"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q, got %q", want, got)
}
}
}

func TestCmdContext_AllJson(t *testing.T) {
setupAllFixture(t)

var out, errOut bytes.Buffer
if err := cmdContext([]string{"--all", "--json"}, &out, &errOut); err != nil {
t.Fatal(err)
}

var entries []map[string]any
if err := json.Unmarshal(out.Bytes(), &entries); err != nil {
t.Fatalf("invalid JSON: %v", err)
}

if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}

for _, e := range entries {
if _, ok := e["Content"]; !ok {
t.Error("missing Content field")
}
if _, ok := e["Match"]; !ok {
t.Error("missing Match field")
}
if _, ok := e["SourceFile"]; !ok {
t.Error("missing SourceFile field")
}
}
}

func TestCmdContext_AllWithPath(t *testing.T) {
err := cmdContext([]string{"--all", "some/path"}, nil, nil)
if !errors.Is(err, errAllAndPath) {
t.Errorf("got error %v, want %v", err, errAllAndPath)
}
}

func TestCmdContext_AllWithFilters(t *testing.T) {
setupAllFixture(t)

var out, errOut bytes.Buffer
if err := cmdContext([]string{"--all", "--on", "read", "--when", "before"}, &out, &errOut); err != nil {
t.Fatal(err)
}

got := out.String()
if !strings.Contains(got, "root guidance") {
t.Errorf("expected 'root guidance', got %q", got)
}
if strings.Contains(got, "sub guidance") {
t.Errorf("should not contain 'sub guidance' (on:edit), got %q", got)
}
}

func TestCmdDecisions_All(t *testing.T) {
setupAllFixture(t)

var out, errOut bytes.Buffer
if err := cmdDecisions([]string{"--all"}, &out, &errOut); err != nil {
t.Fatal(err)
}

got := out.String()
for _, want := range []string{"use Go", "use Postgres", "Match:", "(from AGENTS.yaml)", "(from sub/AGENTS.yaml)"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q, got %q", want, got)
}
}
}

func TestCmdDecisions_AllJson(t *testing.T) {
setupAllFixture(t)

var out, errOut bytes.Buffer
if err := cmdDecisions([]string{"--all", "--json"}, &out, &errOut); err != nil {
t.Fatal(err)
}

var entries []map[string]any
if err := json.Unmarshal(out.Bytes(), &entries); err != nil {
t.Fatalf("invalid JSON: %v", err)
}

if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}

for i, e := range entries {
for _, field := range []string{"Decision", "Match", "SourceFile"} {
if _, ok := e[field]; !ok {
t.Errorf("entry %d: missing %s field", i, field)
}
}
}
}

func TestCmdDecisions_AllWithPath(t *testing.T) {
err := cmdDecisions([]string{"--all", "some/path"}, nil, nil)
if !errors.Is(err, errAllAndPath) {
t.Errorf("got error %v, want %v", err, errAllAndPath)
}
}

func TestCmdDecisions_NoMatch(t *testing.T) {
tmp := t.TempDir()
writeTestFile(t, filepath.Join(tmp, "AGENTS.yaml"), `context:
Expand Down
10 changes: 10 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ sctx context src/api/handler.py
sctx context src/api/handler.py --on edit --when before
sctx context src/api/ # directory query
sctx context src/api/handler.py --json
sctx context --all # every entry from every AGENTS.yaml
sctx context --all --on edit --json
```

Use `--all` to dump every context entry from every `AGENTS.yaml` file in the tree, skipping glob matching entirely. Output includes the source file path and match patterns for each entry so you can see where each entry is defined and what it targets. `--all` and `<path>` are mutually exclusive. `--on` and `--when` filters still apply with `--all`.

### Flags

| Flag | Default | Description |
|---|---|---|
| `--all` | off | Return all entries from all AGENTS.yaml files, skip glob matching |
| `--on <action>` | `all` | Filter by action: `read`, `edit`, `create`, `all` |
| `--when <timing>` | `all` | Filter by timing: `before`, `after`, `all` |
| `--json` | off | Output as JSON instead of human-readable text |
Expand All @@ -50,12 +55,17 @@ Query decisions for a file or directory. Shows architectural decisions that appl
sctx decisions src/api/handler.py
sctx decisions src/api/ # directory query
sctx decisions src/api/handler.py --json
sctx decisions --all # every decision from every AGENTS.yaml
sctx decisions --all --json
```

Use `--all` to dump every decision entry from every `AGENTS.yaml` file in the tree. Like `sctx context --all`, output includes source file paths and match patterns. `--all` and `<path>` are mutually exclusive.

### Flags

| Flag | Default | Description |
|---|---|---|
| `--all` | off | Return all entries from all AGENTS.yaml files, skip glob matching |
| `--json` | off | Output as JSON instead of human-readable text |

## sctx validate [\<dir\>]
Expand Down
Loading