@firatcand/forge 0.1.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 (58) hide show
  1. package/ETHOS.md +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +134 -0
  4. package/agents/backend-dev.md +36 -0
  5. package/agents/code-reviewer.md +37 -0
  6. package/agents/db-architect.md +36 -0
  7. package/agents/design-reviewer.md +31 -0
  8. package/agents/devops-engineer.md +34 -0
  9. package/agents/frontend-dev.md +36 -0
  10. package/agents/learning-curator.md +35 -0
  11. package/agents/linear-syncer.md +36 -0
  12. package/agents/phase-gatekeeper.md +23 -0
  13. package/agents/product-decomposer.md +39 -0
  14. package/agents/qa-engineer.md +31 -0
  15. package/agents/security-auditor.md +34 -0
  16. package/bin/forge.js +368 -0
  17. package/lib/companions.js +67 -0
  18. package/lib/github-helpers.sh +148 -0
  19. package/lib/linear-helpers.sh +188 -0
  20. package/lib/paths.js +13 -0
  21. package/lib/tools.js +68 -0
  22. package/lib/validators.sh +284 -0
  23. package/lib/worktree-helpers.sh +136 -0
  24. package/package.json +53 -0
  25. package/skills/codex/SKILL.md +50 -0
  26. package/skills/decompose/SKILL.md +47 -0
  27. package/skills/draft-design/SKILL.md +55 -0
  28. package/skills/draft-prd/SKILL.md +47 -0
  29. package/skills/draft-spec/SKILL.md +42 -0
  30. package/skills/fix/SKILL.md +23 -0
  31. package/skills/forge/SKILL.md +87 -0
  32. package/skills/implement/SKILL.md +24 -0
  33. package/skills/ingest-spec/SKILL.md +46 -0
  34. package/skills/investigate/SKILL.md +26 -0
  35. package/skills/learn/SKILL.md +53 -0
  36. package/skills/phase-gate/SKILL.md +37 -0
  37. package/skills/pickup-task/SKILL.md +53 -0
  38. package/skills/plan-task/SKILL.md +22 -0
  39. package/skills/push-to-linear/SKILL.md +42 -0
  40. package/skills/qa/SKILL.md +22 -0
  41. package/skills/retro/SKILL.md +27 -0
  42. package/skills/review/SKILL.md +20 -0
  43. package/skills/setup-repo/SKILL.md +63 -0
  44. package/skills/ship/SKILL.md +34 -0
  45. package/skills/sync-status/SKILL.md +14 -0
  46. package/templates/BRIEF.template.md +34 -0
  47. package/templates/CLAUDE.project.template.md +37 -0
  48. package/templates/CRITICAL.template.md +11 -0
  49. package/templates/DESIGN.template.md +37 -0
  50. package/templates/PRD.template.md +30 -0
  51. package/templates/SPEC.template.md +49 -0
  52. package/templates/github-workflows/claude-issue.yml +27 -0
  53. package/templates/github-workflows/claude-pr-review.yml +22 -0
  54. package/templates/github-workflows/claude-scheduled.yml +23 -0
  55. package/templates/github-workflows/test.yml +18 -0
  56. package/templates/learning.template.md +14 -0
  57. package/templates/phases.template.yaml +45 -0
  58. package/templates/retro.template.md +27 -0
