nova-spec 1.0.2 → 1.0.4

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/INSTALL.md ADDED
@@ -0,0 +1,144 @@
1
+ # Installing nova-spec
2
+
3
+ ---
4
+
5
+ ## Quick install
6
+
7
+ ```bash
8
+ npx nova-spec init
9
+ ```
10
+
11
+ The interactive wizard guides you through:
12
+
13
+ 1. **Scope** — install globally (all your projects) or just in the current repo
14
+ 2. **Runtime** — Claude Code, OpenCode, or both
15
+ 3. **Jira** — optional: URL, project key, email, Done transition ID
16
+ 4. **Branch** — base branch (default: `main`)
17
+
18
+ It generates a ready-to-use `novaspec/config.yml`. No manual editing required.
19
+
20
+ ---
21
+
22
+ ## What gets installed
23
+
24
+ ```
25
+ .
26
+ ├── AGENTS.md Repo anchor — first thing the agent reads
27
+ ├── notes.md Scratch pad
28
+
29
+ ├── novaspec/ Framework content (edit any file directly)
30
+ │ ├── config.yml Your project config (gitignored)
31
+ │ ├── commands/ /nova-* slash commands
32
+ │ ├── skills/ Auxiliary skills (Jira, memory, etc.)
33
+ │ ├── agents/ Subagents (context-loader, review)
34
+ │ ├── guardrails/ Shared preconditions (bash scripts)
35
+ │ ├── templates/ Artifact templates (PR body, commit, etc.)
36
+ │ └── .nova-manifest.json Tracks last-shipped hashes (gitignored)
37
+
38
+ ├── .claude/ Symlinks so Claude Code discovers the commands
39
+ │ ├── commands -> ../novaspec/commands
40
+ │ ├── skills -> ../novaspec/skills
41
+ │ └── agents -> ../novaspec/agents
42
+
43
+ ├── .opencode/ (only if you install for OpenCode)
44
+ │ └── ... (same symlinks)
45
+
46
+ └── context/ Architectural memory
47
+ ├── decisions/ Why we did X (one fact per file)
48
+ │ └── archived/
49
+ ├── gotchas/ Non-obvious traps
50
+ ├── services/ One flat file per service
51
+ └── changes/
52
+ ├── active/ In-progress specs
53
+ └── archive/ Closed specs
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Global vs project install
59
+
60
+ | | Global (`~/.claude`) | Project (`.claude/`) |
61
+ |---|---|---|
62
+ | Works everywhere | ✓ | ✗ |
63
+ | Per-repo customization | ✗ | ✓ |
64
+ | Commit with the team | ✗ | ✓ |
65
+
66
+ Choose **global** for personal use. Choose **project** when the team shares the same flow.
67
+
68
+ ---
69
+
70
+ ## Verification
71
+
72
+ After installing, open Claude Code or OpenCode at the repo root and type `/` — you should see the `/nova-*` commands in autocomplete.
73
+
74
+ Run your first ticket:
75
+
76
+ ```
77
+ /nova-start TICKET-ID
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Customizing the framework
83
+
84
+ Edit any file under `novaspec/` directly. There is no separate "custom" folder — your edits live where they're used:
85
+
86
+ ```bash
87
+ # Customize the PR/MR description for your team
88
+ $EDITOR novaspec/templates/pr-body.md
89
+
90
+ # Add an extra step to /nova-wrap
91
+ $EDITOR novaspec/commands/nova-wrap.md
92
+ ```
93
+
94
+ When `npx nova-spec sync` runs, it hashes every framework file and compares it with what was last shipped. If it matches → safe to overwrite with the new version. If it differs → you've edited it, sync skips it and reports the path so you can `/nova-diff <path>` to see what changed upstream.
95
+
96
+ Your customizations are part of your repo. Commit them with the team — every developer gets the same flow.
97
+
98
+ ---
99
+
100
+ ## Keeping up to date
101
+
102
+ `npx nova-spec sync` runs automatically every time Claude Code or OpenCode start (via a `SessionStart` hook). You can also run it manually:
103
+
104
+ ```bash
105
+ npx nova-spec sync
106
+ ```
107
+
108
+ Or from inside Claude Code / OpenCode:
109
+
110
+ ```
111
+ /nova-sync
112
+ ```
113
+
114
+ The sync report lists: new files, updated files (untouched locally), skipped files (you edited), and removed files. Your local edits are always preserved.
115
+
116
+ ---
117
+
118
+ ## Uninstall
119
+
120
+ ```bash
121
+ rm -rf novaspec .claude .opencode
122
+ rm -f AGENTS.md notes.md
123
+ ```
124
+
125
+ > This also removes all architectural memory (`context/`). Move it elsewhere first if you want to keep it.
126
+
127
+ ---
128
+
129
+ ## Common issues
130
+
131
+ ### Commands don't show up
132
+
133
+ 1. Check symlinks: `ls -la .claude/`
134
+ 2. Restart Claude Code (it sometimes caches the listing)
135
+ 3. Verify files in `novaspec/commands/` have valid frontmatter (`description:`)
136
+
137
+ ### Broken symlinks after cloning
138
+
139
+ ```bash
140
+ git config core.symlinks true
141
+ git reset --hard HEAD
142
+ ```
143
+
144
+ On native Windows, symlinks require Developer Mode or admin. Use WSL instead.
package/PHILOSOPHY.md ADDED
@@ -0,0 +1,149 @@
1
+ # Philosophy
2
+
3
+ > **Simple. The developer controls the framework, not the other way around.**
4
+
5
+ This document is the antibody to scope creep. Read it before adding a feature, before adopting nova-spec in a new team, and before saying yes to a feature request.
6
+
7
+ If a future version of nova-spec is "richer" but no longer fits this document, **the document wins**. We will remove features to defend the philosophy. We will not change the philosophy to justify features.
8
+
9
+ ---
10
+
11
+ ## Core principles
12
+
13
+ ### 1. Form, not substance
14
+
15
+ nova-spec enforces **shape** — sequence, file structure, naming conventions. It does **not** enforce **quality** — whether your spec is good, whether your decision is right, whether your code is correct. Quality is the developer's job.
16
+
17
+ A check that requires LLM judgment to evaluate is not a guardrail. It's a suggestion. Don't dress it up as the former. Real guardrails are deterministic (e.g. `guardrails/old-decision-archived.md`: bash, file existence, exit code). Everything else is a check or a convention — call it that.
18
+
19
+ ### 2. Plain markdown, no DSLs
20
+
21
+ Specs, decisions, gotchas, services, templates — all plain markdown. No frontmatter required except where Claude Code demands it for slash commands and skills. No schema enforcement. No build step.
22
+
23
+ If a junior cannot open `context/decisions/<x>.md` and understand the decision in 30 seconds, the file is wrong, not the format.
24
+
25
+ ### 3. No hidden state
26
+
27
+ Everything lives in git: `context/`, `novaspec/`, `AGENTS.md`. No database, no daemon, no telemetry, no cloud component. If Claude Code disappears tomorrow, your repo still tells the full story.
28
+
29
+ This is the property that makes nova-spec survivable across tool changes. Defend it.
30
+
31
+ ### 4. Atomic memory
32
+
33
+ One fact = one file. Filename = index (no `NNNN-` prefixes, no global numbering, no frontmatter). Supersede explicitly with `> Supersedes: <old>.md` and `git mv` the old file to `archived/`. The bash validator in `guardrails/old-decision-archived.md` enforces this invariant — that is a real guardrail because it is deterministic and exits non-zero.
34
+
35
+ ### 5. The developer can always escape
36
+
37
+ If a phase gate gets in the way of real work, the developer can:
38
+
39
+ - Skip it manually (touch the expected file, mark a task done by hand)
40
+ - Edit the relevant command in their own `novaspec/commands/` and re-run
41
+ - Bypass nova-spec entirely and use Claude Code raw
42
+
43
+ The framework must never be the reason a developer cannot ship.
44
+
45
+ ---
46
+
47
+ ## In scope
48
+
49
+ - Ticket → branch → spec → plan → build → review → wrap workflow
50
+ - Atomic markdown memory: `decisions/`, `gotchas/`, `services/`
51
+ - Slash commands for Claude Code and OpenCode (via symlinks)
52
+ - Optional Jira integration as one skill (opt-in via `config.yml`)
53
+ - Phase gates that enforce **sequence** (file existence, branch shape)
54
+ - Human checkpoints where review materially matters (post-spec, pre-wrap)
55
+ - Templates as starting points
56
+
57
+ That's the surface. Everything else needs an explicit decision.
58
+
59
+ ---
60
+
61
+ ## Out of scope (we will say no)
62
+
63
+ We say no to:
64
+
65
+ - **CI/CD integration.** Your CI is your team's concern.
66
+ - **Cross-repo coordination.** One repo = one nova-spec instance.
67
+ - **Auto-merge or auto-approve PRs.** Humans decide.
68
+ - **Hosted memory, DB, or cloud component.** Markdown in git is the system of record.
69
+ - **Issue tracker abstraction layer.** Jira is the integrated path; Linear/GitHub Issues/Asana = fork the skill, don't extend core.
70
+ - **Real-time collaboration features.** Git handles concurrency.
71
+ - **Telemetry or metrics dashboards.** Out of scope by principle.
72
+ - **Auto-generated tests or auto-style fixes.** The dev decides what to test and how to style.
73
+ - **Plugins beyond markdown skills.** If it requires a runtime, server, or daemon, it does not belong in nova-spec.
74
+ - **Multi-tenant config.** One project = one `config.yml`.
75
+ - **Bypassable LLM-judgment-based blocks.** A "guardrail" that depends on the model reading carefully is not a guardrail.
76
+
77
+ This list is allowed to grow. It is not allowed to shrink without justification.
78
+
79
+ ---
80
+
81
+ ## Decision criteria for new features
82
+
83
+ Before merging a feature, the answer to all five must be acceptable:
84
+
85
+ 1. **Form or substance?** If it enforces substance, reject.
86
+ 2. **Is the runtime cost zero?** If it requires a server, daemon, DB, or external service, reject.
87
+ 3. **Can a junior understand it in 5 minutes from the file alone?** If not, simplify or reject.
88
+ 4. **Could a 10-line fork in the consuming repo achieve the same result?** If yes, don't add to core. Document the fork pattern instead.
89
+ 5. **Does it survive if Claude Code is replaced by another tool tomorrow?** If no, think hard.
90
+
91
+ We accept living without features. We do not accept living with bloat. The cost of saying no is borne by one team. The cost of saying yes is borne by every team that adopts nova-spec from then on.
92
+
93
+ ---
94
+
95
+ ## Forking and customization
96
+
97
+ **Today (known limitation).** The installer does `rm -rf novaspec/` and re-copies from source. Local edits to `novaspec/commands/`, `skills/`, `templates/`, or `agents/` are **lost on re-run**. The installer only preserves `novaspec/config.yml` (via backup) and the consumer's `context/`.
98
+
99
+ This contradicts principle 5. We acknowledge the gap. Two practical paths today:
100
+
101
+ - **Team fork**: clone the nova-spec repo into your team's space, customize, install from your fork. Your fork is your `SCRIPT_DIR`.
102
+ - **Manual updates**: don't re-run the installer; pull updates by hand and merge edits.
103
+
104
+ **Planned (not built).** A `team-overrides/` directory that survives re-installs. Until shipped, the fork is the answer. Do not pretend otherwise to consumers.
105
+
106
+ ---
107
+
108
+ ## How another team adopts nova-spec
109
+
110
+ This is not a sales pitch. It is the contract:
111
+
112
+ 1. You will read this document. If anything here is incompatible with how your team works, do not adopt nova-spec — pick a tool whose philosophy fits.
113
+ 2. You will pin to a tagged version (e.g. `v0.x.y`). You will not track `main`.
114
+ 3. If you need something nova-spec does not have, you fork. You do not block on us merging your change.
115
+ 4. If your fork generalizes, open a PR with rationale against the decision criteria above.
116
+ 5. We do not promise backward compatibility across major versions. We do promise to document breaking changes in the release notes.
117
+
118
+ If those terms are unacceptable, OpenSpec or GitHub Spec Kit are externally maintained alternatives — adopt those instead. We won't be offended.
119
+
120
+ ---
121
+
122
+ ## Review cadence
123
+
124
+ Every 3 months, the maintainers re-read this file and answer in writing:
125
+
126
+ - Has any principle been quietly violated?
127
+ - Has any "out of scope" item snuck in?
128
+ - Has the lema started to feel like a slogan instead of a practice?
129
+ - Are we saying yes to feature requests we should be saying no to?
130
+
131
+ If yes to any: revert or remove the offending feature, or rewrite the relevant principle to acknowledge the change explicitly. **The framework is allowed to lose features. It is not allowed to lose its soul.**
132
+
133
+ ---
134
+
135
+ ## Maintainer's commitment
136
+
137
+ The maintainers commit to:
138
+
139
+ - Saying no to feature requests that violate this document, even from people they like.
140
+ - Defending simplicity over completeness when the two conflict.
141
+ - Treating "we already built it" as a reason to remove, not a reason to keep.
142
+ - Writing down what we removed and why, in `context/decisions/`.
143
+
144
+ The maintainers do **not** commit to:
145
+
146
+ - Long-term backward compatibility.
147
+ - Supporting every team's specific tooling.
148
+ - Accepting every PR that "would be useful."
149
+ - Replacing tools that already do the job better externally.
package/README.md CHANGED
@@ -60,6 +60,8 @@ Affected services: auth-api ✓
60
60
  Branch created: feature/PROJ-42-rate-limit-login (from main)
