@aion0/forge 0.5.49 → 0.5.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/RELEASE_NOTES.md +48 -7
  2. package/app/api/craft-system/build/route.ts +78 -0
  3. package/app/api/craft-system/delete/route.ts +28 -0
  4. package/app/api/craft-system/helpers/file/route.ts +20 -0
  5. package/app/api/craft-system/helpers/openapi/route.ts +27 -0
  6. package/app/api/craft-system/helpers/shell/route.ts +26 -0
  7. package/app/api/craft-system/inject/route.ts +41 -0
  8. package/app/api/craft-system/kill-session/route.ts +19 -0
  9. package/app/api/craft-system/manifest/route.ts +71 -0
  10. package/app/api/craft-system/marketplace/install/route.ts +11 -0
  11. package/app/api/craft-system/marketplace/route.ts +18 -0
  12. package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
  13. package/app/api/craft-system/marketplace/update/route.ts +10 -0
  14. package/app/api/craft-system/marketplace/updates/route.ts +17 -0
  15. package/app/api/craft-system/publish/auto/route.ts +173 -0
  16. package/app/api/craft-system/publish/route.ts +50 -0
  17. package/app/api/craft-system/registry/route.ts +16 -0
  18. package/app/api/craft-system/runtime/react/route.ts +26 -0
  19. package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
  20. package/app/api/craft-system/runtime/sdk/route.ts +18 -0
  21. package/app/api/craft-system/scaffold/route.ts +164 -0
  22. package/app/api/craft-system/sessions/route.ts +45 -0
  23. package/app/api/craft-system/storage/route.ts +44 -0
  24. package/app/api/craft-system/tmux-sessions/route.ts +62 -0
  25. package/app/api/craft-system/ui/route.ts +30 -0
  26. package/app/api/crafts/[name]/[...route]/route.ts +48 -0
  27. package/app/api/crafts/route.ts +29 -0
  28. package/components/CraftBuilder.tsx +241 -0
  29. package/components/CraftManifestEditor.tsx +258 -0
  30. package/components/CraftMarketplaceModal.tsx +207 -0
  31. package/components/CraftPublishModal.tsx +285 -0
  32. package/components/CraftTabs.tsx +279 -0
  33. package/components/CraftTerminal.tsx +305 -0
  34. package/components/CraftTerminalPicker.tsx +179 -0
  35. package/components/CraftsDropdown.tsx +186 -0
  36. package/components/CraftsMarketplacePanel.tsx +194 -0
  37. package/components/ProjectDetail.tsx +105 -1
  38. package/components/SkillsPanel.tsx +12 -4
  39. package/components/TaskDetail.tsx +49 -1
  40. package/lib/craft-sdk/client.tsx +260 -0
  41. package/lib/craft-sdk/server.ts +14 -0
  42. package/lib/crafts/loader.ts +117 -0
  43. package/lib/crafts/registry.ts +272 -0
  44. package/lib/crafts/runtime.ts +208 -0
  45. package/lib/crafts/types.ts +92 -0
  46. package/lib/forge-skills/craft-builder.md +231 -0
  47. package/lib/help-docs/15-crafts.md +127 -0
  48. package/lib/help-docs/CLAUDE.md +2 -0
  49. package/lib/terminal-standalone.ts +1 -0
  50. package/next.config.ts +1 -1
  51. package/package.json +2 -1
  52. package/tsconfig.json +6 -0
