@forgeax/game 0.2.0 → 0.2.2
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/README.md +61 -38
- package/dist/main.js +66 -503
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,17 +22,25 @@ externalizes it from the Game bundle.
|
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
### New client setup
|
|
26
|
+
|
|
27
|
+
Run this once per machine to add the ForgeaX MCP server to the selected agent client.
|
|
26
28
|
|
|
27
29
|
```bash
|
|
28
|
-
npx -y
|
|
29
|
-
npx -y -p @forgeax/game forgeax-game init --game snake
|
|
30
|
+
npx -y @forgeax/game install --ide codex,cursor,claude
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
For ZCode, use `--ide zcode`; this writes its native user configuration to
|
|
34
|
+
`~/.zcode/cli/config.json` and can run from any directory:
|
|
33
35
|
|
|
34
36
|
```bash
|
|
35
|
-
npx -y
|
|
37
|
+
npx -y @forgeax/game install --ide zcode
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Configure several clients in one pass:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx -y @forgeax/game install --ide codex,claude,cursor,zcode
|
|
36
44
|
```
|
|
37
45
|
|
|
38
46
|
Omitting `--ide` configures every supported client. User-scoped clients can be
|
|
@@ -41,7 +49,7 @@ when `install` runs inside the project. The original WorkBuddy name remains acce
|
|
|
41
49
|
as an alias of the current CodeBuddy client:
|
|
42
50
|
|
|
43
51
|
```bash
|
|
44
|
-
npx -y
|
|
52
|
+
npx -y @forgeax/game install --ide trae,workbuddy
|
|
45
53
|
```
|
|
46
54
|
|
|
47
55
|
Before writing any config, the installer launches the exact command it intends to
|
|
@@ -49,6 +57,45 @@ store and completes a real `initialize` → `tools/list` → `resources/list` ha
|
|
|
49
57
|
Existing config files are merged, and a changed file is backed up beside the original
|
|
50
58
|
with the `.bak.latest` suffix.
|
|
51
59
|
|
|
60
|
+
### Create a project
|
|
61
|
+
|
|
62
|
+
Choose an empty directory and initialize a game there:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cd /path/to/empty-directory
|
|
66
|
+
npx -y @forgeax/game init
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`init` creates the ForgeaX project, installs the matching Engine SDK, and mounts the
|
|
70
|
+
bundled Skills for every client that already has a ForgeaX MCP entry. Use
|
|
71
|
+
`--game <slug>` to choose a game name or `--ide zcode` when initializing a project
|
|
72
|
+
for ZCode specifically.
|
|
73
|
+
|
|
74
|
+
> [!IMPORTANT]
|
|
75
|
+
> `init` does not install the user-level MCP entry. A new machine must run
|
|
76
|
+
> `install --ide zcode` once before `init`. A user who already has the `forgeax`
|
|
77
|
+
> entry configured in ZCode can run `init` directly for each new project.
|
|
78
|
+
|
|
79
|
+
### Refresh an existing project
|
|
80
|
+
|
|
81
|
+
For a project that was initialized previously, use `update` instead of running the
|
|
82
|
+
same `init` again:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cd /path/to/existing-forgeax-project
|
|
86
|
+
npx -y @forgeax/game update
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
If the project has no game-development Skills yet, use:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx -y @forgeax/game devkit install
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
After installing or refreshing ZCode Skills, open the project as a ZCode workspace,
|
|
96
|
+
refresh Settings → Skills, and start a new session. Check Settings → MCP Servers or
|
|
97
|
+
run `/mcp status` to confirm `forgeax` is connected.
|
|
98
|
+
|
|
52
99
|
For local package development or offline use, pin the currently running executable:
|
|
53
100
|
|
|
54
101
|
```bash
|
|
@@ -60,12 +107,12 @@ bun src/main.ts install --ide codex --local
|
|
|
60
107
|
| Command | Purpose |
|
|
61
108
|
|:--|:--|
|
|
62
109
|
| `install [--ide a,b] [--local]` | Verify the MCP launch command, then merge client configuration |
|
|
63
|
-
| `init [--game <slug>]` | Create a project/game, install routing rules, and materialize the bundled Engine SDK |
|
|
110
|
+
| `init [--game <slug>] [--ide ...]` | Create a project/game, install routing rules, and materialize the bundled Engine SDK |
|
|
64
111
|
| `use <slug>` | Activate an existing game through the server |
|
|
65
112
|
| `doctor` | Check Node, project binding, service tiers, and client configuration |
|
|
66
113
|
| `devkit install` | Install the game-development Skill and host rules; use `forgeax-install` only when available |
|
|
67
114
|
| `agents update` | Insert or refresh only the managed ForgeaX block in `AGENTS.md` |
|
|
68
|
-
| `
|
|
115
|
+
| `update [--ide ...]` | Verify the published launch command, refresh installed client entries, and update project routing rules |
|
|
69
116
|
|
|
70
117
|
`init` can create the minimal `.forgeax/` instance and game in an ordinary empty
|
|
71
118
|
directory. It also installs `.forgeax/engine-sdk/`, containing declarations, API
|
|
@@ -86,8 +133,6 @@ one-time actions on every turn.
|
|
|
86
133
|
| `forgeax://status` | Resource | Preferred read-only project, service, and next-action status |
|
|
87
134
|
| `forgeax_status_lite` | Tool | Status fallback for clients without MCP resource support |
|
|
88
135
|
| `forgeax_run_current_game` | Tool | Build or reuse the active game's static preview, return its URL and health identity, and identify the runtime log file |
|
|
89
|
-
| `forgeax_generate_image` | Tool | Text-to-image, or image-to-image with a local `image`; saves a PNG/JPG into the active game's `assets/` and returns its path |
|
|
90
|
-
| `forgeax_generate_3d` | Tool | Text-to-3D (`prompt`) or image-to-3D (public https `image` URL); runs the async job to completion and saves a `.glb` into `assets/` |
|
|
91
136
|
|
|
92
137
|
When this plugin cold-starts the managed Runtime, runtime output is written to:
|
|
93
138
|
|
|
@@ -113,33 +158,6 @@ The game SDK is available at `.forgeax/engine-sdk/`. Use its declaration files a
|
|
|
113
158
|
`templates/game-default` or `templates/game-empty` before writing imports. If an API
|
|
114
159
|
is absent, inspect `source/<package>/src/` instead of guessing.
|
|
115
160
|
|
|
116
|
-
### Asset generation (LiteLLM)
|
|
117
|
-
|
|
118
|
-
`forgeax_generate_image` and `forgeax_generate_3d` produce art and 3D assets through a
|
|
119
|
-
LiteLLM gateway and save them into the active game's `assets/` directory, returning the
|
|
120
|
-
project-relative path to reference from game code. Configure via environment:
|
|
121
|
-
|
|
122
|
-
| Variable | Required | Default |
|
|
123
|
-
|:--|:-:|:--|
|
|
124
|
-
| `FORGEAX_LITELLM_API_KEY` | yes | — (secret; never commit it) |
|
|
125
|
-
| `FORGEAX_LITELLM_BASE_URL` | no | the shared ForgeaX gateway |
|
|
126
|
-
| `FORGEAX_GEN_IMAGE_MODEL` | no | `gemini-3-pro-image` |
|
|
127
|
-
| `FORGEAX_GEN_3D_TEXT_MODEL` | no | `tripo-3d-text` |
|
|
128
|
-
| `FORGEAX_GEN_3D_IMAGE_MODEL` | no | `tripo-3d-image` |
|
|
129
|
-
| `FORGEAX_COS_BUCKET` / `FORGEAX_COS_REGION` | for local image-to-3D | — |
|
|
130
|
-
| `FORGEAX_COS_SECRET_ID` / `FORGEAX_COS_SECRET_KEY` | for local image-to-3D | — (secret; never commit) |
|
|
131
|
-
|
|
132
|
-
- **Text-to-image / image-to-image**: `forgeax_generate_image({ prompt, image? })`. A
|
|
133
|
-
local `image` path switches to editing that image with the prompt.
|
|
134
|
-
- **Text-to-3D**: `forgeax_generate_3d({ prompt })` — submits, polls to completion
|
|
135
|
-
(~1–2 min), and downloads the `.glb`.
|
|
136
|
-
- **Image-to-3D**: `forgeax_generate_3d({ image })`. `image` is a **public https URL**,
|
|
137
|
-
or a **local file path** when COS is configured — the file is uploaded to the COS
|
|
138
|
-
bucket and passed to the backend as a short-lived presigned URL (the private bucket
|
|
139
|
-
stays private; the URL expires within the hour). This makes the "generate a concept
|
|
140
|
-
image, then turn it into a mesh" flow work end to end. Without COS, only a public URL
|
|
141
|
-
is accepted, because the 3D endpoint rejects local paths and inline base64.
|
|
142
|
-
|
|
143
161
|
## Supported clients
|
|
144
162
|
|
|
145
163
|
| Client ID | Config path | Scope |
|
|
@@ -151,6 +169,7 @@ project-relative path to reference from game code. Configure via environment:
|
|
|
151
169
|
| `codebuddy` / `workbuddy` | `~/.codebuddy/.mcp.json` | User |
|
|
152
170
|
| `windsurf` | `~/.codeium/windsurf/mcp_config.json` | User |
|
|
153
171
|
| `vscode` | `<project>/.vscode/mcp.json` | Project |
|
|
172
|
+
| `zcode` | `~/.zcode/cli/config.json` | User |
|
|
154
173
|
| `opencode` | `~/.config/opencode/opencode.json` | User |
|
|
155
174
|
|
|
156
175
|
> [!NOTE]
|
|
@@ -162,6 +181,10 @@ project-relative path to reference from game code. Configure via environment:
|
|
|
162
181
|
> OpenCode 1.17.9: `opencode mcp list` starts this package and reports
|
|
163
182
|
> `forgeax connected`. If a future client release changes the schema, `doctor`
|
|
164
183
|
> will still report whether the configured entry matches what this package writes.
|
|
184
|
+
>
|
|
185
|
+
> ZCode uses its native `mcp.servers.<name>` user configuration rather than the
|
|
186
|
+
> `.agents/mcp.json` fallback. Project Skills are installed under `.zcode/skills`;
|
|
187
|
+
> start a new ZCode session and run `/mcp status` after installation.
|
|
165
188
|
|
|
166
189
|
## Game development Skill
|
|
167
190
|
|
|
@@ -178,7 +201,7 @@ project, `install`, `init`, and `upgrade` refresh it automatically. It can also
|
|
|
178
201
|
installed explicitly:
|
|
179
202
|
|
|
180
203
|
```bash
|
|
181
|
-
npx -y
|
|
204
|
+
npx -y @forgeax/game devkit install
|
|
182
205
|
```
|
|
183
206
|
|
|
184
207
|
When the project already has a `.forgeax-harness/install-manifest.json`, the command
|
package/dist/main.js
CHANGED
|
@@ -248,7 +248,7 @@ function runStdioServer(spec) {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
// src/mcp/forgeax-server.ts
|
|
251
|
-
import { readFileSync as
|
|
251
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
252
252
|
|
|
253
253
|
// src/status/collect.ts
|
|
254
254
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
@@ -664,14 +664,6 @@ reasoning and game-code edits; the plugin owns ForgeaX Runtime lifecycle and fee
|
|
|
664
664
|
- Reading runtime errors or engine logs: read the file path returned in
|
|
665
665
|
\`runtime_logs.local_file\` with your own file-reading tool. Log tailing is
|
|
666
666
|
deliberately not an MCP tool — the log is a file, so read it like one.
|
|
667
|
-
- Generating art or 3D assets ("make a sprite", "I need a texture", "generate a
|
|
668
|
-
model of…"): call \`forgeax_generate_image\` (text-to-image, or image-to-image
|
|
669
|
-
with a local \`image\`) or \`forgeax_generate_3d\` (text-to-3D via \`prompt\`,
|
|
670
|
-
image-to-3D via \`image\`). Both save into the active game's \`assets/\` directory
|
|
671
|
-
and return the project-relative path to reference from code. Image-to-3D accepts a
|
|
672
|
-
public https URL, or a local file path when COS is configured (it is uploaded and
|
|
673
|
-
passed as a short-lived presigned URL). They need \`FORGEAX_LITELLM_API_KEY\` (and
|
|
674
|
-
\`FORGEAX_COS_*\` for local-file image-to-3D) in the environment.
|
|
675
667
|
- Creating a game, switching the active game, installing or upgrading the plugin:
|
|
676
668
|
these are one-time operations and are CLI subcommands, not MCP tools. Run
|
|
677
669
|
\`npx -y -p @forgeax/game forgeax-game <init|use|doctor|devkit|upgrade>\`.
|
|
@@ -686,27 +678,17 @@ running, and running it.`;
|
|
|
686
678
|
var PLUGIN_SKILL_ID = "forgeax-game";
|
|
687
679
|
var ENGINE_SKILL_PREFIX = "forgeax-engine-";
|
|
688
680
|
var DEVKIT_VERSION = 2;
|
|
689
|
-
var
|
|
690
|
-
codex: ".agents/skills",
|
|
691
|
-
claude: ".claude/skills",
|
|
692
|
-
cursor: ".cursor/skills",
|
|
693
|
-
trae: ".trae/skills",
|
|
694
|
-
codebuddy: ".codebuddy/skills",
|
|
695
|
-
workbuddy: ".codebuddy/skills",
|
|
696
|
-
windsurf: ".codeium/windsurf/skills",
|
|
697
|
-
vscode: ".vscode/skills",
|
|
698
|
-
|
|
699
|
-
}
|
|
700
|
-
var HOST_RULE_MOUNTS = {
|
|
701
|
-
codex: ".agents/rules",
|
|
702
|
-
claude: ".claude/rules",
|
|
703
|
-
cursor: ".cursor/rules",
|
|
704
|
-
trae: ".trae/rules",
|
|
705
|
-
codebuddy: ".codebuddy/rules",
|
|
706
|
-
workbuddy: ".codebuddy/rules",
|
|
707
|
-
windsurf: ".codeium/windsurf/rules",
|
|
708
|
-
vscode: ".vscode/rules",
|
|
709
|
-
opencode: ".config/opencode/rules"
|
|
681
|
+
var HOST_MOUNTS = {
|
|
682
|
+
codex: { skills: ".agents/skills", rules: ".agents/rules" },
|
|
683
|
+
claude: { skills: ".claude/skills", rules: ".claude/rules" },
|
|
684
|
+
cursor: { skills: ".cursor/skills", rules: ".cursor/rules" },
|
|
685
|
+
trae: { skills: ".trae/skills", rules: ".trae/rules" },
|
|
686
|
+
codebuddy: { skills: ".codebuddy/skills", rules: ".codebuddy/rules" },
|
|
687
|
+
workbuddy: { skills: ".codebuddy/skills", rules: ".codebuddy/rules" },
|
|
688
|
+
windsurf: { skills: ".codeium/windsurf/skills", rules: ".codeium/windsurf/rules" },
|
|
689
|
+
vscode: { skills: ".vscode/skills", rules: ".vscode/rules" },
|
|
690
|
+
zcode: { skills: ".zcode/skills" },
|
|
691
|
+
opencode: { skills: ".config/opencode/skills", rules: ".config/opencode/rules" }
|
|
710
692
|
};
|
|
711
693
|
function skillRoots() {
|
|
712
694
|
const here = dirname3(fileURLToPath(import.meta.url));
|
|
@@ -790,24 +772,27 @@ function writeTextIfChanged(path, content) {
|
|
|
790
772
|
return true;
|
|
791
773
|
}
|
|
792
774
|
function selectedHostIds(clients) {
|
|
793
|
-
return [...new Set(clients.map((id) => id === "workbuddy" ? "codebuddy" : id))].filter((id) =>
|
|
775
|
+
return [...new Set(clients.map((id) => id === "workbuddy" ? "codebuddy" : id))].filter((id) => HOST_MOUNTS[id]);
|
|
794
776
|
}
|
|
795
777
|
function hostSkillDirs(projectRoot) {
|
|
796
|
-
return [...new Set(Object.values(
|
|
778
|
+
return [...new Set(Object.values(HOST_MOUNTS).map((mount) => mount.skills))].map((mount) => join3(projectRoot, mount));
|
|
797
779
|
}
|
|
798
780
|
function installHostDevKit(projectRoot, clients, skills = bundledSkills()) {
|
|
799
781
|
const skillPaths = [];
|
|
800
782
|
const rulePaths = [];
|
|
801
783
|
let changed = false;
|
|
802
784
|
for (const id of selectedHostIds(clients)) {
|
|
803
|
-
const
|
|
785
|
+
const mount = HOST_MOUNTS[id];
|
|
786
|
+
const skillsDir = join3(projectRoot, mount.skills);
|
|
804
787
|
for (const skill of skills) {
|
|
805
788
|
changed = copySkill(skill.path, join3(skillsDir, skill.id)) || changed;
|
|
806
789
|
}
|
|
807
|
-
const rulePath = join3(projectRoot, HOST_RULE_MOUNTS[id], `${PLUGIN_SKILL_ID}.md`);
|
|
808
|
-
changed = writeTextIfChanged(rulePath, ROUTING_TEXT) || changed;
|
|
809
790
|
skillPaths.push(skillsDir);
|
|
810
|
-
|
|
791
|
+
if (mount.rules) {
|
|
792
|
+
const rulePath = join3(projectRoot, mount.rules, `${PLUGIN_SKILL_ID}.md`);
|
|
793
|
+
changed = writeTextIfChanged(rulePath, ROUTING_TEXT) || changed;
|
|
794
|
+
rulePaths.push(rulePath);
|
|
795
|
+
}
|
|
811
796
|
}
|
|
812
797
|
const skillIds = skills.map((skill) => skill.id);
|
|
813
798
|
const noun = skillPaths.length === 1 ? "host" : "hosts";
|
|
@@ -896,7 +881,7 @@ function removeDevKit(projectRoot) {
|
|
|
896
881
|
const owned = new Set(bundledSkills().map((skill) => skill.id));
|
|
897
882
|
const removed = [];
|
|
898
883
|
let skillCount = 0;
|
|
899
|
-
for (const mount of new Set(Object.values(
|
|
884
|
+
for (const mount of new Set(Object.values(HOST_MOUNTS).map((entry) => entry.skills))) {
|
|
900
885
|
const dir = join3(projectRoot, mount);
|
|
901
886
|
let entries;
|
|
902
887
|
try {
|
|
@@ -914,7 +899,8 @@ function removeDevKit(projectRoot) {
|
|
|
914
899
|
}
|
|
915
900
|
removed.push(dir);
|
|
916
901
|
}
|
|
917
|
-
|
|
902
|
+
const ruleMounts = Object.values(HOST_MOUNTS).flatMap((entry) => entry.rules ? [entry.rules] : []);
|
|
903
|
+
for (const mount of new Set(ruleMounts)) {
|
|
918
904
|
for (const suffix of [".md", ".md.bak.latest"]) {
|
|
919
905
|
rmSync(join3(projectRoot, mount, `${PLUGIN_SKILL_ID}${suffix}`), { force: true });
|
|
920
906
|
}
|
|
@@ -1666,432 +1652,11 @@ function resolveSlug(root, requested) {
|
|
|
1666
1652
|
return { slug, dir };
|
|
1667
1653
|
}
|
|
1668
1654
|
|
|
1669
|
-
// src/gen/generate.ts
|
|
1670
|
-
import { existsSync as existsSync4, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1671
|
-
import { basename, extname, join as join7, relative as relative3 } from "node:path";
|
|
1672
|
-
|
|
1673
|
-
// src/gen/config.ts
|
|
1674
|
-
var DEFAULT_LITELLM_BASE_URL = "http://21.214.33.175:4000";
|
|
1675
|
-
var DEFAULT_MODELS = {
|
|
1676
|
-
textToImage: "gemini-3-pro-image",
|
|
1677
|
-
textTo3d: "tripo-3d-text",
|
|
1678
|
-
imageTo3d: "tripo-3d-image"
|
|
1679
|
-
};
|
|
1680
|
-
function env(name) {
|
|
1681
|
-
const value = process.env[name]?.trim();
|
|
1682
|
-
return value ? value : undefined;
|
|
1683
|
-
}
|
|
1684
|
-
function resolveLiteLlmConfig() {
|
|
1685
|
-
const baseUrl = (env("FORGEAX_LITELLM_BASE_URL") ?? DEFAULT_LITELLM_BASE_URL).replace(/\/+$/, "");
|
|
1686
|
-
const apiKey = env("FORGEAX_LITELLM_API_KEY");
|
|
1687
|
-
if (!apiKey) {
|
|
1688
|
-
throw new Error("FORGEAX_LITELLM_API_KEY is not set. Export the LiteLLM key so the asset tools can reach the gateway, e.g. `export FORGEAX_LITELLM_API_KEY=sk-...`.");
|
|
1689
|
-
}
|
|
1690
|
-
return {
|
|
1691
|
-
baseUrl,
|
|
1692
|
-
apiKey,
|
|
1693
|
-
models: {
|
|
1694
|
-
textToImage: env("FORGEAX_GEN_IMAGE_MODEL") ?? DEFAULT_MODELS.textToImage,
|
|
1695
|
-
textTo3d: env("FORGEAX_GEN_3D_TEXT_MODEL") ?? DEFAULT_MODELS.textTo3d,
|
|
1696
|
-
imageTo3d: env("FORGEAX_GEN_3D_IMAGE_MODEL") ?? DEFAULT_MODELS.imageTo3d
|
|
1697
|
-
}
|
|
1698
|
-
};
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
// src/gen/cos.ts
|
|
1702
|
-
import { createHash as createHash2, createHmac } from "node:crypto";
|
|
1703
|
-
var DEFAULT_EXPIRES_SEC = 3600;
|
|
1704
|
-
var SKEW_SEC = 60;
|
|
1705
|
-
function env2(name) {
|
|
1706
|
-
const value = process.env[name]?.trim();
|
|
1707
|
-
return value ? value : undefined;
|
|
1708
|
-
}
|
|
1709
|
-
function resolveCosConfig() {
|
|
1710
|
-
const bucket = env2("FORGEAX_COS_BUCKET");
|
|
1711
|
-
const region = env2("FORGEAX_COS_REGION");
|
|
1712
|
-
const secretId = env2("FORGEAX_COS_SECRET_ID");
|
|
1713
|
-
const secretKey = env2("FORGEAX_COS_SECRET_KEY");
|
|
1714
|
-
if (!bucket || !region || !secretId || !secretKey)
|
|
1715
|
-
return;
|
|
1716
|
-
return { bucket, region, secretId, secretKey };
|
|
1717
|
-
}
|
|
1718
|
-
function cosHost(cfg) {
|
|
1719
|
-
return `${cfg.bucket}.cos.${cfg.region}.myqcloud.com`;
|
|
1720
|
-
}
|
|
1721
|
-
function rfc3986(value) {
|
|
1722
|
-
return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
1723
|
-
}
|
|
1724
|
-
function formatKv(map) {
|
|
1725
|
-
const lowered = {};
|
|
1726
|
-
for (const [k, v] of Object.entries(map))
|
|
1727
|
-
lowered[k.toLowerCase()] = v;
|
|
1728
|
-
const keys = Object.keys(lowered).sort();
|
|
1729
|
-
return {
|
|
1730
|
-
serialized: keys.map((k) => `${rfc3986(k)}=${rfc3986(lowered[k])}`).join("&"),
|
|
1731
|
-
keyList: keys.map((k) => rfc3986(k)).join(";")
|
|
1732
|
-
};
|
|
1733
|
-
}
|
|
1734
|
-
function buildAuthorization(cfg, opts) {
|
|
1735
|
-
const start = opts.nowSec - SKEW_SEC;
|
|
1736
|
-
const end = opts.nowSec + (opts.expiresSec ?? DEFAULT_EXPIRES_SEC);
|
|
1737
|
-
const signTime = `${start};${end}`;
|
|
1738
|
-
const signKey = createHmac("sha1", cfg.secretKey).update(signTime).digest("hex");
|
|
1739
|
-
const { serialized: paramStr, keyList: paramList } = formatKv(opts.params ?? {});
|
|
1740
|
-
const { serialized: headerStr, keyList: headerList } = formatKv(opts.headers ?? {});
|
|
1741
|
-
const httpString = `${opts.method.toLowerCase()}
|
|
1742
|
-
${opts.pathname}
|
|
1743
|
-
${paramStr}
|
|
1744
|
-
${headerStr}
|
|
1745
|
-
`;
|
|
1746
|
-
const httpStringSha1 = createHash2("sha1").update(httpString).digest("hex");
|
|
1747
|
-
const stringToSign = `sha1
|
|
1748
|
-
${signTime}
|
|
1749
|
-
${httpStringSha1}
|
|
1750
|
-
`;
|
|
1751
|
-
const signature = createHmac("sha1", signKey).update(stringToSign).digest("hex");
|
|
1752
|
-
return [
|
|
1753
|
-
"q-sign-algorithm=sha1",
|
|
1754
|
-
`q-ak=${cfg.secretId}`,
|
|
1755
|
-
`q-sign-time=${signTime}`,
|
|
1756
|
-
`q-key-time=${signTime}`,
|
|
1757
|
-
`q-header-list=${headerList}`,
|
|
1758
|
-
`q-url-param-list=${paramList}`,
|
|
1759
|
-
`q-signature=${signature}`
|
|
1760
|
-
].join("&");
|
|
1761
|
-
}
|
|
1762
|
-
function presignGetUrl(cfg, key, expiresSec = DEFAULT_EXPIRES_SEC, nowSec = Math.floor(Date.now() / 1000)) {
|
|
1763
|
-
const pathname = key.startsWith("/") ? key : `/${key}`;
|
|
1764
|
-
const auth = buildAuthorization(cfg, { method: "get", pathname, nowSec, expiresSec });
|
|
1765
|
-
return `https://${cosHost(cfg)}${pathname}?${auth}`;
|
|
1766
|
-
}
|
|
1767
|
-
async function uploadObject(cfg, key, bytes, contentType) {
|
|
1768
|
-
const host = cosHost(cfg);
|
|
1769
|
-
const pathname = key.startsWith("/") ? key : `/${key}`;
|
|
1770
|
-
const auth = buildAuthorization(cfg, {
|
|
1771
|
-
method: "put",
|
|
1772
|
-
pathname,
|
|
1773
|
-
headers: { host },
|
|
1774
|
-
nowSec: Math.floor(Date.now() / 1000),
|
|
1775
|
-
expiresSec: 600
|
|
1776
|
-
});
|
|
1777
|
-
const ctrl = new AbortController;
|
|
1778
|
-
const timer = setTimeout(() => ctrl.abort(), 60000);
|
|
1779
|
-
try {
|
|
1780
|
-
const res = await fetch(`https://${host}${pathname}`, {
|
|
1781
|
-
method: "PUT",
|
|
1782
|
-
headers: { authorization: auth, "content-type": contentType },
|
|
1783
|
-
body: bytes,
|
|
1784
|
-
signal: ctrl.signal
|
|
1785
|
-
});
|
|
1786
|
-
if (!res.ok) {
|
|
1787
|
-
const body = await res.text().catch(() => "");
|
|
1788
|
-
throw new Error(`COS upload of ${key} failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 400)}` : ""}`);
|
|
1789
|
-
}
|
|
1790
|
-
} finally {
|
|
1791
|
-
clearTimeout(timer);
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
async function uploadAndPresign(cfg, key, bytes, contentType, expiresSec = DEFAULT_EXPIRES_SEC) {
|
|
1795
|
-
await uploadObject(cfg, key, bytes, contentType);
|
|
1796
|
-
return presignGetUrl(cfg, key, expiresSec);
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
// src/gen/litellm.ts
|
|
1800
|
-
var TASK_TIMEOUT_MS = 420000;
|
|
1801
|
-
var POLL_INTERVAL_MS = 3000;
|
|
1802
|
-
var REQUEST_TIMEOUT_MS = 60000;
|
|
1803
|
-
function authHeaders(cfg) {
|
|
1804
|
-
return { authorization: `Bearer ${cfg.apiKey}` };
|
|
1805
|
-
}
|
|
1806
|
-
async function request(url, init, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
1807
|
-
const ctrl = new AbortController;
|
|
1808
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
1809
|
-
try {
|
|
1810
|
-
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
1811
|
-
if (!res.ok) {
|
|
1812
|
-
const body = await res.text().catch(() => "");
|
|
1813
|
-
throw new Error(`LiteLLM ${init.method ?? "GET"} ${url} failed: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 600)}` : ""}`);
|
|
1814
|
-
}
|
|
1815
|
-
return res;
|
|
1816
|
-
} catch (err) {
|
|
1817
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
1818
|
-
throw new Error(`LiteLLM request timed out after ${timeoutMs}ms: ${url}`);
|
|
1819
|
-
}
|
|
1820
|
-
throw err;
|
|
1821
|
-
} finally {
|
|
1822
|
-
clearTimeout(timer);
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
function decodeImagePayload(item) {
|
|
1826
|
-
if (!item)
|
|
1827
|
-
return {};
|
|
1828
|
-
const b64 = typeof item.b64_json === "string" ? item.b64_json : undefined;
|
|
1829
|
-
const url = typeof item.url === "string" ? item.url : undefined;
|
|
1830
|
-
return { b64, url };
|
|
1831
|
-
}
|
|
1832
|
-
function sniffImageExt(bytes) {
|
|
1833
|
-
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
|
|
1834
|
-
return "jpg";
|
|
1835
|
-
return "png";
|
|
1836
|
-
}
|
|
1837
|
-
async function materializeImage(payload) {
|
|
1838
|
-
if (payload.b64) {
|
|
1839
|
-
const bytes = new Uint8Array(Buffer.from(payload.b64, "base64"));
|
|
1840
|
-
return { bytes, ext: sniffImageExt(bytes) };
|
|
1841
|
-
}
|
|
1842
|
-
if (payload.url) {
|
|
1843
|
-
const res = await request(payload.url, { method: "GET" });
|
|
1844
|
-
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
1845
|
-
return { bytes, ext: sniffImageExt(bytes) };
|
|
1846
|
-
}
|
|
1847
|
-
throw new Error("LiteLLM image response contained neither b64_json nor url.");
|
|
1848
|
-
}
|
|
1849
|
-
async function generateImage(cfg, opts) {
|
|
1850
|
-
const res = await request(`${cfg.baseUrl}/v1/images/generations`, {
|
|
1851
|
-
method: "POST",
|
|
1852
|
-
headers: { ...authHeaders(cfg), "content-type": "application/json" },
|
|
1853
|
-
body: JSON.stringify({ model: opts.model, prompt: opts.prompt, n: 1, ...opts.size ? { size: opts.size } : {} })
|
|
1854
|
-
});
|
|
1855
|
-
const json = await res.json();
|
|
1856
|
-
return materializeImage(decodeImagePayload(json.data?.[0]));
|
|
1857
|
-
}
|
|
1858
|
-
async function editImage(cfg, opts) {
|
|
1859
|
-
const form = new FormData;
|
|
1860
|
-
form.set("model", opts.model);
|
|
1861
|
-
form.set("prompt", opts.prompt);
|
|
1862
|
-
form.set("n", "1");
|
|
1863
|
-
form.set("image", new Blob([opts.image]), opts.filename);
|
|
1864
|
-
const res = await request(`${cfg.baseUrl}/v1/images/edits`, {
|
|
1865
|
-
method: "POST",
|
|
1866
|
-
headers: authHeaders(cfg),
|
|
1867
|
-
body: form
|
|
1868
|
-
});
|
|
1869
|
-
const json = await res.json();
|
|
1870
|
-
return materializeImage(decodeImagePayload(json.data?.[0]));
|
|
1871
|
-
}
|
|
1872
|
-
async function submit3dTask(cfg, body) {
|
|
1873
|
-
const res = await request(`${cfg.baseUrl}/v1/3d/generations`, {
|
|
1874
|
-
method: "POST",
|
|
1875
|
-
headers: { ...authHeaders(cfg), "content-type": "application/json" },
|
|
1876
|
-
body: JSON.stringify(body)
|
|
1877
|
-
});
|
|
1878
|
-
const json = await res.json();
|
|
1879
|
-
if (!json.id)
|
|
1880
|
-
throw new Error(`LiteLLM 3D submit returned no task id: ${JSON.stringify(json).slice(0, 400)}`);
|
|
1881
|
-
return json.id;
|
|
1882
|
-
}
|
|
1883
|
-
async function poll3dTask(cfg, id, onProgress) {
|
|
1884
|
-
const deadline = Date.now() + TASK_TIMEOUT_MS;
|
|
1885
|
-
for (;; ) {
|
|
1886
|
-
const res = await request(`${cfg.baseUrl}/v1/3d/tasks/${encodeURIComponent(id)}`, {
|
|
1887
|
-
method: "GET",
|
|
1888
|
-
headers: authHeaders(cfg)
|
|
1889
|
-
});
|
|
1890
|
-
const state = await res.json();
|
|
1891
|
-
if (typeof state.progress === "number")
|
|
1892
|
-
onProgress?.(state.progress);
|
|
1893
|
-
const status = state.status?.toLowerCase();
|
|
1894
|
-
if (status === "succeeded" || status === "success" || status === "completed")
|
|
1895
|
-
return state;
|
|
1896
|
-
if (status === "failed" || status === "error" || status === "cancelled") {
|
|
1897
|
-
throw new Error(`LiteLLM 3D task ${id} ${state.status}${state.error ? `: ${JSON.stringify(state.error).slice(0, 300)}` : ""}`);
|
|
1898
|
-
}
|
|
1899
|
-
if (Date.now() > deadline) {
|
|
1900
|
-
throw new Error(`LiteLLM 3D task ${id} did not finish within ${TASK_TIMEOUT_MS / 1000}s (last status: ${state.status}).`);
|
|
1901
|
-
}
|
|
1902
|
-
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
async function downloadMesh(state) {
|
|
1906
|
-
const assets = (state.data ?? []).map((d) => ({
|
|
1907
|
-
url: typeof d.url === "string" ? d.url : undefined,
|
|
1908
|
-
type: typeof d.type === "string" ? d.type : "",
|
|
1909
|
-
format: typeof d.format === "string" ? d.format : ""
|
|
1910
|
-
}));
|
|
1911
|
-
const mesh = assets.find((a) => a.url && (a.type === "mesh" || /\.(glb|gltf|obj|fbx|usdz)/i.test(a.url ?? ""))) ?? assets.find((a) => a.url);
|
|
1912
|
-
if (!mesh?.url)
|
|
1913
|
-
throw new Error(`LiteLLM 3D task ${state.id} completed with no downloadable mesh asset.`);
|
|
1914
|
-
const res = await request(mesh.url, { method: "GET" });
|
|
1915
|
-
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
1916
|
-
const ext = mesh.format || (mesh.url.match(/\.([a-z0-9]+)(?:\?|$)/i)?.[1] ?? "glb").toLowerCase();
|
|
1917
|
-
return { bytes, ext, assetType: mesh.type || "mesh" };
|
|
1918
|
-
}
|
|
1919
|
-
async function generate3dFromText(cfg, opts) {
|
|
1920
|
-
const id = await submit3dTask(cfg, { model: opts.model, prompt: opts.prompt });
|
|
1921
|
-
return downloadMesh(await poll3dTask(cfg, id, opts.onProgress));
|
|
1922
|
-
}
|
|
1923
|
-
async function generate3dFromImageUrl(cfg, opts) {
|
|
1924
|
-
const id = await submit3dTask(cfg, { model: opts.model, image_url: opts.imageUrl, ...opts.prompt ? { prompt: opts.prompt } : {} });
|
|
1925
|
-
return downloadMesh(await poll3dTask(cfg, id, opts.onProgress));
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
// src/gen/generate.ts
|
|
1929
|
-
function assetsDirFor(cwd, explicitGame) {
|
|
1930
|
-
const binding = resolveProject(cwd);
|
|
1931
|
-
if (!binding.root) {
|
|
1932
|
-
throw new Error(`No ForgeaX project found from ${binding.searchedFrom}. Run \`forgeax-game init --game <slug>\` in the workspace first, or pass \`game\`/\`target_dir\`.`);
|
|
1933
|
-
}
|
|
1934
|
-
const slug = explicitGame?.trim() || activeGame(binding.root);
|
|
1935
|
-
if (!slug) {
|
|
1936
|
-
const games = listGames(binding.root);
|
|
1937
|
-
throw new Error(`No active game to save the asset into.${games.length ? ` Pass one of: ${games.join(", ")}` : " Create one with `forgeax-game init --game <slug>`."}`);
|
|
1938
|
-
}
|
|
1939
|
-
const dir = gameDir(binding.root, slug);
|
|
1940
|
-
if (!dir)
|
|
1941
|
-
throw new Error(`Game ${JSON.stringify(slug)} not found in this project.`);
|
|
1942
|
-
const assets = join7(dir, "assets");
|
|
1943
|
-
mkdirSync7(assets, { recursive: true });
|
|
1944
|
-
return { dir: assets, root: binding.root, slug };
|
|
1945
|
-
}
|
|
1946
|
-
function safeStem(preferred, fallback) {
|
|
1947
|
-
const source = (preferred ?? fallback).toLowerCase();
|
|
1948
|
-
const stem = source.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
1949
|
-
return stem || "asset";
|
|
1950
|
-
}
|
|
1951
|
-
function uniquePath(dir, stem, ext) {
|
|
1952
|
-
let candidate = join7(dir, `${stem}.${ext}`);
|
|
1953
|
-
for (let i = 1;existsSync4(candidate); i += 1)
|
|
1954
|
-
candidate = join7(dir, `${stem}-${i}.${ext}`);
|
|
1955
|
-
return candidate;
|
|
1956
|
-
}
|
|
1957
|
-
function logProgress(label) {
|
|
1958
|
-
let last = -1;
|
|
1959
|
-
return (pct) => {
|
|
1960
|
-
const step = Math.floor(pct / 10);
|
|
1961
|
-
if (step !== last) {
|
|
1962
|
-
last = step;
|
|
1963
|
-
process.stderr.write(`[forgeax] ${label}: ${pct}%
|
|
1964
|
-
`);
|
|
1965
|
-
}
|
|
1966
|
-
};
|
|
1967
|
-
}
|
|
1968
|
-
var GAME_PROPERTY = {
|
|
1969
|
-
game: {
|
|
1970
|
-
type: "string",
|
|
1971
|
-
description: "Game slug to save the asset into. Defaults to the active game."
|
|
1972
|
-
},
|
|
1973
|
-
target_dir: {
|
|
1974
|
-
type: "string",
|
|
1975
|
-
description: "Directory to resolve the ForgeaX project from. Defaults to the server working directory."
|
|
1976
|
-
},
|
|
1977
|
-
name: {
|
|
1978
|
-
type: "string",
|
|
1979
|
-
description: "Base file name for the saved asset (without extension). Defaults to a slug of the prompt."
|
|
1980
|
-
}
|
|
1981
|
-
};
|
|
1982
|
-
var GENERATE_IMAGE_SCHEMA = {
|
|
1983
|
-
type: "object",
|
|
1984
|
-
properties: {
|
|
1985
|
-
prompt: { type: "string", description: "What to draw. Required for both text-to-image and editing an input image." },
|
|
1986
|
-
image: {
|
|
1987
|
-
type: "string",
|
|
1988
|
-
description: "Optional local image path to edit (image-to-image). When set, the prompt describes the desired change."
|
|
1989
|
-
},
|
|
1990
|
-
model: { type: "string", description: "Override the image model. Defaults to the configured text-to-image model." },
|
|
1991
|
-
...GAME_PROPERTY
|
|
1992
|
-
},
|
|
1993
|
-
required: ["prompt"],
|
|
1994
|
-
additionalProperties: false
|
|
1995
|
-
};
|
|
1996
|
-
var GENERATE_3D_SCHEMA = {
|
|
1997
|
-
type: "object",
|
|
1998
|
-
properties: {
|
|
1999
|
-
prompt: { type: "string", description: "Text description for text-to-3D. Provide this or `image`." },
|
|
2000
|
-
image: {
|
|
2001
|
-
type: "string",
|
|
2002
|
-
description: "Image for image-to-3D: a public https URL, or a local file path when COS is configured (FORGEAX_COS_*) — local files are uploaded to COS and passed as a short-lived presigned URL. Without COS, only a public URL works."
|
|
2003
|
-
},
|
|
2004
|
-
model: { type: "string", description: "Override the 3D model. Defaults to the configured text/image-to-3D model." },
|
|
2005
|
-
...GAME_PROPERTY
|
|
2006
|
-
},
|
|
2007
|
-
additionalProperties: false
|
|
2008
|
-
};
|
|
2009
|
-
var HTTP_URL_RE = /^https?:\/\//i;
|
|
2010
|
-
async function generateImageTool(args, cwd) {
|
|
2011
|
-
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
|
2012
|
-
if (!prompt)
|
|
2013
|
-
throw new Error("`prompt` is required.");
|
|
2014
|
-
const cfg = resolveLiteLlmConfig();
|
|
2015
|
-
const targetDir = typeof args.target_dir === "string" ? args.target_dir : cwd;
|
|
2016
|
-
const { dir, root, slug } = assetsDirFor(targetDir, typeof args.game === "string" ? args.game : undefined);
|
|
2017
|
-
const model = typeof args.model === "string" && args.model.trim() ? args.model.trim() : cfg.models.textToImage;
|
|
2018
|
-
let result;
|
|
2019
|
-
let mode;
|
|
2020
|
-
const inputImage = typeof args.image === "string" ? args.image.trim() : "";
|
|
2021
|
-
if (inputImage) {
|
|
2022
|
-
if (HTTP_URL_RE.test(inputImage)) {
|
|
2023
|
-
throw new Error("Image-to-image expects a LOCAL image path, not a URL. Download it first, then pass the path.");
|
|
2024
|
-
}
|
|
2025
|
-
if (!existsSync4(inputImage))
|
|
2026
|
-
throw new Error(`Input image not found: ${inputImage}`);
|
|
2027
|
-
const bytes = new Uint8Array(readFileSync6(inputImage));
|
|
2028
|
-
result = await editImage(cfg, { model, prompt, image: bytes, filename: basename(inputImage) });
|
|
2029
|
-
mode = "image-to-image";
|
|
2030
|
-
} else {
|
|
2031
|
-
result = await generateImage(cfg, { model, prompt });
|
|
2032
|
-
mode = "text-to-image";
|
|
2033
|
-
}
|
|
2034
|
-
const stem = safeStem(typeof args.name === "string" ? args.name : undefined, prompt);
|
|
2035
|
-
const outPath = uniquePath(dir, stem, result.ext);
|
|
2036
|
-
writeFileSync4(outPath, result.bytes);
|
|
2037
|
-
const rel = relative3(root, outPath);
|
|
2038
|
-
return `Saved ${mode} asset to \`${rel}\` (game: ${slug}, model: ${model}, ${result.bytes.length} bytes). Reference it from game code by this path.`;
|
|
2039
|
-
}
|
|
2040
|
-
function imageContentType(path) {
|
|
2041
|
-
const ext = extname(path).toLowerCase();
|
|
2042
|
-
if (ext === ".jpg" || ext === ".jpeg")
|
|
2043
|
-
return "image/jpeg";
|
|
2044
|
-
if (ext === ".webp")
|
|
2045
|
-
return "image/webp";
|
|
2046
|
-
return "image/png";
|
|
2047
|
-
}
|
|
2048
|
-
async function resolveImageUrlFor3d(image, slug) {
|
|
2049
|
-
if (HTTP_URL_RE.test(image))
|
|
2050
|
-
return image;
|
|
2051
|
-
if (!existsSync4(image))
|
|
2052
|
-
throw new Error(`Input image not found: ${image}`);
|
|
2053
|
-
const cos = resolveCosConfig();
|
|
2054
|
-
if (!cos) {
|
|
2055
|
-
throw new Error("Image-to-3D from a local file needs COS configured (FORGEAX_COS_BUCKET/REGION/SECRET_ID/SECRET_KEY) so the image can be hosted for the backend to fetch. Alternatively pass a public https URL.");
|
|
2056
|
-
}
|
|
2057
|
-
const bytes = new Uint8Array(readFileSync6(image));
|
|
2058
|
-
const stem = safeStem(basename(image, extname(image)), "input");
|
|
2059
|
-
const ext = (extname(image).replace(".", "") || "png").toLowerCase();
|
|
2060
|
-
const key = `forgeax/${slug}/${stem}-${Date.now()}.${ext}`;
|
|
2061
|
-
return uploadAndPresign(cos, key, bytes, imageContentType(image));
|
|
2062
|
-
}
|
|
2063
|
-
async function generate3dTool(args, cwd) {
|
|
2064
|
-
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
|
2065
|
-
const image = typeof args.image === "string" ? args.image.trim() : "";
|
|
2066
|
-
if (!prompt && !image)
|
|
2067
|
-
throw new Error("Provide `prompt` (text-to-3D) or `image` (image-to-3D).");
|
|
2068
|
-
const cfg = resolveLiteLlmConfig();
|
|
2069
|
-
const targetDir = typeof args.target_dir === "string" ? args.target_dir : cwd;
|
|
2070
|
-
const { dir, root, slug } = assetsDirFor(targetDir, typeof args.game === "string" ? args.game : undefined);
|
|
2071
|
-
let result;
|
|
2072
|
-
let mode;
|
|
2073
|
-
if (image) {
|
|
2074
|
-
const imageUrl = await resolveImageUrlFor3d(image, slug);
|
|
2075
|
-
const model = typeof args.model === "string" && args.model.trim() ? args.model.trim() : cfg.models.imageTo3d;
|
|
2076
|
-
result = await generate3dFromImageUrl(cfg, { model, imageUrl, prompt: prompt || undefined, onProgress: logProgress("image-to-3D") });
|
|
2077
|
-
mode = "image-to-3D";
|
|
2078
|
-
} else {
|
|
2079
|
-
const model = typeof args.model === "string" && args.model.trim() ? args.model.trim() : cfg.models.textTo3d;
|
|
2080
|
-
result = await generate3dFromText(cfg, { model, prompt, onProgress: logProgress("text-to-3D") });
|
|
2081
|
-
mode = "text-to-3D";
|
|
2082
|
-
}
|
|
2083
|
-
const stem = safeStem(typeof args.name === "string" ? args.name : undefined, prompt || "model");
|
|
2084
|
-
const outPath = uniquePath(dir, stem, result.ext);
|
|
2085
|
-
writeFileSync4(outPath, result.bytes);
|
|
2086
|
-
const rel = relative3(root, outPath);
|
|
2087
|
-
return `Saved ${mode} ${result.assetType} to \`${rel}\` (game: ${slug}, ${result.bytes.length} bytes). Reference it from game code by this path.`;
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
1655
|
// src/mcp/forgeax-server.ts
|
|
2091
1656
|
function packageVersion() {
|
|
2092
|
-
for (const
|
|
1657
|
+
for (const relative3 of ["../package.json", "../../package.json"]) {
|
|
2093
1658
|
try {
|
|
2094
|
-
const version = JSON.parse(
|
|
1659
|
+
const version = JSON.parse(readFileSync6(new URL(relative3, import.meta.url), "utf8")).version;
|
|
2095
1660
|
if (version)
|
|
2096
1661
|
return version;
|
|
2097
1662
|
} catch {}
|
|
@@ -2133,30 +1698,18 @@ function createForgeaxMcpServer() {
|
|
|
2133
1698
|
description: 'Build, preview, reload, or verify the active game. One call covers what the user means by "run it", "let me see it", "reload", or "does it work": it installs the selected Runtime when needed, builds or reuses a static preview, returns a preview URL to open, and reports the Runtime log file. Read an available `runtime_logs.local_file` with your own file tool — log tailing is intentionally not a tool. Call this after a requested game change, not for ordinary edits the user has not asked to see.',
|
|
2134
1699
|
inputSchema: RUN_TOOL_SCHEMA,
|
|
2135
1700
|
run: async (args, ctx) => runCurrentGame(args, ctx.cwd)
|
|
2136
|
-
},
|
|
2137
|
-
{
|
|
2138
|
-
name: "forgeax_generate_image",
|
|
2139
|
-
description: "Generate a game image asset from a text prompt (text-to-image), or edit a local image when `image` is set (image-to-image). Saves the PNG/JPG into the active game's `assets/` directory and returns its project-relative path to reference from game code. Backed by the ForgeaX LiteLLM gateway; requires FORGEAX_LITELLM_API_KEY. Use when the user asks for a sprite, texture, icon, background, or concept art.",
|
|
2140
|
-
inputSchema: GENERATE_IMAGE_SCHEMA,
|
|
2141
|
-
run: async (args, ctx) => generateImageTool(args, ctx.cwd)
|
|
2142
|
-
},
|
|
2143
|
-
{
|
|
2144
|
-
name: "forgeax_generate_3d",
|
|
2145
|
-
description: "Generate a 3D model (.glb) for the game. Provide `prompt` for text-to-3D, or `image` for image-to-3D — a public https URL, or a local file path when COS is configured (the file is uploaded and passed as a short-lived presigned URL). Runs the async generation to completion (~1–2 min) and saves the mesh into the active game's `assets/` directory, returning its project-relative path. Backed by the ForgeaX LiteLLM gateway; requires FORGEAX_LITELLM_API_KEY (and FORGEAX_COS_* for local-file image-to-3D).",
|
|
2146
|
-
inputSchema: GENERATE_3D_SCHEMA,
|
|
2147
|
-
run: async (args, ctx) => generate3dTool(args, ctx.cwd)
|
|
2148
1701
|
}
|
|
2149
1702
|
]
|
|
2150
1703
|
};
|
|
2151
1704
|
}
|
|
2152
1705
|
|
|
2153
1706
|
// src/cli/dispatch.ts
|
|
2154
|
-
import { existsSync as
|
|
2155
|
-
import { basename
|
|
1707
|
+
import { existsSync as existsSync5, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "node:fs";
|
|
1708
|
+
import { basename, join as join8 } from "node:path";
|
|
2156
1709
|
|
|
2157
1710
|
// src/install/clients.ts
|
|
2158
1711
|
import { homedir as homedir3 } from "node:os";
|
|
2159
|
-
import { join as
|
|
1712
|
+
import { join as join7, resolve as resolve4 } from "node:path";
|
|
2160
1713
|
var HOME = homedir3();
|
|
2161
1714
|
var CLIENTS = [
|
|
2162
1715
|
{
|
|
@@ -2164,7 +1717,7 @@ var CLIENTS = [
|
|
|
2164
1717
|
label: "Codex CLI",
|
|
2165
1718
|
format: "toml",
|
|
2166
1719
|
scope: "user",
|
|
2167
|
-
path: () =>
|
|
1720
|
+
path: () => join7(HOME, ".codex", "config.toml"),
|
|
2168
1721
|
commandShape: "split",
|
|
2169
1722
|
postInstallNote: "Restart Codex, then run /mcp to confirm the server is connected."
|
|
2170
1723
|
},
|
|
@@ -2173,7 +1726,7 @@ var CLIENTS = [
|
|
|
2173
1726
|
label: "Claude Code",
|
|
2174
1727
|
format: "json",
|
|
2175
1728
|
scope: "user",
|
|
2176
|
-
path: () =>
|
|
1729
|
+
path: () => join7(HOME, ".claude.json"),
|
|
2177
1730
|
serverMapKey: ["mcpServers"],
|
|
2178
1731
|
commandShape: "split",
|
|
2179
1732
|
postInstallNote: "Restart Claude Code, then run /mcp to confirm the server is connected."
|
|
@@ -2183,7 +1736,7 @@ var CLIENTS = [
|
|
|
2183
1736
|
label: "Cursor",
|
|
2184
1737
|
format: "json",
|
|
2185
1738
|
scope: "user",
|
|
2186
|
-
path: () =>
|
|
1739
|
+
path: () => join7(HOME, ".cursor", "mcp.json"),
|
|
2187
1740
|
serverMapKey: ["mcpServers"],
|
|
2188
1741
|
commandShape: "split",
|
|
2189
1742
|
postInstallNote: "Reload Cursor, then check Settings > MCP."
|
|
@@ -2193,7 +1746,7 @@ var CLIENTS = [
|
|
|
2193
1746
|
label: "Trae (project)",
|
|
2194
1747
|
format: "json",
|
|
2195
1748
|
scope: "project",
|
|
2196
|
-
path: (projectRoot) =>
|
|
1749
|
+
path: (projectRoot) => join7(projectRoot, ".trae", "mcp.json"),
|
|
2197
1750
|
serverMapKey: ["mcpServers"],
|
|
2198
1751
|
commandShape: "split",
|
|
2199
1752
|
postInstallNote: "Reload Trae, then check the project MCP server list."
|
|
@@ -2204,7 +1757,7 @@ var CLIENTS = [
|
|
|
2204
1757
|
label: "CodeBuddy / WorkBuddy",
|
|
2205
1758
|
format: "json",
|
|
2206
1759
|
scope: "user",
|
|
2207
|
-
path: () =>
|
|
1760
|
+
path: () => join7(HOME, ".codebuddy", ".mcp.json"),
|
|
2208
1761
|
serverMapKey: ["mcpServers"],
|
|
2209
1762
|
commandShape: "split",
|
|
2210
1763
|
postInstallNote: "Restart CodeBuddy or WorkBuddy, then run /mcp to confirm the server is connected."
|
|
@@ -2214,7 +1767,7 @@ var CLIENTS = [
|
|
|
2214
1767
|
label: "Windsurf",
|
|
2215
1768
|
format: "json",
|
|
2216
1769
|
scope: "user",
|
|
2217
|
-
path: () =>
|
|
1770
|
+
path: () => join7(HOME, ".codeium", "windsurf", "mcp_config.json"),
|
|
2218
1771
|
serverMapKey: ["mcpServers"],
|
|
2219
1772
|
commandShape: "split",
|
|
2220
1773
|
postInstallNote: "Reload Windsurf to pick up the new server."
|
|
@@ -2224,17 +1777,27 @@ var CLIENTS = [
|
|
|
2224
1777
|
label: "VS Code (workspace)",
|
|
2225
1778
|
format: "json",
|
|
2226
1779
|
scope: "project",
|
|
2227
|
-
path: (projectRoot) =>
|
|
1780
|
+
path: (projectRoot) => join7(projectRoot, ".vscode", "mcp.json"),
|
|
2228
1781
|
serverMapKey: ["servers"],
|
|
2229
1782
|
commandShape: "split",
|
|
2230
1783
|
postInstallNote: 'Open .vscode/mcp.json and click Start, or run "MCP: List Servers".'
|
|
2231
1784
|
},
|
|
1785
|
+
{
|
|
1786
|
+
id: "zcode",
|
|
1787
|
+
label: "ZCode",
|
|
1788
|
+
format: "json",
|
|
1789
|
+
scope: "user",
|
|
1790
|
+
path: () => join7(HOME, ".zcode", "cli", "config.json"),
|
|
1791
|
+
serverMapKey: ["mcp", "servers"],
|
|
1792
|
+
commandShape: "split",
|
|
1793
|
+
postInstallNote: "Start a new ZCode session, then run /mcp status to confirm the server is connected."
|
|
1794
|
+
},
|
|
2232
1795
|
{
|
|
2233
1796
|
id: "opencode",
|
|
2234
1797
|
label: "OpenCode",
|
|
2235
1798
|
format: "json",
|
|
2236
1799
|
scope: "user",
|
|
2237
|
-
path: () =>
|
|
1800
|
+
path: () => join7(HOME, ".config", "opencode", "opencode.json"),
|
|
2238
1801
|
serverMapKey: ["mcp"],
|
|
2239
1802
|
commandShape: "argv",
|
|
2240
1803
|
extraEntryFields: { type: "local", enabled: true },
|
|
@@ -2258,7 +1821,7 @@ function launchSpec(mode) {
|
|
|
2258
1821
|
}
|
|
2259
1822
|
|
|
2260
1823
|
// src/install/write-config.ts
|
|
2261
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
1824
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2262
1825
|
import { dirname as dirname4 } from "node:path";
|
|
2263
1826
|
|
|
2264
1827
|
// src/install/toml-section.ts
|
|
@@ -2468,11 +2031,11 @@ function mergeTomlConfig(existing, entry) {
|
|
|
2468
2031
|
}
|
|
2469
2032
|
function inspectConfig(spec, projectRoot, launch) {
|
|
2470
2033
|
const path = spec.path(projectRoot);
|
|
2471
|
-
if (!
|
|
2034
|
+
if (!existsSync4(path))
|
|
2472
2035
|
return { path, state: "missing" };
|
|
2473
2036
|
let existing;
|
|
2474
2037
|
try {
|
|
2475
|
-
existing =
|
|
2038
|
+
existing = readFileSync7(path, "utf8");
|
|
2476
2039
|
if (spec.format === "toml") {
|
|
2477
2040
|
const header = `mcp_servers.${SERVER_KEY}`;
|
|
2478
2041
|
if (!hasTomlTable(existing, header)) {
|
|
@@ -2517,25 +2080,25 @@ function inspectConfig(spec, projectRoot, launch) {
|
|
|
2517
2080
|
}
|
|
2518
2081
|
function applyConfig(spec, projectRoot, launch) {
|
|
2519
2082
|
const path = spec.path(projectRoot);
|
|
2520
|
-
const existing =
|
|
2083
|
+
const existing = existsSync4(path) ? readFileSync7(path, "utf8") : undefined;
|
|
2521
2084
|
const entry = buildEntry(spec, launch);
|
|
2522
2085
|
const merged = spec.format === "toml" ? mergeTomlConfig(existing, entry) : mergeJsonConfig(existing, spec, entry);
|
|
2523
2086
|
if (!merged.changed)
|
|
2524
2087
|
return { path, changed: false };
|
|
2525
|
-
|
|
2088
|
+
mkdirSync7(dirname4(path), { recursive: true });
|
|
2526
2089
|
let backup;
|
|
2527
2090
|
if (existing !== undefined) {
|
|
2528
2091
|
backup = `${path}.bak.latest`;
|
|
2529
2092
|
copyFileSync2(path, backup);
|
|
2530
2093
|
}
|
|
2531
|
-
|
|
2094
|
+
writeFileSync4(path, merged.content);
|
|
2532
2095
|
return { path, changed: true, ...backup ? { backup } : {} };
|
|
2533
2096
|
}
|
|
2534
2097
|
function removeConfig(spec, projectRoot) {
|
|
2535
2098
|
const path = spec.path(projectRoot);
|
|
2536
|
-
if (!
|
|
2099
|
+
if (!existsSync4(path))
|
|
2537
2100
|
return { path, changed: false };
|
|
2538
|
-
const existing =
|
|
2101
|
+
const existing = readFileSync7(path, "utf8");
|
|
2539
2102
|
let content;
|
|
2540
2103
|
if (spec.format === "toml") {
|
|
2541
2104
|
content = removeTomlTable(existing, `mcp_servers.${SERVER_KEY}`);
|
|
@@ -2563,7 +2126,7 @@ function removeConfig(spec, projectRoot) {
|
|
|
2563
2126
|
return { path, changed: false };
|
|
2564
2127
|
const backup = `${path}.bak.latest`;
|
|
2565
2128
|
copyFileSync2(path, backup);
|
|
2566
|
-
|
|
2129
|
+
writeFileSync4(path, content);
|
|
2567
2130
|
return { path, changed: true, backup };
|
|
2568
2131
|
}
|
|
2569
2132
|
|
|
@@ -2714,7 +2277,7 @@ import {
|
|
|
2714
2277
|
var HELP = `ForgeaX game development plugin
|
|
2715
2278
|
|
|
2716
2279
|
Usage:
|
|
2717
|
-
forgeax-game install [--ide
|
|
2280
|
+
forgeax-game install [--ide ${CLIENT_CHOICES.join(",")}] [--local]
|
|
2718
2281
|
forgeax-game uninstall [--ide ...] [--purge]
|
|
2719
2282
|
forgeax-game init [--game <slug>] [--ide ...]
|
|
2720
2283
|
forgeax-game use <slug>
|
|
@@ -2767,23 +2330,23 @@ function requireProject() {
|
|
|
2767
2330
|
return project.root;
|
|
2768
2331
|
}
|
|
2769
2332
|
function updateAgentsFile(root) {
|
|
2770
|
-
const path =
|
|
2771
|
-
const existing =
|
|
2333
|
+
const path = join8(root, "AGENTS.md");
|
|
2334
|
+
const existing = existsSync5(path) ? readFileSync8(path, "utf8") : undefined;
|
|
2772
2335
|
const content = upsertBlock(existing, ROUTING_TEXT);
|
|
2773
2336
|
if (content === existing)
|
|
2774
2337
|
return { path, changed: false };
|
|
2775
|
-
|
|
2338
|
+
writeFileSync5(path, content);
|
|
2776
2339
|
return { path, changed: true };
|
|
2777
2340
|
}
|
|
2778
2341
|
function removeAgentsBlock(root) {
|
|
2779
|
-
const path =
|
|
2780
|
-
if (!
|
|
2342
|
+
const path = join8(root, "AGENTS.md");
|
|
2343
|
+
if (!existsSync5(path))
|
|
2781
2344
|
return { path, changed: false };
|
|
2782
|
-
const existing =
|
|
2345
|
+
const existing = readFileSync8(path, "utf8");
|
|
2783
2346
|
const content = removeBlock(existing);
|
|
2784
2347
|
if (content === existing)
|
|
2785
2348
|
return { path, changed: false };
|
|
2786
|
-
|
|
2349
|
+
writeFileSync5(path, content);
|
|
2787
2350
|
return { path, changed: true };
|
|
2788
2351
|
}
|
|
2789
2352
|
async function apiPost(path, body) {
|
|
@@ -2862,7 +2425,7 @@ async function installCommand(args) {
|
|
|
2862
2425
|
return failures === 0 ? 0 : 1;
|
|
2863
2426
|
}
|
|
2864
2427
|
function defaultSlug(root) {
|
|
2865
|
-
const raw =
|
|
2428
|
+
const raw = basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2866
2429
|
return SLUG_RE.test(raw) ? raw : "my-game";
|
|
2867
2430
|
}
|
|
2868
2431
|
var INIT_USAGE = "usage: forgeax-game init [--game <slug>] [--ide codex,claude,cursor,...]";
|
|
@@ -3018,7 +2581,7 @@ async function uninstallCommand(args) {
|
|
|
3018
2581
|
const agents = removeAgentsBlock(root);
|
|
3019
2582
|
process.stdout.write(`${agents.changed ? "REMOVED" : "ABSENT "} routing block: ${agents.path}
|
|
3020
2583
|
`);
|
|
3021
|
-
process.stdout.write(`KEPT your games and project metadata: ${
|
|
2584
|
+
process.stdout.write(`KEPT your games and project metadata: ${join8(root, ".forgeax")}
|
|
3022
2585
|
`);
|
|
3023
2586
|
} else {
|
|
3024
2587
|
process.stdout.write(`INFO no ForgeaX project bound; only client configuration was touched.
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/game",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "@forgeax/game \u2014 ForgeaX game development as a plugin for MCP-capable agent CLIs (Codex, Claude Code, Cursor, Trae, OpenCode, WorkBuddy). Single binary, dual mode: no args = stdio MCP server, subcommand = CLI.",
|
|
6
|
+
"description": "@forgeax/game \u2014 ForgeaX game development as a plugin for MCP-capable agent CLIs (Codex, Claude Code, Cursor, Trae, ZCode, OpenCode, WorkBuddy). Single binary, dual mode: no args = stdio MCP server, subcommand = CLI.",
|
|
7
7
|
"main": "./dist/main.js",
|
|
8
8
|
"bin": {
|
|
9
9
|
"forgeax-game": "dist/main.js"
|