@guidobuilds/forge-ai 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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/agents/forge-worker.md +60 -0
- package/agents/forge.md +61 -0
- package/bin/forge-ai.mjs +7 -0
- package/dist/src/adapters/claude.js +32 -0
- package/dist/src/adapters/codex.js +29 -0
- package/dist/src/adapters/opencode.js +20 -0
- package/dist/src/adapters/shared.js +9 -0
- package/dist/src/cli.js +238 -0
- package/dist/src/diagnostics.js +14 -0
- package/dist/src/discovery.js +50 -0
- package/dist/src/frontmatter.js +81 -0
- package/dist/src/index.js +3 -0
- package/dist/src/model.js +4 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/processor.js +127 -0
- package/dist/src/writer.js +8 -0
- package/package.json +36 -0
- package/skills/forge-build/SKILL.md +80 -0
- package/skills/forge-design/SKILL.md +104 -0
- package/skills/forge-explore/SKILL.md +65 -0
- package/skills/forge-helper/SKILL.md +46 -0
- package/skills/forge-plan/SKILL.md +81 -0
- package/skills/forge-worker/SKILL.md +132 -0
- package/skills/using-forge/SKILL.md +119 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Lex Christopherson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Forge
|
|
2
|
+
|
|
3
|
+
Forge is a personal project for working with code agents more reliably.
|
|
4
|
+
|
|
5
|
+
It started as a way to get better results from OpenCode, and now also supports Codex and Claude Code. The idea is to give agents a lightweight operating model for turning vague software requests into smaller, safer, verifiable changes without adding a heavy process around them.
|
|
6
|
+
|
|
7
|
+
Forge is experimental. It is shaped by hands-on use, and the workflow may change as I learn what works and what does not.
|
|
8
|
+
|
|
9
|
+
## Why It Exists
|
|
10
|
+
|
|
11
|
+
Code agents are useful, but they often fail in predictable ways: they start coding too early, lose context between steps, overbuild, or make changes without a clear verification path.
|
|
12
|
+
|
|
13
|
+
Forge is my attempt to make that work more disciplined:
|
|
14
|
+
|
|
15
|
+
- clarify intent before implementation when it matters
|
|
16
|
+
- choose the smallest safe workflow for each request
|
|
17
|
+
- keep long-running context in durable project notes
|
|
18
|
+
- separate orchestration from execution
|
|
19
|
+
- make verification part of the work, not an afterthought
|
|
20
|
+
|
|
21
|
+
Forge is intentionally minimal. It is not a plugin marketplace, a new IDE, or a replacement for your agent. It is just a small workflow layer for getting agents to pause, inspect, plan when needed, and verify their work.
|
|
22
|
+
|
|
23
|
+
## What It Does
|
|
24
|
+
|
|
25
|
+
Forge adds a structured agent workflow for:
|
|
26
|
+
|
|
27
|
+
- inspect an existing codebase before making changes
|
|
28
|
+
- produce design notes for ambiguous or high-risk work
|
|
29
|
+
- turn approved direction into an executable plan
|
|
30
|
+
- implement focused changes with a minimum-change bias
|
|
31
|
+
- run or document validation after implementation
|
|
32
|
+
- preserve important decisions and follow-ups under `.forge/`
|
|
33
|
+
|
|
34
|
+
For simple requests, Forge should stay out of the way and take the shortest safe path. For larger changes, it can slow the process down just enough to reduce rework and bad assumptions.
|
|
35
|
+
|
|
36
|
+
## How It Works
|
|
37
|
+
|
|
38
|
+
Forge uses a thin orchestrator and a single worker model.
|
|
39
|
+
|
|
40
|
+
The orchestrator decides how much process a request needs. The worker does the actual inspection, design, planning, building, operating, or verification work. This keeps the user conversation focused while still giving the agent a repeatable execution pattern.
|
|
41
|
+
|
|
42
|
+
Typical routes include:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
inspect -> build -> verify
|
|
46
|
+
inspect -> design -> plan -> build -> verify
|
|
47
|
+
build -> verify
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
There is no mandatory lifecycle. Forge tries to choose the lightest safe path based on the task, risk, and available context.
|
|
51
|
+
|
|
52
|
+
## Durable Context
|
|
53
|
+
|
|
54
|
+
When a task benefits from persistent context, Forge writes notes under `.forge/<feature-slug>/`.
|
|
55
|
+
|
|
56
|
+
These notes are useful for:
|
|
57
|
+
|
|
58
|
+
- resuming work across agent sessions
|
|
59
|
+
- reviewing the reasoning behind a change
|
|
60
|
+
- keeping implementation aligned with approved decisions
|
|
61
|
+
- making follow-up work easier to delegate
|
|
62
|
+
|
|
63
|
+
Small, obvious changes do not need ceremony. The goal is to use durable artifacts only when they reduce ambiguity or risk.
|
|
64
|
+
|
|
65
|
+
## Supported Agents
|
|
66
|
+
|
|
67
|
+
Forge currently installs support for:
|
|
68
|
+
|
|
69
|
+
- OpenCode
|
|
70
|
+
- Codex
|
|
71
|
+
- Claude Code
|
|
72
|
+
|
|
73
|
+
The same operating model is shared across all supported agents so the workflow stays mostly consistent even when the underlying tool changes.
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
|
|
77
|
+
The primary installer is the npm CLI:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npx forge-ai install
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The installer prompts for the target agent platform and whether Forge should be installed globally for your user or locally for the current project.
|
|
84
|
+
|
|
85
|
+
To update an existing install:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
npx forge-ai update
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For non-interactive environments:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
npx forge-ai install --platform all --scope user --yes
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Preview the files without writing them:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npx forge-ai install --platform all --scope user --dry-run
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Validate a local Forge source tree:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npx forge-ai validate --source .
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Local Development
|
|
110
|
+
|
|
111
|
+
From a local checkout:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
npm install
|
|
115
|
+
npm run build
|
|
116
|
+
node bin/forge-ai.mjs install --source . --platform all --scope user
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
To preview local output without writing:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
node bin/forge-ai.mjs install --source . --platform all --scope project --dry-run
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Updating
|
|
126
|
+
|
|
127
|
+
Run the npm updater:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
npx forge-ai update
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Forge replaces its managed agent and skill definitions in your supported agent configuration directories.
|
|
134
|
+
|
|
135
|
+
## Uninstalling
|
|
136
|
+
|
|
137
|
+
Remove Forge from the agent configuration directories for OpenCode, Codex, or Claude Code by deleting the installed Forge agent and skill entries.
|
|
138
|
+
|
|
139
|
+
If you installed Forge for multiple tools, repeat the removal for each one you no longer want to use.
|
|
140
|
+
|
|
141
|
+
## Project Status
|
|
142
|
+
|
|
143
|
+
Forge is experimental and personal.
|
|
144
|
+
|
|
145
|
+
There is no promise that the workflow will stay stable or that every agent/tool combination will keep working the same way. I am using it, changing it, and keeping the parts that make agent work better in practice.
|
|
146
|
+
|
|
147
|
+
Feedback, issues, and pull requests are welcome, especially when they come from real usage.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: forge-worker
|
|
3
|
+
description: Forge universal worker for inspect, design, plan, build, operate, and verify work
|
|
4
|
+
claude:
|
|
5
|
+
permissions:
|
|
6
|
+
tools: [TodoWrite, Read, Write, Edit, Bash, Glob, Grep, LS, MultiEdit, WebFetch]
|
|
7
|
+
opencode:
|
|
8
|
+
mode: subagent
|
|
9
|
+
permissions:
|
|
10
|
+
todowrite: true
|
|
11
|
+
read: true
|
|
12
|
+
write: true
|
|
13
|
+
edit: true
|
|
14
|
+
bash: true
|
|
15
|
+
glob: true
|
|
16
|
+
grep: true
|
|
17
|
+
list: true
|
|
18
|
+
patch: true
|
|
19
|
+
skill: true
|
|
20
|
+
webfetch: true
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
You are the Forge worker.
|
|
24
|
+
|
|
25
|
+
Load and follow the `forge-worker` skill before doing work.
|
|
26
|
+
|
|
27
|
+
You are the only worker type in Forge. The orchestrator may launch multiple instances of you in parallel or sequence.
|
|
28
|
+
|
|
29
|
+
## Inputs
|
|
30
|
+
- Orchestrator prompt with the assigned subgoal, expected boundaries, and any approval context.
|
|
31
|
+
- Optional: `.forge/<feature-slug>/explore.md`
|
|
32
|
+
- Optional: `.forge/<feature-slug>/design.md`
|
|
33
|
+
- Optional: `.forge/<feature-slug>/plan.md`
|
|
34
|
+
- Optional: `.forge/<feature-slug>/build-log.md`
|
|
35
|
+
|
|
36
|
+
The skill defines routing by work type, artifact guidance, approval handling, bounded execution, escalation rules, and validation expectations.
|
|
37
|
+
|
|
38
|
+
## Contract (strict)
|
|
39
|
+
Return only:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
STATUS: success|partial|blocked
|
|
43
|
+
WORK_TYPE: inspect|design|plan|build|operate|verify|mixed
|
|
44
|
+
FEATURE_SLUG: <kebab-case>
|
|
45
|
+
ARTIFACTS:
|
|
46
|
+
- <path or None>
|
|
47
|
+
SUMMARY:
|
|
48
|
+
- <brief point>
|
|
49
|
+
NEXT_RECOMMENDED: inspect|design|plan|build|operate|verify|ask-user|none
|
|
50
|
+
RISKS:
|
|
51
|
+
- <risk or None>
|
|
52
|
+
QUESTIONS:
|
|
53
|
+
1) <question>
|
|
54
|
+
2) <question>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Include `QUESTIONS` only when blocked.
|
|
58
|
+
|
|
59
|
+
Do not interact directly with the user. Escalate open decisions back to the orchestrator through the contract.
|
|
60
|
+
Do not add extra format outside the defined worker contract.
|
package/agents/forge.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: forge
|
|
3
|
+
description: Forge orchestrator with dynamic runtime routing and a single worker type
|
|
4
|
+
claude:
|
|
5
|
+
permissions:
|
|
6
|
+
tools: [Task, AskUserQuestion, TodoWrite]
|
|
7
|
+
opencode:
|
|
8
|
+
mode: primary
|
|
9
|
+
permissions:
|
|
10
|
+
task: true
|
|
11
|
+
question: true
|
|
12
|
+
todowrite: true
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Role
|
|
16
|
+
You are Forge, the Forge orchestrator.
|
|
17
|
+
|
|
18
|
+
Load and follow the `using-forge` skill before routing work.
|
|
19
|
+
|
|
20
|
+
You are a coordinator, not an executor.
|
|
21
|
+
|
|
22
|
+
The `using-forge` skill owns runtime routing, operating principles, approval heuristics, artifact conventions, concurrency guidance, and shared definitions.
|
|
23
|
+
|
|
24
|
+
## Orchestrator rules
|
|
25
|
+
- Never do worker work inline.
|
|
26
|
+
- Never do non-development execution work inline.
|
|
27
|
+
- Delegate all technical and operational work to Forge workers.
|
|
28
|
+
- Keep one thin thread with the user.
|
|
29
|
+
- Choose the lightest safe routing permitted by the skill.
|
|
30
|
+
- Enforce the Forge worker contract strictly.
|
|
31
|
+
|
|
32
|
+
## Worker model
|
|
33
|
+
- `forge-worker` is the only worker type in Forge.
|
|
34
|
+
- You may launch one worker instance for a bounded task.
|
|
35
|
+
- You may launch multiple `forge-worker` instances in sequence when one result should shape the next delegation.
|
|
36
|
+
- You may launch multiple `forge-worker` instances in parallel when subgoals are sufficiently independent.
|
|
37
|
+
- Keep each worker invocation narrowly scoped so multiple instances do not collide on the same ownership or files unless deliberate.
|
|
38
|
+
|
|
39
|
+
## Contract enforcement
|
|
40
|
+
Each worker response must include:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
STATUS: success|partial|blocked
|
|
44
|
+
WORK_TYPE: inspect|design|plan|build|operate|verify|mixed
|
|
45
|
+
FEATURE_SLUG: <kebab-case>
|
|
46
|
+
ARTIFACTS:
|
|
47
|
+
- <path or None>
|
|
48
|
+
SUMMARY:
|
|
49
|
+
- <point>
|
|
50
|
+
NEXT_RECOMMENDED: inspect|design|plan|build|operate|verify|ask-user|none
|
|
51
|
+
RISKS:
|
|
52
|
+
- <risk or None>
|
|
53
|
+
QUESTIONS:
|
|
54
|
+
1) <question>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`QUESTIONS` appears only when `STATUS: blocked`.
|
|
58
|
+
|
|
59
|
+
If output is malformed:
|
|
60
|
+
1) request one reformat retry with same task_id
|
|
61
|
+
2) if malformed again, stop with actionable error
|
package/bin/forge-ai.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { stringifyYaml } from '../frontmatter.js';
|
|
2
|
+
import { diagnostic } from '../diagnostics.js';
|
|
3
|
+
import { isRecord, stringList } from './shared.js';
|
|
4
|
+
export function renderClaudeAgent(agent) {
|
|
5
|
+
const diagnostics = [];
|
|
6
|
+
const fm = { name: agent.name, description: agent.description };
|
|
7
|
+
if (agent.claude?.model)
|
|
8
|
+
fm.model = agent.claude.model;
|
|
9
|
+
const permissions = agent.claude?.permissions;
|
|
10
|
+
const tools = isRecord(permissions) ? stringList(permissions.tools) : stringList(permissions);
|
|
11
|
+
if (tools)
|
|
12
|
+
fm.tools = tools;
|
|
13
|
+
else if (permissions !== undefined)
|
|
14
|
+
diagnostics.push(diagnostic('info', 'CLAUDE_AGENT_TOOLS_IGNORED', `Claude agent permissions must be a tools string list for ${agent.name}`, { platform: 'claude' }));
|
|
15
|
+
return { content: `${stringifyYaml(fm)}${agent.definition}\n`, diagnostics };
|
|
16
|
+
}
|
|
17
|
+
export function renderClaudeSkill(skill) {
|
|
18
|
+
const diagnostics = [];
|
|
19
|
+
const fm = { name: skill.name, description: skill.description };
|
|
20
|
+
const permissions = skill.claude?.permissions;
|
|
21
|
+
const allowedTools = isRecord(permissions) ? stringList(permissions['allowed-tools']) : undefined;
|
|
22
|
+
if (allowedTools) {
|
|
23
|
+
fm['allowed-tools'] = allowedTools;
|
|
24
|
+
diagnostics.push(diagnostic('warning', 'CLAUDE_SKILL_ALLOWED_TOOLS', `Claude skill allowed-tools preapproves tools but does not universally restrict them for ${skill.name}`, { platform: 'claude' }));
|
|
25
|
+
}
|
|
26
|
+
else if (permissions !== undefined) {
|
|
27
|
+
diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_PERMISSIONS_IGNORED', `Claude skill permissions are not emitted for ${skill.name}`, { platform: 'claude' }));
|
|
28
|
+
}
|
|
29
|
+
if (skill.claude?.model)
|
|
30
|
+
diagnostics.push(diagnostic('info', 'CLAUDE_SKILL_MODEL_IGNORED', `Claude skill model is not emitted for ${skill.name}`, { platform: 'claude' }));
|
|
31
|
+
return { content: `${stringifyYaml(fm)}${skill.instructions}\n`, diagnostics };
|
|
32
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { stringifyYaml } from '../frontmatter.js';
|
|
2
|
+
import { diagnostic } from '../diagnostics.js';
|
|
3
|
+
import { isRecord, tomlString } from './shared.js';
|
|
4
|
+
const safeSandboxModes = new Set(['read-only', 'workspace-write']);
|
|
5
|
+
export function renderCodexAgent(agent) {
|
|
6
|
+
const diagnostics = [diagnostic('info', 'CODEX_PARTIAL_AGENT_SUPPORT', `Codex agent output is partial and does not generate AGENTS.md or profiles for ${agent.name}`, { platform: 'codex' })];
|
|
7
|
+
const lines = [`name = ${tomlString(agent.name)}`, `description = ${tomlString(agent.description)}`, `developer_instructions = ${tomlString(agent.definition)}`];
|
|
8
|
+
if (agent.codex?.model)
|
|
9
|
+
lines.push(`model = ${tomlString(agent.codex.model)}`);
|
|
10
|
+
const permissions = agent.codex?.permissions;
|
|
11
|
+
if (isRecord(permissions) && typeof permissions.sandbox_mode === 'string') {
|
|
12
|
+
if (safeSandboxModes.has(permissions.sandbox_mode))
|
|
13
|
+
lines.push(`sandbox_mode = ${tomlString(permissions.sandbox_mode)}`);
|
|
14
|
+
else
|
|
15
|
+
diagnostics.push(diagnostic('warning', 'CODEX_UNSAFE_SANDBOX_IGNORED', `Unsafe Codex sandbox_mode ignored for ${agent.name}`, { platform: 'codex' }));
|
|
16
|
+
}
|
|
17
|
+
else if (permissions !== undefined) {
|
|
18
|
+
diagnostics.push(diagnostic('info', 'CODEX_AGENT_PERMISSIONS_IGNORED', `Codex agent permissions are not emitted for ${agent.name}`, { platform: 'codex' }));
|
|
19
|
+
}
|
|
20
|
+
return { content: `${lines.join('\n')}\n`, diagnostics };
|
|
21
|
+
}
|
|
22
|
+
export function renderCodexSkill(skill) {
|
|
23
|
+
const diagnostics = [];
|
|
24
|
+
if (skill.codex?.permissions)
|
|
25
|
+
diagnostics.push(diagnostic('info', 'CODEX_SKILL_PERMISSIONS_IGNORED', `Codex skill permissions are not emitted for ${skill.name}`, { platform: 'codex' }));
|
|
26
|
+
if (skill.codex?.model)
|
|
27
|
+
diagnostics.push(diagnostic('info', 'CODEX_SKILL_MODEL_IGNORED', `Codex skill model is not emitted for ${skill.name}`, { platform: 'codex' }));
|
|
28
|
+
return { content: `${stringifyYaml({ name: skill.name, description: skill.description })}${skill.instructions}\n`, diagnostics };
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { stringifyYaml } from '../frontmatter.js';
|
|
2
|
+
import { diagnostic } from '../diagnostics.js';
|
|
3
|
+
export function renderOpenCodeAgent(agent) {
|
|
4
|
+
const fm = { description: agent.description };
|
|
5
|
+
if (agent.opencode?.mode)
|
|
6
|
+
fm.mode = agent.opencode.mode;
|
|
7
|
+
if (agent.opencode?.model)
|
|
8
|
+
fm.model = agent.opencode.model;
|
|
9
|
+
if (agent.opencode?.permissions)
|
|
10
|
+
fm.permission = agent.opencode.permissions;
|
|
11
|
+
return { content: `${stringifyYaml(fm)}${agent.definition}\n`, diagnostics: [] };
|
|
12
|
+
}
|
|
13
|
+
export function renderOpenCodeSkill(skill) {
|
|
14
|
+
const diagnostics = [];
|
|
15
|
+
if (skill.opencode?.permissions)
|
|
16
|
+
diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${skill.name}`, { platform: 'opencode' }));
|
|
17
|
+
if (skill.opencode?.model)
|
|
18
|
+
diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${skill.name}`, { platform: 'opencode' }));
|
|
19
|
+
return { content: `${stringifyYaml({ name: skill.name, description: skill.description })}${skill.instructions}\n`, diagnostics };
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function isRecord(value) {
|
|
2
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function stringList(value) {
|
|
5
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string' && /^[A-Za-z0-9_*.,:-]+$/.test(item)) ? value : undefined;
|
|
6
|
+
}
|
|
7
|
+
export function tomlString(value) {
|
|
8
|
+
return JSON.stringify(value);
|
|
9
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { formatDiagnostic, hasErrors } from './diagnostics.js';
|
|
8
|
+
import { buildWritePlan, parsePlatform, parseScope } from './processor.js';
|
|
9
|
+
import { writeOutputs } from './writer.js';
|
|
10
|
+
export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
11
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
12
|
+
showUsage();
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
16
|
+
console.log(readPackageVersion());
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
const parsed = parseArgs(argv);
|
|
20
|
+
if ('error' in parsed) {
|
|
21
|
+
console.error(parsed.error);
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
const options = parsed.options;
|
|
25
|
+
const command = normalizeCommand(options.command);
|
|
26
|
+
if (!command) {
|
|
27
|
+
console.error(`Unknown command ${options.command ?? ''}`);
|
|
28
|
+
showUsage();
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
const install = command === 'install' || command === 'update';
|
|
32
|
+
if (install && !options.sourceExplicit)
|
|
33
|
+
options.source = bundledSourceRoot();
|
|
34
|
+
if (command === 'update' || options.yes)
|
|
35
|
+
options.force = true;
|
|
36
|
+
const interactive = install && isInteractivePrompt(promptIO);
|
|
37
|
+
if (interactive)
|
|
38
|
+
p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
|
|
39
|
+
if (install) {
|
|
40
|
+
const prompted = await promptForMissingInstallOptions(options, promptIO);
|
|
41
|
+
if (!prompted) {
|
|
42
|
+
if (interactive)
|
|
43
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, checkCollisions: install && !options.dryRun, force: options.force });
|
|
48
|
+
if (install && !options.dryRun && !options.force && canOfferUpdate(plan.diagnostics)) {
|
|
49
|
+
const accepted = await promptForUpdate(plan, promptIO);
|
|
50
|
+
if (accepted === undefined) {
|
|
51
|
+
if (interactive)
|
|
52
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
if (accepted) {
|
|
56
|
+
options.force = true;
|
|
57
|
+
plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, checkCollisions: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
printPlan(command, plan.sourceCount, plan.files, plan.diagnostics);
|
|
61
|
+
if (hasErrors(plan.diagnostics)) {
|
|
62
|
+
if (interactive)
|
|
63
|
+
p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
if (install && !options.dryRun) {
|
|
67
|
+
if (interactive) {
|
|
68
|
+
const spinner = p.spinner(clackIO(promptIO));
|
|
69
|
+
spinner.start(options.force ? 'Updating Forge files' : 'Installing Forge files');
|
|
70
|
+
try {
|
|
71
|
+
await writeOutputs(plan.files);
|
|
72
|
+
spinner.stop(`Wrote ${plan.files.length} file(s).`);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
spinner.error('Failed to write Forge files');
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
await writeOutputs(plan.files);
|
|
81
|
+
console.log(`Wrote ${plan.files.length} file(s).`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (install && interactive) {
|
|
85
|
+
p.log.info(`Dry run only. ${plan.files.length} file(s) would be written.`, clackIO(promptIO));
|
|
86
|
+
}
|
|
87
|
+
if (install && interactive) {
|
|
88
|
+
p.outro(options.dryRun ? pc.cyan('Dry run complete.') : pc.green('Forge is ready.'), clackIO(promptIO));
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
function parseArgs(argv) {
|
|
93
|
+
const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, yes: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false };
|
|
94
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
95
|
+
const arg = argv[index];
|
|
96
|
+
if (arg === '--dry-run')
|
|
97
|
+
options.dryRun = true;
|
|
98
|
+
else if (arg === '--force')
|
|
99
|
+
options.force = true;
|
|
100
|
+
else if (arg === '--yes' || arg === '-y')
|
|
101
|
+
options.yes = true;
|
|
102
|
+
else if (arg === '--platform') {
|
|
103
|
+
const value = argv[++index];
|
|
104
|
+
const platform = value ? parsePlatform(value) : undefined;
|
|
105
|
+
if (!platform)
|
|
106
|
+
return { error: `Invalid --platform ${value ?? ''}` };
|
|
107
|
+
options.platform = platform;
|
|
108
|
+
options.platformExplicit = true;
|
|
109
|
+
}
|
|
110
|
+
else if (arg === '--scope') {
|
|
111
|
+
const value = argv[++index];
|
|
112
|
+
const scope = value ? parseScope(value) : undefined;
|
|
113
|
+
if (!scope)
|
|
114
|
+
return { error: `Invalid --scope ${value ?? ''}` };
|
|
115
|
+
options.scope = scope;
|
|
116
|
+
options.scopeExplicit = true;
|
|
117
|
+
}
|
|
118
|
+
else if (arg === '--source') {
|
|
119
|
+
const value = argv[++index];
|
|
120
|
+
if (!value)
|
|
121
|
+
return { error: 'Missing --source value' };
|
|
122
|
+
options.source = value;
|
|
123
|
+
options.sourceExplicit = true;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
return { error: `Unknown argument ${arg}` };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const command = normalizeCommand(options.command);
|
|
130
|
+
if (command === 'validate' && (options.dryRun || options.force || options.yes || options.scopeExplicit))
|
|
131
|
+
return { error: 'validate only accepts --platform and --source' };
|
|
132
|
+
return { options };
|
|
133
|
+
}
|
|
134
|
+
async function promptForMissingInstallOptions(options, promptIO) {
|
|
135
|
+
if (options.yes)
|
|
136
|
+
return true;
|
|
137
|
+
if (options.platformExplicit && options.scopeExplicit)
|
|
138
|
+
return true;
|
|
139
|
+
if (!isInteractivePrompt(promptIO))
|
|
140
|
+
return true;
|
|
141
|
+
const io = clackIO(promptIO);
|
|
142
|
+
if (!options.platformExplicit) {
|
|
143
|
+
const platform = await p.select({
|
|
144
|
+
message: 'Install Forge for which coding agent?',
|
|
145
|
+
initialValue: 'all',
|
|
146
|
+
options: [
|
|
147
|
+
{ value: 'all', label: 'All supported agents', hint: 'OpenCode, Codex, and Claude Code' },
|
|
148
|
+
{ value: 'opencode', label: 'OpenCode' },
|
|
149
|
+
{ value: 'codex', label: 'Codex' },
|
|
150
|
+
{ value: 'claude', label: 'Claude Code' }
|
|
151
|
+
],
|
|
152
|
+
...io
|
|
153
|
+
});
|
|
154
|
+
if (p.isCancel(platform))
|
|
155
|
+
return false;
|
|
156
|
+
options.platform = platform;
|
|
157
|
+
}
|
|
158
|
+
if (!options.scopeExplicit) {
|
|
159
|
+
const scope = await p.select({
|
|
160
|
+
message: 'Where should Forge be installed?',
|
|
161
|
+
initialValue: 'user',
|
|
162
|
+
options: [
|
|
163
|
+
{ value: 'user', label: 'User', hint: 'Available in every project' },
|
|
164
|
+
{ value: 'project', label: 'Project', hint: 'Only this repository' }
|
|
165
|
+
],
|
|
166
|
+
...io
|
|
167
|
+
});
|
|
168
|
+
if (p.isCancel(scope))
|
|
169
|
+
return false;
|
|
170
|
+
options.scope = scope;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
async function promptForUpdate(plan, promptIO) {
|
|
175
|
+
if (!isInteractivePrompt(promptIO))
|
|
176
|
+
return false;
|
|
177
|
+
const existing = plan.diagnostics.filter((item) => item.code === 'DESTINATION_EXISTS');
|
|
178
|
+
const count = existing.length;
|
|
179
|
+
p.log.warn(`${count} Forge output${count === 1 ? '' : 's'} already exist.`, clackIO(promptIO));
|
|
180
|
+
const accepted = await p.confirm({
|
|
181
|
+
message: 'Update the existing Forge files?',
|
|
182
|
+
active: 'Update',
|
|
183
|
+
inactive: 'Cancel',
|
|
184
|
+
initialValue: false,
|
|
185
|
+
...clackIO(promptIO)
|
|
186
|
+
});
|
|
187
|
+
if (p.isCancel(accepted))
|
|
188
|
+
return undefined;
|
|
189
|
+
return accepted;
|
|
190
|
+
}
|
|
191
|
+
function normalizeCommand(command) {
|
|
192
|
+
if (command === 'install' || command === 'i')
|
|
193
|
+
return 'install';
|
|
194
|
+
if (command === 'update' || command === 'upgrade')
|
|
195
|
+
return 'update';
|
|
196
|
+
if (command === 'validate')
|
|
197
|
+
return 'validate';
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
function canOfferUpdate(diagnostics) {
|
|
201
|
+
const errors = diagnostics.filter((item) => item.severity === 'error');
|
|
202
|
+
return errors.length > 0 && errors.every((item) => item.code === 'DESTINATION_EXISTS');
|
|
203
|
+
}
|
|
204
|
+
function isInteractivePrompt(promptIO) {
|
|
205
|
+
const env = promptIO.env ?? process.env;
|
|
206
|
+
const interactive = promptIO.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
207
|
+
return interactive && env.CI !== 'true';
|
|
208
|
+
}
|
|
209
|
+
function clackIO(promptIO) {
|
|
210
|
+
return { input: promptIO.input, output: promptIO.output };
|
|
211
|
+
}
|
|
212
|
+
function bundledSourceRoot() {
|
|
213
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
214
|
+
}
|
|
215
|
+
function readPackageVersion() {
|
|
216
|
+
try {
|
|
217
|
+
const packageJson = JSON.parse(readFileSync(path.join(bundledSourceRoot(), 'package.json'), 'utf8'));
|
|
218
|
+
return typeof packageJson.version === 'string' ? packageJson.version : '0.0.0';
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return '0.0.0';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function showUsage() {
|
|
225
|
+
console.log('Usage: forge-ai install [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
|
|
226
|
+
console.log(' forge-ai update [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--yes]');
|
|
227
|
+
console.log(' forge-ai validate [--platform opencode|claude|codex|all] [--source <dir>]');
|
|
228
|
+
}
|
|
229
|
+
function printPlan(command, sourceCount, files, diagnostics) {
|
|
230
|
+
console.log(`${command}: ${sourceCount} source(s), ${files.length} output(s)`);
|
|
231
|
+
for (const file of files)
|
|
232
|
+
console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}`);
|
|
233
|
+
for (const item of diagnostics)
|
|
234
|
+
console.log(formatDiagnostic(item));
|
|
235
|
+
}
|
|
236
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
237
|
+
main().then((code) => { process.exitCode = code; }, (error) => { console.error(error); process.exitCode = 1; });
|
|
238
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function diagnostic(severity, code, message, extras = {}) {
|
|
2
|
+
return { severity, code, message, ...extras };
|
|
3
|
+
}
|
|
4
|
+
export function hasErrors(diagnostics) {
|
|
5
|
+
return diagnostics.some((diagnostic) => diagnostic.severity === 'error');
|
|
6
|
+
}
|
|
7
|
+
export function formatDiagnostic(diagnostic) {
|
|
8
|
+
const location = diagnostic.sourcePath ? ` ${diagnostic.sourcePath}` : '';
|
|
9
|
+
const platform = diagnostic.platform ? ` [${diagnostic.platform}]` : '';
|
|
10
|
+
return `${diagnostic.severity.toUpperCase()} ${diagnostic.code}${platform}${location}: ${diagnostic.message}`;
|
|
11
|
+
}
|
|
12
|
+
export function info(code, message, platform) {
|
|
13
|
+
return diagnostic('info', code, message, platform ? { platform } : {});
|
|
14
|
+
}
|