@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.
- package/RELEASE_NOTES.md +48 -7
- 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/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 +105 -1
- package/components/SkillsPanel.tsx +12 -4
- package/components/TaskDetail.tsx +49 -1
- 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 -0
- package/lib/terminal-standalone.ts +1 -0
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
const TEMPLATES = [
|
|
6
|
+
{ label: '๐ Dashboard', text: 'A dashboard view that shows ' },
|
|
7
|
+
{ label: '๐ Explorer', text: 'An explorer for browsing ' },
|
|
8
|
+
{ label: 'โก Runner', text: 'A panel that runs ' },
|
|
9
|
+
{ label: '๐ Editor', text: 'An editor for ' },
|
|
10
|
+
{ label: '๐งช Tester', text: 'A tester that validates ' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
interface AgentSummary {
|
|
14
|
+
id: string;
|
|
15
|
+
displayName?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
detected?: boolean;
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function slugify(text: string): string {
|
|
22
|
+
// Pull first 4-6 meaningful words โ kebab-case
|
|
23
|
+
const cleaned = text.toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
24
|
+
const words = cleaned.split(' ').filter(w => w.length > 2 && !['the', 'and', 'for', 'with', 'that', 'this', 'show', 'shows', 'lets', 'list'].includes(w));
|
|
25
|
+
return words.slice(0, 4).join('-').slice(0, 30) || `craft-${Date.now().toString(36).slice(-4)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function CraftBuilderModal({ projectPath, projectName, refineCraftName, onClose, onCreated }: {
|
|
29
|
+
projectPath: string;
|
|
30
|
+
projectName: string;
|
|
31
|
+
refineCraftName?: string;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
onCreated: () => void;
|
|
34
|
+
}) {
|
|
35
|
+
const refining = !!refineCraftName;
|
|
36
|
+
const [name, setName] = useState(refining ? refineCraftName! : '');
|
|
37
|
+
const [nameTouched, setNameTouched] = useState(false);
|
|
38
|
+
const [displayName, setDisplayName] = useState('');
|
|
39
|
+
const [text, setText] = useState('');
|
|
40
|
+
const [mode, setMode] = useState<'terminal' | 'task'>('terminal');
|
|
41
|
+
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
|
42
|
+
const [agentId, setAgentId] = useState<string>('');
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const [err, setErr] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
// Auto-fill name from description when user hasn't manually edited it
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!nameTouched && !refining && text.trim()) {
|
|
49
|
+
setName(slugify(text));
|
|
50
|
+
}
|
|
51
|
+
}, [text, nameTouched, refining]);
|
|
52
|
+
|
|
53
|
+
// Load available agents (Forge returns { agents, defaultAgent })
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
fetch('/api/agents')
|
|
56
|
+
.then(r => r.ok ? r.json() : { agents: [], defaultAgent: null })
|
|
57
|
+
.then((res: { agents?: AgentSummary[]; defaultAgent?: string | null }) => {
|
|
58
|
+
const list = res.agents || [];
|
|
59
|
+
// Show all enabled agents โ API/custom agents may not set `detected`, so don't filter on it.
|
|
60
|
+
const enabled = list.filter((a: any) => a.enabled !== false);
|
|
61
|
+
setAgents(enabled);
|
|
62
|
+
if (enabled.length > 0 && !agentId) {
|
|
63
|
+
setAgentId(res.defaultAgent && enabled.find(a => a.id === res.defaultAgent) ? res.defaultAgent : enabled[0].id);
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {});
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const submit = async () => {
|
|
70
|
+
setErr(null);
|
|
71
|
+
if (!text.trim()) { setErr('Description is required'); return; }
|
|
72
|
+
if (!refining && !name.trim()) { setErr('Name is required'); return; }
|
|
73
|
+
setBusy(true);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (mode === 'terminal' && !refining) {
|
|
77
|
+
const res = await fetch('/api/craft-system/scaffold', {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
projectPath, projectName,
|
|
82
|
+
name, displayName: displayName || undefined,
|
|
83
|
+
description: text,
|
|
84
|
+
agentId,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
const j = await res.json();
|
|
88
|
+
if (!j.ok) throw new Error(j.error || 'scaffold failed');
|
|
89
|
+
onCreated();
|
|
90
|
+
onClose();
|
|
91
|
+
// Note: user opens the session via the Sessions tab or reattaches in any terminal.
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Task mode (or refine โ refines always go through builder task)
|
|
96
|
+
const res = await fetch('/api/craft-system/build', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
projectPath, projectName,
|
|
101
|
+
request: text,
|
|
102
|
+
craftName: refining ? refineCraftName : (name || undefined),
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
const j = await res.json();
|
|
106
|
+
if (!j.ok) throw new Error(j.error || 'build failed');
|
|
107
|
+
onCreated();
|
|
108
|
+
onClose();
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
setErr(e?.message || String(e));
|
|
111
|
+
setBusy(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
117
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-2xl w-[640px] max-w-[95vw] max-h-[90vh] flex flex-col"
|
|
118
|
+
onClick={e => e.stopPropagation()}>
|
|
119
|
+
<div className="px-4 py-2.5 border-b border-[var(--border)] flex items-center gap-2">
|
|
120
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
|
121
|
+
{refining ? `โ Refine craft: ${refineCraftName}` : '+ New Craft'}
|
|
122
|
+
</span>
|
|
123
|
+
<div className="flex-1" />
|
|
124
|
+
<button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">โ</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="p-4 space-y-3 overflow-auto">
|
|
128
|
+
{!refining && (
|
|
129
|
+
<>
|
|
130
|
+
{/* Name + display name */}
|
|
131
|
+
<div className="grid grid-cols-2 gap-3">
|
|
132
|
+
<Field label="Name (kebab-case ยท dir name)" hint="Auto-derived from description; click to override.">
|
|
133
|
+
<input value={name}
|
|
134
|
+
onChange={e => { setName(e.target.value); setNameTouched(true); }}
|
|
135
|
+
placeholder="e.g. api-dashboard"
|
|
136
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 font-mono" />
|
|
137
|
+
</Field>
|
|
138
|
+
<Field label="Display name (tab label)" hint="Optional โ defaults to ๐ + name.">
|
|
139
|
+
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
|
|
140
|
+
placeholder="e.g. ๐ API Dashboard"
|
|
141
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1" />
|
|
142
|
+
</Field>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* Quick templates */}
|
|
146
|
+
<div className="flex flex-wrap gap-1">
|
|
147
|
+
<span className="text-[10px] text-[var(--text-secondary)] mr-1">Quick start:</span>
|
|
148
|
+
{TEMPLATES.map(t => (
|
|
149
|
+
<button key={t.label} onClick={() => setText(t.text)}
|
|
150
|
+
className="text-[10px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)]">
|
|
151
|
+
{t.label}
|
|
152
|
+
</button>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
</>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Description */}
|
|
159
|
+
<Field label={refining ? 'What should change?' : 'What should this craft do?'}>
|
|
160
|
+
<textarea
|
|
161
|
+
value={text}
|
|
162
|
+
onChange={e => setText(e.target.value)}
|
|
163
|
+
autoFocus
|
|
164
|
+
disabled={busy}
|
|
165
|
+
placeholder={refining
|
|
166
|
+
? 'e.g. add a column for last-modified date'
|
|
167
|
+
: 'e.g. dashboard of all our REST endpoints with migration status, allow batch run + AI fix on failures'}
|
|
168
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 font-mono min-h-[100px] resize-vertical"
|
|
169
|
+
/>
|
|
170
|
+
</Field>
|
|
171
|
+
|
|
172
|
+
{/* Mode + agent picker (only for new crafts) */}
|
|
173
|
+
{!refining && (
|
|
174
|
+
<div className="grid grid-cols-2 gap-3">
|
|
175
|
+
<Field label="Run mode">
|
|
176
|
+
<div className="flex gap-1 text-[10px]">
|
|
177
|
+
<button onClick={() => setMode('terminal')}
|
|
178
|
+
className={`flex-1 px-2 py-1.5 rounded border ${mode === 'terminal' ? 'bg-[var(--accent)]/20 text-[var(--accent)] border-[var(--accent)]/40' : 'border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'}`}>
|
|
179
|
+
๐ฅ Terminal session<br /><span className="opacity-70 text-[9px]">interactive โ debug as it builds</span>
|
|
180
|
+
</button>
|
|
181
|
+
<button onClick={() => setMode('task')}
|
|
182
|
+
className={`flex-1 px-2 py-1.5 rounded border ${mode === 'task' ? 'bg-[var(--accent)]/20 text-[var(--accent)] border-[var(--accent)]/40' : 'border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'}`}>
|
|
183
|
+
๐ Background task<br /><span className="opacity-70 text-[9px]">fire-and-forget โ open in Tasks tab</span>
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</Field>
|
|
187
|
+
<Field label="Agent" hint="The CLI that builds the craft.">
|
|
188
|
+
<select value={agentId} onChange={e => setAgentId(e.target.value)}
|
|
189
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1">
|
|
190
|
+
{agents.length === 0 && <option value="">no agents detected</option>}
|
|
191
|
+
{agents.map(a => (
|
|
192
|
+
<option key={a.id} value={a.id}>{a.displayName || a.name || a.id}</option>
|
|
193
|
+
))}
|
|
194
|
+
</select>
|
|
195
|
+
</Field>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{err && (
|
|
200
|
+
<div className="text-[11px] text-red-300 bg-red-500/10 border border-red-500/30 rounded px-2 py-1.5">
|
|
201
|
+
{err}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
<div className="text-[10px] text-[var(--text-secondary)] opacity-70 leading-relaxed">
|
|
206
|
+
{refining ? (
|
|
207
|
+
<>The agent will read this craft's existing files and the refine prompt as a background task. Watch progress in the Tasks tab.</>
|
|
208
|
+
) : mode === 'terminal' ? (
|
|
209
|
+
<>Forge will create <code className="text-[var(--accent)]">.forge/crafts/{name || '<name>'}/</code>, scaffold the manifest + a placeholder UI, then start <b>{agents.find(a => a.id === agentId)?.displayName || agentId || 'the agent'}</b> in a tmux session at that directory and inject the builder prompt. The new tab appears immediately; it hot-reloads as the agent writes files.</>
|
|
210
|
+
) : (
|
|
211
|
+
<>Forge spawns a background task in this project. Modal closes immediately โ open the Tasks tab to follow progress.</>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="px-4 py-2 border-t border-[var(--border)] flex justify-end gap-2">
|
|
217
|
+
<button onClick={onClose}
|
|
218
|
+
className="text-xs px-3 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">
|
|
219
|
+
Cancel
|
|
220
|
+
</button>
|
|
221
|
+
<button onClick={submit} disabled={busy || !text.trim() || (!refining && !name.trim())}
|
|
222
|
+
className="text-xs px-3 py-1 rounded bg-[var(--accent)]/30 text-[var(--accent)] hover:bg-[var(--accent)]/40 disabled:opacity-40">
|
|
223
|
+
{busy ? 'โณ โฆ' : (refining ? 'Apply changes' : (mode === 'terminal' ? '๐ฅ Start session' : '๐ Spawn task'))}
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
232
|
+
return (
|
|
233
|
+
<label className="flex flex-col gap-1">
|
|
234
|
+
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-2">
|
|
235
|
+
{label}
|
|
236
|
+
{hint && <span className="opacity-60 font-normal">{hint}</span>}
|
|
237
|
+
</span>
|
|
238
|
+
{children}
|
|
239
|
+
</label>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Inline editor for craft.yaml. Two views:
|
|
4
|
+
// 1. Form mode โ quick fields for the common edits (version + bumps,
|
|
5
|
+
// displayName, description, tags, requires).
|
|
6
|
+
// 2. Raw mode โ full YAML textarea for anything the form doesn't surface.
|
|
7
|
+
// Saves to disk via PUT /api/craft-system/manifest. Persisted manifest is
|
|
8
|
+
// what the publish flow + marketplace install both read, so editing here is
|
|
9
|
+
// authoritative.
|
|
10
|
+
|
|
11
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
12
|
+
|
|
13
|
+
interface Manifest {
|
|
14
|
+
name?: string;
|
|
15
|
+
displayName?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
author?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
requires?: { hasFile?: string[]; hasGlob?: string[] };
|
|
21
|
+
ui?: { tab?: string; showWhen?: string };
|
|
22
|
+
server?: { entry?: string };
|
|
23
|
+
[k: string]: any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bumpVersion(v: string, kind: 'patch' | 'minor' | 'major'): string {
|
|
27
|
+
const parts = (v || '0.0.0').split('.').map(Number);
|
|
28
|
+
while (parts.length < 3) parts.push(0);
|
|
29
|
+
if (kind === 'major') { parts[0]++; parts[1] = 0; parts[2] = 0; }
|
|
30
|
+
else if (kind === 'minor') { parts[1]++; parts[2] = 0; }
|
|
31
|
+
else { parts[2]++; }
|
|
32
|
+
return parts.join('.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function CraftManifestEditor({ projectPath, craftName, onClose, onSaved }: {
|
|
36
|
+
projectPath: string;
|
|
37
|
+
craftName: string;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
onSaved?: (manifest: Manifest) => void;
|
|
40
|
+
}) {
|
|
41
|
+
const [raw, setRaw] = useState<string>('');
|
|
42
|
+
const [originalRaw, setOriginalRaw] = useState<string>('');
|
|
43
|
+
const [parsed, setParsed] = useState<Manifest | null>(null);
|
|
44
|
+
const [tab, setTab] = useState<'form' | 'raw'>('form');
|
|
45
|
+
const [error, setError] = useState<string | null>(null);
|
|
46
|
+
const [busy, setBusy] = useState(false);
|
|
47
|
+
|
|
48
|
+
// Form-state (mirrors parsed; saving rebuilds the YAML via patch endpoint)
|
|
49
|
+
const [version, setVersion] = useState('');
|
|
50
|
+
const [displayName, setDisplayName] = useState('');
|
|
51
|
+
const [description, setDescription] = useState('');
|
|
52
|
+
const [tagsText, setTagsText] = useState('');
|
|
53
|
+
const [author, setAuthor] = useState('');
|
|
54
|
+
const [showWhen, setShowWhen] = useState('');
|
|
55
|
+
const [hasFileText, setHasFileText] = useState('');
|
|
56
|
+
const [hasGlobText, setHasGlobText] = useState('');
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
fetch(`/api/craft-system/manifest?projectPath=${encodeURIComponent(projectPath)}&name=${encodeURIComponent(craftName)}`)
|
|
60
|
+
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
|
|
61
|
+
.then(j => {
|
|
62
|
+
setRaw(j.raw || '');
|
|
63
|
+
setOriginalRaw(j.raw || '');
|
|
64
|
+
const p = j.parsed || {};
|
|
65
|
+
setParsed(p);
|
|
66
|
+
setVersion(p.version || '0.1.0');
|
|
67
|
+
setDisplayName(p.displayName || '');
|
|
68
|
+
setDescription(p.description || '');
|
|
69
|
+
setTagsText((p.tags || []).join(', '));
|
|
70
|
+
setAuthor(p.author || '');
|
|
71
|
+
setShowWhen(p.ui?.showWhen || '');
|
|
72
|
+
setHasFileText((p.requires?.hasFile || []).join('\n'));
|
|
73
|
+
setHasGlobText((p.requires?.hasGlob || []).join('\n'));
|
|
74
|
+
if (j.parseError) setError(`YAML parse error: ${j.parseError}`);
|
|
75
|
+
})
|
|
76
|
+
.catch(e => setError(e?.message || String(e)));
|
|
77
|
+
}, [projectPath, craftName]);
|
|
78
|
+
|
|
79
|
+
const dirty = useMemo(() => {
|
|
80
|
+
if (tab === 'raw') return raw !== originalRaw;
|
|
81
|
+
if (!parsed) return false;
|
|
82
|
+
return (
|
|
83
|
+
version !== (parsed.version || '0.1.0') ||
|
|
84
|
+
displayName !== (parsed.displayName || '') ||
|
|
85
|
+
description !== (parsed.description || '') ||
|
|
86
|
+
tagsText !== (parsed.tags || []).join(', ') ||
|
|
87
|
+
author !== (parsed.author || '') ||
|
|
88
|
+
showWhen !== (parsed.ui?.showWhen || '') ||
|
|
89
|
+
hasFileText !== (parsed.requires?.hasFile || []).join('\n') ||
|
|
90
|
+
hasGlobText !== (parsed.requires?.hasGlob || []).join('\n')
|
|
91
|
+
);
|
|
92
|
+
}, [tab, raw, originalRaw, parsed, version, displayName, description, tagsText, author, showWhen, hasFileText, hasGlobText]);
|
|
93
|
+
|
|
94
|
+
const save = async () => {
|
|
95
|
+
setBusy(true); setError(null);
|
|
96
|
+
try {
|
|
97
|
+
let body: any;
|
|
98
|
+
if (tab === 'raw') {
|
|
99
|
+
body = { projectPath, name: craftName, raw };
|
|
100
|
+
} else {
|
|
101
|
+
const tags = tagsText.split(',').map(s => s.trim()).filter(Boolean);
|
|
102
|
+
const hasFile = hasFileText.split('\n').map(s => s.trim()).filter(Boolean);
|
|
103
|
+
const hasGlob = hasGlobText.split('\n').map(s => s.trim()).filter(Boolean);
|
|
104
|
+
const requires = (hasFile.length || hasGlob.length) ? { hasFile, hasGlob } : undefined;
|
|
105
|
+
const ui = parsed?.ui ? { ...parsed.ui, ...(showWhen ? { showWhen } : {}) } : (showWhen ? { tab: 'ui.tsx', showWhen } : { tab: 'ui.tsx' });
|
|
106
|
+
const patch: any = {
|
|
107
|
+
version, displayName, description, tags, author,
|
|
108
|
+
...(requires ? { requires } : {}),
|
|
109
|
+
ui,
|
|
110
|
+
};
|
|
111
|
+
// Strip empty strings so they don't pollute the yaml
|
|
112
|
+
for (const k of Object.keys(patch)) {
|
|
113
|
+
if (patch[k] === '' || patch[k] === undefined) delete patch[k];
|
|
114
|
+
}
|
|
115
|
+
body = { projectPath, name: craftName, patch };
|
|
116
|
+
}
|
|
117
|
+
const r = await fetch('/api/craft-system/manifest', {
|
|
118
|
+
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
});
|
|
121
|
+
const j = await r.json();
|
|
122
|
+
if (!r.ok || j.error) throw new Error(j.error || `${r.status}`);
|
|
123
|
+
setRaw(j.raw);
|
|
124
|
+
setOriginalRaw(j.raw);
|
|
125
|
+
// Re-parse for the form view
|
|
126
|
+
try {
|
|
127
|
+
const YAML = await import('yaml');
|
|
128
|
+
const p = YAML.parse(j.raw);
|
|
129
|
+
setParsed(p);
|
|
130
|
+
if (onSaved) onSaved(p);
|
|
131
|
+
} catch {}
|
|
132
|
+
} catch (e: any) {
|
|
133
|
+
setError(e?.message || String(e));
|
|
134
|
+
} finally {
|
|
135
|
+
setBusy(false);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
|
141
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-2xl w-[900px] max-w-[95vw] max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
142
|
+
<div className="px-4 py-2.5 border-b border-[var(--border)] flex items-center gap-2">
|
|
143
|
+
<span className="text-sm font-semibold text-[var(--text-primary)]">๐ Edit manifest: {craftName}</span>
|
|
144
|
+
{dirty && <span className="text-[9px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-300">unsaved</span>}
|
|
145
|
+
<div className="flex-1" />
|
|
146
|
+
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
147
|
+
{(['form', 'raw'] as const).map(t => (
|
|
148
|
+
<button key={t} onClick={() => setTab(t)}
|
|
149
|
+
className={`text-[10px] px-2 py-0.5 rounded ${tab === t ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}>
|
|
150
|
+
{t === 'form' ? 'Form' : 'YAML'}
|
|
151
|
+
</button>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
<button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">โ</button>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{error && <div className="m-3 p-2 text-[11px] text-red-300 bg-red-500/10 border border-red-500/30 rounded">{error}</div>}
|
|
158
|
+
|
|
159
|
+
{tab === 'form' && parsed && (
|
|
160
|
+
<div className="p-4 space-y-3 overflow-auto flex-1">
|
|
161
|
+
<Field label="Display name (tab label)">
|
|
162
|
+
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
|
|
163
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1" />
|
|
164
|
+
</Field>
|
|
165
|
+
|
|
166
|
+
<Field label="Version">
|
|
167
|
+
<div className="flex gap-1 items-center">
|
|
168
|
+
<input value={version} onChange={e => setVersion(e.target.value)}
|
|
169
|
+
className="flex-1 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 font-mono" />
|
|
170
|
+
<button onClick={() => setVersion(bumpVersion(version, 'patch'))}
|
|
171
|
+
className="text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)]"
|
|
172
|
+
title="x.y.Z+1">patch</button>
|
|
173
|
+
<button onClick={() => setVersion(bumpVersion(version, 'minor'))}
|
|
174
|
+
className="text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)]"
|
|
175
|
+
title="x.Y+1.0">minor</button>
|
|
176
|
+
<button onClick={() => setVersion(bumpVersion(version, 'major'))}
|
|
177
|
+
className="text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)]"
|
|
178
|
+
title="X+1.0.0">major</button>
|
|
179
|
+
</div>
|
|
180
|
+
</Field>
|
|
181
|
+
|
|
182
|
+
<Field label="Description (one line)">
|
|
183
|
+
<input value={description} onChange={e => setDescription(e.target.value)}
|
|
184
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1" />
|
|
185
|
+
</Field>
|
|
186
|
+
|
|
187
|
+
<div className="grid grid-cols-2 gap-3">
|
|
188
|
+
<Field label="Tags (comma-separated)">
|
|
189
|
+
<input value={tagsText} onChange={e => setTagsText(e.target.value)}
|
|
190
|
+
placeholder="openapi, java, dashboard"
|
|
191
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 font-mono" />
|
|
192
|
+
</Field>
|
|
193
|
+
<Field label="Author (github handle)">
|
|
194
|
+
<input value={author} onChange={e => setAuthor(e.target.value)}
|
|
195
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 font-mono" />
|
|
196
|
+
</Field>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<Field label="Show tab when (optional)" hint="hasFile("path") or `always`. Empty = always show.">
|
|
200
|
+
<input value={showWhen} onChange={e => setShowWhen(e.target.value)}
|
|
201
|
+
placeholder='hasFile("docs/openapi.json")'
|
|
202
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 font-mono" />
|
|
203
|
+
</Field>
|
|
204
|
+
|
|
205
|
+
<div className="grid grid-cols-2 gap-3">
|
|
206
|
+
<Field label="Requires hasFile (one per line)" hint="OR โ any one match = compatible">
|
|
207
|
+
<textarea value={hasFileText} onChange={e => setHasFileText(e.target.value)}
|
|
208
|
+
placeholder="docs/openapi.json"
|
|
209
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 font-mono min-h-[60px]" />
|
|
210
|
+
</Field>
|
|
211
|
+
<Field label="Requires hasGlob (one per line)">
|
|
212
|
+
<textarea value={hasGlobText} onChange={e => setHasGlobText(e.target.value)}
|
|
213
|
+
placeholder="**/*.java"
|
|
214
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 font-mono min-h-[60px]" />
|
|
215
|
+
</Field>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{tab === 'raw' && (
|
|
221
|
+
<div className="flex-1 flex flex-col overflow-hidden p-3">
|
|
222
|
+
<textarea
|
|
223
|
+
value={raw} onChange={e => setRaw(e.target.value)}
|
|
224
|
+
spellCheck={false}
|
|
225
|
+
className="flex-1 text-[11px] font-mono bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-2 resize-none"
|
|
226
|
+
/>
|
|
227
|
+
<div className="text-[9px] text-[var(--text-secondary)] mt-1 opacity-70">
|
|
228
|
+
Direct YAML. The <code>name</code> field must stay as <code className="text-[var(--accent)]">{craftName}</code>.
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
<div className="px-4 py-2 border-t border-[var(--border)] flex justify-end gap-2">
|
|
234
|
+
<button onClick={onClose}
|
|
235
|
+
className="text-xs px-3 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">
|
|
236
|
+
Cancel
|
|
237
|
+
</button>
|
|
238
|
+
<button onClick={save} disabled={!dirty || busy}
|
|
239
|
+
className="text-xs px-3 py-1 rounded bg-[var(--accent)]/30 text-[var(--accent)] hover:bg-[var(--accent)]/40 disabled:opacity-40">
|
|
240
|
+
{busy ? 'โณ' : 'Save manifest'}
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
249
|
+
return (
|
|
250
|
+
<label className="flex flex-col gap-1">
|
|
251
|
+
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-2">
|
|
252
|
+
{label}
|
|
253
|
+
{hint && <span className="opacity-60 font-normal">{hint}</span>}
|
|
254
|
+
</span>
|
|
255
|
+
{children}
|
|
256
|
+
</label>
|
|
257
|
+
);
|
|
258
|
+
}
|