diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index f75701b..45e4aa5 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -3,14 +3,18 @@ name: Sync to consumers on: push: branches: [main] - paths: ['lib/**'] + paths: ['lib/**', 'templates/**', 'scripts/generate-claudemd.js'] + +permissions: + contents: read jobs: sync: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - repo: [agentsys, next-task, ship, enhance, deslop, learn, consult, debate, drift-detect, repo-map, sync-docs, audit-project, perf] + repo: [agentsys, next-task, ship, enhance, deslop, learn, consult, debate, drift-detect, repo-map, sync-docs, audit-project, perf, web-ctl] steps: - name: Checkout agent-core uses: actions/checkout@v4 @@ -29,27 +33,37 @@ jobs: rm -rf target/lib/ cp -r source/lib/ target/lib/ + - name: Generate CLAUDE.md + run: node source/scripts/generate-claudemd.js --target target --template source/templates/CLAUDE.md.tmpl + - name: Check for changes id: diff working-directory: target run: | - git diff --quiet && echo "changed=false" >> $GITHUB_OUTPUT || echo "changed=true" >> $GITHUB_OUTPUT + if [ -n "$(git status --porcelain)" ]; then + echo "changed=true" >> $GITHUB_OUTPUT + else + echo "changed=false" >> $GITHUB_OUTPUT + fi - name: Create PR if: steps.diff.outputs.changed == 'true' working-directory: target env: GH_TOKEN: ${{ secrets.SYNC_TOKEN }} + TARGET_REPO: agent-sh/${{ matrix.repo }} + REPO_NAME: ${{ matrix.repo }} run: | - BRANCH="chore/sync-core-$(date +%Y%m%d-%H%M%S)" + BRANCH="chore/sync-core-${REPO_NAME}-$(date +%Y%m%d-%H%M%S)" git config user.name "agent-core-bot" git config user.email "noreply@agent-sh.github.io" git checkout -b "$BRANCH" - git add lib/ - git commit -m "chore: sync core lib from agent-core" + git add lib/ CLAUDE.md + git commit -m "chore: sync core lib and CLAUDE.md from agent-core" git push origin "$BRANCH" gh pr create \ - --repo agent-sh/${{ matrix.repo }} \ - --title "chore: sync core lib from agent-core" \ - --body "Automated sync of lib/ from [agent-core](https://github.com/agent-sh/agent-core)." \ + --repo "$TARGET_REPO" \ + --base main \ + --title "chore: sync core lib and CLAUDE.md from agent-core" \ + --body "Automated sync of lib/ and CLAUDE.md from [agent-core](https://github.com/agent-sh/agent-core)." \ --head "$BRANCH" diff --git a/README.md b/README.md index 90f1aaa..df49c0b 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,45 @@ Shared core libraries for all agent-sh plugins. Changes here are automatically s ## Consumers -| Repo | How it receives lib/ | -|------|---------------------| +| Repo | How it receives lib/ and CLAUDE.md | +|------|------------------------------------| | agentsys | PR → merge → `sync-lib` propagates to 13 bundled plugins | -| agnix | PR → merge (plugin uses lib/ directly) | +| next-task | PR → merge (plugin uses lib/ directly) | +| ship | PR → merge (plugin uses lib/ directly) | +| enhance | PR → merge (plugin uses lib/ directly) | +| deslop | PR → merge (plugin uses lib/ directly) | +| learn | PR → merge (plugin uses lib/ directly) | +| consult | PR → merge (plugin uses lib/ directly) | +| debate | PR → merge (plugin uses lib/ directly) | +| drift-detect | PR → merge (plugin uses lib/ directly) | +| repo-map | PR → merge (plugin uses lib/ directly) | +| sync-docs | PR → merge (plugin uses lib/ directly) | +| audit-project | PR → merge (plugin uses lib/ directly) | +| perf | PR → merge (plugin uses lib/ directly) | | web-ctl | PR → merge (plugin uses lib/ directly) | ## How sync works -On merge to `main`, the `sync-core` workflow opens PRs in all consumer repos with the updated `lib/` directory. Consumer repos review and merge at their own pace. +On merge to `main`, the `sync` workflow opens PRs in all consumer repos with the updated `lib/` directory and a freshly generated `CLAUDE.md` (rendered from `templates/CLAUDE.md.tmpl`). Consumer repos review and merge at their own pace. + +## CLAUDE.md generation + +Each consumer repo receives a generated `CLAUDE.md` rendered from `templates/CLAUDE.md.tmpl`. The generator reads `package.json` and optionally `components.json` from the target repo. + +Available template variables: +- `{{pluginName}}` - package name with `@agentsys/` prefix stripped +- `{{description}}` - package.json description +- `{{#agents}}` / `{{#skills}}` / `{{#commands}}` - conditional sections from components.json + +To test generation locally: + +```bash +node scripts/generate-claudemd.js --target ../some-plugin --template templates/CLAUDE.md.tmpl +``` ## Developing -Edit files in `lib/`. On merge, changes propagate automatically. To test locally before merging: +Edit files in `lib/` for library changes. Edit `templates/CLAUDE.md.tmpl` to change the CLAUDE.md generated for all consumer plugins. On merge, changes propagate automatically. To test locally before merging: ```bash # Copy to a consumer repo for testing diff --git a/scripts/generate-claudemd.js b/scripts/generate-claudemd.js new file mode 100644 index 0000000..321c4ac --- /dev/null +++ b/scripts/generate-claudemd.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +function parseArgs(argv) { + const args = {}; + for (let i = 2; i < argv.length; i += 2) { + if (!argv[i].startsWith('--')) { + console.error(`[ERROR] Expected flag starting with --, got: ${argv[i]}`); + process.exit(1); + } + const key = argv[i].replace(/^--/, ''); + if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) { + console.error(`[ERROR] Missing value for flag: ${argv[i]}`); + process.exit(1); + } + args[key] = argv[i + 1]; + } + return args; +} + +function renderTemplate(template, vars) { + let result = template; + + // Process conditional sections: {{#key}}...{{/key}} + result = result.replace(/\{\{#(\w+)\}\}\n([\s\S]*?)\{\{\/\1\}\}\n?/g, (_, key, block) => { + const section = vars[key]; + if (!section || !section.items || section.items.length === 0) { + return ''; + } + const itemList = section.items + .map(item => `- ${String(item).replace(/\{\{/g, '{ {').replace(/\}\}/g, '} }')}`) + .join('\n'); + return block.replace(/\{\{items\}\}/g, itemList); + }); + + // Replace simple variables + result = result.replace(/\{\{(\w+)\}\}/g, (_, key) => { + return vars[key] !== undefined ? vars[key] : ''; + }); + + return result; +} + +function main() { + const args = parseArgs(process.argv); + + if (!args.target) { + console.error('[ERROR] --target is required'); + process.exit(1); + } + if (!args.template) { + console.error('[ERROR] --template is required'); + process.exit(1); + } + + const targetDir = path.resolve(args.target); + const templatePath = path.resolve(args.template); + + // Read template + let template; + try { + template = fs.readFileSync(templatePath, 'utf8'); + } catch (err) { + console.error(`[ERROR] Cannot read template: ${err.message}`); + process.exit(1); + } + + // Read package.json + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8')); + } catch (err) { + console.error(`[ERROR] Cannot read package.json: ${err.message}`); + process.exit(1); + } + + // Extract plugin name (strip @agentsys/ prefix) + const pluginName = (pkg.name || '').replace(/^@agentsys\//, ''); + const description = pkg.description || ''; + + // Read components.json (optional) + let components = { agents: [], skills: [], commands: [] }; + const componentsPath = path.join(targetDir, 'components.json'); + try { + components = JSON.parse(fs.readFileSync(componentsPath, 'utf8')); + } catch (err) { + if (err.code !== 'ENOENT') { + console.error(`[WARN] Cannot parse components.json: ${err.message}`); + } + } + + const vars = { + pluginName, + description, + agents: { items: components.agents || [] }, + skills: { items: components.skills || [] }, + commands: { items: components.commands || [] }, + }; + + const output = renderTemplate(template, vars); + + // Write CLAUDE.md + const outputPath = path.join(targetDir, 'CLAUDE.md'); + try { + fs.writeFileSync(outputPath, output, 'utf8'); + } catch (err) { + console.error(`[ERROR] Cannot write CLAUDE.md: ${err.message}`); + process.exit(1); + } + console.log(`[OK] Generated ${outputPath}`); +} + +main(); diff --git a/scripts/generate-claudemd.test.js b/scripts/generate-claudemd.test.js new file mode 100644 index 0000000..aa953d0 --- /dev/null +++ b/scripts/generate-claudemd.test.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +'use strict'; + +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawnSync } = require('node:child_process'); + +const SCRIPT = path.join(__dirname, 'generate-claudemd.js'); +const TEMPLATE = path.join(__dirname, '..', 'templates', 'CLAUDE.md.tmpl'); + +function makeTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'claudemd-test-')); +} + +function run(targetDir) { + return execFileSync('node', [SCRIPT, '--target', targetDir, '--template', TEMPLATE], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function runRaw(...args) { + return spawnSync('node', [SCRIPT, ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function writeJson(dir, filename, data) { + fs.writeFileSync(path.join(dir, filename), JSON.stringify(data, null, 2)); +} + +describe('generate-claudemd', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('generates full CLAUDE.md with all components (next-task shape)', () => { + writeJson(tmpDir, 'package.json', { + name: '@agentsys/next-task', + description: 'Master workflow orchestrator', + }); + writeJson(tmpDir, 'components.json', { + agents: ['ci-fixer', 'ci-monitor', 'delivery-validator', 'exploration-agent', + 'implementation-agent', 'planning-agent', 'simple-fixer', + 'task-discoverer', 'test-coverage-checker', 'worktree-manager'], + skills: ['discover-tasks', 'orchestrate-review', 'validate-delivery'], + commands: ['delivery-approval', 'next-task'], + }); + + run(tmpDir); + const output = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf8'); + + assert.match(output, /^# next-task/m); + assert.match(output, /> Master workflow orchestrator/); + assert.match(output, /## Agents/); + assert.match(output, /- ci-fixer/); + assert.match(output, /- worktree-manager/); + assert.match(output, /## Skills/); + assert.match(output, /- discover-tasks/); + assert.match(output, /## Commands/); + assert.match(output, /- next-task/); + assert.match(output, /## Critical Rules/); + assert.match(output, /## Model Selection/); + assert.match(output, /## Core Priorities/); + }); + + it('generates CLAUDE.md with only commands (ship shape)', () => { + writeJson(tmpDir, 'package.json', { + name: '@agentsys/ship', + description: 'Complete PR workflow', + }); + writeJson(tmpDir, 'components.json', { + agents: [], + skills: [], + commands: ['ship-ci-review-loop', 'ship-deployment', 'ship-error-handling', 'ship'], + }); + + run(tmpDir); + const output = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf8'); + + assert.match(output, /^# ship/m); + assert.ok(!output.includes('## Agents')); + assert.ok(!output.includes('## Skills')); + assert.match(output, /## Commands/); + assert.match(output, /- ship$/m); + }); + + it('generates CLAUDE.md with all empty components', () => { + writeJson(tmpDir, 'package.json', { + name: '@agentsys/empty-plugin', + description: 'An empty plugin', + }); + writeJson(tmpDir, 'components.json', { + agents: [], + skills: [], + commands: [], + }); + + run(tmpDir); + const output = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf8'); + + assert.match(output, /^# empty-plugin/m); + assert.ok(!output.includes('## Agents')); + assert.ok(!output.includes('## Skills')); + assert.ok(!output.includes('## Commands')); + assert.match(output, /## Critical Rules/); + }); + + it('handles missing components.json gracefully', () => { + writeJson(tmpDir, 'package.json', { + name: '@agentsys/no-components', + description: 'No components file', + }); + + run(tmpDir); + const output = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf8'); + + assert.match(output, /^# no-components/m); + assert.ok(!output.includes('## Agents')); + assert.ok(!output.includes('## Skills')); + assert.ok(!output.includes('## Commands')); + }); + + it('strips @agentsys/ prefix from plugin name', () => { + writeJson(tmpDir, 'package.json', { + name: '@agentsys/my-plugin', + description: 'Test', + }); + + run(tmpDir); + const output = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf8'); + + assert.match(output, /^# my-plugin/m); + assert.ok(!output.includes('@agentsys/')); + }); + + it('handles name without prefix', () => { + writeJson(tmpDir, 'package.json', { + name: 'plain-name', + description: 'No prefix', + }); + + run(tmpDir); + const output = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf8'); + + assert.match(output, /^# plain-name/m); + }); + + it('exits with error when --target is missing', () => { + const result = runRaw('--template', TEMPLATE); + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /\[ERROR\].*--target/); + }); + + it('exits with error when package.json is missing', () => { + const result = runRaw('--target', tmpDir, '--template', TEMPLATE); + assert.strictEqual(result.status, 1); + assert.match(result.stderr, /\[ERROR\].*package\.json/); + }); + + it('warns on malformed components.json and still generates output', () => { + writeJson(tmpDir, 'package.json', { + name: '@agentsys/bad-components', + description: 'Bad components', + }); + fs.writeFileSync(path.join(tmpDir, 'components.json'), '{bad json'); + + run(tmpDir); + const output = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf8'); + + assert.match(output, /^# bad-components/m); + assert.doesNotMatch(output, /## Agents/); + }); + + it('verifies stdout contains OK message', () => { + writeJson(tmpDir, 'package.json', { + name: 'stdout-test', + description: 'Test', + }); + + const stdout = run(tmpDir); + assert.match(stdout, /\[OK\] Generated/); + }); +}); diff --git a/templates/CLAUDE.md.tmpl b/templates/CLAUDE.md.tmpl new file mode 100644 index 0000000..03574fd --- /dev/null +++ b/templates/CLAUDE.md.tmpl @@ -0,0 +1,61 @@ + +# {{pluginName}} + +> {{description}} + +{{#agents}} +## Agents + +{{items}} + +{{/agents}} +{{#skills}} +## Skills + +{{items}} + +{{/skills}} +{{#commands}} +## Commands + +{{items}} + +{{/commands}} +## Critical Rules + +1. **Plain text output** - No emojis, no ASCII art. Use `[OK]`, `[ERROR]`, `[WARN]`, `[CRITICAL]` for status markers. +2. **No unnecessary files** - Don't create summary files, plan files, audit files, or temp docs. +3. **Task is not done until tests pass** - Every feature/fix must have quality tests. +4. **Create PRs for non-trivial changes** - No direct pushes to main. +5. **Always run git hooks** - Never bypass pre-commit or pre-push hooks. +6. **Use single dash for em-dashes** - In prose, use ` - ` (single dash with spaces), never ` -- `. +7. **Report script failures before manual fallback** - Never silently bypass broken tooling. +8. **Token efficiency** - Be concise. Save tokens over decorations. + +## Model Selection + +| Model | When to Use | +|-------|-------------| +| **Opus** | Complex reasoning, analysis, planning | +| **Sonnet** | Validation, pattern matching, most agents | +| **Haiku** | Mechanical execution, no judgment needed | + +## Core Priorities + +1. User DX (plugin users first) +2. Worry-free automation +3. Token efficiency +4. Quality output +5. Simplicity + +## Dev Commands + +```bash +npm test # Run tests +npm run validate # All validators +``` + +## References + +- Part of the [agentsys](https://github.com/agent-sh/agentsys) ecosystem +- https://agentskills.io