diff --git a/openspec/changes/agent-claude-gx-ci-init-workflows-2026-05-14-01-38/.openspec.yaml b/openspec/changes/agent-claude-gx-ci-init-workflows-2026-05-14-01-38/.openspec.yaml new file mode 100644 index 0000000..93831bd --- /dev/null +++ b/openspec/changes/agent-claude-gx-ci-init-workflows-2026-05-14-01-38/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-13 diff --git a/openspec/changes/agent-claude-gx-ci-init-workflows-2026-05-14-01-38/notes.md b/openspec/changes/agent-claude-gx-ci-init-workflows-2026-05-14-01-38/notes.md new file mode 100644 index 0000000..33df178 --- /dev/null +++ b/openspec/changes/agent-claude-gx-ci-init-workflows-2026-05-14-01-38/notes.md @@ -0,0 +1,16 @@ +# agent-claude-gx-ci-init-workflows-2026-05-14-01-38 (minimal / T1) + +Branch: `agent//` + +Describe the change in a sentence or two. Commit message is the spec of record. + +## Handoff + +- Handoff: change=`agent-claude-gx-ci-init-workflows-2026-05-14-01-38`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-gx-ci-init-workflows-2026-05-14-01-38` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-gx-ci-init-workflows-2026-05-14-01-38/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/ci-init/index.js b/src/ci-init/index.js new file mode 100644 index 0000000..71bce0d --- /dev/null +++ b/src/ci-init/index.js @@ -0,0 +1,265 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const { TEMPLATE_FILES, toDestinationPath, TEMPLATE_ROOT, PACKAGE_ROOT } = require('../context'); + +const TOOL_NAME = 'gx'; + +const WORKFLOW_TEMPLATE_PREFIX = 'github/workflows/'; + +function listWorkflowTemplates() { + return TEMPLATE_FILES.filter((entry) => entry.startsWith(WORKFLOW_TEMPLATE_PREFIX)); +} + +function resolveTemplateSource(relativeTemplatePath) { + const localTemplate = path.join(TEMPLATE_ROOT, relativeTemplatePath); + if (fs.existsSync(localTemplate)) return localTemplate; + const packageTemplate = path.join(PACKAGE_ROOT, 'templates', relativeTemplatePath); + if (fs.existsSync(packageTemplate)) return packageTemplate; + return null; +} + +function copyFileEnsuringDir(sourcePath, destinationAbsolute) { + fs.mkdirSync(path.dirname(destinationAbsolute), { recursive: true }); + fs.copyFileSync(sourcePath, destinationAbsolute); +} + +function shouldCopy(destinationAbsolute, options) { + if (!fs.existsSync(destinationAbsolute)) return { copy: true, reason: 'create' }; + if (options.force) return { copy: true, reason: 'overwrite' }; + return { copy: false, reason: 'exists' }; +} + +function planCiInitOperations(options) { + const targetRoot = path.resolve(options.target || process.cwd()); + const operations = []; + for (const templateRelative of listWorkflowTemplates()) { + const destinationRelative = toDestinationPath(templateRelative); + const destinationAbsolute = path.join(targetRoot, destinationRelative); + const sourcePath = resolveTemplateSource(templateRelative); + if (!sourcePath) { + operations.push({ + template: templateRelative, + destination: destinationRelative, + status: 'missing-source', + }); + continue; + } + const decision = shouldCopy(destinationAbsolute, options); + operations.push({ + template: templateRelative, + source: sourcePath, + destination: destinationRelative, + destinationAbsolute, + status: decision.copy ? decision.reason : 'skipped', + }); + } + return { targetRoot, operations }; +} + +function performCiInitOperations(operations, { dryRun }) { + const summary = { copied: [], overwritten: [], skipped: [], missing: [] }; + for (const op of operations) { + if (op.status === 'missing-source') { + summary.missing.push(op.template); + continue; + } + if (op.status === 'skipped') { + summary.skipped.push(op.destination); + continue; + } + if (!dryRun) { + copyFileEnsuringDir(op.source, op.destinationAbsolute); + } + if (op.status === 'overwrite') { + summary.overwritten.push(op.destination); + } else { + summary.copied.push(op.destination); + } + } + return summary; +} + +function maybeStageOnAgentBranch(targetRoot, summary, options) { + if (options.dryRun || options.noStage) return null; + if (!summary.copied.length && !summary.overwritten.length) return null; + // Best-effort stage: only when the target is itself a git repo. Failures + // are non-fatal — the user can always `git add` themselves. + const isGit = cp.spawnSync('git', ['-C', targetRoot, 'rev-parse', '--is-inside-work-tree'], { + encoding: 'utf8', + }); + if (isGit.status !== 0) return { staged: false, reason: 'target is not a git repo' }; + const files = [...summary.copied, ...summary.overwritten]; + const add = cp.spawnSync('git', ['-C', targetRoot, 'add', '--', ...files], { encoding: 'utf8' }); + if (add.status !== 0) { + return { staged: false, reason: (add.stderr || add.stdout || '').trim() }; + } + return { staged: true, count: files.length }; +} + +function formatCiInitReport({ targetRoot, summary, stageResult, dryRun }) { + const lines = []; + const mode = dryRun ? 'dry-run' : 'apply'; + lines.push(`${TOOL_NAME} ci-init (${mode}) — target: ${targetRoot}`); + if (summary.copied.length) { + lines.push(` created (${summary.copied.length}):`); + for (const file of summary.copied) lines.push(` + ${file}`); + } + if (summary.overwritten.length) { + lines.push(` overwritten (${summary.overwritten.length}):`); + for (const file of summary.overwritten) lines.push(` ~ ${file}`); + } + if (summary.skipped.length) { + lines.push(` skipped (already exists, pass --force to overwrite):`); + for (const file of summary.skipped) lines.push(` = ${file}`); + } + if (summary.missing.length) { + lines.push(` missing source (${summary.missing.length}):`); + for (const file of summary.missing) lines.push(` ? ${file}`); + } + if (stageResult) { + if (stageResult.staged) { + lines.push(` staged ${stageResult.count} file(s) for commit.`); + } else { + lines.push(` not staged: ${stageResult.reason}`); + } + } + if (dryRun) { + lines.push(` (no files written; re-run without --dry-run to apply)`); + } + return lines.join('\n'); +} + +function parseCiInitArgs(rawArgs) { + const options = { + target: null, + dryRun: false, + force: false, + json: false, + noStage: false, + help: false, + }; + const args = Array.isArray(rawArgs) ? [...rawArgs] : []; + while (args.length > 0) { + const arg = args.shift(); + if (arg === '--help' || arg === '-h' || arg === 'help') { + options.help = true; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--force') { + options.force = true; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } + if (arg === '--no-stage') { + options.noStage = true; + continue; + } + if (arg === '--target') { + options.target = args.shift(); + continue; + } + if (arg.startsWith('--target=')) { + options.target = arg.slice('--target='.length); + continue; + } + const err = new Error(`Unknown ci-init argument: ${arg}`); + err.code = 'CI_INIT_BAD_ARG'; + throw err; + } + return options; +} + +function renderCiInitHelp() { + return [ + `${TOOL_NAME} ci-init — scaffold budget-friendly GitHub Actions workflows into a target repo.`, + '', + 'Usage:', + ` ${TOOL_NAME} ci-init [--target ] [--dry-run] [--force] [--no-stage] [--json]`, + '', + 'Options:', + ` --target Repo to scaffold into (default: current working directory).`, + ` --dry-run Show what would be written; do not touch the filesystem.`, + ` --force Overwrite existing files instead of skipping them.`, + ` --no-stage Skip the post-copy 'git add' step.`, + ` --json Emit a structured summary instead of the text report.`, + '', + 'Files copied (from gitguardex templates/github/workflows/):', + ` - ci.yml PR-time CI with draft-skip + concurrency-cancel.`, + ` - ci-full.yml Weekly cross-runtime matrix + label opt-in.`, + ` - cr.yml AI code review with agent/* + draft skip.`, + ` - README.md Documents the budget posture and customization knobs.`, + '', + 'The command stages copied files with git add when the target is a git repo;', + 'pair with `gx branch start "" "claude-code"` to land them on a new agent', + 'branch instead of the primary checkout.', + ].join('\n'); +} + +function runCiInitCommand(rawArgs) { + let options; + try { + options = parseCiInitArgs(rawArgs); + } catch (err) { + console.error(`[${TOOL_NAME}] ${err.message}`); + console.error(renderCiInitHelp()); + process.exitCode = 1; + return; + } + + if (options.help) { + console.log(renderCiInitHelp()); + return; + } + + const { targetRoot, operations } = planCiInitOperations(options); + const summary = performCiInitOperations(operations, { dryRun: options.dryRun }); + const stageResult = + summary.copied.length || summary.overwritten.length + ? maybeStageOnAgentBranch(targetRoot, summary, options) + : null; + + if (options.json) { + process.stdout.write( + `${JSON.stringify( + { + targetRoot, + dryRun: options.dryRun, + force: options.force, + summary, + stageResult, + }, + null, + 2, + )}\n`, + ); + } else { + console.log(formatCiInitReport({ targetRoot, summary, stageResult, dryRun: options.dryRun })); + } + + if (summary.missing.length > 0) { + process.exitCode = 1; + } else { + process.exitCode = 0; + } +} + +module.exports = { + runCiInitCommand, + parseCiInitArgs, + planCiInitOperations, + performCiInitOperations, + formatCiInitReport, + renderCiInitHelp, + listWorkflowTemplates, +}; diff --git a/src/cli/main.js b/src/cli/main.js index 8e5f6d7..67f48b0 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -12,6 +12,7 @@ const agentCleanupSessions = require('../agents/cleanup-sessions'); const { finishAgentSession } = require('../agents/finish'); const sessionSeverityReport = require('../report/session-severity'); const budgetModule = require('../budget'); +const ciInitModule = require('../ci-init'); const cockpitModule = require('../cockpit'); const agentsStart = require('../agents/start'); const prReviewModule = require('../pr-review'); @@ -3973,6 +3974,7 @@ async function main() { if (command === 'cleanup') return cleanup(rest); if (command === 'release') return release(rest); if (command === 'budget') return budgetModule.runBudgetCommand(rest); + if (command === 'ci-init') return ciInitModule.runCiInitCommand(rest); const suggestion = maybeSuggestCommand(command); if (suggestion) { diff --git a/src/context.js b/src/context.js index 004bab2..93d137f 100644 --- a/src/context.js +++ b/src/context.js @@ -171,7 +171,10 @@ const TEMPLATE_FILES = [ 'scripts/guardex-env.sh', 'scripts/install-vscode-active-agents-extension.js', 'github/pull.yml.example', + 'github/workflows/ci.yml', + 'github/workflows/ci-full.yml', 'github/workflows/cr.yml', + 'github/workflows/README.md', 'vscode/guardex-active-agents/package.json', 'vscode/guardex-active-agents/extension.js', 'vscode/guardex-active-agents/session-schema.js', @@ -415,6 +418,7 @@ const SUGGESTIBLE_COMMANDS = [ 'print-agents-snippet', 'release', 'budget', + 'ci-init', ]; // CLI_COMMAND_GROUPS is the grouped source of truth the `gx --help` / // `gx` no-args renderer uses. Each group is ordered roughly by how often a diff --git a/test/ci-init.test.js b/test/ci-init.test.js new file mode 100644 index 0000000..ac0e7e5 --- /dev/null +++ b/test/ci-init.test.js @@ -0,0 +1,152 @@ +'use strict'; + +const test = 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 { + parseCiInitArgs, + planCiInitOperations, + performCiInitOperations, + formatCiInitReport, + renderCiInitHelp, + listWorkflowTemplates, +} = require('../src/ci-init'); + +function makeTempRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gx-ci-init-test-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +test('parseCiInitArgs accepts documented flags', () => { + assert.deepEqual(parseCiInitArgs([]), { + target: null, + dryRun: false, + force: false, + json: false, + noStage: false, + help: false, + }); + const parsed = parseCiInitArgs(['--target', '/repo', '--dry-run', '--force', '--json']); + assert.equal(parsed.target, '/repo'); + assert.equal(parsed.dryRun, true); + assert.equal(parsed.force, true); + assert.equal(parsed.json, true); +}); + +test('parseCiInitArgs rejects unknown flags', () => { + assert.throws(() => parseCiInitArgs(['--bogus']), /Unknown ci-init argument: --bogus/); +}); + +test('listWorkflowTemplates returns only github/workflows/* templates', () => { + const list = listWorkflowTemplates(); + assert.ok(list.length >= 3, `expected >=3 workflow templates, got ${list.length}`); + for (const entry of list) { + assert.match(entry, /^github\/workflows\//); + } + assert.ok(list.includes('github/workflows/ci.yml')); + assert.ok(list.includes('github/workflows/cr.yml')); +}); + +test('planCiInitOperations + performCiInitOperations copy workflow templates into an empty target', () => { + const target = makeTempRoot(); + try { + const { targetRoot, operations } = planCiInitOperations({ target }); + assert.equal(targetRoot, path.resolve(target)); + assert.ok(operations.length >= 3); + // No file exists in target yet, so every op should be 'create'. + for (const op of operations) { + assert.equal(op.status, 'create', `expected create for ${op.template}, got ${op.status}`); + } + const summary = performCiInitOperations(operations, { dryRun: false }); + assert.ok(summary.copied.length >= 3); + assert.equal(summary.overwritten.length, 0); + assert.equal(summary.skipped.length, 0); + for (const file of summary.copied) { + assert.ok( + fs.existsSync(path.join(target, file)), + `expected file written: ${file}`, + ); + } + } finally { + cleanup(target); + } +}); + +test('planCiInitOperations skips existing files without --force', () => { + const target = makeTempRoot(); + try { + const dest = path.join(target, '.github', 'workflows', 'ci.yml'); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, 'user-customized: true\n'); + const { operations } = planCiInitOperations({ target }); + const ciOp = operations.find((op) => op.template === 'github/workflows/ci.yml'); + assert.equal(ciOp?.status, 'skipped'); + const summary = performCiInitOperations(operations, { dryRun: false }); + assert.ok(summary.skipped.includes('.github/workflows/ci.yml')); + // User's content must be preserved. + assert.equal(fs.readFileSync(dest, 'utf8'), 'user-customized: true\n'); + } finally { + cleanup(target); + } +}); + +test('planCiInitOperations overwrites existing files with --force', () => { + const target = makeTempRoot(); + try { + const dest = path.join(target, '.github', 'workflows', 'ci.yml'); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, 'stale: true\n'); + const { operations } = planCiInitOperations({ target, force: true }); + const ciOp = operations.find((op) => op.template === 'github/workflows/ci.yml'); + assert.equal(ciOp?.status, 'overwrite'); + const summary = performCiInitOperations(operations, { dryRun: false }); + assert.ok(summary.overwritten.includes('.github/workflows/ci.yml')); + assert.notEqual(fs.readFileSync(dest, 'utf8'), 'stale: true\n'); + } finally { + cleanup(target); + } +}); + +test('performCiInitOperations honors dryRun without touching disk', () => { + const target = makeTempRoot(); + try { + const { operations } = planCiInitOperations({ target }); + performCiInitOperations(operations, { dryRun: true }); + assert.equal(fs.existsSync(path.join(target, '.github')), false); + } finally { + cleanup(target); + } +}); + +test('formatCiInitReport surfaces dry-run + counts', () => { + const text = formatCiInitReport({ + targetRoot: '/repo', + summary: { + copied: ['.github/workflows/ci.yml', '.github/workflows/cr.yml'], + overwritten: [], + skipped: [], + missing: [], + }, + stageResult: { staged: true, count: 2 }, + dryRun: true, + }); + assert.match(text, /dry-run/); + assert.match(text, /\+ \.github\/workflows\/ci\.yml/); + assert.match(text, /staged 2 file/); + assert.match(text, /no files written/); +}); + +test('renderCiInitHelp covers documented flags + file list', () => { + const help = renderCiInitHelp(); + assert.match(help, /--target/); + assert.match(help, /--dry-run/); + assert.match(help, /--force/); + assert.match(help, /ci\.yml/); + assert.match(help, /cr\.yml/); +});