inject --status Design
Goal: Give users visibility into the state of all sap-devs injections across detected AI tools — whether content is present, well-formed, current, and what share of each config file sap-devs occupies.
Architecture: A new Status() method on Engine iterates file-inject adapters, reads each target file, and returns a []StatusRow slice. The command layer renders the rows as either a tabwriter table or JSON. Staleness is detected by rendering the current content pipeline and comparing it to the on-disk section content.
Tech Stack: Go stdlib (os, strings, regexp, encoding/json, text/tabwriter); reuses existing findSection, ExpandHome, and pack-render helpers already in internal/adapter/.
Command Interface
inject --status is a new flag on the existing inject command.
Compatible flags:
--tool <id>— limit scan to one adapter (e.g.--tool claude-code)--project— scan project-scope targets only (default: global)--json— emit JSON array instead of tabwriter table--verbose— show stretch-goal columns (file size, token breakdown, other sections)
Mutually exclusive with --status:
--uninstall--dry-run--sync/--no-sync--stats
Human-readable output (default):
Tool Scope File Status
Claude Code global ~/.claude/CLAUDE.md ✓ current
Claude Code project .claude/CLAUDE.md ✗ stale
Cursor global ~/.cursor/rules/sap.mdc ✗ not found
Copilot global ~/.github/copilot.md ✓ currentWith --verbose (stretch-goal columns appended):
Tool Scope File Status Size Tokens SAP% Other sections
Claude Code global ~/.claude/CLAUDE.md ✓ current 14 KB 3200 42% cursor(1)With --json:
[
{
"adapter": "claude-code",
"name": "Claude Code",
"scope": "global",
"path": "~/.claude/CLAUDE.md",
"file_exists": true,
"injected": true,
"orphaned": false,
"stale": false,
"file_size_bytes": 14200,
"file_token_est": 3200,
"sap_devs_tokens": 1350,
"other_sections": [{"name": "cursor", "tokens": 400}]
}
]--json always includes all fields (including stretch-goal fields), regardless of --verbose.
Data Model
New file: internal/adapter/status.go
// SectionInfo describes a non-sap-devs fenced block found in a target file.
type SectionInfo struct {
Name string // tool prefix, e.g. "cursor" from <!-- cursor:start:Rules -->
Tokens int
}
// StatusRow is the result of inspecting one adapter target (one row per adapter+target pair).
// An adapter with both a global and a project target produces two StatusRows.
type StatusRow struct {
AdapterName string
AdapterID string
Scope string
TargetPath string // unexpanded (~-form)
FileExists bool
Injected bool // sap-devs section present and well-formed
Orphaned bool // markers found but mismatched/reversed
// Stale is true when the on-disk section content differs from what
// inject would write today. Always false when FileExists=false or
// Injected=false, or when the engine has no packs loaded.
Stale bool
// Stretch-goal fields — always populated when FileExists=true.
FileSizeBytes int
FileTokenEst int // word count × 1.3
SapDevsTokens int // token estimate for sap-devs section only
OtherSections []SectionInfo // non-sap-devs fenced blocks in the file
}JSON tags are added to all fields (snake_case). OtherSections marshals as [] not null when empty.
Engine Method
Status() ([]StatusRow, error) is added to Engine in internal/adapter/engine.go.
Algorithm per adapter target:
- Skip adapters where
ToolFilterdoesn't match (existing filter logic). - Skip non-
file-injectadapters (MCP wire already hasmcp status). - Skip targets where
target.Scope != e.opts.Scope. ExpandHome(target.Path)→ absolute path.os.ReadFile(path)— ifIsNotExist, setFileExists=falseand continue. Other errors are collected witherrors.Joinbut don't abort.FileExists=true. RunfindSectionforreplace-sectiontargets:sectionFound→Injected=truesectionOrphaned→Orphaned=truesectionNotFound→ neither flag set
- For
replace-filetargets: file existing meansInjected=true. - Staleness (only when
Injected=trueande.packs != nil):- Call
renderSectionContent(a)to get the current rendered string (see Render Helper — appliesTrimPackswith the adapter's budget). - For
replace-section: extract the on-disk bytes between the markers.findSectionreturnsstartIdx/endIdxpointing to the start of each marker string; the inner content slice isfileBytes[startIdx+len(startMarker)+1 : endIdx](skip marker + trailing\n). - For
replace-file: use the full file bytes (minus the preamble prefix). Stale = (rendered != onDisk)— direct string equality afterTrimSpace.
- Call
- Stretch-goal fields (always populated when
FileExists=true):FileSizeBytes = len(fileBytes)FileTokenEst = estimateTokens(string(fileBytes))whereestimateTokens(s) = len(strings.Fields(s)) * 13 / 10SapDevsTokens=estimateTokensof the sap-devs section slice (or 0 if not injected)OtherSections= result ofscanOtherSections(string(fileBytes))
- Append row to
rows.
Error handling: errors.Join collects all per-target errors. The partial rows slice is returned alongside the error so the caller can display whatever was found.
Helper Functions (in status.go)
// estimateTokens returns a rough token estimate: word count × 1.3.
func estimateTokens(s string) int {
return len(strings.Fields(s)) * 13 / 10
}
// scanOtherSections finds non-sap-devs HTML-comment fenced blocks.
// Pattern: <!-- <prefix>:start:<name> --> where prefix != "sap-devs".
func scanOtherSections(content string) []SectionInfoscanOtherSections uses a single compiled regexp: <!-- ([^:]+):start:[^>]+ -->. For each match where group 1 is not "sap-devs", find the matching end marker and record the token estimate of the enclosed content. Returns []SectionInfo{} (not nil) when no sections found.
Render Helper
renderSectionContent(a Adapter) string is a new private method on Engine that mirrors the full render pipeline used in Run(): apply content.TrimPacks(e.packs, maxBytes) using the adapter's MaxBytes/MaxTokens budget, then render context and format output. It returns the string that would be written between markers (or as the full file for replace-file).
This must replicate TrimPacks to avoid false-positive staleness reports on budget-constrained adapters: if packs are rendered without trimming, the comparison content will exceed what inject actually wrote, making every budget-trimmed file appear stale.
Where the rendering currently lives: In Run(), not in runFileInject. The current flow is Run() → renders ctx string → passes ctx to runFileInject(a, ctx). Introducing renderSectionContent means Run() calls renderSectionContent(a) instead of inlining the render steps, and Status() also calls renderSectionContent(a) for the staleness check. runFileInject continues to receive a pre-rendered string — its signature does not change.
Staleness Algorithm Detail
For replace-section:
rendered = renderSectionContent(a) // what inject would write today
onDisk = bytes between start and end markers // what's currently in the file
Stale = strings.TrimSpace(rendered) != strings.TrimSpace(onDisk)TrimSpace normalises trailing newlines to avoid false positives from whitespace-only differences.
For replace-file:
// mirrors ReplaceFile: preamble + "\n" + content when preamble non-empty
rendered = preamble + "\n" + renderSectionContent(a) // when target.Preamble != ""
rendered = renderSectionContent(a) // when target.Preamble == ""
onDisk = string(fileBytes)
Stale = strings.TrimSpace(rendered) != strings.TrimSpace(onDisk)Command Layer
In cmd/inject.go, the --status block follows the same early-return pattern as --uninstall:
if injectStatus {
// mutual exclusion check
// load adapters + packs + profile
// build engine with Status-appropriate options
res, err := eng.Status()
if err != nil { return err }
if injectJSON {
// json.MarshalIndent(res, ...) → stdout
} else {
// tabwriter table; if --verbose, include stretch-goal columns
}
return nil
}New package-level vars: injectStatus bool, injectJSON bool, injectVerbose bool.
--json and --verbose are registered as flags but are silently valid only when --status is also set. If used without --status, they are ignored (no error) — this is intentional: keeping them as simple booleans avoids cross-flag validation complexity, and a user who accidentally passes --json to a normal inject run will not see broken output (inject produces no stdout anyway). --stats is similarly a different output mode and is listed as mutually exclusive with --status (see exclusion list above); this must be validated in the mutual-exclusion check, not just in the flag documentation.
i18n Keys
New keys in internal/i18n/catalogs/en.json and de.json:
| Key | English value |
|---|---|
inject.status.header_tool | Tool |
inject.status.header_scope | Scope |
inject.status.header_file | File |
inject.status.header_status | Status |
inject.status.current | ✓ current |
inject.status.stale | ✗ stale |
inject.status.not_found | ✗ not found |
inject.status.orphaned | ✗ orphaned |
inject.status.not_injected | ✗ not injected |
inject.status.no_results | No file-inject adapters found for the given scope/tool. |
inject.status.append_warning | sap-devs warning: {{.Path}} uses append mode — injection state cannot be determined |
Testing
internal/adapter/status_test.go (new file, package adapter):
TestStatus_Current— write a file with a valid sap-devs section matching current render; assertInjected=true, Stale=falseTestStatus_Stale— write a file with outdated content in the section; assertStale=trueTestStatus_NotFound— target file absent; assertFileExists=false, Injected=falseTestStatus_Orphaned— file with start marker but no end marker; assertOrphaned=trueTestStatus_NotInjected— file exists but has no sap-devs markers; assertFileExists=true, Injected=falseTestStatus_ToolFilter— two adapters,ToolFilterset to one; assert only one row returnedTestStatus_ScopeFilter— target has scope "project", engine scope "global"; assert no rowsTestStatus_ReplaceFile—replace-filemode target; assert file-existence maps toInjected=trueTestStatus_OtherSections— file with one sap-devs block + one cursor block; assertOtherSectionshas one entryTestStatus_TokenEstimate— known string; assertFileTokenEstmatches expected valueTestStatus_ErrorContinues— one target with unreadable path (permissions error); assert error returned but other rows still populated
cmd/inject_status_test.go (new file, package cmd):
TestInjectStatus_FlagExists—--statusflag registeredTestInjectStatus_MutualExclusion—--status+--uninstallreturns errorTestInjectStatus_JSONAndVerboseNoErrorWithoutStatus—--json+--verbosealone don't error
internal/adapter/status_helpers_test.go (or inline in status_test.go):
TestEstimateTokens— unit test for token estimatorTestScanOtherSections_Empty— no non-sap-devs sectionsTestScanOtherSections_OneMatch— one cursor block foundTestScanOtherSections_IgnoresSapDevs— sap-devs block not included in results
Out of Scope
- MCP wire adapter status (already covered by
mcp status) - Clipboard-export adapters (ephemeral; no persistent state to check)
append-mode targets (no markers to detect; emit a warning to stderr using a new i18n keyinject.status.append_warning:"sap-devs warning: {{.Path}} uses append mode — injection state cannot be determined")- Automatic repair / re-injection on stale detection