package/bin/forge.js ADDED
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { checkbox, input, confirm } from '@inquirer/prompts';
4
+ import chalk from 'chalk';
5
+ import fs from 'fs-extra';
6
+ import path from 'path';
7
+ import { execSync } from 'child_process';
8
+
9
+ import { detectTools, installToTool } from '../lib/tools.js';
10
+ import { COMPANIONS, installCompanion } from '../lib/companions.js';
11
+ import { FORGE_ROOT, getPackageVersion } from '../lib/paths.js';
12
+
13
+ function showHelp() {
14
+ console.log(`
15
+ ${chalk.bold('🔨 forge')} — A lightweight framework for shipping software products with AI coding agents
16
+
17
+ ${chalk.bold('Usage:')}
18
+ npx @firatcand/forge Run interactive setup (default)
19
+ npx @firatcand/forge install Install forge skills + agents only
20
+ npx @firatcand/forge init [name] Initialize a forge project in current directory
21
+ npx @firatcand/forge companions Install founder-skills companions
22
+ npx @firatcand/forge --version Show version
23
+ npx @firatcand/forge --help Show this help
24
+
25
+ ${chalk.bold('Skills (use inside your AI coding tool):')}
26
+ /forge Socratic ideation → BRIEF.md
27
+ /draft-prd Generate PRD.md from BRIEF.md
28
+ /draft-spec Generate SPEC.md from PRD.md
29
+ /draft-design Generate DESIGN.md from PRD.md (optional)
30
+ /ingest-spec Validate spec docs
31
+ /decompose spec → phases.yaml
32
+ /setup-repo GitHub repo + workflows + branch protection
33
+ /push-to-linear Linear project + cycles + issues
34
+ /pickup-task Claim next task, create worktree
35
+ /plan-task Plan mode for task
36
+ /implement Execute plan
37
+ /review Multi-agent review
38
+ /qa Tests + browser checks
39
+ /codex Second-opinion review via Codex CLI
40
+ /ship Push branch, open PR
41
+ /learn Capture learning
42
+ /phase-gate Advance phase ceremony
43
+ /retro Phase retrospective
44
+
45
+ ${chalk.dim('More: https://github.com/firatcand/forge')}
46
+ `);
47
+ }
48
+
49
+ async function runInteractiveSetup() {
50
+ console.log(chalk.bold('\n🔨 forge') + chalk.dim(` v${getPackageVersion()}`));
51
+ console.log(chalk.dim('A lightweight framework for shipping software products with AI coding agents\n'));
52
+
53
+ console.log(chalk.bold('Detecting installed AI coding tools...'));
54
+ const detected = detectTools();
55
+
56
+ if (detected.length === 0) {
57
+ console.log(chalk.red('\n ⚠️ No supported AI coding tools detected.'));
58
+ console.log(chalk.dim(' Install one of: Claude Code, Codex CLI, Cursor, Gemini CLI'));
59
+ console.log(chalk.dim(' Then re-run: npx @firatcand/forge\n'));
60
+ process.exit(1);
61
+ }
62
+
63
+ for (const tool of detected) {
64
+ console.log(chalk.green(' ✓') + ' ' + tool.name + chalk.dim(' (' + tool.dir + ')'));
65
+ }
66
+ console.log();
67
+
68
+ let selectedTools;
69
+ if (detected.length === 1) {
70
+ selectedTools = [detected[0].key];
71
+ console.log(chalk.dim(`Installing to ${detected[0].name} (only tool detected).\n`));
72
+ } else {
73
+ selectedTools = await checkbox({
74
+ message: 'Install forge skills + agents to:',
75
+ choices: detected.map(t => ({
76
+ value: t.key,
77
+ name: t.name,
78
+ checked: true,
79
+ })),
80
+ validate: (answer) => answer.length > 0 || 'Select at least one tool.',
81
+ });
82
+ }
83
+
84
+ console.log(chalk.bold('\n📦 Installing forge skills (21) and agents (12)...\n'));
85
+ for (const toolKey of selectedTools) {
86
+ const tool = detected.find(t => t.key === toolKey);
87
+ await installToTool(tool, FORGE_ROOT);
88
+ console.log(chalk.green(' ✓') + ` Installed to ${tool.name}`);
89
+ }
90
+
91
+ console.log();
92
+ const wantsCompanions = await confirm({
93
+ message: 'Install founder-skills companions? These extend /draft-prd, /draft-spec, /draft-design, and /review with deeper domain expertise.',
94
+ default: true,
95
+ });
96
+
97
+ if (wantsCompanions) {
98
+ const selectedCompanions = await checkbox({
99
+ message: 'Choose which companions to install (Space to select, Enter to confirm):',
100
+ choices: COMPANIONS.map(c => ({
101
+ value: c.value,
102
+ name: `${c.name} ${chalk.dim(`(${c.description})`)}`,
103
+ checked: c.recommended,
104
+ })),
105
+ });
106
+
107
+ if (selectedCompanions.length > 0) {
108
+ console.log(chalk.bold(`\n📦 Installing ${selectedCompanions.length} companion(s) via npx skills...\n`));
109
+ for (const slug of selectedCompanions) {
110
+ try {
111
+ await installCompanion(slug, selectedTools);
112
+ console.log(chalk.green(' ✓') + ` ${slug}`);
113
+ } catch (err) {
114
+ console.log(chalk.yellow(' ⚠️') + ` ${slug} — ${err.message}`);
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ console.log(chalk.bold.green('\n✅ Forge installed successfully\n'));
121
+ console.log(chalk.bold('Next steps:'));
122
+ console.log(' 1. ' + chalk.cyan('cd into a project directory'));
123
+ console.log(' 2. ' + chalk.cyan('npx @firatcand/forge init') + ' — initialize project structure');
124
+ console.log(' 3. Open your AI coding tool (claude, codex)');
125
+ console.log(' 4. Run ' + chalk.cyan('/forge') + ' to start the lifecycle\n');
126
+ console.log(chalk.dim('Docs: https://github.com/firatcand/forge#quickstart\n'));
127
+ }
128
+
129
+ async function commandInstall() {
130
+ console.log(chalk.bold('\n🔨 forge install\n'));
131
+
132
+ const detected = detectTools();
133
+ if (detected.length === 0) {
134
+ console.log(chalk.red('No supported tools detected.'));
135
+ process.exit(1);
136
+ }
137
+
138
+ const selectedTools = detected.length === 1
139
+ ? [detected[0].key]
140
+ : await checkbox({
141
+ message: 'Install to:',
142
+ choices: detected.map(t => ({ value: t.key, name: t.name, checked: true })),
143
+ validate: (a) => a.length > 0 || 'Select at least one.',
144
+ });
145
+
146
+ for (const toolKey of selectedTools) {
147
+ const tool = detected.find(t => t.key === toolKey);
148
+ await installToTool(tool, FORGE_ROOT);
149
+ console.log(chalk.green(' ✓') + ` Installed to ${tool.name}`);
150
+ }
151
+
152
+ console.log(chalk.bold.green('\n✅ Done\n'));
153
+ }
154
+
155
+ async function commandCompanions() {
156
+ console.log(chalk.bold('\n🔨 forge companions\n'));
157
+
158
+ const detected = detectTools();
159
+ if (detected.length === 0) {
160
+ console.log(chalk.red('No supported tools detected.'));
161
+ process.exit(1);
162
+ }
163
+
164
+ const selectedCompanions = await checkbox({
165
+ message: 'Choose which founder-skills companions to install:',
166
+ choices: COMPANIONS.map(c => ({
167
+ value: c.value,
168
+ name: `${c.name} ${chalk.dim(`(${c.description})`)}`,
169
+ checked: c.recommended,
170
+ })),
171
+ validate: (a) => a.length > 0 || 'Select at least one.',
172
+ });
173
+
174
+ const selectedTools = detected.length === 1
175
+ ? [detected[0].key]
176
+ : await checkbox({
177
+ message: 'Install companions to:',
178
+ choices: detected.map(t => ({ value: t.key, name: t.name, checked: true })),
179
+ validate: (a) => a.length > 0 || 'Select at least one.',
180
+ });
181
+
182
+ for (const slug of selectedCompanions) {
183
+ try {
184
+ await installCompanion(slug, selectedTools);
185
+ console.log(chalk.green(' ✓') + ` ${slug}`);
186
+ } catch (err) {
187
+ console.log(chalk.yellow(' ⚠️') + ` ${slug} — ${err.message}`);
188
+ }
189
+ }
190
+
191
+ console.log(chalk.bold.green('\n✅ Done\n'));
192
+ }
193
+
194
+ async function commandInit(projectNameArg) {
195
+ console.log(chalk.bold('\n🔨 forge init\n'));
196
+
197
+ const cwd = process.cwd();
198
+
199
+ if (await fs.pathExists(path.join(cwd, 'CLAUDE.md'))) {
200
+ const overwrite = await confirm({
201
+ message: 'CLAUDE.md already exists. Overwrite?',
202
+ default: false,
203
+ });
204
+ if (!overwrite) {
205
+ console.log(chalk.dim('Cancelled.\n'));
206
+ process.exit(0);
207
+ }
208
+ }
209
+
210
+ const projectName = projectNameArg || await input({
211
+ message: 'Project name:',
212
+ default: path.basename(cwd),
213
+ });
214
+
215
+ const initGit = await fs.pathExists(path.join(cwd, '.git'))
216
+ ? false
217
+ : await confirm({ message: 'Initialize git repo?', default: true });
218
+
219
+ console.log(chalk.bold('\n📁 Creating directory structure...'));
220
+ await fs.ensureDir(path.join(cwd, 'spec'));
221
+ await fs.ensureDir(path.join(cwd, 'plans', 'tasks'));
222
+ await fs.ensureDir(path.join(cwd, 'docs', 'learnings'));
223
+ await fs.ensureDir(path.join(cwd, 'docs', 'retros'));
224
+ console.log(chalk.green(' ✓') + ' spec/, plans/, docs/{learnings,retros}/');
225
+
226
+ console.log(chalk.bold('\n📄 Copying templates...'));
227
+ const templatesDir = path.join(FORGE_ROOT, 'templates');
228
+
229
+ const claudeTemplate = await fs.readFile(
230
+ path.join(templatesDir, 'CLAUDE.project.template.md'),
231
+ 'utf-8'
232
+ );
233
+ await fs.writeFile(
234
+ path.join(cwd, 'CLAUDE.md'),
235
+ claudeTemplate.replaceAll('{{PROJECT_NAME}}', projectName)
236
+ );
237
+ console.log(chalk.green(' ✓') + ' CLAUDE.md');
238
+
239
+ const criticalPath = path.join(cwd, 'CRITICAL.md');
240
+ let writeCritical = true;
241
+ if (await fs.pathExists(criticalPath)) {
242
+ writeCritical = await confirm({
243
+ message: 'CRITICAL.md already exists. Overwrite?',
244
+ default: false,
245
+ });
246
+ }
247
+ if (writeCritical) {
248
+ await fs.copy(
249
+ path.join(templatesDir, 'CRITICAL.template.md'),
250
+ criticalPath,
251
+ { overwrite: true }
252
+ );
253
+ console.log(chalk.green(' ✓') + ' CRITICAL.md');
254
+ } else {
255
+ console.log(chalk.dim(' - CRITICAL.md (kept existing)'));
256
+ }
257
+
258
+ const projectTemplates = path.join(cwd, 'templates');
259
+ let copyTemplates = true;
260
+ if (await fs.pathExists(projectTemplates)) {
261
+ copyTemplates = await confirm({
262
+ message: 'templates/ already exists. Refresh from forge templates? (your edits will be overwritten)',
263
+ default: false,
264
+ });
265
+ }
266
+ if (copyTemplates) {
267
+ const skipList = new Set(['CLAUDE.project.template.md', 'CRITICAL.template.md']);
268
+ await fs.ensureDir(projectTemplates);
269
+ const entries = await fs.readdir(templatesDir);
270
+ for (const entry of entries) {
271
+ if (skipList.has(entry)) continue;
272
+ await fs.copy(
273
+ path.join(templatesDir, entry),
274
+ path.join(projectTemplates, entry),
275
+ { overwrite: true }
276
+ );
277
+ }
278
+ console.log(chalk.green(' ✓') + ' templates/ (BRIEF, PRD, SPEC, DESIGN, phases, retro, learning + github-workflows/)');
279
+ } else {
280
+ console.log(chalk.dim(' - templates/ (kept existing)'));
281
+ }
282
+
283
+ if (!await fs.pathExists(path.join(cwd, '.env.example'))) {
284
+ await fs.writeFile(
285
+ path.join(cwd, '.env.example'),
286
+ '# Environment variables — populate based on spec/SPEC.md\n# Copy to .env (gitignored) for local development\n'
287
+ );
288
+ console.log(chalk.green(' ✓') + ' .env.example');
289
+ }
290
+
291
+ const gitignorePath = path.join(cwd, '.gitignore');
292
+ const forgeIgnoreLines = [
293
+ '# Forge defaults',
294
+ '.env',
295
+ '.env.local',
296
+ '.env.production',
297
+ 'node_modules/',
298
+ '*.log',
299
+ '.DS_Store',
300
+ ];
301
+ let existing = '';
302
+ if (await fs.pathExists(gitignorePath)) {
303
+ existing = await fs.readFile(gitignorePath, 'utf-8');
304
+ }
305
+ if (!existing.includes('# Forge defaults')) {
306
+ await fs.writeFile(gitignorePath, existing + '\n' + forgeIgnoreLines.join('\n') + '\n');
307
+ console.log(chalk.green(' ✓') + ' .gitignore (forge defaults appended)');
308
+ }
309
+
310
+ if (initGit) {
311
+ try {
312
+ execSync('git init', { cwd, stdio: 'ignore' });
313
+ console.log(chalk.green(' ✓') + ' git initialized');
314
+ } catch (err) {
315
+ console.log(chalk.yellow(' ⚠️') + ' git init failed (is git installed?)');
316
+ }
317
+ }
318
+
319
+ console.log(chalk.bold.green('\n✅ Project initialized\n'));
320
+ console.log(chalk.bold('Next:'));
321
+ console.log(' Open your AI coding tool: ' + chalk.cyan('claude') + ' or ' + chalk.cyan('codex'));
322
+ console.log(' Run: ' + chalk.cyan('/forge') + ' (Socratic ideation → BRIEF.md)\n');
323
+ }
324
+
325
+ async function main() {
326
+ const args = process.argv.slice(2);
327
+ const command = args[0];
328
+
329
+ try {
330
+ switch (command) {
331
+ case 'install':
332
+ await commandInstall();
333
+ break;
334
+ case 'companions':
335
+ await commandCompanions();
336
+ break;
337
+ case 'init':
338
+ await commandInit(args[1]);
339
+ break;
340
+ case '-h':
341
+ case '--help':
342
+ case 'help':
343
+ showHelp();
344
+ break;
345
+ case '-v':
346
+ case '--version':
347
+ console.log(getPackageVersion());
348
+ break;
349
+ case undefined:
350
+ await runInteractiveSetup();
351
+ break;
352
+ default:
353
+ console.log(chalk.red(`Unknown command: ${command}\n`));
354
+ showHelp();
355
+ process.exit(1);
356
+ }
357
+ } catch (err) {
358
+ if (err.name === 'ExitPromptError') {
359
+ console.log(chalk.dim('\nCancelled.\n'));
360
+ process.exit(0);
361
+ }
362
+ console.error(chalk.red('\n❌ Error:'), err.message);
363
+ if (process.env.DEBUG) console.error(err.stack);
364
+ process.exit(1);
365
+ }
366
+ }
367
+
368
+ main();
@@ -0,0 +1,67 @@
1
+ import { spawnSync } from 'child_process';
2
+
3
+ export const COMPANIONS = [
4
+ {
5
+ value: 'product-spec',
6
+ name: 'product-spec',
7
+ description: 'powers /draft-prd',
8
+ sourceUrl: 'https://github.com/firatcand/founder-skills/tree/main/skills/product-spec',
9
+ recommended: true,
10
+ },
11
+ {
12
+ value: 'software-architect',
13
+ name: 'software-architect',
14
+ description: 'powers /draft-spec',
15
+ sourceUrl: 'https://github.com/firatcand/founder-skills/tree/main/skills/software-architect',
16
+ recommended: true,
17
+ },
18
+ {
19
+ value: 'ux-design',
20
+ name: 'ux-design',
21
+ description: 'powers /draft-design',
22
+ sourceUrl: 'https://github.com/firatcand/founder-skills/tree/main/skills/ux-design',
23
+ recommended: false,
24
+ },
25
+ {
26
+ value: 'ui-design',
27
+ name: 'ui-design',
28
+ description: 'powers /review for UI tasks',
29
+ sourceUrl: 'https://github.com/firatcand/founder-skills/tree/main/skills/ui-design',
30
+ recommended: false,
31
+ },
32
+ ];
33
+
34
+ const TOOL_KEY_TO_AGENT_ID = {
35
+ claude: 'claude-code',
36
+ codex: 'codex',
37
+ cursor: 'cursor',
38
+ gemini: 'gemini-cli',
39
+ };
40
+
41
+ export async function installCompanion(slug, targetTools) {
42
+ const companion = COMPANIONS.find(c => c.value === slug);
43
+ if (!companion) {
44
+ throw new Error(`Unknown companion: ${slug}`);
45
+ }
46
+
47
+ const agentIds = (targetTools ?? [])
48
+ .map(key => TOOL_KEY_TO_AGENT_ID[key])
49
+ .filter(Boolean);
50
+
51
+ const args = ['-y', 'skills', 'add', companion.sourceUrl, '--global', '--yes'];
52
+ if (agentIds.length > 0) {
53
+ args.push('--agent', ...agentIds);
54
+ }
55
+
56
+ const result = spawnSync('npx', args, {
57
+ stdio: 'pipe',
58
+ encoding: 'utf-8',
59
+ });
60
+
61
+ if (result.status !== 0) {
62
+ throw new Error(
63
+ result.stderr?.split('\n')[0] ||
64
+ `npx skills failed (exit code ${result.status})`
65
+ );
66
+ }
67
+ }
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bash
2
+ # github-helpers.sh — gh CLI wrappers used by /setup-repo.
3
+ #
4
+ # Source this file: `source "${FORGE_DIR}/lib/github-helpers.sh"`
5
+
6
+ set -euo pipefail
7
+
8
+ # ──────────────────────────────────────────────
9
+ # Logging
10
+ # ──────────────────────────────────────────────
11
+ _log() { printf '\033[1;35m[gh]\033[0m %s\n' "$*" >&2; }
12
+ _warn() { printf '\033[1;33m[gh]\033[0m %s\n' "$*" >&2; }
13
+ _err() { printf '\033[1;31m[gh]\033[0m %s\n' "$*" >&2; }
14
+
15
+ # ──────────────────────────────────────────────
16
+ # gh_check_auth
17
+ # Verifies that gh CLI is installed and authenticated.
18
+ # Returns 0 if ready, 1 otherwise.
19
+ # ──────────────────────────────────────────────
20
+ gh_check_auth() {
21
+ if ! command -v gh >/dev/null 2>&1; then
22
+ _err "gh CLI not installed. https://cli.github.com/"
23
+ return 1
24
+ fi
25
+
26
+ if ! gh auth status >/dev/null 2>&1; then
27
+ _err "gh CLI not authenticated. Run: gh auth login"
28
+ return 1
29
+ fi
30
+
31
+ _log "gh CLI ready"
32
+ }
33
+
34
+ # ──────────────────────────────────────────────
35
+ # gh_create_repo NAME [PRIVACY=private]
36
+ # Creates a GitHub repo from current directory and sets origin remote.
37
+ # Skips creation if origin remote already configured.
38
+ # ──────────────────────────────────────────────
39
+ gh_create_repo() {
40
+ local name="${1:?name required}"
41
+ local privacy="${2:-private}"
42
+
43
+ if git remote get-url origin >/dev/null 2>&1; then
44
+ _warn "origin remote already configured: $(git remote get-url origin)"
45
+ return 0
46
+ fi
47
+
48
+ _log "Creating repo: ${name} (${privacy})"
49
+ gh repo create "${name}" "--${privacy}" --source=. --remote=origin
50
+ }
51
+
52
+ # ──────────────────────────────────────────────
53
+ # gh_protect_branch BRANCH [REQUIRE_REVIEWS=1] [REQUIRE_CHECKS_CSV=test]
54
+ # Sets branch protection: require PR review, require status checks, no
55
+ # direct pushes, no force pushes.
56
+ # ──────────────────────────────────────────────
57
+ gh_protect_branch() {
58
+ local branch="${1:?branch required}"
59
+ local require_reviews="${2:-1}"
60
+ local require_checks="${3:-test}"
61
+
62
+ local repo
63
+ repo=$(gh repo view --json nameWithOwner -q '.nameWithOwner')
64
+
65
+ _log "Protecting ${repo}:${branch}"
66
+
67
+ local checks_json
68
+ checks_json=$(printf '%s\n' "${require_checks}" | tr ',' '\n' | jq -R . | jq -sc '{strict: true, contexts: .}')
69
+
70
+ local payload
71
+ payload=$(jq -n \
72
+ --argjson reviews "${require_reviews}" \
73
+ --argjson checks "${checks_json}" \
74
+ '{required_status_checks: $checks,
75
+ enforce_admins: true,
76
+ required_pull_request_reviews: {required_approving_review_count: $reviews},
77
+ restrictions: null,
78
+ allow_force_pushes: false,
79
+ allow_deletions: false}')
80
+
81
+ gh api -X PUT \
82
+ "repos/${repo}/branches/${branch}/protection" \
83
+ -H "Accept: application/vnd.github+json" \
84
+ --input - <<<"${payload}" >/dev/null
85
+
86
+ _log "✓ ${branch} protected"
87
+ }
88
+
89
+ # ──────────────────────────────────────────────
90
+ # gh_create_environment NAME [REQUIRE_APPROVAL=false]
91
+ # Creates a GitHub Environment. Set REQUIRE_APPROVAL=true to gate deploys.
92
+ # ──────────────────────────────────────────────
93
+ gh_create_environment() {
94
+ local name="${1:?name required}"
95
+ local require_approval="${2:-false}"
96
+
97
+ local repo
98
+ repo=$(gh repo view --json nameWithOwner -q '.nameWithOwner')
99
+
100
+ _log "Creating environment: ${name}"
101
+
102
+ local reviewers='[]'
103
+ if [[ "${require_approval}" == "true" ]]; then
104
+ local user_id
105
+ user_id=$(gh api user -q '.id')
106
+ reviewers=$(jq -nc --argjson id "${user_id}" '[{type: "User", id: $id}]')
107
+ fi
108
+
109
+ local payload
110
+ payload=$(jq -n --argjson reviewers "${reviewers}" \
111
+ '{wait_timer: 0, reviewers: $reviewers, deployment_branch_policy: null}')
112
+
113
+ gh api -X PUT \
114
+ "repos/${repo}/environments/${name}" \
115
+ -H "Accept: application/vnd.github+json" \
116
+ --input - <<<"${payload}" >/dev/null
117
+
118
+ _log "✓ environment ${name} (approval: ${require_approval})"
119
+ }
120
+
121
+ # ──────────────────────────────────────────────
122
+ # gh_set_secret KEY VALUE
123
+ # Sets a repo-level GitHub Actions secret. Reads VALUE from arg or stdin.
124
+ # ──────────────────────────────────────────────
125
+ gh_set_secret() {
126
+ local key="${1:?key required}"
127
+ local value="${2:-}"
128
+
129
+ if [[ -z "${value}" ]]; then
130
+ if [[ -t 0 ]]; then
131
+ _err "VALUE not provided (arg or stdin)"
132
+ return 1
133
+ fi
134
+ value=$(cat)
135
+ fi
136
+
137
+ printf '%s' "${value}" | gh secret set "${key}"
138
+ _log "✓ secret ${key} set"
139
+ }
140
+
141
+ # ──────────────────────────────────────────────
142
+ # gh_set_oauth_token VALUE
143
+ # Convenience: sets CLAUDE_CODE_OAUTH_TOKEN as a repo secret.
144
+ # ──────────────────────────────────────────────
145
+ gh_set_oauth_token() {
146
+ local token="${1:?token required}"
147
+ gh_set_secret CLAUDE_CODE_OAUTH_TOKEN "${token}"
148
+ }