@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +6 -0
- package/AGENTS.md +24 -0
- package/README.md +244 -0
- package/agents/code-reviewer.md +36 -0
- package/agents/docs-change-steward.md +71 -0
- package/agents/feature-planner.md +24 -0
- package/agents/implementation.md +35 -0
- package/agents/refactoring-planner.md +38 -0
- package/agents/repo-search.md +32 -0
- package/agents/test-runner.md +34 -0
- package/bin/cli.js +311 -0
- package/gemini-extension.json +5 -0
- package/package.json +47 -0
- package/plugin.json +12 -0
- package/skills/api-design/SKILL.md +18 -0
- package/skills/architecture-planning/SKILL.md +113 -0
- package/skills/backlog-management/SKILL.md +73 -0
- package/skills/backlog-management/backends/github-issues.md +37 -0
- package/skills/backlog-management/backends/markdown.md +34 -0
- package/skills/backlog-management/scripts/detect-backend.sh +30 -0
- package/skills/backlog-management/scripts/generate-policy.sh +49 -0
- package/skills/code-review/SKILL.md +95 -0
- package/skills/code-standards/SKILL.md +73 -0
- package/skills/docs-standards/SKILL.md +95 -0
- package/skills/git-conventions/SKILL.md +95 -0
- package/skills/hatch-workflow/SKILL.md +147 -0
- package/skills/python-best-practices/SKILL.md +107 -0
- package/skills/python-coding-guidelines/SKILL.md +58 -0
- package/skills/python-design-patterns/SKILL.md +28 -0
- package/skills/rust-best-practices/SKILL.md +171 -0
- package/skills/rust-coding-guidelines/SKILL.md +77 -0
- package/skills/rust-design-patterns/SKILL.md +83 -0
- package/skills/task-workflow/SKILL.md +122 -0
- package/skills/tech-debt/SKILL.md +41 -0
- package/skills/test-driven-development/SKILL.md +113 -0
- package/skills/testing-strategy/SKILL.md +35 -0
- package/skills/typescript-coding-guidelines/SKILL.md +55 -0
- package/src/plan.js +116 -0
- package/src/targets.js +95 -0
- 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
|
+
}
|