61
61
 
62
62
  Loaded context:
63
+ Stack: ✓ loaded
64
+ Conventions: ✓ loaded
63
65
  Services: auth-api ✓
64
66
  Decisions read: throttling-strategy.md, redis-usage.md
65
67
  Gaps: none
@@ -86,31 +88,44 @@ No code yet. The agent classified the work, created the branch, and pulled in on
86
88
  | `/nova-wrap` | Updates memory, archives the spec, creates commit and PR |
87
89
  | `/nova-status [TICKET]` | Current status of the ticket (read-only) |
88
90
  | `/nova-sync` | Updates nova-spec core to the latest version |
89
- | `/nova-diff <name>` | Shows diff between your custom override and the new core version |
91
+ | `/nova-diff <path>` | Shows diff between your local edits and the latest package version |
90
92
 
91
93
  `quick-fix` tickets skip `/nova-spec` and `/nova-plan`.
92
94
 
93
- ## Customizing skills and commands
95
+ ## Customizing the framework
94
96
 
95
- Place any file in `novaspec/custom/` to override the core version same name, your rules:
97
+ Edit any file under `novaspec/` directly. **There is no separate "custom" folder** your edits live where they're used. `npx nova-spec sync` hash-compares every file and never overwrites the ones you've touched.
96
98
 