@@ -0,0 +1,272 @@
1
+ // Craft marketplace — slim version of skills.ts.
2
+ //
3
+ // Mirrors the Forge skills marketplace pattern: a GitHub-hosted repo with
4
+ // `registry.json` at the root + per-craft folders containing the manifest
5
+ // and ui.tsx/server.ts. Default repo: aiwatching/forge-crafts. Override via
6
+ // settings.craftsRepoUrl.
7
+ //
8
+ // Install state for crafts is implicit (file-system): if
9
+ // <project>/.forge/crafts/<name>/ exists, it's installed. No DB rows needed.
10
+
11
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, statSync } from 'node:fs';
12
+ import { join, relative } from 'node:path';
13
+ import * as YAML from 'yaml';
14
+ import { loadSettings } from '../settings';
15
+ import type { CraftManifest, CraftRequirements } from './types';
16
+ import { matchesRequirements, listProjectCrafts } from './loader';
17
+
18
+ export interface RegistryEntry {
19
+ name: string;
20
+ displayName: string;
21
+ description?: string;
22
+ version: string;
23
+ author?: string;
24
+ tags?: string[];
25
+ requires?: CraftRequirements;
26
+ files: string[]; // relative paths inside the craft dir on the registry
27
+ sourceUrl?: string; // GitHub web URL for browsing
28
+ }
29
+
30
+ export interface RegistryItem extends RegistryEntry {
31
+ // Per-project install info (populated by listMarketplace for a given project)
32
+ installed: boolean;
33
+ installedVersion?: string;
34
+ hasUpdate: boolean;
35
+ compatible: boolean; // requires gate against the project
36
+ }
37
+
38
+ function getBaseUrl(): string {
39
+ const s = loadSettings();
40
+ return (s as any).craftsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-crafts/main';
41
+ }
42
+
43
+ function compareVersions(a: string, b: string): number {
44
+ const pa = (a || '0.0.0').split('.').map(Number);
45
+ const pb = (b || '0.0.0').split('.').map(Number);
46
+ for (let i = 0; i < 3; i++) {
47
+ const d = (pa[i] || 0) - (pb[i] || 0);
48
+ if (d !== 0) return d;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ // ── Registry fetch ─────────────────────────────────────
54
+
55
+ let cachedRegistry: { fetchedAt: number; entries: RegistryEntry[] } | null = null;
56
+ // Short TTL — registry.json is tiny + GitHub Pages caches independently anyway.
57
+ // 5-minute cache used to leave users staring at stale data after their own
58
+ // publish landed; 30s is the right "barely noticeable but still saves repeat
59
+ // hits during a single UI session" balance.
60
+ const REGISTRY_TTL_MS = 30_000;
61
+
62
+ export function invalidateRegistry() {
63
+ cachedRegistry = null;
64
+ }
65
+
66
+ export async function fetchRegistry(force = false): Promise<RegistryEntry[]> {
67
+ if (!force && cachedRegistry && Date.now() - cachedRegistry.fetchedAt < REGISTRY_TTL_MS) {
68
+ return cachedRegistry.entries;
69
+ }
70
+ const baseUrl = getBaseUrl();
71
+ try {
72
+ const ctrl = new AbortController();
73
+ const t = setTimeout(() => ctrl.abort(), 10000);
74
+ const res = await fetch(`${baseUrl}/registry.json?_t=${Date.now()}`, {
75
+ signal: ctrl.signal,
76
+ headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
77
+ });
78
+ clearTimeout(t);
79
+ if (!res.ok) {
80
+ console.warn(`[craft-registry] fetch failed: ${res.status}`);
81
+ return cachedRegistry?.entries || [];
82
+ }
83
+ const data = await res.json() as { crafts?: RegistryEntry[] };
84
+ const entries = (data.crafts || []).filter(e => e.name && e.version && Array.isArray(e.files));
85
+ cachedRegistry = { fetchedAt: Date.now(), entries };
86
+ return entries;
87
+ } catch (e) {
88
+ console.warn('[craft-registry] fetch error', e);
89
+ return cachedRegistry?.entries || [];
90
+ }
91
+ }
92
+
93
+ // ── Per-project marketplace listing ────────────────────
94
+
95
+ export async function listMarketplace(projectPath: string): Promise<RegistryItem[]> {
96
+ const [entries, projectCrafts] = await Promise.all([
97
+ fetchRegistry(),
98
+ Promise.resolve(listProjectCrafts(projectPath)),
99
+ ]);
100
+ const installedByName = new Map(projectCrafts.filter(c => c.__scope === 'project').map(c => [c.name, c]));
101
+
102
+ return entries.map(e => {
103
+ const inst = installedByName.get(e.name);
104
+ const installedVersion = inst?.version;
105
+ const hasUpdate = !!installedVersion && compareVersions(e.version, installedVersion) > 0;
106
+ const compatible = e.requires ? matchesRequirements(e.requires, projectPath) : true;
107
+ return {
108
+ ...e,
109
+ installed: !!inst,
110
+ installedVersion,
111
+ hasUpdate,
112
+ compatible,
113
+ };
114
+ });
115
+ }
116
+
117
+ // ── Install / uninstall ────────────────────────────────
118
+
119
+ export async function installCraft(name: string, projectPath: string): Promise<{ ok: boolean; error?: string }> {
120
+ // Always pull fresh on install — user just clicked install, can't be stale.
121
+ const entries = await fetchRegistry(true);
122
+ const entry = entries.find(e => e.name === name);
123
+ if (!entry) return { ok: false, error: `craft "${name}" not in registry` };
124
+
125
+ const baseUrl = getBaseUrl();
126
+ const targetDir = join(projectPath, '.forge', 'crafts', name);
127
+ if (existsSync(targetDir)) {
128
+ return { ok: false, error: `craft "${name}" already installed at ${targetDir} (uninstall first to upgrade)` };
129
+ }
130
+ mkdirSync(targetDir, { recursive: true });
131
+ mkdirSync(join(targetDir, 'data'), { recursive: true });
132
+
133
+ for (const rel of entry.files) {
134
+ try {
135
+ const url = `${baseUrl}/${name}/${rel}?_t=${Date.now()}`;
136
+ const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
137
+ if (!res.ok) throw new Error(`${url}: ${res.status}`);
138
+ const text = await res.text();
139
+ const dest = join(targetDir, rel);
140
+ mkdirSync(join(dest, '..'), { recursive: true });
141
+ writeFileSync(dest, text, 'utf8');
142
+ } catch (e: any) {
143
+ // Roll back partial install
144
+ try { rmSync(targetDir, { recursive: true, force: true }); } catch {}
145
+ return { ok: false, error: `download failed: ${e?.message || e}` };
146
+ }
147
+ }
148
+ return { ok: true };
149
+ }
150
+
151
+ export function uninstallCraft(name: string, projectPath: string): { ok: boolean; error?: string } {
152
+ const targetDir = join(projectPath, '.forge', 'crafts', name);
153
+ if (!existsSync(targetDir)) return { ok: false, error: 'not installed' };
154
+ rmSync(targetDir, { recursive: true, force: true });
155
+ return { ok: true };
156
+ }
157
+
158
+ // In-place update — preserves <project>/.forge/crafts/<name>/data/ (which holds
159
+ // useStore JSON) and overwrites everything else. Atomic: downloads first, then
160
+ // writes; if any download fails the existing install is untouched.
161
+ export async function updateCraft(name: string, projectPath: string): Promise<{ ok: boolean; error?: string; from?: string; to?: string }> {
162
+ const targetDir = join(projectPath, '.forge', 'crafts', name);
163
+ if (!existsSync(targetDir)) return { ok: false, error: 'not installed' };
164
+
165
+ const entries = await fetchRegistry(true);
166
+ const entry = entries.find(e => e.name === name);
167
+ if (!entry) return { ok: false, error: `craft "${name}" not in registry` };
168
+
169
+ // Read current version from local craft.yaml for the response
170
+ let fromVersion: string | undefined;
171
+ try {
172
+ const m = YAML.parse(readFileSync(join(targetDir, 'craft.yaml'), 'utf8')) as any;
173
+ fromVersion = m?.version;
174
+ } catch {}
175
+
176
+ // Download everything into memory first
177
+ const baseUrl = getBaseUrl();
178
+ const downloaded: { rel: string; content: string }[] = [];
179
+ for (const rel of entry.files) {
180
+ try {
181
+ const res = await fetch(`${baseUrl}/${name}/${rel}?_t=${Date.now()}`, { headers: { 'Cache-Control': 'no-cache' } });
182
+ if (!res.ok) throw new Error(`${rel}: HTTP ${res.status}`);
183
+ downloaded.push({ rel, content: await res.text() });
184
+ } catch (e: any) {
185
+ return { ok: false, error: `download failed for ${rel}: ${e?.message || e}` };
186
+ }
187
+ }
188
+
189
+ // All downloads succeeded — write them. data/ stays intact.
190
+ const fs = require('node:fs');
191
+ for (const d of downloaded) {
192
+ const dest = join(targetDir, d.rel);
193
+ mkdirSync(dest.split('/').slice(0, -1).join('/'), { recursive: true });
194
+ fs.writeFileSync(dest, d.content, 'utf8');
195
+ }
196
+ return { ok: true, from: fromVersion, to: entry.version };
197
+ }
198
+
199
+ // Returns just the names of installed crafts that have updates available.
200
+ // Used by the dropdown badge — cheap, no per-craft enrichment beyond version
201
+ // compare.
202
+ export async function listAvailableUpdates(projectPath: string): Promise<{ name: string; from?: string; to: string }[]> {
203
+ const items = await listMarketplace(projectPath);
204
+ return items
205
+ .filter(it => it.installed && it.hasUpdate)
206
+ .map(it => ({ name: it.name, from: it.installedVersion, to: it.version }));
207
+ }
208
+
209
+ // ── File scanner ───────────────────────────────────────
210
+
211
+ const EXCLUDE_DIRS = new Set(['data', '.git', 'node_modules', '.next', 'dist', 'build', '.cache']);
212
+ const EXCLUDE_FILES = new Set(['prompt.md', '.DS_Store']);
213
+ const INCLUDE_EXT = new Set(['.yaml', '.yml', '.tsx', '.ts', '.jsx', '.js', '.md', '.json', '.css']);
214
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MB per file
215
+
216
+ function collectCraftFiles(dir: string): { path: string; content: string }[] {
217
+ const out: { path: string; content: string }[] = [];
218
+ const walk = (d: string) => {
219
+ for (const name of readdirSync(d)) {
220
+ if (name.startsWith('.') && name !== '.gitignore') continue;
221
+ if (EXCLUDE_FILES.has(name)) continue;
222
+ const full = join(d, name);
223
+ const st = statSync(full);
224
+ if (st.isDirectory()) {
225
+ if (EXCLUDE_DIRS.has(name)) continue;
226
+ walk(full);
227
+ continue;
228
+ }
229
+ if (!st.isFile()) continue;
230
+ if (st.size > MAX_FILE_BYTES) continue;
231
+ const ext = name.lastIndexOf('.') >= 0 ? name.slice(name.lastIndexOf('.')) : '';
232
+ if (ext && !INCLUDE_EXT.has(ext)) continue;
233
+ out.push({ path: relative(dir, full), content: readFileSync(full, 'utf8') });
234
+ }
235
+ };
236
+ walk(dir);
237
+ return out;
238
+ }
239
+
240
+ // ── Publish helper ─────────────────────────────────────
241
+ // MVP: returns a registry-entry JSON snippet + a tar of the craft dir for
242
+ // the user to attach to a PR on the registry repo. Real "press a button to
243
+ // publish" requires GitHub auth + write — out of scope for v1.
244
+
245
+ export function bundleCraftForPublish(projectPath: string, craftName: string): { entry: RegistryEntry; files: { path: string; content: string }[]; error?: string } {
246
+ const dir = join(projectPath, '.forge', 'crafts', craftName);
247
+ if (!existsSync(dir)) return { entry: null as any, files: [], error: 'craft not found' };
248
+
249
+ let manifest: CraftManifest;
250
+ try {
251
+ manifest = YAML.parse(readFileSync(join(dir, 'craft.yaml'), 'utf8'));
252
+ } catch {
253
+ return { entry: null as any, files: [], error: 'craft.yaml unreadable' };
254
+ }
255
+
256
+ // Recursive scan — pick up arbitrary multi-file crafts (e.g. server.ts split
257
+ // into _types.ts / _runner.ts / etc.) while excluding runtime data, history,
258
+ // and anything obviously not source.
259
+ const files = collectCraftFiles(dir);
260
+
261
+ const entry: RegistryEntry = {
262
+ name: manifest.name,
263
+ displayName: manifest.displayName || manifest.name,
264
+ description: manifest.description,
265
+ version: manifest.version || '0.1.0',
266
+ author: manifest.author,
267
+ tags: manifest.tags,
268
+ requires: manifest.requires,
269
+ files: files.map(f => f.path),
270
+ };
271
+ return { entry, files };
272
+ }
@@ -0,0 +1,208 @@
1
+ // Runtime: load + cache craft server modules; build the ForgeServerApi per request.
2
+
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { tmpdir } from 'node:os';
7
+ import * as esbuild from 'esbuild';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { createTask } from '@/lib/task-manager';
10
+ import type { CraftDescriptor, CraftServerDef, CraftRouteHandler, ForgeServerApi } from './types';
11
+
12
+ // Module cache: dir → { mtimeMs, mod }
13
+ interface CachedMod { mtimeMs: number; def: CraftServerDef; }
14
+ const cache = new Map<string, CachedMod>();
15
+
16
+ // Function-wrapped dynamic import so Turbopack doesn't try to statically resolve the URL.
17
+ const dynamicImport = new Function('u', 'return import(u)') as (u: string) => Promise<any>;
18
+
19
+ async function transpileToFile(src: string, resolveDir: string): Promise<string> {
20
+ // Compile TS → JS, bundling the @forge/craft/server SDK inline (node_modules stays external).
21
+ // Cache key includes a salt so older transpile outputs are skipped after format changes.
22
+ const hash = require('node:crypto').createHash('md5').update('v2:' + src).digest('hex').slice(0, 16);
23
+ const out = join(tmpdir(), `forge-craft-${hash}.mjs`);
24
+ if (existsSync(out)) return out;
25
+ const sdkServerEntry = require('node:path').resolve(process.cwd(), 'lib/craft-sdk/server.ts');
26
+ const result = await esbuild.build({
27
+ stdin: { contents: src, loader: 'ts', resolveDir },
28
+ bundle: true,
29
+ format: 'esm',
30
+ platform: 'node',
31
+ target: 'node20',
32
+ packages: 'external',
33
+ alias: {
34
+ '@forge/craft/server': sdkServerEntry,
35
+ },
36
+ write: false,
37
+ });
38
+ writeFileSync(out, result.outputFiles[0].text, 'utf8');
39
+ return out;
40
+ }
41
+
42
+ export async function loadServer(craft: CraftDescriptor): Promise<CraftServerDef | null> {
43
+ if (!craft.hasServer) return null;
44
+ const file = join(craft.__dir, craft.server?.entry || 'server.ts');
45
+ const stat = statSync(file);
46
+ const cached = cache.get(craft.__dir);
47
+ if (cached && cached.mtimeMs === stat.mtimeMs) return cached.def;
48
+
49
+ const src = readFileSync(file, 'utf8');
50
+ const compiled = await transpileToFile(src, craft.__dir);
51
+ // Bust ESM cache by adding a query string (Node ESM caches by URL).
52
+ // Use Function() so Turbopack doesn't try to statically analyze the import.
53
+ const url = pathToFileURL(compiled).href + `?t=${stat.mtimeMs}`;
54
+ const mod = await dynamicImport(url);
55
+ const def: CraftServerDef = mod.default ?? mod.craft ?? mod;
56
+ if (!def || typeof def !== 'object' || !def.routes) {
57
+ throw new Error(`Craft "${craft.name}" server.ts must export default defineCraftServer({...})`);
58
+ }
59
+ cache.set(craft.__dir, { mtimeMs: stat.mtimeMs, def });
60
+ return def;
61
+ }
62
+
63
+ // Match a route key like "GET /items/:id" against a request method + path.
64
+ function matchRoute(routeKey: string, method: string, path: string): Record<string, string> | null {
65
+ const [m, pat] = routeKey.split(/\s+/, 2);
66
+ if (m.toUpperCase() !== method.toUpperCase()) return null;
67
+ // Normalize: ensure leading slash on both
68
+ const cleanPath = path.startsWith('/') ? path : '/' + path;
69
+ const cleanPat = pat.startsWith('/') ? pat : '/' + pat;
70
+ const pSegs = cleanPath.split('/').filter(Boolean);
71
+ const tSegs = cleanPat.split('/').filter(Boolean);
72
+ if (pSegs.length !== tSegs.length) return null;
73
+ const params: Record<string, string> = {};
74
+ for (let i = 0; i < tSegs.length; i++) {
75
+ const t = tSegs[i];
76
+ const p = pSegs[i];
77
+ if (t.startsWith(':')) params[t.slice(1)] = decodeURIComponent(p);
78
+ else if (t !== p) return null;
79
+ }
80
+ return params;
81
+ }
82
+
83
+ export function findHandler(def: CraftServerDef, method: string, path: string): { handler: CraftRouteHandler; params: Record<string, string> } | null {
84
+ for (const [key, handler] of Object.entries(def.routes)) {
85
+ const params = matchRoute(key, method, path);
86
+ if (params) return { handler, params };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ // ── Forge server-side API ────────────────────────────────
92
+
93
+ export function buildForgeApi(craft: CraftDescriptor, projectPath: string, projectName?: string): ForgeServerApi {
94
+ const dataDir = join(craft.__dir, 'data');
95
+ // For builtin crafts, redirect storage to the project so writes don't go into the install
96
+ const writableDataDir = craft.__scope === 'builtin'
97
+ ? join(projectPath, '.forge', 'crafts', craft.name, 'data')
98
+ : dataDir;
99
+
100
+ return {
101
+ project: { path: projectPath, name: projectName },
102
+
103
+ storage: {
104
+ read<T>(file: string): T | null {
105
+ const f = join(writableDataDir, file);
106
+ if (!existsSync(f)) return null;
107
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return null; }
108
+ },
109
+ write(file: string, data: any): void {
110
+ if (!existsSync(writableDataDir)) mkdirSync(writableDataDir, { recursive: true });
111
+ writeFileSync(join(writableDataDir, file), JSON.stringify(data, null, 2), 'utf8');
112
+ },
113
+ listFiles(): string[] {
114
+ if (!existsSync(writableDataDir)) return [];
115
+ return readdirSync(writableDataDir);
116
+ },
117
+ },
118
+
119
+ exec(cmd, opts = {}) {
120
+ try {
121
+ const stdout = execSync(cmd, {
122
+ cwd: projectPath,
123
+ timeout: opts.timeout ?? 30000,
124
+ input: opts.input,
125
+ encoding: 'utf8',
126
+ maxBuffer: 10 * 1024 * 1024,
127
+ }).toString();
128
+ return { stdout, stderr: '', code: 0 };
129
+ } catch (e: any) {
130
+ return { stdout: (e?.stdout || '').toString(), stderr: (e?.stderr || e?.message || '').toString(), code: e?.status ?? 1 };
131
+ }
132
+ },
133
+
134
+ task(opts) {
135
+ const task = createTask({
136
+ projectName: projectName || projectPath.split('/').filter(Boolean).pop() || 'project',
137
+ projectPath,
138
+ prompt: opts.prompt,
139
+ agent: opts.agent,
140
+ });
141
+ return { id: task.id };
142
+ },
143
+
144
+ inject(text, opts = {}) {
145
+ // Reuse migration session-resolver logic inline
146
+ try {
147
+ const sessionName = opts.sessionName || resolveBoundSession(projectPath);
148
+ if (!sessionName) return { ok: false };
149
+ const buf = join(tmpdir(), `forge-craft-inject-${Date.now()}.txt`);
150
+ writeFileSync(buf, text);
151
+ execSync(`tmux load-buffer -t "${sessionName}" "${buf}" && tmux paste-buffer -t "${sessionName}" && sleep 0.2 && tmux send-keys -t "${sessionName}" Enter`, { timeout: 5000 });
152
+ try { require('node:fs').unlinkSync(buf); } catch {}
153
+ return { ok: true, sessionName };
154
+ } catch (e) {
155
+ return { ok: false };
156
+ }
157
+ },
158
+
159
+ openapi(specPath) {
160
+ try {
161
+ const file = join(projectPath, specPath);
162
+ if (!existsSync(file)) return null;
163
+ return JSON.parse(readFileSync(file, 'utf8'));
164
+ } catch { return null; }
165
+ },
166
+
167
+ log: (...args) => console.log(`[craft:${craft.name}]`, ...args),
168
+ };
169
+ }
170
+
171
+ function resolveBoundSession(projectPath: string): string | null {
172
+ try {
173
+ const sessions = execSync(`tmux list-sessions -F '#{session_name}'`, { encoding: 'utf8', timeout: 2000 })
174
+ .trim().split('\n').filter(Boolean).filter(n => /^mw[a-z0-9]*-/.test(n));
175
+ for (const s of sessions) {
176
+ try {
177
+ const cwd = execSync(`tmux display-message -p -t '${s}' '#{pane_current_path}'`, { encoding: 'utf8', timeout: 2000 }).trim();
178
+ if (cwd === projectPath || cwd.startsWith(projectPath + '/')) return s;
179
+ } catch {}
180
+ }
181
+ } catch {}
182
+ return null;
183
+ }
184
+
185
+ // ── UI transpile (TSX → JS) for browser dynamic import ──
186
+
187
+ export async function transpileUi(craft: CraftDescriptor): Promise<string> {
188
+ const file = join(craft.__dir, craft.ui?.tab || 'ui.tsx');
189
+ if (!existsSync(file)) throw new Error('No UI file');
190
+ const src = readFileSync(file, 'utf8');
191
+ const result = await esbuild.build({
192
+ stdin: { contents: src, loader: 'tsx', resolveDir: craft.__dir },
193
+ bundle: true,
194
+ format: 'esm',
195
+ platform: 'browser',
196
+ target: 'es2022',
197
+ jsx: 'automatic',
198
+ write: false,
199
+ // Mark React + SDK as externals so they share the host page's instances
200
+ external: ['react', 'react/jsx-runtime', 'react-dom', '@forge/craft'],
201
+ });
202
+ // Rewrite SDK + react bare imports → absolute URLs that Forge serves.
203
+ let code = result.outputFiles[0].text;
204
+ code = code.replace(/from\s*["']react["']/g, 'from "/api/craft-system/runtime/react"');
205
+ code = code.replace(/from\s*["']react\/jsx-runtime["']/g, 'from "/api/craft-system/runtime/react-jsx"');
206
+ code = code.replace(/from\s*["']@forge\/craft["']/g, 'from "/api/craft-system/runtime/sdk"');
207
+ return code;
208
+ }
@@ -0,0 +1,92 @@
1
+ // Craft = a project-scoped mini-app: UI tab + optional API routes.
2
+ // Lives at <project>/.forge/crafts/<name>/ or in lib/builtin-crafts/<name>/.
3
+
4
+ export interface CraftRequirements {
5
+ hasFile?: string[]; // any of these paths must exist (relative to project)
6
+ hasGlob?: string[]; // any glob must match (e.g. "**/*.java")
7
+ // future: language, framework detection
8
+ }
9
+
10
+ export interface CraftManifest {
11
+ name: string; // unique slug, dir name
12
+ displayName?: string; // tab label (default = name)
13
+ icon?: string; // emoji shown in tab
14
+ description?: string;
15
+ version?: string;
16
+ author?: string; // marketplace metadata
17
+ tags?: string[]; // for marketplace browsing/filtering
18
+ requires?: CraftRequirements; // project-type compatibility gate
19
+
20
+ ui?: {
21
+ tab?: string; // path to ui.tsx (default 'ui.tsx')
22
+ showWhen?: string; // expression: hasFile("X") | always (v2)
23
+ };
24
+
25
+ server?: {
26
+ entry?: string; // path to server.ts (default 'server.ts')
27
+ };
28
+
29
+ // Source of truth — set by loader, not authored.
30
+ __dir?: string; // absolute dir
31
+ __scope?: 'builtin' | 'project';
32
+ }
33
+
34
+ export interface CraftDescriptor extends CraftManifest {
35
+ __dir: string;
36
+ __scope: 'builtin' | 'project';
37
+ hasUi: boolean;
38
+ hasServer: boolean;
39
+ }
40
+
41
+ export interface CraftRouteHandlerCtx {
42
+ projectPath: string;
43
+ projectName?: string;
44
+ query: Record<string, string>;
45
+ params: Record<string, string>;
46
+ body?: any;
47
+ headers: Record<string, string>;
48
+ forge: ForgeServerApi;
49
+ }
50
+
51
+ export type CraftRouteHandler = (ctx: CraftRouteHandlerCtx) => Promise<any> | any;
52
+
53
+ export interface CraftServerDef {
54
+ routes: Record<string, CraftRouteHandler>; // key = "METHOD path", e.g. "GET /items"
55
+ onLoad?: (ctx: { projectPath: string; forge: ForgeServerApi }) => Promise<void> | void;
56
+ schedule?: string; // optional cron — v2
57
+ }
58
+
59
+ // Server-side helper bag passed into every handler. Keep small + stable.
60
+ export interface ForgeServerApi {
61
+ // Project context
62
+ project: {
63
+ path: string;
64
+ name?: string;
65
+ };
66
+
67
+ // Storage scoped to <project>/.forge/crafts/<name>/data/
68
+ storage: {
69
+ read<T = any>(file: string): T | null;
70
+ write(file: string, data: any): void;
71
+ listFiles(): string[];
72
+ };
73
+
74
+ // Run a shell command in the project cwd
75
+ exec(cmd: string, opts?: { timeout?: number; input?: string }): {
76
+ stdout: string;
77
+ stderr: string;
78
+ code: number;
79
+ };
80
+
81
+ // Spawn a Forge background task in this project
82
+ task(opts: { prompt: string; agent?: string }): { id: string };
83
+
84
+ // Inject text into the project's bound tmux session (auto-resolves)
85
+ inject(text: string, opts?: { sessionName?: string }): { ok: boolean; sessionName?: string };
86
+
87
+ // Lazy access to OpenAPI loader (when project has one configured)
88
+ openapi(specPath: string): any | null;
89
+
90
+ // Structured logging — visible in Forge logs
91
+ log: (...args: any[]) => void;
92
+ }