@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.
Files changed (72) hide show
  1. package/CLAUDE.md +0 -1
  2. package/RELEASE_NOTES.md +50 -4
  3. package/app/api/craft-system/build/route.ts +78 -0
  4. package/app/api/craft-system/delete/route.ts +28 -0
  5. package/app/api/craft-system/helpers/file/route.ts +20 -0
  6. package/app/api/craft-system/helpers/openapi/route.ts +27 -0
  7. package/app/api/craft-system/helpers/shell/route.ts +26 -0
  8. package/app/api/craft-system/inject/route.ts +41 -0
  9. package/app/api/craft-system/kill-session/route.ts +19 -0
  10. package/app/api/craft-system/manifest/route.ts +71 -0
  11. package/app/api/craft-system/marketplace/install/route.ts +11 -0
  12. package/app/api/craft-system/marketplace/route.ts +18 -0
  13. package/app/api/craft-system/marketplace/uninstall/route.ts +11 -0
  14. package/app/api/craft-system/marketplace/update/route.ts +10 -0
  15. package/app/api/craft-system/marketplace/updates/route.ts +17 -0
  16. package/app/api/craft-system/publish/auto/route.ts +173 -0
  17. package/app/api/craft-system/publish/route.ts +50 -0
  18. package/app/api/craft-system/registry/route.ts +16 -0
  19. package/app/api/craft-system/runtime/react/route.ts +26 -0
  20. package/app/api/craft-system/runtime/react-jsx/route.ts +11 -0
  21. package/app/api/craft-system/runtime/sdk/route.ts +18 -0
  22. package/app/api/craft-system/scaffold/route.ts +164 -0
  23. package/app/api/craft-system/sessions/route.ts +45 -0
  24. package/app/api/craft-system/storage/route.ts +44 -0
  25. package/app/api/craft-system/tmux-sessions/route.ts +62 -0
  26. package/app/api/craft-system/ui/route.ts +30 -0
  27. package/app/api/crafts/[name]/[...route]/route.ts +48 -0
  28. package/app/api/crafts/route.ts +29 -0
  29. package/app/api/tasks/[id]/log/entry/route.ts +13 -0
  30. package/app/api/tasks/[id]/log/route.ts +23 -0
  31. package/app/api/tasks/route.ts +2 -2
  32. package/components/CraftBuilder.tsx +241 -0
  33. package/components/CraftManifestEditor.tsx +258 -0
  34. package/components/CraftMarketplaceModal.tsx +207 -0
  35. package/components/CraftPublishModal.tsx +285 -0
  36. package/components/CraftTabs.tsx +279 -0
  37. package/components/CraftTerminal.tsx +305 -0
  38. package/components/CraftTerminalPicker.tsx +179 -0
  39. package/components/CraftsDropdown.tsx +186 -0
  40. package/components/CraftsMarketplacePanel.tsx +194 -0
  41. package/components/ProjectDetail.tsx +102 -13
  42. package/components/SkillsPanel.tsx +12 -4
  43. package/components/TaskDetail.tsx +250 -52
  44. package/lib/craft-sdk/client.tsx +260 -0
  45. package/lib/craft-sdk/server.ts +14 -0
  46. package/lib/crafts/loader.ts +117 -0
  47. package/lib/crafts/registry.ts +272 -0
  48. package/lib/crafts/runtime.ts +208 -0
  49. package/lib/crafts/types.ts +92 -0
  50. package/lib/forge-skills/craft-builder.md +231 -0
  51. package/lib/help-docs/15-crafts.md +127 -0
  52. package/lib/help-docs/CLAUDE.md +2 -2
  53. package/lib/task-manager.ts +110 -0
  54. package/lib/terminal-standalone.ts +1 -0
  55. package/next.config.ts +1 -1
  56. package/package.json +2 -1
  57. package/src/types/index.ts +7 -0
  58. package/tsconfig.json +6 -0
  59. package/app/api/migration/config/route.ts +0 -19
  60. package/app/api/migration/discover/route.ts +0 -26
  61. package/app/api/migration/failures/route.ts +0 -35
  62. package/app/api/migration/fix/route.ts +0 -82
  63. package/app/api/migration/run/route.ts +0 -22
  64. package/app/api/migration/run-batch/route.ts +0 -86
  65. package/components/MigrationCockpit.tsx +0 -541
  66. package/lib/help-docs/14-migration.md +0 -154
  67. package/lib/migration/differ.ts +0 -193
  68. package/lib/migration/discoverer.ts +0 -363
  69. package/lib/migration/openapi.ts +0 -137
  70. package/lib/migration/runner.ts +0 -219
  71. package/lib/migration/store.ts +0 -89
  72. 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.
@@ -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
- | `14-migration.md` | API Migration Cockpit parity testing legacy new module, doc-driven discovery, batch run, AI fix |
47
+ | `15-crafts.md` | Craftsproject-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
- - Migration/API parity/legacy vs new/501 stub/parity test/diff endpoints → `14-migration.md`
71
+ - Craft/custom tab/mini-app/extend project/AI-generated tab/builder → `15-crafts.md`