97
- ```
98
- novaspec/
99
- ├── skills/ core (managed by nova-spec)
100
- └── custom/
101
- └── skills/
102
- └── nova-wrap/ ← your override, same name wins
103
- ```
99
+ ### The 5 things your team will likely want to change
100
+
101
+ 1. **PR / MR template** `novaspec/templates/pr-body.md`
102
+ What `/nova-wrap` puts in the description box. Add your team's QA checklist, ticket-link format, security notes.
103
+
104
+ 2. **Code review checklist** `novaspec/templates/review.md`
105
+ The structure `/nova-review` follows. Add your conventions, your blockers, your sections.
106
+
107
+ 3. **Commit message format** → `novaspec/templates/commit.md`
108
+ Conventional commits, custom prefixes, ticket-in-subject — whatever your team enforces.
104
109
 
105
- Run `/nova-sync` to update the core. Your `custom/` folder is never touched.
110
+ 4. **Ticket system** `novaspec/config.yml` `ticket_system`
111
+ Set to `jira` for Atlassian Jira, or `none` to paste tickets manually. `none` skips ticket-key validation in `/nova-start`.
112
+
113
+ 5. **Stack & conventions context** → `context/stack.md` and `context/conventions.md`
114
+ Loaded at the start of every ticket so the agent knows your tech, your patterns, your "we don't do that here". The installer creates both files with comments explaining what to put in them.
115
+
116
+ Other useful tweaks: forge (`config.yml` → `forge.type`: github/gitlab), branch base (`branch.base`), guardrails (`novaspec/guardrails/*.sh`).
117
+
118
+ After any edit, commit it with the team — everyone gets the same flow.
106
119
 
