Skip to content
Open
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
44 changes: 23 additions & 21 deletions .agent/CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._
|-- internal
| |-- cli
| | |-- cli.go
| | `-- cli_test.go
| | |-- cli_test.go
| | |-- status.go
| | `-- status_test.go
| |-- flavors
| | |-- claudecowork
| | |-- common
Expand Down Expand Up @@ -90,8 +92,8 @@ _Generated by `gen-codemap.sh`. Re-run after structural changes._

### Go
internal/cli/cli.go:66:type Version struct {
internal/cli/cli.go:152:type App struct {
internal/cli/cli.go:160:func New(out, errOut io.Writer, version Version) App {
internal/cli/cli.go:161:type App struct {
internal/cli/cli.go:169:func New(out, errOut io.Writer, version Version) App {
internal/cli/cli_test.go:15:func TestListFlavors(t *testing.T) {
internal/cli/cli_test.go:28:func TestInitLegacyTargetArgument(t *testing.T) {
internal/cli/cli_test.go:47:func TestInitLegacyTargetArgumentWithoutFlag(t *testing.T) {
Expand Down Expand Up @@ -133,6 +135,17 @@ internal/cli/cli_test.go:799:func TestUnknownFlavorErrorPointsAtHelp(t *testing.
internal/cli/cli_test.go:815:func TestHelpFlagsMatchDocs(t *testing.T) {
internal/cli/cli_test.go:842:func TestVersion(t *testing.T) {
internal/cli/cli_test.go:855:func TestVersionDefaultsToDev(t *testing.T) {
internal/cli/status_test.go:52:func TestStatusSharedWhenNoBlockAnywhere(t *testing.T) {
internal/cli/status_test.go:75:func TestStatusLocalWhenBlockInGitignore(t *testing.T) {
internal/cli/status_test.go:109:func TestStatusHiddenWhenBlockInGitInfoExclude(t *testing.T) {
internal/cli/status_test.go:146:func TestStatusShadowedByGlobalWhenOnlyGlobalCarriesBlock(t *testing.T) {
internal/cli/status_test.go:189:func TestStatusPrefersLocalOverHiddenOverGlobal(t *testing.T) {
internal/cli/status_test.go:226:func TestStatusDefaultTargetIsCwd(t *testing.T) {
internal/cli/status_test.go:250:func TestStatusExplicitTargetIsResolvedToAbsolutePath(t *testing.T) {
internal/cli/status_test.go:267:func TestStatusRejectsExtraPositionalArgs(t *testing.T) {
internal/cli/status_test.go:282:func TestStatusRejectsUnknownFlag(t *testing.T) {
internal/cli/status_test.go:294:func TestStatusHelp(t *testing.T) {
internal/cli/status_test.go:317:func TestStatusIgnoresGlobalLookupErrors(t *testing.T) {
internal/flavors/claudecowork/flavor.go:11:func Templates() embed.FS {
internal/flavors/claudecowork/flavor.go:18:func ExecutablePaths() []string {
internal/flavors/claudecowork/flavor.go:25:func NextSteps(target string) string {
Expand Down Expand Up @@ -172,28 +185,17 @@ internal/gitconfig/gitconfig_test.go:162:func TestEnsureGlobalExpandsTildeInConf
internal/gitconfig/gitconfig_test.go:176:func TestEnsureGlobalAppendsToExistingFileWithoutOurBlock(t *testing.T) {
internal/gitconfig/gitconfig_test.go:198:func TestEnsureGlobalIsIdempotent(t *testing.T) {
internal/gitconfig/gitconfig_test.go:223:func TestGlobalPathDoesNotWriteOrSet(t *testing.T) {
internal/gitignore/gitignore.go:48:func Block() string {
internal/gitignore/gitignore.go:63:func LocalPath(target string) (string, error) {
internal/gitignore/gitignore.go:75:func EnsureLocal(target string) (string, error) {
internal/gitignore/gitignore.go:94:func HiddenPath(target string) (string, error) {
internal/gitignore/gitignore.go:115:func EnsureHidden(target string) (string, error) {
internal/gitignore/gitignore.go:139:func Upsert(content string) string {
internal/gitignore/gitignore_test.go:10:func TestBlockHasMarkersAndEnvelope(t *testing.T) {
internal/gitignore/gitignore_test.go:27:func TestEnsureLocalCreatesAndAppends(t *testing.T) {
internal/gitignore/gitignore_test.go:70:func TestEnsureLocalIsIdempotent(t *testing.T) {
internal/gitignore/gitignore_test.go:95:func TestEnsureLocalReplacesStaleBlockInPlace(t *testing.T) {
internal/gitignore/gitignore_test.go:128:func TestEnsureHiddenCreatesAndAppends(t *testing.T) {
internal/gitignore/gitignore_test.go:174:func TestEnsureHiddenIsIdempotent(t *testing.T) {
internal/gitignore/gitignore_test.go:196:func TestEnsureHiddenReplacesStaleBlockInPlace(t *testing.T) {
internal/gitignore/gitignore_test.go:234:func TestEnsureHiddenWritesNoGitignore(t *testing.T) {
internal/scaffold/color_test.go:9:func TestColorDisabledForNonTTYOutputs(t *testing.T) {
internal/scaffold/color_test.go:26:func TestColorDisabledByEnvironment(t *testing.T) {
internal/scaffold/color_test.go:50:func TestColorEnabledForTerminalFile(t *testing.T) {
internal/gitignore/gitignore.go:56:func Block() string {
internal/gitignore/gitignore.go:71:func LocalPath(target string) (string, error) {
internal/gitignore/gitignore.go:83:func EnsureLocal(target string) (string, error) {
internal/gitignore/gitignore.go:102:func HiddenPath(target string) (string, error) {
internal/gitignore/gitignore.go:123:func EnsureHidden(target string) (string, error) {
internal/gitignore/gitignore.go:147:func Upsert(content string) string {

## Stats

```
Total tracked files: 350
Total tracked files: 352
```

<!-- HAND-WRITTEN BELOW — EDIT FREELY -->
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ go build \
```bash
agent-init init [flavor] [target-dir]
agent-init add-tracker <tracker> <target-dir>
agent-init status [target]
agent-init list-flavors
agent-init list-trackers
agent-init version
Expand Down
40 changes: 39 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# CLI

`agent-init` is a small CLI with five subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go).
`agent-init` is a small CLI with six subcommands. Source: [internal/cli/cli.go](../internal/cli/cli.go).

```
agent-init init [flavor] [target-dir]
agent-init add-tracker <tracker> <target-dir>
agent-init status [target]
agent-init list-flavors
agent-init list-trackers
agent-init version
Expand Down Expand Up @@ -88,6 +89,43 @@ There is no `remove-tracker` subcommand yet. Manual cleanup:
2. Remove the entry from `.mcp.json` under `mcpServers`.
3. Remove the tracker name from `AGENTS.md`'s "Active trackers" line.

## `status`

Reports how the scaffold's agentic envelope is currently tracked by git. Read-only — `status` writes no files and touches no git configuration.

```bash
agent-init status # report status of the current directory
agent-init status ./my-tool # report status of ./my-tool
```

The optional positional argument defaults to `.`. The target is resolved to an absolute path before reporting, so the printed paths are unambiguous.

### Output

Each line is `<label>: <value>`. The fields:

| Field | Meaning |
|-------|---------|
| `mode` | One of `shared` (no agent-init ignore block found), `local` (block in the committed `.gitignore`), `hidden` (block in `.git/info/exclude`), or `shadowed-by-global` (no repo-local block, but a block exists in your machine-wide git excludes file). |
| `target` | Absolute path of the directory `status` was run against. |
| `ignore` | Absolute path of the file carrying the agent-init ignore block. Omitted in `shared` mode. Annotated `(machine-wide)` for `shadowed-by-global`. |
| `undo` | A portable `sed` invocation that deletes the fenced block from the carrier, plus a fallback instruction for users who would rather edit by hand. The markers come from [internal/gitignore](../internal/gitignore/gitignore.go) (`MarkerStart` / `MarkerEnd`) so they cannot drift from what is on disk. |
| `note` | Only printed for `shadowed-by-global`. Explains that already-tracked files stay tracked (a global ignore does not retroactively untrack files in the index) and prints the same force-add line `init --visibility=global-default` shows, for repos where the scaffold is being newly added but should be committed openly. |

### Detection precedence

Git's ignore precedence is `.gitignore` > `.git/info/exclude` > `core.excludesfile`, so the block can in principle exist in more than one file at once. `status` reports the most-local (highest-precedence) carrier it finds. `shadowed-by-global` is reported only when neither `.gitignore` nor `.git/info/exclude` carries the block but the machine-wide excludes file does — that is the state where the scaffold is committed openly here yet ignored everywhere else.

The carrier-path helpers come from [internal/gitignore](../internal/gitignore/gitignore.go) (`LocalPath`, `HiddenPath`) and the global-excludes path from [internal/gitconfig](../internal/gitconfig/gitconfig.go) (`GlobalPath`, which is read-only — it never sets `core.excludesfile`). The presence check uses the same managed-block markers the writers do, exposed via `gitignore.HasBlock`.

### Behavior

- Reads at most three files: the target's `.gitignore`, its `.git/info/exclude`, and the machine-wide excludes file. May invoke `git config --global --get core.excludesfile` to locate the machine-wide file. A missing file is not an error — it is the normal `shared` case.
- Writes nothing. Never sets a git config key. Safe to run in CI or against a repo you do not own.
- Detection is marker-based, not effective-behavior. `status` looks for the managed fenced block; it does not evaluate the surrounding `.gitignore`. A user who adds a manual `!.agent/` rule below the block to opt back in still gets `mode: local`/`hidden`/`shadowed-by-global`.
- No flags. `--help` and `-h` print the usage as on every subcommand.
- Source: [internal/cli/status.go](../internal/cli/status.go) (`runStatus`, `detectStatus`, `writeStatusReport`).

## `list-flavors`

Prints registered flavors with one-line descriptions, sorted by name.
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ var commands = []commandHelp{
"agent-init add-tracker jira ~/work/pm",
},
},
{
name: "status",
summary: "report how the scaffold is tracked (read-only)",
usage: "agent-init status [target]",
examples: []string{
"agent-init status # report status of the current directory",
"agent-init status ./my-tool # report status of ./my-tool",
},
},
{
name: "list-flavors",
summary: "print available flavors with descriptions",
Expand Down Expand Up @@ -188,6 +197,8 @@ func (a App) Run(ctx context.Context, args []string) error {
return a.runAddTracker(ctx, args[1:])
case "version":
return a.runVersion(args[1:])
case "status":
return a.runStatus(args[1:])
case "help":
// `help <subcommand>` prints that subcommand's help; bare help is
// the top-level overview. (`-h` / `--help` are caught earlier.)
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ func TestTopLevelHelpListsAllSubcommands(t *testing.T) {
t.Fatalf("Run(%v) error = %v; help must exit 0", trigger, err)
}
got := out.String()
for _, sub := range []string{"init", "add-tracker", "list-flavors", "list-trackers", "version"} {
for _, sub := range []string{"init", "add-tracker", "status", "list-flavors", "list-trackers", "version"} {
if !strings.Contains(got, sub) {
t.Errorf("top-level help missing subcommand %q:\n%s", sub, got)
}
Expand Down Expand Up @@ -768,7 +768,7 @@ func TestSubcommandHelpPrintsFlagsAndExamples(t *testing.T) {

func TestFlaglessSubcommandHelp(t *testing.T) {
t.Parallel()
for _, name := range []string{"list-flavors", "list-trackers", "version"} {
for _, name := range []string{"status", "list-flavors", "list-trackers", "version"} {
name := name
t.Run(name, func(t *testing.T) {
t.Parallel()
Expand Down
185 changes: 185 additions & 0 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package cli

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"

"github.com/Lillevang/agent-init/internal/gitconfig"
"github.com/Lillevang/agent-init/internal/gitignore"
)

// runStatus reports how the scaffold's agentic envelope is being tracked (or
// ignored) for the target repository. It is strictly read-only: no file on
// disk and no git config is mutated. The acceptance criteria come from
// issue #60: print the current visibility mode, the absolute path of the file
// carrying the agent-init ignore block, the exact undo command, and — when the
// scaffold is committed locally but a global-default ignore exists — the
// force-add hint explaining why files are still tracked.
func (a App) runStatus(args []string) error {
if wantsHelp(args) {
cmd, _ := lookupCommand("status")
a.printCommandHelp(cmd)
return nil
}
flags := a.newFlagSet("status")
if err := flags.Parse(args); err != nil {
return err
}
rest := flags.Args()
if len(rest) > 1 {
return fmt.Errorf("usage: agent-init status [target]\nRun 'agent-init status --help' for usage")
}
target := "."
if len(rest) == 1 {
target = rest[0]
}
absTarget, err := filepath.Abs(target)
if err != nil {
return fmt.Errorf("resolving target path: %w", err)
}
return a.reportStatus(absTarget, gitconfig.NewExecRunner(), gitconfig.OSEnv{})
}

// statusMode names the four visibility states `status` reports. Kept separate
// from the writer-side `visibility` type because the reader has one extra
// observable state (`shadowed-by-global`) that no `--visibility` flag value
// maps to: it is the combination of "no repo-local block here" plus "a global
// block does exist".
type statusMode string

const (
statusModeShared statusMode = "shared"
statusModeLocal statusMode = "local"
statusModeHidden statusMode = "hidden"
statusModeShadowed statusMode = "shadowed-by-global"
)

// statusFinding bundles what `status` discovered about the target repo so the
// reporter is a pure formatter — easy to test, easy to extend with new fields.
type statusFinding struct {
mode statusMode
target string // absolute path of the target directory
carrier string // absolute path of the file holding the ignore block, "" if none
// machineWide is true when the carrier is the global excludes file, so the
// printed path is annotated accordingly and the force-add hint is shown.
machineWide bool
}

// reportStatus runs the read-only detection and writes the human-readable
// report to a.out. The runner/env seam matches applyGlobalVisibility so the
// global-excludes path resolution is testable via GIT_CONFIG_GLOBAL plus a
// fake HOME (see internal/cli/cli_test.go:isolateGlobalGitConfig).
func (a App) reportStatus(absTarget string, runner gitconfig.Runner, env gitconfig.Env) error {
finding, err := detectStatus(absTarget, runner, env)
if err != nil {
return err
}
writeStatusReport(a.out, finding)
return nil
}

// detectStatus probes the three carrier files in git's precedence order — the
// committed .gitignore beats .git/info/exclude beats the global excludes file —
// and returns the most-local hit. When no repo-local block is found but a
// block sits in the machine-wide excludes, the finding is `shadowed-by-global`
// rather than `shared`, matching the issue-60 spec.
//
// Length: slightly over the ~40-line house guideline because it is a linear
// three-step pipeline (local → hidden → global) with identical structure per
// step; factoring it would scatter the precedence rule across helpers, making
// the order harder to audit. Kept inline deliberately.
func detectStatus(absTarget string, runner gitconfig.Runner, env gitconfig.Env) (statusFinding, error) {
finding := statusFinding{target: absTarget, mode: statusModeShared}

localPath, err := gitignore.LocalPath(absTarget)
if err != nil {
return statusFinding{}, fmt.Errorf("resolving .gitignore path: %w", err)
}
if has, err := fileHasBlock(localPath); err != nil {
return statusFinding{}, err
} else if has {
finding.mode = statusModeLocal
finding.carrier = localPath
return finding, nil
}

hiddenPath, err := gitignore.HiddenPath(absTarget)
if err != nil {
return statusFinding{}, fmt.Errorf("resolving .git/info/exclude path: %w", err)
}
if has, err := fileHasBlock(hiddenPath); err != nil {
return statusFinding{}, err
} else if has {
finding.mode = statusModeHidden
finding.carrier = hiddenPath
return finding, nil
}

// Only consult the global excludes file when no repo-local block carries
// the scaffold. status is read-only, so unlike applyGlobalVisibility it
// must not write or set core.excludesfile — GlobalPath does neither.
globalPath, err := gitconfig.GlobalPath(runner, env)
if err != nil {
// A failure to resolve the global path (e.g. no HOME) is not fatal
// for status — we still want to report the local state. Treat it as
// "no global carrier" so the user sees `shared` rather than an error.
return finding, nil
}
if has, err := fileHasBlock(globalPath); err != nil {
return statusFinding{}, err
} else if has {
finding.mode = statusModeShadowed
finding.carrier = globalPath
finding.machineWide = true
}
return finding, nil
}

// fileHasBlock reads path and returns whether the managed agent-init block is
// present. A missing file is not an error (it is the normal `shared` case);
// any other read error is propagated so the user sees why detection failed.
func fileHasBlock(path string) (bool, error) {
content, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("reading %s: %w", path, err)
}
return gitignore.HasBlock(string(content)), nil
}

// writeStatusReport prints the finding in a short, scannable layout matching
// the style of `init`'s output: labeled lines, no decorative borders, no
// emojis. The exact undo command uses a portable sed invocation that works on
// both GNU and BSD sed; the marker strings come from the gitignore package so
// they cannot drift from what is on disk.
func writeStatusReport(w io.Writer, f statusFinding) {
_, _ = fmt.Fprintf(w, "mode: %s\n", f.mode)
_, _ = fmt.Fprintf(w, "target: %s\n", f.target)
if f.carrier == "" {
_, _ = fmt.Fprintln(w, "no agent-init ignore block found")
return
}
if f.machineWide {
_, _ = fmt.Fprintf(w, "ignore: %s (machine-wide)\n", f.carrier)
} else {
_, _ = fmt.Fprintf(w, "ignore: %s\n", f.carrier)
}
_, _ = fmt.Fprintf(w, "undo: sed -i.bak '/%s/,/%s/d' %s\n",
gitignore.MarkerStart, gitignore.MarkerEnd, f.carrier)
_, _ = fmt.Fprintf(w, " (-i.bak leaves %s.bak as a one-shot backup; delete it after verifying)\n", f.carrier)
_, _ = fmt.Fprintf(w, " (or open %s and delete the lines from\n", f.carrier)
_, _ = fmt.Fprintf(w, " '%s' through '%s' inclusive)\n",
gitignore.MarkerStart, gitignore.MarkerEnd)
if f.mode == statusModeShadowed {
_, _ = fmt.Fprintln(w, "note: git does not apply ignore rules to files that are already in the")
_, _ = fmt.Fprintln(w, " index, so the scaffold may still be tracked in this repo. To stop")
_, _ = fmt.Fprintln(w, " tracking it, remove it from the index (git rm --cached ...). To commit")
_, _ = fmt.Fprintln(w, " the scaffold openly in a repo where it is newly being added, force-add it:")
_, _ = fmt.Fprintln(w, " git add -f .agent AGENTS.md CLAUDE.md .devcontainer Justfile .pre-commit-config.yaml")
}
}
Loading
Loading