@aion0/forge 0.5.48 → 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.
- package/CLAUDE.md +0 -1
- package/RELEASE_NOTES.md +50 -4
- package/app/api/craft-system/build/route.ts +78 -0
- package/app/api/craft-system/delete/route.ts +28 -0
- package/app/api/craft-system/helpers/file/route.ts +20 -0
- package/app/api/craft-system/helpers/openapi/route.ts +27 -0
- package/app/api/craft-system/helpers/shell/route.ts +26 -0
- package/app/api/craft-system/inject/route.ts +41 -0
- package/app/api/craft-system/kill-session/route.ts +19 -0
- package/app/api/craft-system/manifest/route.ts +71 -0
- package/app/api/craft-system/marketplace/install/route.ts +11 -0
- package/app/api/craft-system/marketplace/route.ts +18 -0
- package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
- package/app/api/craft-system/marketplace/update/route.ts +10 -0
- package/app/api/craft-system/marketplace/updates/route.ts +17 -0
- package/app/api/craft-system/publish/auto/route.ts +173 -0
- package/app/api/craft-system/publish/route.ts +50 -0
- package/app/api/craft-system/registry/route.ts +16 -0
- package/app/api/craft-system/runtime/react/route.ts +26 -0
- package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
- package/app/api/craft-system/runtime/sdk/route.ts +18 -0
- package/app/api/craft-system/scaffold/route.ts +164 -0
- package/app/api/craft-system/sessions/route.ts +45 -0
- package/app/api/craft-system/storage/route.ts +44 -0
- package/app/api/craft-system/tmux-sessions/route.ts +62 -0
- package/app/api/craft-system/ui/route.ts +30 -0
- package/app/api/crafts/[name]/[...route]/route.ts +48 -0
- package/app/api/crafts/route.ts +29 -0
- package/app/api/tasks/[id]/log/entry/route.ts +13 -0
- package/app/api/tasks/[id]/log/route.ts +23 -0
- package/app/api/tasks/route.ts +2 -2
- package/components/CraftBuilder.tsx +241 -0
- package/components/CraftManifestEditor.tsx +258 -0
- package/components/CraftMarketplaceModal.tsx +207 -0
- package/components/CraftPublishModal.tsx +285 -0
- package/components/CraftTabs.tsx +279 -0
- package/components/CraftTerminal.tsx +305 -0
- package/components/CraftTerminalPicker.tsx +179 -0
- package/components/CraftsDropdown.tsx +186 -0
- package/components/CraftsMarketplacePanel.tsx +194 -0
- package/components/ProjectDetail.tsx +102 -13
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +250 -52
- package/lib/craft-sdk/client.tsx +260 -0
- package/lib/craft-sdk/server.ts +14 -0
- package/lib/crafts/loader.ts +117 -0
- package/lib/crafts/registry.ts +272 -0
- package/lib/crafts/runtime.ts +208 -0
- package/lib/crafts/types.ts +92 -0
- package/lib/forge-skills/craft-builder.md +231 -0
- package/lib/help-docs/15-crafts.md +127 -0
- package/lib/help-docs/CLAUDE.md +2 -2
- package/lib/task-manager.ts +110 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/src/types/index.ts +7 -0
- package/tsconfig.json +6 -0
- package/app/api/migration/config/route.ts +0 -19
- package/app/api/migration/discover/route.ts +0 -26
- package/app/api/migration/failures/route.ts +0 -35
- package/app/api/migration/fix/route.ts +0 -82
- package/app/api/migration/run/route.ts +0 -22
- package/app/api/migration/run-batch/route.ts +0 -86
- package/components/MigrationCockpit.tsx +0 -541
- package/lib/help-docs/14-migration.md +0 -154
- package/lib/migration/differ.ts +0 -193
- package/lib/migration/discoverer.ts +0 -363
- package/lib/migration/openapi.ts +0 -137
- package/lib/migration/runner.ts +0 -219
- package/lib/migration/store.ts +0 -89
- package/lib/migration/types.ts +0 -115
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: craft-builder
|
|
3
|
+
description: Build a Forge "Craft" — a project-scoped mini-app exposed as a tab in Forge. Use when the user asks Forge to "make a tab/dashboard/tool that does X" inside their project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Forge Craft Builder
|
|
7
|
+
|
|
8
|
+
A **Craft** is a project-scoped mini-app that appears as a tab in Forge. It can have:
|
|
9
|
+
|
|
10
|
+
- A React UI (`ui.tsx`) — renders inside Forge's project view
|
|
11
|
+
- An optional API server (`server.ts`) — handlers run on Forge's Node process
|
|
12
|
+
|
|
13
|
+
Crafts live at `<project>/.forge/crafts/<craft-name>/` and travel with the project (commit them to git so the team sees the same tabs).
|
|
14
|
+
|
|
15
|
+
## Your job
|
|
16
|
+
|
|
17
|
+
When invoked, you produce ALL of these files in `<project>/.forge/crafts/<name>/`:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
craft.yaml # manifest
|
|
21
|
+
ui.tsx # React component (default export)
|
|
22
|
+
server.ts # optional — only if user needs server-side work
|
|
23
|
+
prompt.md # the original user request + iteration history (you maintain this)
|
|
24
|
+
README.md # 1-paragraph "what it does"
|
|
25
|
+
data/ # auto-created when craft writes via useStore
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
After writing files, tell the user the new tab will appear in Forge after refresh (or hot-reload if dev mode).
|
|
29
|
+
|
|
30
|
+
## Naming
|
|
31
|
+
|
|
32
|
+
Pick a kebab-case `name` based on what the user asked for. Keep it short. Example: "API endpoint dashboard" → `api-dashboard`.
|
|
33
|
+
|
|
34
|
+
The `displayName` is the tab label — include an emoji prefix matching the function (📊 for dashboards, 🔍 for explorers, ⚡ for runners, 📝 for editors, 🧪 for testers).
|
|
35
|
+
|
|
36
|
+
## SDK — UI side (`ui.tsx`)
|
|
37
|
+
|
|
38
|
+
Import from `@forge/craft`. ONLY these hooks are available:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { useProject, useForgeFetch, useInject, useTask, useStore } from '@forge/craft';
|
|
42
|
+
|
|
43
|
+
// 1. Project context
|
|
44
|
+
const { projectPath, projectName } = useProject();
|
|
45
|
+
|
|
46
|
+
// 2. Fetch data — auto-appends ?projectPath=...; returns { data, loading, error, refetch }
|
|
47
|
+
const { data, loading, error, refetch } = useForgeFetch<MyType>('/api/crafts/<your-name>/items');
|
|
48
|
+
// or any Forge core API:
|
|
49
|
+
const git = useForgeFetch('/api/git/status');
|
|
50
|
+
|
|
51
|
+
// 3. Inject text into the project's bound tmux terminal (auto-resolves session)
|
|
52
|
+
const inject = useInject();
|
|
53
|
+
await inject('Run the test suite'); // sends text + Enter
|
|
54
|
+
|
|
55
|
+
// 4. Spawn a Forge background task in the project
|
|
56
|
+
const runTask = useTask();
|
|
57
|
+
const t = await runTask('Refactor the auth module per CLAUDE.md');
|
|
58
|
+
const stop = t.watch(entry => console.log(entry), final => console.log('done', final));
|
|
59
|
+
|
|
60
|
+
// 5. Persistent JSON storage in <project>/.forge/crafts/<name>/data/<file>.json
|
|
61
|
+
const [items, setItems, { loading, reload }] = useStore<Item[]>('items.json', []);
|
|
62
|
+
await setItems([...items!, newItem]); // writes to disk
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Component must `export default` a React component. Use Tailwind classes and Forge CSS variables (`var(--accent)`, `var(--bg-secondary)`, `var(--text-primary)`, `var(--text-secondary)`, `var(--border)`, `var(--bg-primary)`, `var(--bg-tertiary)`) so the tab matches Forge's theme.
|
|
66
|
+
|
|
67
|
+
The component is rendered inside `<div className="flex-1 flex flex-col min-h-0 overflow-hidden">` — the outermost element should be a fragment or `<div className="flex-1 ...">`.
|
|
68
|
+
|
|
69
|
+
**Do not** import React directly (it's auto-injected). Do not import any other npm package — only `@forge/craft`.
|
|
70
|
+
|
|
71
|
+
## SDK — Server side (`server.ts`, optional)
|
|
72
|
+
|
|
73
|
+
Skip this file entirely if the craft only needs to call existing Forge APIs.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { defineCraftServer } from '@forge/craft/server';
|
|
77
|
+
|
|
78
|
+
export default defineCraftServer({
|
|
79
|
+
routes: {
|
|
80
|
+
'GET /items': async ({ projectPath, query, forge }) => {
|
|
81
|
+
// Run shell in project cwd
|
|
82
|
+
const r = forge.exec('git log --oneline -20', { timeout: 10000 });
|
|
83
|
+
return { lines: r.stdout.split('\n').filter(Boolean) };
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
'POST /create': async ({ body, forge }) => {
|
|
87
|
+
forge.storage.write('records.json', body);
|
|
88
|
+
return { ok: true };
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
'GET /load-spec': async ({ forge }) => {
|
|
92
|
+
const spec = forge.openapi('docs/openapi.json');
|
|
93
|
+
return { paths: Object.keys(spec?.paths || {}) };
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
'POST /fix': async ({ body, forge }) => {
|
|
97
|
+
const t = forge.task({ prompt: body.prompt });
|
|
98
|
+
return { taskId: t.id };
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
'POST /run-cmd': async ({ body, forge }) => {
|
|
102
|
+
forge.inject(body.cmd); // paste into bound terminal
|
|
103
|
+
return { ok: true };
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`forge` injected helper API:
|
|
110
|
+
- `forge.project` — `{ path, name }`
|
|
111
|
+
- `forge.storage` — `read(file)`, `write(file, data)`, `listFiles()` (scoped to the craft's data dir)
|
|
112
|
+
- `forge.exec(cmd, opts?)` — synchronous shell exec in project cwd, returns `{ stdout, stderr, code }`
|
|
113
|
+
- `forge.task({ prompt, agent? })` — spawn Forge background task, returns `{ id }`
|
|
114
|
+
- `forge.inject(text, opts?)` — paste into bound tmux session
|
|
115
|
+
- `forge.openapi(specPath)` — load + parse OpenAPI JSON from project
|
|
116
|
+
- `forge.log(...)` — structured logging
|
|
117
|
+
|
|
118
|
+
Routes are auto-mounted at `/api/crafts/<craft-name>/<route>`. The UI calls them via `useForgeFetch`.
|
|
119
|
+
|
|
120
|
+
## Manifest (`craft.yaml`)
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
name: api-dashboard # kebab-case, dir name
|
|
124
|
+
displayName: 📊 API Dashboard # tab label (with emoji)
|
|
125
|
+
description: One-line summary of what it does
|
|
126
|
+
version: 0.1.0
|
|
127
|
+
icon: "📊" # optional, mainly cosmetic
|
|
128
|
+
author: aion0 # optional, shown in marketplace
|
|
129
|
+
tags: # optional — for marketplace browsing
|
|
130
|
+
- openapi
|
|
131
|
+
- dashboard
|
|
132
|
+
- migration
|
|
133
|
+
requires: # optional — project-type compatibility gate.
|
|
134
|
+
hasFile: # craft is hidden + can't install if NONE match.
|
|
135
|
+
- docs/openapi.json
|
|
136
|
+
- openapi.yaml
|
|
137
|
+
hasGlob:
|
|
138
|
+
- "**/*.java" # any of the matchers passing → compatible
|
|
139
|
+
ui:
|
|
140
|
+
tab: ui.tsx
|
|
141
|
+
showWhen: hasFile("docs/openapi.json") # optional extra UI condition
|
|
142
|
+
server:
|
|
143
|
+
entry: server.ts # omit this whole block if no server.ts
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Tags + requires guidance:**
|
|
147
|
+
|
|
148
|
+
- `tags` are free-form keywords used by the marketplace search. Common ones:
|
|
149
|
+
language (`java`, `typescript`, `python`), framework (`spring`, `react`),
|
|
150
|
+
use-case (`migration`, `testing`, `linting`, `debugging`).
|
|
151
|
+
- `requires.hasFile` lists files that MUST exist somewhere in the project for
|
|
152
|
+
the craft to make sense (any one match = compatible).
|
|
153
|
+
- `requires.hasGlob` is for broader matches like `**/*.java` (project has Java)
|
|
154
|
+
or `package.json` (Node project).
|
|
155
|
+
- Be specific — a craft tagged `java` + `requires.hasGlob: ["**/*.java"]` won't
|
|
156
|
+
show up in a TypeScript project's marketplace.
|
|
157
|
+
|
|
158
|
+
## prompt.md
|
|
159
|
+
|
|
160
|
+
Always write/update this file with:
|
|
161
|
+
- The original user request (verbatim)
|
|
162
|
+
- Each refine request and what you changed
|
|
163
|
+
- Used by future Refine runs as context
|
|
164
|
+
|
|
165
|
+
## Iteration
|
|
166
|
+
|
|
167
|
+
When called to refine an existing craft (the dir already exists), READ existing files first, KEEP what works, change only what the user asked. Append the refine request to `prompt.md`.
|
|
168
|
+
|
|
169
|
+
## Minimum viable example
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
# craft.yaml
|
|
173
|
+
name: hello
|
|
174
|
+
displayName: 👋 Hello
|
|
175
|
+
description: Demo craft — counts project files by extension
|
|
176
|
+
version: 0.1.0
|
|
177
|
+
ui:
|
|
178
|
+
tab: ui.tsx
|
|
179
|
+
server:
|
|
180
|
+
entry: server.ts
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// server.ts
|
|
185
|
+
import { defineCraftServer } from '@forge/craft/server';
|
|
186
|
+
|
|
187
|
+
export default defineCraftServer({
|
|
188
|
+
routes: {
|
|
189
|
+
'GET /count': async ({ forge }) => {
|
|
190
|
+
const r = forge.exec(`git ls-files | awk -F. 'NF>1{print $NF}' | sort | uniq -c | sort -rn | head -20`);
|
|
191
|
+
return { lines: r.stdout.split('\n').filter(Boolean) };
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
// ui.tsx
|
|
199
|
+
import { useProject, useForgeFetch } from '@forge/craft';
|
|
200
|
+
|
|
201
|
+
export default function Tab() {
|
|
202
|
+
const { projectName } = useProject();
|
|
203
|
+
const { data, loading } = useForgeFetch<{ lines: string[] }>('/api/crafts/hello/count');
|
|
204
|
+
return (
|
|
205
|
+
<div className="flex-1 p-4 text-xs overflow-auto">
|
|
206
|
+
<h2 className="font-semibold mb-2">{projectName} — file counts</h2>
|
|
207
|
+
{loading && <div className="text-[var(--text-secondary)]">Loading…</div>}
|
|
208
|
+
{data?.lines.map((l, i) => <div key={i} className="font-mono">{l}</div>)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Style guide
|
|
215
|
+
|
|
216
|
+
- Tailwind classes only. Use Forge's color variables, not hardcoded colors.
|
|
217
|
+
- Text sizes: `text-xs` (default), `text-[11px]` for dense tables, `text-[10px]` for metadata.
|
|
218
|
+
- Buttons: `text-[10px] px-2 py-1 rounded bg-[var(--accent)]/20 text-[var(--accent)] hover:bg-[var(--accent)]/30`.
|
|
219
|
+
- Sections inside the tab: `flex-1 flex flex-col min-h-0 overflow-auto p-4 gap-3`.
|
|
220
|
+
- For tables/lists, prefer simple `<table>` or `<div>` grids — no extra deps.
|
|
221
|
+
- Match Forge's compact density (rows ~24-28px tall).
|
|
222
|
+
|
|
223
|
+
## Final report
|
|
224
|
+
|
|
225
|
+
After writing files, report:
|
|
226
|
+
1. What craft you created (name + displayName)
|
|
227
|
+
2. The route(s) registered (if any server)
|
|
228
|
+
3. The data files used (if any)
|
|
229
|
+
4. Any assumptions you made
|
|
230
|
+
|
|
231
|
+
End with `[FORGE_DONE]`.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Crafts — project-scoped mini-apps
|
|
2
|
+
|
|
3
|
+
A **Craft** is a tab inside Forge that lives at `<project>/.forge/crafts/<name>/`. It can be hand-written, AI-generated, or shipped as a Forge builtin (open-source samples). Crafts travel with the project — commit them to git so the team sees the same tabs.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
In any project, click **+ Craft** next to the project tabs. Type what you want (e.g. "show all our REST endpoints with migration status, allow batch run + AI fix"). Forge spawns a background task that uses the `craft-builder` skill to generate the files. After ~30-60s the new tab appears.
|
|
8
|
+
|
|
9
|
+
For an existing craft, switch to its tab and click the small **⚙** badge to refine it ("add a sort button" / "this column should be wider").
|
|
10
|
+
|
|
11
|
+
## Anatomy
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
<project>/.forge/crafts/<name>/
|
|
15
|
+
├── craft.yaml # manifest (name, displayName, icon, conditions)
|
|
16
|
+
├── ui.tsx # React component (default export)
|
|
17
|
+
├── server.ts # optional API routes
|
|
18
|
+
├── prompt.md # original user request + iteration history
|
|
19
|
+
├── README.md # what this craft does
|
|
20
|
+
└── data/ # craft's persistent JSON storage
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## SDK
|
|
24
|
+
|
|
25
|
+
Imports from `@forge/craft` (UI side):
|
|
26
|
+
|
|
27
|
+
| Hook | What it does |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `useProject()` | `{ projectPath, projectName }` |
|
|
30
|
+
| `useForgeFetch(path)` | Fetch wrapper, auto-injects `?projectPath=...`, returns `{ data, loading, error, refetch }` |
|
|
31
|
+
| `useInject()` | `(text) => Promise` — paste prompt + Enter into the project's bound tmux session |
|
|
32
|
+
| `useTask()` | `(prompt) => TaskHandle` — spawn Forge background task, watch its log stream |
|
|
33
|
+
| `useStore(file, default)` | `[value, save, { loading, reload }]` — JSON storage in `data/<file>.json` |
|
|
34
|
+
| `useOpenAPI(path)` | Load + parse OpenAPI 3 spec from project |
|
|
35
|
+
| `useFile(path, { watch? })` | Read project file with optional polling |
|
|
36
|
+
| `useShell()` | `(cmd) => Promise<{ stdout, stderr, code }>` — exec in project cwd |
|
|
37
|
+
| `useGit()` | Git status / log info |
|
|
38
|
+
| `useToast()` | `(msg, kind)` — quick top notification |
|
|
39
|
+
|
|
40
|
+
Server side (`server.ts`):
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { defineCraftServer } from '@forge/craft/server';
|
|
44
|
+
|
|
45
|
+
export default defineCraftServer({
|
|
46
|
+
routes: {
|
|
47
|
+
'GET /items': async ({ forge, query, params }) => {
|
|
48
|
+
const r = forge.exec('git log --oneline -20');
|
|
49
|
+
return { lines: r.stdout.split('\n') };
|
|
50
|
+
},
|
|
51
|
+
'POST /run': async ({ body, forge }) => {
|
|
52
|
+
const t = forge.task({ prompt: body.prompt });
|
|
53
|
+
return { taskId: t.id };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`forge` injected helpers:
|
|
60
|
+
- `forge.project` — `{ path, name }`
|
|
61
|
+
- `forge.storage` — `read(file)`, `write(file, data)`, `listFiles()` (scoped to craft data dir)
|
|
62
|
+
- `forge.exec(cmd, opts?)` — sync shell exec in project cwd
|
|
63
|
+
- `forge.task({ prompt })` — spawn background task
|
|
64
|
+
- `forge.inject(text)` — paste into bound tmux session
|
|
65
|
+
- `forge.openapi(specPath)` — load + parse OpenAPI JSON
|
|
66
|
+
- `forge.log(...)` — structured logging
|
|
67
|
+
|
|
68
|
+
Routes are mounted at `/api/crafts/<craft-name>/<route>`. The UI calls them via `useForgeFetch`.
|
|
69
|
+
|
|
70
|
+
## Manifest
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
name: api-dashboard # kebab-case, dir name
|
|
74
|
+
displayName: 📊 API Dashboard # tab label
|
|
75
|
+
description: One-line summary
|
|
76
|
+
version: 0.1.0
|
|
77
|
+
icon: "📊"
|
|
78
|
+
ui:
|
|
79
|
+
tab: ui.tsx # default
|
|
80
|
+
showWhen: hasFile("docs/openapi.json") # optional condition
|
|
81
|
+
server:
|
|
82
|
+
entry: server.ts # default; omit if no server
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`showWhen` supports `hasFile("path")` (only show tab when file exists) or `always`.
|
|
86
|
+
|
|
87
|
+
## Builtins
|
|
88
|
+
|
|
89
|
+
`lib/builtin-crafts/<name>/` is the slot for crafts that ship with Forge by default. Currently empty — every craft is project-local at `<project>/.forge/crafts/<name>/`. Builtins (when present) appear automatically in every project; project-local crafts override builtins by name.
|
|
90
|
+
|
|
91
|
+
## Marketplace
|
|
92
|
+
|
|
93
|
+
Crafts can be published to a shared registry (default: `aiwatching/forge-crafts` on GitHub). The marketplace browser is reachable from the **Crafts ▾** dropdown in any project tab — pick **🛒 Marketplace** to see installable crafts filtered by your project's compatibility.
|
|
94
|
+
|
|
95
|
+
### Browse + install
|
|
96
|
+
- **Compatible / All / Installed** filter
|
|
97
|
+
- Shows version, author, tags, and a per-item Install / Update / Uninstall button
|
|
98
|
+
- Install copies the registry's files into `<project>/.forge/crafts/<name>/`; the new tab appears immediately
|
|
99
|
+
|
|
100
|
+
### Project-type filtering (`requires`)
|
|
101
|
+
|
|
102
|
+
Add a `requires` block to `craft.yaml` so the marketplace only suggests the craft to compatible projects:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
requires:
|
|
106
|
+
hasFile: # any of these files must exist
|
|
107
|
+
- docs/openapi.json
|
|
108
|
+
hasGlob:
|
|
109
|
+
- "**/*.java" # any of these globs must match
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Either matcher passing is enough (OR logic). With an empty/missing `requires`, the craft is compatible with every project.
|
|
113
|
+
|
|
114
|
+
### Publish
|
|
115
|
+
|
|
116
|
+
When a project-local craft is the active tab, the **📦** button next to ⚙ opens the publish modal. It shows:
|
|
117
|
+
1. **How to publish** — step-by-step (open a PR on the registry repo).
|
|
118
|
+
2. **registry.json entry** — JSON snippet to append under `crafts: [...]`.
|
|
119
|
+
3. **Files** — copy each file's contents (`craft.yaml`, `ui.tsx`, `server.ts`, `README.md`) to drop into the registry repo's `<name>/` folder.
|
|
120
|
+
|
|
121
|
+
Forge does NOT auto-push to GitHub. Submit the PR; once merged, all Forge users see it in their marketplace.
|
|
122
|
+
|
|
123
|
+
The repo URL is configurable via `craftsRepoUrl` in `~/.forge/data/settings.yaml` so teams can run their own private registry.
|
|
124
|
+
|
|
125
|
+
## Architectural model
|
|
126
|
+
|
|
127
|
+
Forge is the **orchestrator**: discovers crafts, mounts UI tabs + API routes, provides the SDK. The craft is **your project's content** — stored in `<project>/.forge/`, not in Forge core. Generic features (Migration Cockpit will eventually move here) end up as crafts that live in your repo, not in Forge.
|
package/lib/help-docs/CLAUDE.md
CHANGED
|
@@ -44,7 +44,7 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
|
|
|
44
44
|
| `11-workspace.md` | Workspace (Forge Smiths) — multi-agent orchestration, daemon, message bus, profiles |
|
|
45
45
|
| `12-usage.md` | Token usage analytics — charts, heatmap, cost estimation, by model/project/source |
|
|
46
46
|
| `13-ide-plugins.md` | VSCode extension + IntelliJ plugin — install, tabs, multi-connection, agent terminal launching |
|
|
47
|
-
| `
|
|
47
|
+
| `15-crafts.md` | Crafts — project-scoped mini-app tabs with SDK; AI-generated via "+ Craft" button |
|
|
48
48
|
|
|
49
49
|
## Matching questions to docs
|
|
50
50
|
|
|
@@ -68,4 +68,4 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
|
|
|
68
68
|
- Sidebar collapse/project tabs/favorites → `07-projects.md`
|
|
69
69
|
- VSCode/IntelliJ/IDE plugin/extension/marketplace → `13-ide-plugins.md`
|
|
70
70
|
- vsce/vsix/JetBrains marketplace publish → `13-ide-plugins.md`
|
|
71
|
-
-
|
|
71
|
+
- Craft/custom tab/mini-app/extend project/AI-generated tab/builder → `15-crafts.md`
|