107
120
  ## Keeping up to date
108
121
 
122
+ `npx nova-spec sync` runs automatically when Claude Code or OpenCode start (via a `SessionStart` hook). You can also run it manually:
123
+
109
124
  ```bash
110
125
  npx nova-spec sync
111
126
  ```
112
127
 
113
- Updates the core, preserves your custom overrides and `config.yml`, and tells you if any of your overrides have upstream changes worth reviewing.
128
+ It updates only the files you haven't edited locally and reports the rest.
114
129
 
115
130
  ## Principles
116
131
 
package/lib/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
3
5
  const { init } = require('./installer.js');
4
6
  const { sync } = require('./sync.js');
7
+ const jira = require('./jira.js');
5
8
 
6
9
  async function run() {
7
10
  const command = process.argv[2];
@@ -14,11 +17,160 @@ async function run() {
14
17
  case 'sync':
15
18
  await sync();
16
19
  break;
20
+ case 'jira':
21
+ await runJira(process.argv.slice(3));
22
+ break;
23
+ case 'forge':
24
+ await runForge(process.argv.slice(3));
25
+ break;
26
+ case 'source':
27
+ runSource(process.argv.slice(3));
28
+ break;
17
29
  default:
18
30
  console.error(`Unknown command: ${command}`);
19
- console.error('Usage: nova-spec [init|sync]');
31
+ console.error('Usage: nova-spec [init|sync|jira <subcmd>|forge <subcmd>|source <path>]');
20
32
  process.exit(1);
21
33
  }
22
34
  }
