@anyberg/agent-conventions 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.codex-plugin/plugin.json +6 -0
  4. package/AGENTS.md +24 -0
  5. package/README.md +244 -0
  6. package/agents/code-reviewer.md +36 -0
  7. package/agents/docs-change-steward.md +71 -0
  8. package/agents/feature-planner.md +24 -0
  9. package/agents/implementation.md +35 -0
  10. package/agents/refactoring-planner.md +38 -0
  11. package/agents/repo-search.md +32 -0
  12. package/agents/test-runner.md +34 -0
  13. package/bin/cli.js +311 -0
  14. package/gemini-extension.json +5 -0
  15. package/package.json +47 -0
  16. package/plugin.json +12 -0
  17. package/skills/api-design/SKILL.md +18 -0
  18. package/skills/architecture-planning/SKILL.md +113 -0
  19. package/skills/backlog-management/SKILL.md +73 -0
  20. package/skills/backlog-management/backends/github-issues.md +37 -0
  21. package/skills/backlog-management/backends/markdown.md +34 -0
  22. package/skills/backlog-management/scripts/detect-backend.sh +30 -0
  23. package/skills/backlog-management/scripts/generate-policy.sh +49 -0
  24. package/skills/code-review/SKILL.md +95 -0
  25. package/skills/code-standards/SKILL.md +73 -0
  26. package/skills/docs-standards/SKILL.md +95 -0
  27. package/skills/git-conventions/SKILL.md +95 -0
  28. package/skills/hatch-workflow/SKILL.md +147 -0
  29. package/skills/python-best-practices/SKILL.md +107 -0
  30. package/skills/python-coding-guidelines/SKILL.md +58 -0
  31. package/skills/python-design-patterns/SKILL.md +28 -0
  32. package/skills/rust-best-practices/SKILL.md +171 -0
  33. package/skills/rust-coding-guidelines/SKILL.md +77 -0
  34. package/skills/rust-design-patterns/SKILL.md +83 -0
  35. package/skills/task-workflow/SKILL.md +122 -0
  36. package/skills/tech-debt/SKILL.md +41 -0
  37. package/skills/test-driven-development/SKILL.md +113 -0
  38. package/skills/testing-strategy/SKILL.md +35 -0
  39. package/skills/typescript-coding-guidelines/SKILL.md +55 -0
  40. package/src/plan.js +116 -0
  41. package/src/targets.js +95 -0
  42. package/src/write.js +184 -0
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: tech-debt
3
+ description: Identify, categorize, and prioritize technical debt. Trigger with "tech debt", "technical debt audit", "what should we refactor", "code health", or when the user asks about code quality, refactoring priorities, or maintenance backlog.
4
+ ---
5
+
6
+ # Tech Debt Management
7
+
8
+ Systematically identify, categorize, and prioritize technical debt.
9
+
10
+ ## Categories
11
+
12
+ | Type | Examples | Risk |
13
+ |------|----------|------|
14
+ | **Code debt** | Duplicated logic, poor abstractions, magic numbers | Bugs, slow development |
15
+ | **Architecture debt** | Monolith that should be split, wrong data store | Scaling limits |
16
+ | **Test debt** | Low coverage, flaky tests, missing integration tests | Regressions ship |
17
+ | **Dependency debt** | Outdated libraries, unmaintained dependencies | Security vulns |
18
+ | **Documentation debt** | Missing runbooks, outdated READMEs, tribal knowledge | Onboarding pain |
19
+ | **Infrastructure debt** | Manual deploys, no monitoring, no IaC | Incidents, slow recovery |
20
+
21
+ ## Prioritization Framework
22
+
23
+ Score each item on:
24
+ - **Impact**: How much does it slow the team down? (1 = negligible, 5 = severe)
25
+ - **Risk**: What happens if we don't fix it? (1 = harmless, 5 = critical)
26
+ - **Effort**: How hard is the fix? (1 = trivial, 5 = very hard)
27
+
28
+ Priority = (Impact + Risk) × (6 − Effort)
29
+
30
+ Score Effort on its raw scale — the `(6 − Effort)` term inverts it so cheap, high-value fixes rank highest. Higher score = higher priority.
31
+
32
+ **Example:** A flaky test suite (Impact 4, Risk 3, Effort 2) scores `(4 + 3) × (6 − 2) = 28`. A monolith split (Impact 5, Risk 4, Effort 5) scores `(5 + 4) × (6 − 5) = 9` — high value, but its cost pushes it below cheaper wins.
33
+
34
+ ## Output
35
+
36
+ Produce a prioritized list with estimated effort, business justification for each item, and a phased remediation plan that can be done alongside feature work.
37
+
38
+ Feed the results into the existing workflow rather than letting them sit in a report:
39
+
40
+ - Add each item to `BACKLOG.md` as a `refactor` (or matching type) row via the **backlog-management** skill.
41
+ - Promote high-priority items to task files through the **task-workflow** skill before starting work.
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: test-driven-development
3
+ description: Use when implementing any feature or bugfix, before writing implementation code
4
+ ---
5
+
6
+ # Test-Driven Development (TDD)
7
+
8
+ ## Overview
9
+
10
+ Write the test first. Watch it fail. Write minimal code to pass.
11
+
12
+ **Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.
13
+
14
+ **Scope:** TDD is the *loop* for building one behavior at a time. To decide *which* behaviors deserve tests and *what* coverage to aim for, work out the plan with the **testing-strategy** skill first, then drive each item through the cycle below.
15
+
16
+ ## When to Use
17
+
18
+ **Always:** new features, bug fixes, refactoring, behavior changes.
19
+
20
+ **Exceptions (confirm with user):** throwaway prototypes, generated code, configuration files.
21
+
22
+ ## The Iron Law
23
+
24
+ ```
25
+ NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
26
+ ```
27
+
28
+ Write code before the test? Delete it. No exceptions — no keeping it as "reference," no "adapting" it. Delete means delete.
29
+
30
+ ## Red-Green-Refactor
31
+
32
+ ### RED — Write Failing Test
33
+
34
+ Write one minimal test showing what should happen. Pick the behavior from your test plan — cover business-critical paths, error handling, edge cases, and security boundaries first; skip trivial getters/setters and framework code (see the **testing-strategy** skill).
35
+
36
+ **Requirements:**
37
+ - Tests one behavior
38
+ - Name describes the behavior
39
+ - Uses real code (no mocks unless unavoidable)
40
+
41
+ ### Verify RED — Watch It Fail
42
+
43
+ **MANDATORY. Never skip.**
44
+
45
+ Run the test and confirm:
46
+ - It fails (not errors)
47
+ - Failure message is expected
48
+ - It fails because the feature is missing, not due to typos
49
+
50
+ Test passes immediately? You're testing existing behavior — fix the test.
51
+
52
+ ### GREEN — Write Minimal Code
53
+
54
+ Write the simplest code that passes the test. Do not add features, refactor other code, or "improve" beyond what the test requires.
55
+
56
+ ### Verify GREEN — Watch It Pass
57
+
58
+ **MANDATORY.**
59
+
60
+ Run the test suite and confirm:
61
+ - The new test passes
62
+ - All other tests still pass
63
+ - Output is clean (no errors or warnings)
64
+
65
+ Test fails? Fix code, not test. Other tests fail? Fix them now.
66
+
67
+ ### REFACTOR — Clean Up
68
+
69
+ After green only: remove duplication, improve names, extract helpers. Keep tests green. Do not add behavior.
70
+
71
+ ### Repeat
72
+
73
+ Write the next failing test for the next behavior.
74
+
75
+ ## Good Tests
76
+
77
+ | Quality | Good | Bad |
78
+ |---------|------|-----|
79
+ | **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` |
80
+ | **Clear** | Name describes the behavior | `test('test1')` |
81
+ | **Shows intent** | Demonstrates desired API | Obscures what code should do |
82
+
83
+ ## Red Flags — Stop and Start Over
84
+
85
+ - Code written before test
86
+ - Test added after implementation
87
+ - Test passes immediately without explanation
88
+ - Can't explain why the test failed
89
+ - Rationalizing "just this once"
90
+ - "I already manually tested it"
91
+ - "Tests after achieve the same purpose"
92
+ - "Keep as reference" or "adapt existing code"
93
+ - "Already spent X hours, deleting is wasteful"
94
+ - "TDD is dogmatic, I'm being pragmatic"
95
+ - "This is different because..."
96
+ - "It's about spirit not ritual"
97
+
98
+ **All of these mean: delete the code, start over with TDD.**
99
+
100
+ ## Verification Checklist
101
+
102
+ Before marking work complete:
103
+
104
+ - [ ] Every new function/method has a test
105
+ - [ ] Watched each test fail before implementing
106
+ - [ ] Each test failed for expected reason (feature missing, not typo)
107
+ - [ ] Wrote minimal code to pass each test
108
+ - [ ] All tests pass
109
+ - [ ] Output pristine (no errors, warnings)
110
+ - [ ] Tests use real code (mocks only if unavoidable)
111
+ - [ ] Edge cases and errors covered
112
+
113
+ Can't check all boxes? You skipped TDD. Start over.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: testing-strategy
3
+ description: Design test strategies and test plans. Trigger with "how should we test", "test strategy for", "write tests for", "test plan", "what tests do we need", or when the user needs help with testing approaches, coverage, or test architecture.
4
+ ---
5
+
6
+ # Testing Strategy
7
+
8
+ Design effective testing strategies balancing coverage, speed, and maintenance.
9
+
10
+ ## Testing Pyramid
11
+
12
+ ```
13
+ / E2E \ Few, slow, high confidence
14
+ / Integration \ Some, medium speed
15
+ / Unit Tests \ Many, fast, focused
16
+ ```
17
+
18
+ ## Strategy by Component Type
19
+
20
+ - **API endpoints**: Unit tests for business logic, integration tests for HTTP layer, contract tests for consumers
21
+ - **Data pipelines**: Input validation, transformation correctness, idempotency tests
22
+ - **Frontend**: Component tests, interaction tests, visual regression, accessibility
23
+ - **Infrastructure**: Smoke tests, chaos engineering, load tests
24
+
25
+ ## What to Cover
26
+
27
+ Focus on: business-critical paths, error handling, edge cases, security boundaries, data integrity.
28
+
29
+ Skip: trivial getters/setters, framework code, one-off scripts.
30
+
31
+ ## Output
32
+
33
+ Produce a test plan with: what to test, test type for each area, coverage targets, and example test cases. Identify gaps in existing coverage.
34
+
35
+ Drive each item in the plan through the **test-driven-development** skill's Red-Green-Refactor loop — the plan decides *what* to test; TDD governs *how* each test gets written.
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: typescript-coding-guidelines
3
+ description: Rules for TypeScript and JavaScript code — idioms, type system usage, error handling, naming, imports, and eliminating unnecessary complexity. Use when reading or writing .ts, .tsx, .js, or .jsx files.
4
+ ---
5
+
6
+ # TypeScript & JavaScript Coding Guidelines
7
+
8
+ Applies to all `.ts`, `.tsx`, `.js`, and `.jsx` files. Builds on the **Universal Code Rules** in `code-standards`; only TypeScript/JavaScript-specific rules are listed here. TypeScript-only rules are marked **[TS]**.
9
+
10
+ ## Type System [TS]
11
+
12
+ - Use `instanceof` for class-based type narrowing; use discriminated unions with a `kind` or `type` literal field for sum-type narrowing — Enables proper type narrowing for static analysis and prevents fragile duck-typing
13
+ - Use `unknown` instead of `any` — forces explicit narrowing before use, catching errors at compile time rather than runtime
14
+ - Avoid `as` type assertions except when runtime logic guarantees safety and static analysis cannot narrow (e.g., after a literal check or known invariant) — use type guards or discriminated unions instead; document the safety reasoning when `as` is genuinely necessary
15
+ - Use `satisfies` to validate an expression against a type without widening the inferred type — preserves literal types while catching structural mismatches at the point of definition
16
+ - Use `as const` assertions for readonly literal tuples and objects — prevents widening to mutable primitive types
17
+ - Use `Literal` union types for fixed string/number value sets in parameters, fields, and return types — Makes valid values explicit in signatures and enables static catch of invalid values
18
+ - Create type aliases for complex types (3+ union branches, object types used 2+ times) — reduces duplication and improves readability; skip aliases for simple one-off internal types
19
+ - Use `interface` for extensible object shapes (declaration merging, class `implements`); use `type` for unions, intersections, mapped types, and aliases — matches the intended extension model of each construct
20
+ - Use `readonly` on arrays (`readonly T[]`) and object properties that must not be mutated — makes immutability intent explicit and catches accidental writes at compile time
21
+ - Use `never` to enforce exhaustive checks in `switch`/`if`-else chains over discriminated unions — a compile-time guarantee that all cases are handled
22
+ - Remove `| undefined` from fields when values are guaranteed to be initialized — prevents false optionality and unnecessary non-null checks
23
+ - Avoid `@ts-ignore`; use `@ts-expect-error` only when unavoidable, always with a comment explaining why the suppression is safe and what error it hides
24
+ - Fix type errors properly — use type annotations, narrowing, or `as` with explanatory comments — prevents masking real type errors that indicate structural problems
25
+ - **`strict: true` is non-negotiable** — it enables `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`, and related checks; never disable it project-wide
26
+
27
+ ## Error Handling
28
+
29
+ - Use domain-specific error classes extending `Error` — set `this.name` in the constructor for clear stack traces and `instanceof` checks
30
+ - Use the `cause` option when re-throwing: `throw new AppError("message", { cause: err })` — preserves the original stack trace and error chain
31
+ - Avoid swallowing errors in `.catch(() => {})` — at minimum log them with enough context to diagnose the issue later
32
+
33
+ ## Naming
34
+
35
+ - Use `camelCase` for variables, functions, and methods; `PascalCase` for classes, interfaces, types, and enums; `UPPER_CASE` for module-level constants (`const MAX_RETRIES = 3`)
36
+ - Prefix internal module constants with `_` when they are not part of the public export surface (`const _CACHE_TTL_MS = 60_000`)
37
+
38
+ ## Imports
39
+
40
+ - Prefer named exports over default exports — named exports improve refactoring support, IDE discoverability, and make re-exporting explicit
41
+ - Use barrel files (`index.ts`) to define the public API of a module — export only what consumers need; do not re-export internal implementation details
42
+ - Avoid circular imports — if two modules depend on each other, extract shared logic into a third module
43
+ - Group imports consistently: external packages first, then internal modules (`@/`, `~/`, relative), separated by a blank line; enforce with ESLint's `import/order` rule or Biome
44
+
45
+ ## Testing
46
+
47
+ - Prefer `vi.fn()` / `jest.fn()` stubs over full module mocks when testing units in isolation — avoids over-specification and keeps tests resilient to refactoring
48
+
49
+ ## General
50
+
51
+ - Use `pnpm` by default unless `package.json` scripts or project docs specify `npm` or `yarn`
52
+ - Run `eslint` and `prettier` (or `biome`) before committing — enforce via pre-commit hooks or CI; never disable rules project-wide without a documented reason
53
+ - Prefer `const` over `let`; never use `var` — `const` communicates immutability of the binding and prevents accidental reassignment
54
+ - Use optional chaining (`?.`) and nullish coalescing (`??`) instead of manual null guards — more concise and avoids incorrectly coalescing on `0`, `""`, or `false`
55
+ - Use `structuredClone()` for deep cloning plain objects instead of `JSON.parse(JSON.stringify(...))` — handles more types correctly and is faster in modern runtimes
package/src/plan.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Build the complete set of writes before performing any of them.
3
+ *
4
+ * Planning is separate from writing so the disclosure screen shows exactly what
5
+ * `--dry-run` would do and exactly what a confirmed run will do — the same
6
+ * structure, rendered once and executed once. A preview that is computed
7
+ * differently from the action it previews is worse than no preview.
8
+ */
9
+
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { instructionTargets, receiptPath, skillTargets } from './targets.js'
13
+ import { planInstructionWrite } from './write.js'
14
+
15
+ /** Skill directories shipped in this package. */
16
+ export function bundledSkills(packageRoot) {
17
+ const dir = path.join(packageRoot, 'skills')
18
+ return fs
19
+ .readdirSync(dir, { withFileTypes: true })
20
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
21
+ .map((e) => ({ name: e.name, dir: path.join(dir, e.name) }))
22
+ .sort((a, b) => a.name.localeCompare(b.name))
23
+ }
24
+
25
+ export function buildPlan({
26
+ packageRoot,
27
+ scope,
28
+ components,
29
+ agents,
30
+ home,
31
+ cwd,
32
+ copy = false,
33
+ }) {
34
+ const plan = { scope, components, mode: copy ? 'copy' : 'symlink', skills: null, instructions: [] }
35
+
36
+ if (components.includes('skills')) {
37
+ const targets = skillTargets(scope, { home, cwd })
38
+ const skills = bundledSkills(packageRoot)
39
+ // Claude Code is the only agent needing a link; skip it if unselected.
40
+ const links = targets.links.filter((l) => agents.includes(l.agent))
41
+ plan.skills = {
42
+ canonical: targets.canonical,
43
+ canonicalReadBy: targets.canonicalReadBy,
44
+ names: skills.map((s) => s.name),
45
+ sources: skills,
46
+ links,
47
+ }
48
+ }
49
+
50
+ if (components.includes('instructions')) {
51
+ // Global only. A project's own AGENTS.md belongs to the project, and
52
+ // overwriting it with ours is never the right default.
53
+ if (scope !== 'global') {
54
+ plan.instructionsSkipped = 'instructions install at global scope only'
55
+ } else {
56
+ plan.instructions = instructionTargets({ home })
57
+ .filter((t) => agents.includes(t.agent))
58
+ .map((t) => ({ ...planInstructionWrite(t.file), label: t.label, agent: t.agent }))
59
+ }
60
+ }
61
+
62
+ plan.receipt = receiptPath(scope, { home, cwd })
63
+ return plan
64
+ }
65
+
66
+ /** Render the plan as the disclosure the user confirms against. */
67
+ export function renderDisclosure(plan, { packageVersion }) {
68
+ const lines = []
69
+ const where = plan.scope === 'global' ? 'GLOBAL' : 'PROJECT'
70
+ lines.push(`agent-conventions ${packageVersion} — ${where} scope`)
71
+ lines.push('')
72
+
73
+ if (plan.scope === 'global') {
74
+ lines.push('These paths live outside your project and affect every repo on this machine.')
75
+ lines.push('')
76
+ }
77
+
78
+ if (plan.skills) {
79
+ lines.push(` ${plan.skills.canonical}`)
80
+ lines.push(` ${plan.skills.names.length} skills — read by ${plan.skills.canonicalReadBy.join(', ')}`)
81
+ for (const link of plan.skills.links) {
82
+ lines.push(` ${link.dir}`)
83
+ lines.push(` ${plan.mode} → the directory above (${link.label} does not read .agents/skills)`)
84
+ }
85
+ lines.push('')
86
+ }
87
+
88
+ if (plan.instructionsSkipped) {
89
+ lines.push(` instructions: skipped — ${plan.instructionsSkipped}`)
90
+ lines.push('')
91
+ }
92
+
93
+ if (plan.instructions.length) {
94
+ for (const item of plan.instructions) {
95
+ lines.push(` ${item.file}`)
96
+ lines.push(` ${item.detail}`)
97
+ }
98
+ lines.push('')
99
+ lines.push(' Content is wrapped in <!-- BEGIN/END aanyberg/agent-conventions --> markers.')
100
+ lines.push(' Everything outside the markers is preserved; uninstall removes only the block.')
101
+ lines.push('')
102
+ }
103
+
104
+ const refusals = plan.instructions.filter((i) => i.action.startsWith('refuse'))
105
+ if (refusals.length) {
106
+ lines.push(' REFUSED without --replace-symlinks:')
107
+ for (const r of refusals) {
108
+ lines.push(` ${r.file} is a ${r.detail}`)
109
+ }
110
+ lines.push(' Writing through a symlink would modify the file it points at.')
111
+ lines.push('')
112
+ }
113
+
114
+ lines.push(` receipt: ${plan.receipt}`)
115
+ return lines.join('\n')
116
+ }
package/src/targets.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The one place that knows where anything goes.
3
+ *
4
+ * Two facts keep this table small, and both were verified by running the
5
+ * ecosystem's own installer rather than read off documentation:
6
+ *
7
+ * 1. `.agents/skills/` is universal. Codex, GitHub Copilot, OpenCode, Cursor,
8
+ * Gemini CLI, Cline, Zed and Amp all read it. Claude Code is the sole
9
+ * holdout, so it gets a symlink into the same files rather than a copy.
10
+ * 2. That holds at BOTH scopes. `~/.agents/skills/` is universal too, so the
11
+ * global case needs no per-agent table either.
12
+ *
13
+ * Instructions are the opposite: every tool keeps global guidance in its own
14
+ * directory, and two of them expect a different filename there. That is why the
15
+ * instruction table has real entries while the skills one has two.
16
+ */
17
+
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+
21
+ /** Skills: one real directory, plus the agents that need a link into it. */
22
+ export function skillTargets(scope, { home = os.homedir(), cwd = process.cwd() } = {}) {
23
+ const root = scope === 'global' ? home : cwd
24
+ return {
25
+ // The real files. Everything else points here.
26
+ canonical: path.join(root, '.agents', 'skills'),
27
+ canonicalReadBy: [
28
+ 'Codex', 'GitHub Copilot', 'OpenCode', 'Cursor',
29
+ 'Gemini CLI', 'Cline', 'Zed', 'Amp',
30
+ ],
31
+ // Agents that do not read `.agents/skills` and need their own path.
32
+ links: [{ agent: 'claude-code', label: 'Claude Code', dir: path.join(root, '.claude', 'skills') }],
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Instructions: global only, and never the project's own AGENTS.md.
38
+ *
39
+ * A consumer's project-root AGENTS.md belongs to them. Writing it would clobber
40
+ * their content with ours, so this installer does not offer to.
41
+ */
42
+ export const INSTRUCTION_TARGETS = [
43
+ { agent: 'claude-code', label: 'Claude Code', rel: ['.claude', 'CLAUDE.md'] },
44
+ { agent: 'github-copilot', label: 'GitHub Copilot', rel: ['.copilot', 'copilot-instructions.md'] },
45
+ { agent: 'codex', label: 'Codex', rel: ['.codex', 'AGENTS.md'] },
46
+ { agent: 'gemini-cli', label: 'Gemini CLI', rel: ['.gemini', 'GEMINI.md'] },
47
+ ]
48
+
49
+ export function instructionTargets({ home = os.homedir() } = {}) {
50
+ return INSTRUCTION_TARGETS.map((t) => ({ ...t, file: path.join(home, ...t.rel) }))
51
+ }
52
+
53
+ /** Agents selectable at global scope. Project scope needs no selection: one write covers all. */
54
+ export const SELECTABLE_AGENTS = [
55
+ { id: 'claude-code', label: 'Claude Code' },
56
+ { id: 'codex', label: 'Codex' },
57
+ { id: 'github-copilot', label: 'GitHub Copilot' },
58
+ { id: 'opencode', label: 'OpenCode' },
59
+ { id: 'cursor', label: 'Cursor' },
60
+ { id: 'gemini-cli', label: 'Gemini CLI' },
61
+ ]
62
+
63
+ /** Where the receipt lives, so update and uninstall touch only what we wrote. */
64
+ export function receiptPath(scope, { home = os.homedir(), cwd = process.cwd() } = {}) {
65
+ const root = scope === 'global' ? home : cwd
66
+ return path.join(root, '.agent-conventions.json')
67
+ }
68
+
69
+ export const MARKER_BEGIN = '<!-- BEGIN aanyberg/agent-conventions -->'
70
+ export const MARKER_END = '<!-- END aanyberg/agent-conventions -->'
71
+
72
+ /**
73
+ * Interpret an answer to the global agent prompt.
74
+ *
75
+ * Pure so it can be tested without a terminal: the prompt itself needs a TTY,
76
+ * but the rule for turning "1 3", "a" or "" into a set of agents is the part
77
+ * with edge cases worth pinning down.
78
+ *
79
+ * An answer that matches nothing is treated as a typo and falls back to the
80
+ * preset — installing for no agents at all is never what someone meant.
81
+ */
82
+ export function parseAgentSelection(answer, { preset }) {
83
+ const all = SELECTABLE_AGENTS.map((a) => a.id)
84
+ const text = String(answer ?? '').trim()
85
+ if (text === '') return { agents: preset, reason: 'preset' }
86
+ if (/^(a|all)$/i.test(text)) return { agents: all, reason: 'all' }
87
+ const chosen = text
88
+ .split(/[,\s]+/)
89
+ .filter(Boolean)
90
+ .map((n) => SELECTABLE_AGENTS[Number(n) - 1])
91
+ .filter(Boolean)
92
+ .map((a) => a.id)
93
+ const unique = [...new Set(chosen)]
94
+ return unique.length ? { agents: unique, reason: 'chosen' } : { agents: preset, reason: 'unrecognised' }
95
+ }
package/src/write.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Everything that touches the filesystem, and the rules that keep it safe.
3
+ *
4
+ * Two invariants matter more than anything else here:
5
+ *
6
+ * 1. **Never write through a symlink.** The previous README told people to run
7
+ * `ln -s /path/to/agent-conventions/AGENTS.md ~/.claude/CLAUDE.md`. Opening
8
+ * that path in append mode writes *into their clone of this repository* and
9
+ * corrupts the source of truth, which then gets committed. Every write
10
+ * lstats first and refuses to follow a link.
11
+ *
12
+ * 2. **Never remove a file we did not write.** Uninstall works from the
13
+ * receipt, not from a guess about what an install would have produced.
14
+ */
15
+
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { MARKER_BEGIN, MARKER_END } from './targets.js'
19
+
20
+ /** What is actually at this path? Uses lstat, so a symlink reports as one. */
21
+ export function classify(file) {
22
+ let st
23
+ try {
24
+ st = fs.lstatSync(file)
25
+ } catch (err) {
26
+ if (err.code === 'ENOENT') return { kind: 'absent' }
27
+ throw err
28
+ }
29
+ if (st.isSymbolicLink()) {
30
+ let resolved = null
31
+ try {
32
+ resolved = fs.realpathSync(file)
33
+ } catch {
34
+ resolved = fs.readlinkSync(file) // dangling link — report where it pointed
35
+ }
36
+ return { kind: 'symlink', resolved }
37
+ }
38
+ if (st.isDirectory()) return { kind: 'directory' }
39
+ return { kind: 'file' }
40
+ }
41
+
42
+ /** Wrap content in the markers that make it removable later. */
43
+ export function renderBlock(content) {
44
+ return `${MARKER_BEGIN}\n${content.trimEnd()}\n${MARKER_END}\n`
45
+ }
46
+
47
+ export function hasBlock(text) {
48
+ return text.includes(MARKER_BEGIN) && text.includes(MARKER_END)
49
+ }
50
+
51
+ /**
52
+ * Insert or replace our block, preserving everything outside it.
53
+ *
54
+ * Deliberately string-based rather than regex: the content can contain any
55
+ * markdown, and a greedy pattern over user text is the kind of thing that
56
+ * silently eats a paragraph.
57
+ */
58
+ export function upsertBlock(existing, content) {
59
+ const block = renderBlock(content)
60
+ if (!hasBlock(existing)) {
61
+ const sep = existing.length === 0 || existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n'
62
+ return existing + sep + block
63
+ }
64
+ const start = existing.indexOf(MARKER_BEGIN)
65
+ const end = existing.indexOf(MARKER_END) + MARKER_END.length
66
+ if (end < start) throw new Error('markers are out of order; refusing to edit')
67
+ const trailing = existing.slice(end).replace(/^\n/, '')
68
+ return existing.slice(0, start) + block + trailing
69
+ }
70
+
71
+ /**
72
+ * Strip our block and leave the rest untouched.
73
+ *
74
+ * `upsertBlock` inserts a blank-line separator before the block when appending
75
+ * to a file that ends in a single newline, so removal has to take that
76
+ * separator back out. Without this the file gains one blank line per
77
+ * install/uninstall cycle — the round-trip tests below are what caught it.
78
+ */
79
+ export function removeBlock(existing) {
80
+ if (!hasBlock(existing)) return existing
81
+ const start = existing.indexOf(MARKER_BEGIN)
82
+ const end = existing.indexOf(MARKER_END) + MARKER_END.length
83
+ const before = existing.slice(0, start)
84
+ const after = existing.slice(end).replace(/^\n/, '')
85
+ // Only collapse when the block sat at the end; mid-file, the surrounding
86
+ // blank lines are the user's own and must survive untouched.
87
+ const restored = after === '' ? before.replace(/\n\n$/, '\n') : before + after
88
+ return restored.trim().length === 0 ? '' : restored
89
+ }
90
+
91
+ /**
92
+ * Decide what an instruction write would do, without doing it.
93
+ *
94
+ * Returns an `action` the caller renders in the disclosure screen, so the user
95
+ * sees "replace symlink" before it happens rather than after.
96
+ */
97
+ export function planInstructionWrite(file) {
98
+ const at = classify(file)
99
+ if (at.kind === 'absent') return { file, action: 'create', detail: 'creates file' }
100
+ if (at.kind === 'directory') return { file, action: 'refuse', detail: 'a directory exists here' }
101
+ if (at.kind === 'symlink') {
102
+ return {
103
+ file,
104
+ action: 'refuse-symlink',
105
+ detail: `symlink → ${at.resolved}`,
106
+ resolved: at.resolved,
107
+ }
108
+ }
109
+ const text = fs.readFileSync(file, 'utf8')
110
+ const kept = removeBlock(text).split('\n').filter(Boolean).length
111
+ return {
112
+ file,
113
+ action: hasBlock(text) ? 'update' : 'append',
114
+ detail: hasBlock(text) ? 'updates existing block' : `appends block (${kept} existing lines kept)`,
115
+ }
116
+ }
117
+
118
+ /** Apply an instruction write. Refuses anything the plan marked as refusable. */
119
+ export function applyInstructionWrite(plan, content, { replaceSymlinks = false } = {}) {
120
+ if (plan.action === 'refuse') {
121
+ throw new Error(`${plan.file}: ${plan.detail}`)
122
+ }
123
+ if (plan.action === 'refuse-symlink' && !replaceSymlinks) {
124
+ throw new Error(
125
+ `${plan.file} is a symlink → ${plan.resolved}. Writing through it would modify ` +
126
+ `that file. Re-run with --replace-symlinks to replace the link with a real file.`,
127
+ )
128
+ }
129
+ fs.mkdirSync(path.dirname(plan.file), { recursive: true })
130
+ if (plan.action === 'refuse-symlink') {
131
+ fs.unlinkSync(plan.file) // replace the link itself, never follow it
132
+ fs.writeFileSync(plan.file, renderBlock(content), 'utf8')
133
+ return { ...plan, applied: 'replaced-symlink' }
134
+ }
135
+ const existing = plan.action === 'create' ? '' : fs.readFileSync(plan.file, 'utf8')
136
+ fs.writeFileSync(plan.file, upsertBlock(existing, content), 'utf8')
137
+ return { ...plan, applied: plan.action }
138
+ }
139
+
140
+ /** Copy a skill directory, replacing any previous copy of the same skill. */
141
+ export function installSkill(sourceDir, destDir) {
142
+ fs.rmSync(destDir, { recursive: true, force: true })
143
+ fs.mkdirSync(path.dirname(destDir), { recursive: true })
144
+ fs.cpSync(sourceDir, destDir, { recursive: true })
145
+ }
146
+
147
+ /**
148
+ * Link a skill into an agent-specific directory.
149
+ *
150
+ * Relative targets, so a committed project tree still resolves after a clone to
151
+ * a different path. Falls back to a copy where symlinks are unavailable, which
152
+ * is Windows without Developer Mode more often than anything else.
153
+ */
154
+ export function linkSkill(canonicalDir, linkPath, { copy = false } = {}) {
155
+ fs.rmSync(linkPath, { recursive: true, force: true })
156
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true })
157
+ if (copy) {
158
+ fs.cpSync(canonicalDir, linkPath, { recursive: true })
159
+ return 'copy'
160
+ }
161
+ const rel = path.relative(path.dirname(linkPath), canonicalDir)
162
+ try {
163
+ fs.symlinkSync(rel, linkPath, 'dir')
164
+ return 'symlink'
165
+ } catch (err) {
166
+ if (err.code !== 'EPERM' && err.code !== 'EACCES') throw err
167
+ fs.cpSync(canonicalDir, linkPath, { recursive: true })
168
+ return 'copy'
169
+ }
170
+ }
171
+
172
+ export function readReceipt(file) {
173
+ try {
174
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
175
+ } catch (err) {
176
+ if (err.code === 'ENOENT') return null
177
+ throw err
178
+ }
179
+ }
180
+
181
+ export function writeReceipt(file, receipt) {
182
+ fs.mkdirSync(path.dirname(file), { recursive: true })
183
+ fs.writeFileSync(file, JSON.stringify(receipt, null, 2) + '\n', 'utf8')
184
+ }