Skip to content

Commit 795af11

Browse files
NagyViktNagyViktclaude
authored
feat(cli): gx ci-init — scaffold budget-friendly workflows into a repo (#575)
Wires the gitguardex CI workflow templates into target repos in one command. Mirrors what `gx setup` would do for the full installer set but scoped to .github/workflows/ only — useful for projects that already have their other guardex state set up and just want the budget posture. gx ci-init [--target <path>] [--dry-run] [--force] [--no-stage] [--json] - Copies ci.yml, ci-full.yml, cr.yml, README.md from templates/github/workflows/ into <target>/.github/workflows/. - Skips files that already exist; --force overwrites. - Stages copied files with `git add` when the target is a git repo; --no-stage opts out. - --dry-run lists the plan without touching the filesystem. - --json emits the structured plan/summary. Also adds ci-full.yml + README.md to TEMPLATE_FILES so `gx setup` ships them alongside the existing cr.yml. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b50cc2 commit 795af11

6 files changed

Lines changed: 441 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-13
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# agent-claude-gx-ci-init-workflows-2026-05-14-01-38 (minimal / T1)
2+
3+
Branch: `agent/<your-name>/<branch-slug>`
4+
5+
Describe the change in a sentence or two. Commit message is the spec of record.
6+
7+
## Handoff
8+
9+
- Handoff: change=`agent-claude-gx-ci-init-workflows-2026-05-14-01-38`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
10+
- Copy prompt: Continue `agent-claude-gx-ci-init-workflows-2026-05-14-01-38` on branch `agent/<your-name>/<branch-slug>`. 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/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.
11+
12+
## Cleanup
13+
14+
- [ ] Run: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`
15+
- [ ] Record PR URL + `MERGED` state in the completion handoff.
16+
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).

src/ci-init/index.js

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
const cp = require('node:child_process');
6+
7+
const { TEMPLATE_FILES, toDestinationPath, TEMPLATE_ROOT, PACKAGE_ROOT } = require('../context');
8+
9+
const TOOL_NAME = 'gx';
10+
11+
const WORKFLOW_TEMPLATE_PREFIX = 'github/workflows/';
12+
13+
function listWorkflowTemplates() {
14+
return TEMPLATE_FILES.filter((entry) => entry.startsWith(WORKFLOW_TEMPLATE_PREFIX));
15+
}
16+
17+
function resolveTemplateSource(relativeTemplatePath) {
18+
const localTemplate = path.join(TEMPLATE_ROOT, relativeTemplatePath);
19+
if (fs.existsSync(localTemplate)) return localTemplate;
20+
const packageTemplate = path.join(PACKAGE_ROOT, 'templates', relativeTemplatePath);
21+
if (fs.existsSync(packageTemplate)) return packageTemplate;
22+
return null;
23+
}
24+
25+
function copyFileEnsuringDir(sourcePath, destinationAbsolute) {
26+
fs.mkdirSync(path.dirname(destinationAbsolute), { recursive: true });
27+
fs.copyFileSync(sourcePath, destinationAbsolute);
28+
}
29+
30+
function shouldCopy(destinationAbsolute, options) {
31+
if (!fs.existsSync(destinationAbsolute)) return { copy: true, reason: 'create' };
32+
if (options.force) return { copy: true, reason: 'overwrite' };
33+
return { copy: false, reason: 'exists' };
34+
}
35+
36+
function planCiInitOperations(options) {
37+
const targetRoot = path.resolve(options.target || process.cwd());
38+
const operations = [];
39+
for (const templateRelative of listWorkflowTemplates()) {
40+
const destinationRelative = toDestinationPath(templateRelative);
41+
const destinationAbsolute = path.join(targetRoot, destinationRelative);
42+
const sourcePath = resolveTemplateSource(templateRelative);
43+
if (!sourcePath) {
44+
operations.push({
45+
template: templateRelative,
46+
destination: destinationRelative,
47+
status: 'missing-source',
48+
});
49+
continue;
50+
}
51+
const decision = shouldCopy(destinationAbsolute, options);
52+
operations.push({
53+
template: templateRelative,
54+
source: sourcePath,
55+
destination: destinationRelative,
56+
destinationAbsolute,
57+
status: decision.copy ? decision.reason : 'skipped',
58+
});
59+
}
60+
return { targetRoot, operations };
61+
}
62+
63+
function performCiInitOperations(operations, { dryRun }) {
64+
const summary = { copied: [], overwritten: [], skipped: [], missing: [] };
65+
for (const op of operations) {
66+
if (op.status === 'missing-source') {
67+
summary.missing.push(op.template);
68+
continue;
69+
}
70+
if (op.status === 'skipped') {
71+
summary.skipped.push(op.destination);
72+
continue;
73+
}
74+
if (!dryRun) {
75+
copyFileEnsuringDir(op.source, op.destinationAbsolute);
76+
}
77+
if (op.status === 'overwrite') {
78+
summary.overwritten.push(op.destination);
79+
} else {
80+
summary.copied.push(op.destination);
81+
}
82+
}
83+
return summary;
84+
}
85+
86+
function maybeStageOnAgentBranch(targetRoot, summary, options) {
87+
if (options.dryRun || options.noStage) return null;
88+
if (!summary.copied.length && !summary.overwritten.length) return null;
89+
// Best-effort stage: only when the target is itself a git repo. Failures
90+
// are non-fatal — the user can always `git add` themselves.
91+
const isGit = cp.spawnSync('git', ['-C', targetRoot, 'rev-parse', '--is-inside-work-tree'], {
92+
encoding: 'utf8',
93+
});
94+
if (isGit.status !== 0) return { staged: false, reason: 'target is not a git repo' };
95+
const files = [...summary.copied, ...summary.overwritten];
96+
const add = cp.spawnSync('git', ['-C', targetRoot, 'add', '--', ...files], { encoding: 'utf8' });
97+
if (add.status !== 0) {
98+
return { staged: false, reason: (add.stderr || add.stdout || '').trim() };
99+
}
100+
return { staged: true, count: files.length };
101+
}
102+
103+
function formatCiInitReport({ targetRoot, summary, stageResult, dryRun }) {
104+
const lines = [];
105+
const mode = dryRun ? 'dry-run' : 'apply';
106+
lines.push(`${TOOL_NAME} ci-init (${mode}) — target: ${targetRoot}`);
107+
if (summary.copied.length) {
108+
lines.push(` created (${summary.copied.length}):`);
109+
for (const file of summary.copied) lines.push(` + ${file}`);
110+
}
111+
if (summary.overwritten.length) {
112+
lines.push(` overwritten (${summary.overwritten.length}):`);
113+
for (const file of summary.overwritten) lines.push(` ~ ${file}`);
114+
}
115+
if (summary.skipped.length) {
116+
lines.push(` skipped (already exists, pass --force to overwrite):`);
117+
for (const file of summary.skipped) lines.push(` = ${file}`);
118+
}
119+
if (summary.missing.length) {
120+
lines.push(` missing source (${summary.missing.length}):`);
121+
for (const file of summary.missing) lines.push(` ? ${file}`);
122+
}
123+
if (stageResult) {
124+
if (stageResult.staged) {
125+
lines.push(` staged ${stageResult.count} file(s) for commit.`);
126+
} else {
127+
lines.push(` not staged: ${stageResult.reason}`);
128+
}
129+
}
130+
if (dryRun) {
131+
lines.push(` (no files written; re-run without --dry-run to apply)`);
132+
}
133+
return lines.join('\n');
134+
}
135+
136+
function parseCiInitArgs(rawArgs) {
137+
const options = {
138+
target: null,
139+
dryRun: false,
140+
force: false,
141+
json: false,
142+
noStage: false,
143+
help: false,
144+
};
145+
const args = Array.isArray(rawArgs) ? [...rawArgs] : [];
146+
while (args.length > 0) {
147+
const arg = args.shift();
148+
if (arg === '--help' || arg === '-h' || arg === 'help') {
149+
options.help = true;
150+
continue;
151+
}
152+
if (arg === '--dry-run') {
153+
options.dryRun = true;
154+
continue;
155+
}
156+
if (arg === '--force') {
157+
options.force = true;
158+
continue;
159+
}
160+
if (arg === '--json') {
161+
options.json = true;
162+
continue;
163+
}
164+
if (arg === '--no-stage') {
165+
options.noStage = true;
166+
continue;
167+
}
168+
if (arg === '--target') {
169+
options.target = args.shift();
170+
continue;
171+
}
172+
if (arg.startsWith('--target=')) {
173+
options.target = arg.slice('--target='.length);
174+
continue;
175+
}
176+
const err = new Error(`Unknown ci-init argument: ${arg}`);
177+
err.code = 'CI_INIT_BAD_ARG';
178+
throw err;
179+
}
180+
return options;
181+
}
182+
183+
function renderCiInitHelp() {
184+
return [
185+
`${TOOL_NAME} ci-init — scaffold budget-friendly GitHub Actions workflows into a target repo.`,
186+
'',
187+
'Usage:',
188+
` ${TOOL_NAME} ci-init [--target <path>] [--dry-run] [--force] [--no-stage] [--json]`,
189+
'',
190+
'Options:',
191+
` --target <path> Repo to scaffold into (default: current working directory).`,
192+
` --dry-run Show what would be written; do not touch the filesystem.`,
193+
` --force Overwrite existing files instead of skipping them.`,
194+
` --no-stage Skip the post-copy 'git add' step.`,
195+
` --json Emit a structured summary instead of the text report.`,
196+
'',
197+
'Files copied (from gitguardex templates/github/workflows/):',
198+
` - ci.yml PR-time CI with draft-skip + concurrency-cancel.`,
199+
` - ci-full.yml Weekly cross-runtime matrix + label opt-in.`,
200+
` - cr.yml AI code review with agent/* + draft skip.`,
201+
` - README.md Documents the budget posture and customization knobs.`,
202+
'',
203+
'The command stages copied files with git add when the target is a git repo;',
204+
'pair with `gx branch start "<task>" "claude-code"` to land them on a new agent',
205+
'branch instead of the primary checkout.',
206+
].join('\n');
207+
}
208+
209+
function runCiInitCommand(rawArgs) {
210+
let options;
211+
try {
212+
options = parseCiInitArgs(rawArgs);
213+
} catch (err) {
214+
console.error(`[${TOOL_NAME}] ${err.message}`);
215+
console.error(renderCiInitHelp());
216+
process.exitCode = 1;
217+
return;
218+
}
219+
220+
if (options.help) {
221+
console.log(renderCiInitHelp());
222+
return;
223+
}
224+
225+
const { targetRoot, operations } = planCiInitOperations(options);
226+
const summary = performCiInitOperations(operations, { dryRun: options.dryRun });
227+
const stageResult =
228+
summary.copied.length || summary.overwritten.length
229+
? maybeStageOnAgentBranch(targetRoot, summary, options)
230+
: null;
231+
232+
if (options.json) {
233+
process.stdout.write(
234+
`${JSON.stringify(
235+
{
236+
targetRoot,
237+
dryRun: options.dryRun,
238+
force: options.force,
239+
summary,
240+
stageResult,
241+
},
242+
null,
243+
2,
244+
)}\n`,
245+
);
246+
} else {
247+
console.log(formatCiInitReport({ targetRoot, summary, stageResult, dryRun: options.dryRun }));
248+
}
249+
250+
if (summary.missing.length > 0) {
251+
process.exitCode = 1;
252+
} else {
253+
process.exitCode = 0;
254+
}
255+
}
256+
257+
module.exports = {
258+
runCiInitCommand,
259+
parseCiInitArgs,
260+
planCiInitOperations,
261+
performCiInitOperations,
262+
formatCiInitReport,
263+
renderCiInitHelp,
264+
listWorkflowTemplates,
265+
};

src/cli/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const agentCleanupSessions = require('../agents/cleanup-sessions');
1212
const { finishAgentSession } = require('../agents/finish');
1313
const sessionSeverityReport = require('../report/session-severity');
1414
const budgetModule = require('../budget');
15+
const ciInitModule = require('../ci-init');
1516
const cockpitModule = require('../cockpit');
1617
const agentsStart = require('../agents/start');
1718
const prReviewModule = require('../pr-review');
@@ -3973,6 +3974,7 @@ async function main() {
39733974
if (command === 'cleanup') return cleanup(rest);
39743975
if (command === 'release') return release(rest);
39753976
if (command === 'budget') return budgetModule.runBudgetCommand(rest);
3977+
if (command === 'ci-init') return ciInitModule.runCiInitCommand(rest);
39763978

39773979
const suggestion = maybeSuggestCommand(command);
39783980
if (suggestion) {

src/context.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,10 @@ const TEMPLATE_FILES = [
171171
'scripts/guardex-env.sh',
172172
'scripts/install-vscode-active-agents-extension.js',
173173
'github/pull.yml.example',
174+
'github/workflows/ci.yml',
175+
'github/workflows/ci-full.yml',
174176
'github/workflows/cr.yml',
177+
'github/workflows/README.md',
175178
'vscode/guardex-active-agents/package.json',
176179
'vscode/guardex-active-agents/extension.js',
177180
'vscode/guardex-active-agents/session-schema.js',
@@ -415,6 +418,7 @@ const SUGGESTIBLE_COMMANDS = [
415418
'print-agents-snippet',
416419
'release',
417420
'budget',
421+
'ci-init',
418422
];
419423
// CLI_COMMAND_GROUPS is the grouped source of truth the `gx --help` /
420424
// `gx` no-args renderer uses. Each group is ordered roughly by how often a

0 commit comments

Comments
 (0)