23
35
 
36
+ function runSource(args) {
37
+ const [relPath] = args;
38
+ if (!relPath) {
39
+ console.error('Usage: nova-spec source <relative-path>');
40
+ process.exit(2);
41
+ }
42
+ const PACKAGE_ROOT = path.join(__dirname, '..');
43
+ const abs = path.join(PACKAGE_ROOT, relPath);
44
+ if (!fs.existsSync(abs)) {
45
+ console.error(`✗ ${relPath} is not part of the nova-spec package.`);
46
+ process.exit(1);
47
+ }
48
+ console.log(abs);
49
+ }
50
+
51
+ async function runJira(args) {
52
+ const [subcmd, ...rest] = args;
53
+ if (!subcmd) {
54
+ console.error('Usage: nova-spec jira <get|transitions|transition> [args...]');
55
+ process.exit(2);
56
+ }
57
+
58
+ const config = readJiraConfig();
59
+ if (!config) {
60
+ console.error(' ✗ Jira not configured. Run `npx nova-spec init` and enable Jira.');
61
+ process.exit(1);
62
+ }
63
+
64
+ try {
65
+ let result;
66
+ if (subcmd === 'get') {
67
+ if (!rest[0]) throw new Error('Usage: nova-spec jira get <TICKET>');
68
+ result = await jira.getIssueAsync({ ...config, ticket: rest[0] });
69
+ } else if (subcmd === 'transitions') {
70
+ if (!rest[0]) throw new Error('Usage: nova-spec jira transitions <TICKET>');
71
+ result = await jira.listTransitionsAsync({ ...config, ticket: rest[0] });
72
+ } else if (subcmd === 'transition') {
73
+ if (!rest[0] || !rest[1]) throw new Error('Usage: nova-spec jira transition <TICKET> <ID>');
74
+ result = await jira.transitionAsync({ ...config, ticket: rest[0], transitionId: rest[1] });
75
+ } else {
76
+ throw new Error(`Unknown jira subcommand: ${subcmd}`);
77
+ }
78
+ if (result != null) console.log(JSON.stringify(result, null, 2));
79
+ } catch (err) {
80
+ if (err.status === 401) {
81
+ console.error(' ✗ Jira 401: invalid credentials. Regenerate JIRA_API_TOKEN.');
82
+ } else if (err.status === 404) {
83
+ console.error(` ✗ Jira 404: ticket not found.`);
84
+ } else {
85
+ console.error(` ✗ ${err.message}`);
86
+ }
87
+ process.exit(err.status || 1);
88
+ }
89
+ }
90
+
91
+ async function runForge(args) {
92
+ const { detectForge, buildPrCommand, reviewTerm, checkCliAvailable } = require('./forge.js');
93
+ const [subcmd, ...rest] = args;
94
+
95
+ if (subcmd === 'detect') {
96
+ const f = detectForge();
97
+ if (f) console.log(f);
98
+ else process.exit(1);
99
+ return;
100
+ }
101
+
102
+ if (subcmd === 'pr-command') {
103
+ const config = readForgeConfig();
104
+ const forge = config.type !== 'auto' ? config.type : detectForge();
105
+ if (!forge || forge === 'none') {
106
+ console.error(' ✗ Forge not detected. Set forge.type in novaspec/config.yml.');
107
+ process.exit(2);
108
+ }
109
+ const cli = config.cli !== 'auto' ? config.cli : null;
110
+ const [title, body, base] = rest;
111
+ if (!title || !body || !base) {
112
+ console.error('Usage: nova-spec forge pr-command <title> <body> <base>');
113
+ process.exit(2);
114
+ }
115
+ console.log(buildPrCommand({ forge, cli, title, body, base }));
116
+ return;
117
+ }
118
+
119
+ if (subcmd === 'term') {
120
+ const config = readForgeConfig();
121
+ const forge = config.type !== 'auto' ? config.type : detectForge();
122
+ console.log(reviewTerm(forge));
123
+ return;
124
+ }
125
+
126
+ console.error('Usage: nova-spec forge <detect|pr-command|term>');
127
+ process.exit(2);
128
+ }
129
+
130
+ function readJiraConfig() {
131
+ const configPath = path.join(process.cwd(), 'novaspec', 'config.yml');
132
+ if (!fs.existsSync(configPath)) return null;
133
+ const text = fs.readFileSync(configPath, 'utf8');
134
+
135
+ const url = extractYamlScalar(text, 'jira', 'url');
136
+ const email = extractYamlScalar(text, 'jira', 'email');
137
+ let token = extractYamlScalar(text, 'jira', 'token');
138
+
139
+ if (token && /^\$\{[A-Z_]+\}$/.test(token)) {
140
+ const envName = token.slice(2, -1);
141
+ token = process.env[envName];
142
+ } else if (token === '${JIRA_API_TOKEN}' || !token) {
143
+ token = process.env.JIRA_API_TOKEN;
144
+ }
145
+
146
+ if (!url || !email || !token) return null;
147
+ return { url, email, token };
148
+ }
149
+
150
+ function readForgeConfig() {
151
+ const configPath = path.join(process.cwd(), 'novaspec', 'config.yml');
152
+ const defaults = { type: 'auto', cli: 'auto' };
153
+ if (!fs.existsSync(configPath)) return defaults;
154
+ const text = fs.readFileSync(configPath, 'utf8');
155
+ return {
156
+ type: extractYamlScalar(text, 'forge', 'type') || 'auto',
157
+ cli: extractYamlScalar(text, 'forge', 'cli') || 'auto',
158
+ };
159
+ }
160
+
161
+ function extractYamlScalar(text, parent, key) {
162
+ // Minimal YAML reader for `parent: \n key: value` blocks. Strips quotes.
163
+ const re = new RegExp(`^${parent}:\\s*$([\\s\\S]*?)(?=^\\S|\\Z)`, 'm');
164
+ const block = text.match(re);
165
+ if (!block) return null;
166
+ const lineRe = new RegExp(`^\\s+${key}:\\s*(.*)$`, 'm');
167
+ const match = block[1].match(lineRe);
168
+ if (!match) return null;
169
+ let value = match[1].trim();
170
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
171
+ value = value.slice(1, -1);
172
+ }
173
+ return value;
174
+ }
175
+
24
176
  module.exports = { run };
