@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,179 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// First-launch picker for a craft's terminal: choose agent, then (if Claude) pick session.
|
|
4
|
+
|
|
5
|
+
import React, { useEffect, useState } from 'react';
|
|
6
|
+
import type { SessionInfo } from './TerminalLauncher';
|
|
7
|
+
|
|
8
|
+
export interface AgentSummary { id: string; name?: string; path?: string; cliType?: string; }
|
|
9
|
+
|
|
10
|
+
export interface CraftTerminalChoice {
|
|
11
|
+
agentId: string;
|
|
12
|
+
agentName?: string;
|
|
13
|
+
// Session selection — only meaningful when agent supports sessions (e.g. claude)
|
|
14
|
+
sessionMode: 'last' | 'new' | 'specific';
|
|
15
|
+
sessionId?: string; // required when sessionMode === 'specific'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function fetchCraftSessions(cwd: string): Promise<SessionInfo[]> {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`/api/craft-system/sessions?cwd=${encodeURIComponent(cwd)}`);
|
|
21
|
+
if (!res.ok) return [];
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
return Array.isArray(data) ? data : [];
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatRelativeTime(iso: string): string {
|
|
30
|
+
if (!iso) return '';
|
|
31
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
32
|
+
if (diff < 60_000) return 'just now';
|
|
33
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
34
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
35
|
+
return new Date(iso).toLocaleDateString();
|
|
36
|
+
}
|
|
37
|
+
function formatSize(b: number): string {
|
|
38
|
+
if (!b) return '';
|
|
39
|
+
if (b < 1024) return `${b}B`;
|
|
40
|
+
if (b < 1_048_576) return `${(b / 1024).toFixed(0)}KB`;
|
|
41
|
+
return `${(b / 1_048_576).toFixed(1)}MB`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SESSION_AWARE = new Set(['claude', 'claude-code']); // matched by id or cliType
|
|
45
|
+
|
|
46
|
+
export default function CraftTerminalPicker({ projectName, craftDir, defaultAgentId, onPick, onCancel }: {
|
|
47
|
+
projectName: string;
|
|
48
|
+
craftDir: string;
|
|
49
|
+
defaultAgentId?: string;
|
|
50
|
+
onPick: (choice: CraftTerminalChoice) => void;
|
|
51
|
+
onCancel: () => void;
|
|
52
|
+
}) {
|
|
53
|
+
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
|
54
|
+
const [agentId, setAgentId] = useState<string>(defaultAgentId || '');
|
|
55
|
+
const [sessions, setSessions] = useState<SessionInfo[] | null>(null);
|
|
56
|
+
const [showAll, setShowAll] = useState(false);
|
|
57
|
+
|
|
58
|
+
// Load agents
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
fetch('/api/agents')
|
|
61
|
+
.then(r => r.ok ? r.json() : { agents: [] })
|
|
62
|
+
.then((res: any) => {
|
|
63
|
+
const list = (res.agents || []).filter((a: any) => a.enabled !== false);
|
|
64
|
+
setAgents(list);
|
|
65
|
+
if (!agentId && list.length > 0) {
|
|
66
|
+
setAgentId(res.defaultAgent && list.find((a: any) => a.id === res.defaultAgent) ? res.defaultAgent : list[0].id);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
70
|
+
|
|
71
|
+
// Load sessions when picked agent supports them
|
|
72
|
+
const picked = agents.find(a => a.id === agentId);
|
|
73
|
+
const sessionAware = !!picked && (SESSION_AWARE.has(picked.id) || (picked.cliType && SESSION_AWARE.has(picked.cliType)));
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!sessionAware) { setSessions(null); return; }
|
|
77
|
+
setSessions(null);
|
|
78
|
+
fetchCraftSessions(craftDir)
|
|
79
|
+
.then(list => {
|
|
80
|
+
// Already sorted by API but keep defensive sort
|
|
81
|
+
const sorted = [...list].sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
82
|
+
setSessions(sorted);
|
|
83
|
+
});
|
|
84
|
+
}, [sessionAware, craftDir]);
|
|
85
|
+
|
|
86
|
+
const last = sessions?.[0];
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/60" onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
|
90
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-2xl w-[420px] max-w-[95vw]">
|
|
91
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-2">
|
|
92
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">🖥 Open craft terminal</span>
|
|
93
|
+
<div className="flex-1" />
|
|
94
|
+
<button onClick={onCancel} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div className="p-4 space-y-3 text-xs">
|
|
98
|
+
{/* Agent picker */}
|
|
99
|
+
<div>
|
|
100
|
+
<div className="text-[10px] text-[var(--text-secondary)] mb-1">Agent</div>
|
|
101
|
+
<select value={agentId} onChange={e => setAgentId(e.target.value)}
|
|
102
|
+
className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1">
|
|
103
|
+
{agents.length === 0 && <option value="">no agents detected</option>}
|
|
104
|
+
{agents.map(a => <option key={a.id} value={a.id}>{a.name || a.id}</option>)}
|
|
105
|
+
</select>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Session picker — only for session-aware agents */}
|
|
109
|
+
{sessionAware && (
|
|
110
|
+
<div>
|
|
111
|
+
<div className="text-[10px] text-[var(--text-secondary)] mb-1">
|
|
112
|
+
Session <span className="opacity-60">— scoped to this craft's directory</span>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{sessions === null && <div className="text-[10px] text-[var(--text-secondary)] py-1">Loading sessions…</div>}
|
|
116
|
+
|
|
117
|
+
{sessions && (
|
|
118
|
+
<div className="space-y-1.5">
|
|
119
|
+
{/* Resume last (default) — only when sessions exist */}
|
|
120
|
+
{last && (
|
|
121
|
+
<button onClick={() => onPick({ agentId, agentName: picked?.name, sessionMode: 'last', sessionId: last.id })}
|
|
122
|
+
className="w-full text-left px-2.5 py-1.5 rounded border border-emerald-500/40 hover:border-emerald-500 hover:bg-emerald-500/5 transition-colors">
|
|
123
|
+
<div className="text-[11px] font-semibold flex items-center gap-1.5 text-[var(--text-primary)]">
|
|
124
|
+
<span className="text-emerald-400">●</span> Resume last session
|
|
125
|
+
</div>
|
|
126
|
+
<div className="text-[9px] text-[var(--text-secondary)] font-mono mt-0.5 flex gap-2">
|
|
127
|
+
<span>{last.id.slice(0, 12)}…</span>
|
|
128
|
+
<span>{formatRelativeTime(last.modified)}</span>
|
|
129
|
+
<span>{formatSize(last.size)}</span>
|
|
130
|
+
</div>
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Fresh — always available */}
|
|
135
|
+
<button onClick={() => onPick({ agentId, agentName: picked?.name, sessionMode: 'new' })}
|
|
136
|
+
className="w-full text-left px-2.5 py-1.5 rounded border border-[var(--border)] hover:border-[var(--accent)] hover:bg-[var(--accent)]/5 transition-colors">
|
|
137
|
+
<div className="text-[11px] font-semibold text-[var(--text-primary)]">+ New session</div>
|
|
138
|
+
<div className="text-[9px] text-[var(--text-secondary)]">Start a fresh Claude session in this craft's directory</div>
|
|
139
|
+
</button>
|
|
140
|
+
|
|
141
|
+
{/* Other sessions */}
|
|
142
|
+
{sessions.length > 1 && (
|
|
143
|
+
<button onClick={() => setShowAll(v => !v)}
|
|
144
|
+
className="w-full text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] py-1 text-left">
|
|
145
|
+
{showAll ? '▼' : '▶'} Other sessions ({sessions.length - 1})
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{showAll && sessions.slice(1).map(s => (
|
|
150
|
+
<button key={s.id} onClick={() => onPick({ agentId, agentName: picked?.name, sessionMode: 'specific', sessionId: s.id })}
|
|
151
|
+
className="w-full text-left px-2.5 py-1 rounded hover:bg-[var(--bg-tertiary)] transition-colors flex items-center gap-2 text-[10px]">
|
|
152
|
+
<span className="font-mono text-[var(--text-secondary)]">{s.id.slice(0, 10)}</span>
|
|
153
|
+
<span className="text-[var(--text-secondary)]">{formatRelativeTime(s.modified)}</span>
|
|
154
|
+
<span className="text-[var(--text-secondary)] opacity-60 ml-auto">{formatSize(s.size)}</span>
|
|
155
|
+
</button>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{!sessionAware && (
|
|
163
|
+
<button onClick={() => onPick({ agentId, agentName: picked?.name, sessionMode: 'new' })}
|
|
164
|
+
className="w-full px-3 py-2 rounded bg-[var(--accent)]/30 text-[var(--accent)] hover:bg-[var(--accent)]/40 text-xs">
|
|
165
|
+
Open terminal with {picked?.name || agentId}
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div className="px-4 py-2 border-t border-[var(--border)] flex justify-end">
|
|
171
|
+
<button onClick={onCancel}
|
|
172
|
+
className="text-[10px] px-3 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">
|
|
173
|
+
Cancel
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Compact crafts menu for the project tab bar. Shows the active craft as a
|
|
4
|
+
// chip (with ⚙ refine + 🗑 delete shortcuts) plus a dropdown listing all
|
|
5
|
+
// available crafts. Replaces the previous flat tab-per-craft layout that
|
|
6
|
+
// blew the workspace bar wide once a few crafts existed.
|
|
7
|
+
|
|
8
|
+
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
9
|
+
|
|
10
|
+
interface Craft {
|
|
11
|
+
name: string;
|
|
12
|
+
displayName: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
scope: 'builtin' | 'project' | string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function CraftsDropdown({
|
|
18
|
+
crafts,
|
|
19
|
+
activeTab,
|
|
20
|
+
projectPath,
|
|
21
|
+
onPick,
|
|
22
|
+
onNew,
|
|
23
|
+
onRefine,
|
|
24
|
+
onDelete,
|
|
25
|
+
onMarketplace,
|
|
26
|
+
onPublish,
|
|
27
|
+
onEditManifest,
|
|
28
|
+
}: {
|
|
29
|
+
crafts: Craft[];
|
|
30
|
+
activeTab: string;
|
|
31
|
+
projectPath: string;
|
|
32
|
+
onPick: (name: string) => void;
|
|
33
|
+
onNew: () => void;
|
|
34
|
+
onRefine: (name: string) => void;
|
|
35
|
+
onDelete: (name: string, displayName: string) => void;
|
|
36
|
+
onMarketplace: () => void;
|
|
37
|
+
onPublish: (name: string) => void;
|
|
38
|
+
onEditManifest: (name: string) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [open, setOpen] = useState(false);
|
|
41
|
+
const popoverRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const [updates, setUpdates] = useState<{ name: string; from?: string; to: string }[]>([]);
|
|
43
|
+
|
|
44
|
+
// Background poll for available updates. Cheap (just a registry version compare).
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
const poll = async () => {
|
|
48
|
+
try {
|
|
49
|
+
const r = await fetch(`/api/craft-system/marketplace/updates?projectPath=${encodeURIComponent(projectPath)}`);
|
|
50
|
+
if (!r.ok) return;
|
|
51
|
+
const j = await r.json();
|
|
52
|
+
if (!cancelled) setUpdates(j.updates || []);
|
|
53
|
+
} catch {}
|
|
54
|
+
};
|
|
55
|
+
poll();
|
|
56
|
+
// Re-check whenever the dropdown opens, plus every 5 minutes in background
|
|
57
|
+
const id = setInterval(poll, 5 * 60_000);
|
|
58
|
+
return () => { cancelled = true; clearInterval(id); };
|
|
59
|
+
}, [projectPath, crafts.length]);
|
|
60
|
+
|
|
61
|
+
const updatesByName = useMemo(() => {
|
|
62
|
+
const m = new Map<string, { from?: string; to: string }>();
|
|
63
|
+
for (const u of updates) m.set(u.name, { from: u.from, to: u.to });
|
|
64
|
+
return m;
|
|
65
|
+
}, [updates]);
|
|
66
|
+
|
|
67
|
+
// Close on outside click
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!open) return;
|
|
70
|
+
const onDown = (e: MouseEvent) => {
|
|
71
|
+
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) setOpen(false);
|
|
72
|
+
};
|
|
73
|
+
document.addEventListener('mousedown', onDown);
|
|
74
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
75
|
+
}, [open]);
|
|
76
|
+
|
|
77
|
+
const activeName = activeTab.startsWith('craft:') ? activeTab.slice('craft:'.length) : null;
|
|
78
|
+
const active = activeName ? crafts.find(c => c.name === activeName) : null;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="relative inline-flex items-stretch gap-0.5" ref={popoverRef}>
|
|
82
|
+
{/* Active craft chip with refine + delete */}
|
|
83
|
+
{active && (
|
|
84
|
+
<>
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => onPick(active.name)}
|
|
87
|
+
className="text-[11px] font-medium px-2.5 py-1 rounded bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40"
|
|
88
|
+
title={active.description || active.displayName}
|
|
89
|
+
>
|
|
90
|
+
{active.displayName}
|
|
91
|
+
</button>
|
|
92
|
+
{active.scope === 'project' && (
|
|
93
|
+
<>
|
|
94
|
+
<button onClick={() => onEditManifest(active.name)}
|
|
95
|
+
className="text-[10px] px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)]"
|
|
96
|
+
title="Edit craft.yaml (version, displayName, tags, requires)">📝</button>
|
|
97
|
+
<button onClick={() => onRefine(active.name)}
|
|
98
|
+
className="text-[10px] px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)]"
|
|
99
|
+
title="Refine this craft (AI iterates on it)">⚙</button>
|
|
100
|
+
<button onClick={() => onPublish(active.name)}
|
|
101
|
+
className="text-[10px] px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)]"
|
|
102
|
+
title="Publish to the crafts marketplace">📦</button>
|
|
103
|
+
<button onClick={() => onDelete(active.name, active.displayName)}
|
|
104
|
+
className="text-[10px] px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:bg-red-500/20 hover:text-red-300"
|
|
105
|
+
title="Delete this craft">🗑</button>
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{/* Dropdown trigger */}
|
|
112
|
+
<button
|
|
113
|
+
onClick={() => setOpen(v => !v)}
|
|
114
|
+
className={`relative text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
115
|
+
open ? 'bg-[var(--bg-tertiary)] text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
116
|
+
}`}
|
|
117
|
+
title={updates.length > 0 ? `${updates.length} craft update${updates.length === 1 ? '' : 's'} available` : `${crafts.length} craft${crafts.length === 1 ? '' : 's'}`}
|
|
118
|
+
>
|
|
119
|
+
{active ? '▾' : `🛠 Crafts ${crafts.length > 0 ? `(${crafts.length})` : ''} ▾`}
|
|
120
|
+
{updates.length > 0 && (
|
|
121
|
+
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-yellow-500 text-black text-[8px] font-bold leading-[14px] flex items-center justify-center"
|
|
122
|
+
title={`${updates.length} update${updates.length === 1 ? '' : 's'} available`}>
|
|
123
|
+
{updates.length}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</button>
|
|
127
|
+
|
|
128
|
+
{/* Dropdown menu */}
|
|
129
|
+
{open && (
|
|
130
|
+
<div className="absolute top-full left-0 mt-1 z-30 bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-xl min-w-[260px] max-w-[400px] py-1">
|
|
131
|
+
{crafts.length === 0 && (
|
|
132
|
+
<div className="px-3 py-2 text-[11px] text-[var(--text-secondary)] italic">
|
|
133
|
+
No crafts yet — click + Craft to add one.
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
{crafts.map(c => {
|
|
137
|
+
const isActive = c.name === activeName;
|
|
138
|
+
const upd = updatesByName.get(c.name);
|
|
139
|
+
return (
|
|
140
|
+
<div key={c.name}
|
|
141
|
+
className={`group flex items-center gap-2 px-2 py-1.5 cursor-pointer ${
|
|
142
|
+
isActive ? 'bg-[var(--accent)]/10' : 'hover:bg-[var(--bg-tertiary)]'
|
|
143
|
+
}`}
|
|
144
|
+
onClick={() => { onPick(c.name); setOpen(false); }}
|
|
145
|
+
>
|
|
146
|
+
<span className={`flex-1 text-[11px] truncate ${isActive ? 'text-[var(--accent)] font-semibold' : 'text-[var(--text-primary)]'}`}>
|
|
147
|
+
{c.displayName}
|
|
148
|
+
</span>
|
|
149
|
+
{upd && (
|
|
150
|
+
<span className="text-[8px] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-300"
|
|
151
|
+
title={`Update available: ${upd.from || '?'} → ${upd.to}`}>
|
|
152
|
+
↑ {upd.to}
|
|
153
|
+
</span>
|
|
154
|
+
)}
|
|
155
|
+
{c.description && <span className="text-[9px] text-[var(--text-secondary)] truncate max-w-[120px] opacity-0 group-hover:opacity-100">{c.description}</span>}
|
|
156
|
+
{c.scope === 'builtin' && (
|
|
157
|
+
<span className="text-[8px] px-1 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">builtin</span>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
{updates.length > 0 && (
|
|
163
|
+
<button onClick={() => { setOpen(false); onMarketplace(); }}
|
|
164
|
+
className="w-full px-3 py-2 text-left bg-yellow-500/10 hover:bg-yellow-500/15 border-t border-yellow-500/30 text-[10px] text-yellow-300 flex items-center gap-2">
|
|
165
|
+
<span>⚠ {updates.length} update{updates.length === 1 ? '' : 's'} available — open Marketplace to apply</span>
|
|
166
|
+
<span className="ml-auto opacity-70">→</span>
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
{crafts.length > 0 && <div className="border-t border-[var(--border)] my-1" />}
|
|
170
|
+
<div className="px-2 py-1 flex items-center gap-1">
|
|
171
|
+
<button onClick={() => { setOpen(false); onNew(); }}
|
|
172
|
+
className="text-[10px] px-2 py-1 rounded bg-[var(--accent)]/20 text-[var(--accent)] hover:bg-[var(--accent)]/30 flex-1 text-left"
|
|
173
|
+
title="Build a new craft">
|
|
174
|
+
+ New craft
|
|
175
|
+
</button>
|
|
176
|
+
<button onClick={() => { setOpen(false); onMarketplace(); }}
|
|
177
|
+
className="text-[10px] px-2 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--accent)]"
|
|
178
|
+
title="Browse the crafts marketplace">
|
|
179
|
+
🛒 Marketplace
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Read-only browse view of the global crafts registry, used inside the
|
|
4
|
+
// Dashboard Marketplace tab. Installation requires a target project; we
|
|
5
|
+
// surface a project picker per row that lists the user's recent projects.
|
|
6
|
+
|
|
7
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
8
|
+
|
|
9
|
+
interface RegistryItem {
|
|
10
|
+
name: string;
|
|
11
|
+
displayName: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
version: string;
|
|
14
|
+
author?: string;
|
|
15
|
+
tags?: string[];
|
|
16
|
+
requires?: { hasFile?: string[]; hasGlob?: string[] };
|
|
17
|
+
files?: string[];
|
|
18
|
+
sourceUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ProjectInfo {
|
|
22
|
+
path: string;
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function CraftsMarketplacePanel({ searchQuery = '' }: { searchQuery?: string }) {
|
|
27
|
+
const [items, setItems] = useState<RegistryItem[]>([]);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
31
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
32
|
+
const [toast, setToast] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
const refresh = async (force = false) => {
|
|
35
|
+
setLoading(true); setError(null);
|
|
36
|
+
try {
|
|
37
|
+
const r = await fetch(`/api/craft-system/registry${force ? '?refresh=1' : ''}`);
|
|
38
|
+
const j = await r.json();
|
|
39
|
+
if (!r.ok) throw new Error(j.error || `${r.status}`);
|
|
40
|
+
setItems(j.items || []);
|
|
41
|
+
} catch (e: any) { setError(e?.message || String(e)); }
|
|
42
|
+
finally { setLoading(false); }
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Force-refresh on mount so we don't show stale entries right after a publish landed.
|
|
46
|
+
useEffect(() => { refresh(true); }, []);
|
|
47
|
+
|
|
48
|
+
// Pull recent projects (favorites + last opened) for the install picker
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
Promise.all([
|
|
51
|
+
fetch('/api/favorites').then(r => r.ok ? r.json() : []).catch(() => []),
|
|
52
|
+
fetch('/api/projects').then(r => r.ok ? r.json() : []).catch(() => []),
|
|
53
|
+
]).then(([favs, all]) => {
|
|
54
|
+
const favSet = new Set<string>(Array.isArray(favs) ? favs : []);
|
|
55
|
+
const list = Array.isArray(all) ? all : [];
|
|
56
|
+
const ranked = list.map((p: any) => ({
|
|
57
|
+
path: p.path,
|
|
58
|
+
name: p.name || p.path.split('/').pop(),
|
|
59
|
+
fav: favSet.has(p.path),
|
|
60
|
+
}));
|
|
61
|
+
ranked.sort((a, b) => Number(b.fav) - Number(a.fav) || a.name.localeCompare(b.name));
|
|
62
|
+
setProjects(ranked);
|
|
63
|
+
}).catch(() => {});
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const filtered = useMemo(() => {
|
|
67
|
+
const q = searchQuery.trim().toLowerCase();
|
|
68
|
+
if (!q) return items;
|
|
69
|
+
return items.filter(it =>
|
|
70
|
+
`${it.name} ${it.displayName} ${it.description || ''} ${(it.tags || []).join(' ')}`
|
|
71
|
+
.toLowerCase().includes(q)
|
|
72
|
+
);
|
|
73
|
+
}, [items, searchQuery]);
|
|
74
|
+
|
|
75
|
+
const flash = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2500); };
|
|
76
|
+
|
|
77
|
+
const install = async (name: string, projectPath: string) => {
|
|
78
|
+
setBusyId(`${name}::${projectPath}`);
|
|
79
|
+
try {
|
|
80
|
+
const r = await fetch('/api/craft-system/marketplace/install', {
|
|
81
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ projectPath, name }),
|
|
83
|
+
});
|
|
84
|
+
const j = await r.json();
|
|
85
|
+
if (!j.ok) throw new Error(j.error || 'install failed');
|
|
86
|
+
flash(`Installed ${name} → ${projectPath.split('/').pop()}`);
|
|
87
|
+
} catch (e: any) { flash(`Failed: ${e?.message || e}`); }
|
|
88
|
+
finally { setBusyId(null); }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="flex-1 overflow-auto relative">
|
|
93
|
+
{/* Toolbar */}
|
|
94
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-secondary)]/30 sticky top-0 z-10">
|
|
95
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{items.length} craft{items.length === 1 ? '' : 's'} in registry</span>
|
|
96
|
+
<div className="flex-1" />
|
|
97
|
+
<button onClick={() => refresh(true)} disabled={loading}
|
|
98
|
+
className="text-[10px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]">
|
|
99
|
+
{loading ? '⏳' : '↻ Sync'}
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{error && (
|
|
104
|
+
<div className="m-4 p-2 text-[11px] text-red-300 bg-red-500/10 border border-red-500/30 rounded">
|
|
105
|
+
{error}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{!loading && filtered.length === 0 && (
|
|
110
|
+
<div className="p-8 text-center text-xs text-[var(--text-secondary)]">
|
|
111
|
+
{items.length === 0
|
|
112
|
+
? 'No crafts in the registry yet. Build one in any project, then click 📦 Publish to submit it.'
|
|
113
|
+
: 'No crafts match your search.'}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{filtered.map(it => (
|
|
118
|
+
<div key={it.name} className="px-4 py-3 border-b border-[var(--border)]/50 flex items-start gap-3">
|
|
119
|
+
<div className="flex-1 min-w-0">
|
|
120
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
121
|
+
<span className="text-[12px] font-semibold text-[var(--text-primary)]">{it.displayName}</span>
|
|
122
|
+
<span className="text-[9px] font-mono text-[var(--text-secondary)]">{it.name}</span>
|
|
123
|
+
<span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">v{it.version}</span>
|
|
124
|
+
{it.author && <span className="text-[9px] text-[var(--text-secondary)]">by {it.author}</span>}
|
|
125
|
+
{it.sourceUrl && (
|
|
126
|
+
<a href={it.sourceUrl} target="_blank" rel="noreferrer" className="text-[9px] text-[var(--accent)] hover:underline">
|
|
127
|
+
source ↗
|
|
128
|
+
</a>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
{it.description && <div className="text-[11px] text-[var(--text-secondary)] mt-0.5">{it.description}</div>}
|
|
132
|
+
{it.tags && it.tags.length > 0 && (
|
|
133
|
+
<div className="flex gap-1 mt-1 flex-wrap">
|
|
134
|
+
{it.tags.map(t => (
|
|
135
|
+
<span key={t} className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">{t}</span>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
{it.requires && (it.requires.hasFile?.length || it.requires.hasGlob?.length) && (
|
|
140
|
+
<div className="text-[9px] text-[var(--text-secondary)] opacity-70 mt-1 font-mono">
|
|
141
|
+
requires: {[...(it.requires.hasFile || []), ...(it.requires.hasGlob || [])].join(', ')}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Install picker */}
|
|
147
|
+
<div className="shrink-0">
|
|
148
|
+
<ProjectPickerInstall
|
|
149
|
+
craftName={it.name}
|
|
150
|
+
projects={projects}
|
|
151
|
+
busy={busyId?.startsWith(it.name + '::') || false}
|
|
152
|
+
onInstall={(p) => install(it.name, p)}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
|
|
158
|
+
{toast && (
|
|
159
|
+
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs rounded bg-[var(--bg-tertiary)] border border-[var(--border)] shadow-lg z-50">
|
|
160
|
+
{toast}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ProjectPickerInstall({ craftName, projects, busy, onInstall }: {
|
|
168
|
+
craftName: string; projects: { path: string; name: string }[]; busy: boolean; onInstall: (p: string) => void;
|
|
169
|
+
}) {
|
|
170
|
+
const [open, setOpen] = useState(false);
|
|
171
|
+
if (projects.length === 0) {
|
|
172
|
+
return <span className="text-[10px] text-[var(--text-secondary)] italic">no projects</span>;
|
|
173
|
+
}
|
|
174
|
+
return (
|
|
175
|
+
<div className="relative">
|
|
176
|
+
<button onClick={() => setOpen(v => !v)} disabled={busy}
|
|
177
|
+
className="text-[10px] px-2.5 py-1 rounded bg-[var(--accent)]/30 text-[var(--accent)] hover:bg-[var(--accent)]/40 disabled:opacity-40">
|
|
178
|
+
{busy ? '⏳' : 'Install ▾'}
|
|
179
|
+
</button>
|
|
180
|
+
{open && !busy && (
|
|
181
|
+
<div className="absolute right-0 mt-1 z-30 bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-xl min-w-[240px] max-h-72 overflow-auto">
|
|
182
|
+
<div className="px-2 py-1 text-[10px] text-[var(--text-secondary)] border-b border-[var(--border)]">Install to which project?</div>
|
|
183
|
+
{projects.map(p => (
|
|
184
|
+
<button key={p.path} onClick={() => { setOpen(false); onInstall(p.path); }}
|
|
185
|
+
className="w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)]">
|
|
186
|
+
<div className="font-medium text-[var(--text-primary)]">{p.name}</div>
|
|
187
|
+
<div className="text-[9px] text-[var(--text-secondary)] truncate">{p.path}</div>
|
|
188
|
+
</button>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|