package/lib/forge.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+
5
+ function detectForge(cwd = process.cwd()) {
6
+ let remote = '';
7
+ try {
8
+ remote = execSync('git remote get-url origin', { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
9
+ .toString()
10
+ .trim();
11
+ } catch (_) {
12
+ return null;
13
+ }
14
+
15
+ if (!remote) return null;
16
+ if (/github\.com[:/]/i.test(remote)) return 'github';
17
+ if (/gitlab[.-][\w.-]+[:/]|gitlab\.com[:/]/i.test(remote)) return 'gitlab';
18
+ if (/bitbucket\.org[:/]/i.test(remote)) return 'bitbucket';
19
+ return null;
20
+ }
21
+
22
+ function pickCli(forge, configCli) {
23
+ if (configCli && configCli !== 'auto') return configCli;
24
+ if (forge === 'github') return 'gh';
25
+ if (forge === 'gitlab') return 'glab';
26
+ return null;
27
+ }
28
+
29
+ function buildPrCommand({ forge, cli, title, body, base }) {
30
+ const resolvedCli = cli || pickCli(forge);
31
+ if (!resolvedCli) {
32
+ throw new Error(`No CLI configured for forge "${forge}". Set forge.cli in config.yml.`);
33
+ }
34
+
35
+ const q = (s) => `'${String(s ?? '').replace(/'/g, `'\\''`)}'`;
36
+
37
+ if (forge === 'github' || resolvedCli === 'gh') {
38
+ return `gh pr create --base ${q(base)} --title ${q(title)} --body ${q(body)}`;
39
+ }
40
+ if (forge === 'gitlab' || resolvedCli === 'glab') {
41
+ return `glab mr create --target-branch ${q(base)} --title ${q(title)} --description ${q(body)} --fill`;
42
+ }
43
+ throw new Error(`Unknown forge: ${forge}`);
44
+ }
45
+
46
+ function reviewTerm(forge) {
47
+ // GitHub uses "PR" / "Pull Request"; GitLab uses "MR" / "Merge Request".
48
+ if (forge === 'gitlab') return 'MR';
49
+ return 'PR';
50
+ }
51
+
52
+ function checkCliAvailable(cli) {
53
+ try {
54
+ execSync(`${cli} --version`, { stdio: 'ignore' });
55
+ return true;
56
+ } catch (_) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ module.exports = { detectForge, pickCli, buildPrCommand, reviewTerm, checkCliAvailable };