@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
|
@@ -7,6 +7,12 @@ import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLaunc
|
|
|
7
7
|
const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
|
|
8
8
|
const WorkspaceViewLazy = lazy(() => import('./WorkspaceView'));
|
|
9
9
|
const SessionViewLazy = lazy(() => import('./SessionView'));
|
|
10
|
+
const CraftTabLazy = lazy(() => import('./CraftTabs').then(m => ({ default: m.CraftTab })));
|
|
11
|
+
const CraftBuilderModalLazy = lazy(() => import('./CraftBuilder').then(m => ({ default: m.CraftBuilderModal })));
|
|
12
|
+
const CraftMarketplaceModalLazy = lazy(() => import('./CraftMarketplaceModal'));
|
|
13
|
+
const CraftPublishModalLazy = lazy(() => import('./CraftPublishModal'));
|
|
14
|
+
const CraftManifestEditorLazy = lazy(() => import('./CraftManifestEditor'));
|
|
15
|
+
import CraftsDropdown from './CraftsDropdown';
|
|
10
16
|
|
|
11
17
|
// ─── Syntax highlighting ─────────────────────────────────
|
|
12
18
|
const KEYWORDS = new Set([
|
|
@@ -85,7 +91,27 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
85
91
|
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
86
92
|
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
87
93
|
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
88
|
-
const [projectTab, setProjectTab] = useState<
|
|
94
|
+
const [projectTab, setProjectTab] = useState<string>('code');
|
|
95
|
+
const [crafts, setCrafts] = useState<Array<{ name: string; displayName: string; icon?: string; description?: string; scope: string; hasUi: boolean; hasServer: boolean }>>([]);
|
|
96
|
+
const [craftBuilder, setCraftBuilder] = useState<{ refineName?: string } | null>(null);
|
|
97
|
+
const [craftMarketplaceOpen, setCraftMarketplaceOpen] = useState(false);
|
|
98
|
+
const [craftPublishName, setCraftPublishName] = useState<string | null>(null);
|
|
99
|
+
const [craftManifestName, setCraftManifestName] = useState<string | null>(null);
|
|
100
|
+
// Once a craft is visited it stays mounted (hidden via CSS) so its terminal + WS persist.
|
|
101
|
+
const [visitedCrafts, setVisitedCrafts] = useState<Set<string>>(() => new Set());
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (projectTab.startsWith('craft:')) {
|
|
104
|
+
const name = projectTab.slice('craft:'.length);
|
|
105
|
+
setVisitedCrafts(prev => prev.has(name) ? prev : new Set(prev).add(name));
|
|
106
|
+
}
|
|
107
|
+
}, [projectTab]);
|
|
108
|
+
const refreshCrafts = useCallback(() => {
|
|
109
|
+
fetch(`/api/crafts?projectPath=${encodeURIComponent(projectPath)}`)
|
|
110
|
+
.then(r => r.ok ? r.json() : { crafts: [] })
|
|
111
|
+
.then(j => setCrafts(j.crafts || []))
|
|
112
|
+
.catch(() => {});
|
|
113
|
+
}, [projectPath]);
|
|
114
|
+
useEffect(() => { refreshCrafts(); }, [refreshCrafts]);
|
|
89
115
|
// Lazy-mount workspace: only mount after first visit, keep mounted to preserve terminal state
|
|
90
116
|
const [wsMounted, setWsMounted] = useState(false);
|
|
91
117
|
useEffect(() => { if (projectTab === 'workspace') setWsMounted(true); }, [projectTab]);
|
|
@@ -617,6 +643,25 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
617
643
|
Pipelines
|
|
618
644
|
{pipelineBindings.length > 0 && <span className="ml-1 text-[9px] opacity-70">({pipelineBindings.length})</span>}
|
|
619
645
|
</button>
|
|
646
|
+
<CraftsDropdown
|
|
647
|
+
crafts={crafts}
|
|
648
|
+
activeTab={projectTab}
|
|
649
|
+
projectPath={projectPath}
|
|
650
|
+
onPick={(name) => setProjectTab(`craft:${name}`)}
|
|
651
|
+
onNew={() => setCraftBuilder({})}
|
|
652
|
+
onRefine={(name) => setCraftBuilder({ refineName: name })}
|
|
653
|
+
onPublish={(name) => setCraftPublishName(name)}
|
|
654
|
+
onMarketplace={() => setCraftMarketplaceOpen(true)}
|
|
655
|
+
onEditManifest={(name) => setCraftManifestName(name)}
|
|
656
|
+
onDelete={async (name, displayName) => {
|
|
657
|
+
if (!confirm(`Delete craft "${displayName}"?\n\nThis removes <project>/.forge/crafts/${name}/ permanently.`)) return;
|
|
658
|
+
const r = await fetch(`/api/craft-system/delete?projectPath=${encodeURIComponent(projectPath)}&name=${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
659
|
+
if (r.ok) {
|
|
660
|
+
setProjectTab('code');
|
|
661
|
+
refreshCrafts();
|
|
662
|
+
}
|
|
663
|
+
}}
|
|
664
|
+
/>
|
|
620
665
|
</div>
|
|
621
666
|
</div>
|
|
622
667
|
{projectTab === 'code' && gitInfo?.lastCommit && (
|
|
@@ -1373,6 +1418,65 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
1373
1418
|
</div>
|
|
1374
1419
|
)}
|
|
1375
1420
|
|
|
1421
|
+
{/* Craft tabs — lazy-mount on first visit, then keep mounted (hidden via CSS)
|
|
1422
|
+
so each craft's terminal panel + WebSocket survive tab switches. */}
|
|
1423
|
+
{crafts.filter(c => visitedCrafts.has(c.name)).map(c => {
|
|
1424
|
+
const isActive = projectTab === `craft:${c.name}`;
|
|
1425
|
+
return (
|
|
1426
|
+
<div key={c.name} className={`flex-1 flex flex-col min-h-0 overflow-hidden ${isActive ? '' : 'hidden'}`}>
|
|
1427
|
+
<Suspense fallback={<div className="p-4 text-xs text-[var(--text-secondary)]">Loading craft {c.displayName}…</div>}>
|
|
1428
|
+
<CraftTabLazy craft={c as any} projectPath={projectPath} projectName={projectName} />
|
|
1429
|
+
</Suspense>
|
|
1430
|
+
</div>
|
|
1431
|
+
);
|
|
1432
|
+
})}
|
|
1433
|
+
|
|
1434
|
+
{/* Craft Marketplace modal */}
|
|
1435
|
+
{craftMarketplaceOpen && (
|
|
1436
|
+
<Suspense fallback={null}>
|
|
1437
|
+
<CraftMarketplaceModalLazy
|
|
1438
|
+
projectPath={projectPath}
|
|
1439
|
+
onClose={() => setCraftMarketplaceOpen(false)}
|
|
1440
|
+
onInstalled={refreshCrafts}
|
|
1441
|
+
/>
|
|
1442
|
+
</Suspense>
|
|
1443
|
+
)}
|
|
1444
|
+
|
|
1445
|
+
{/* Craft Publish modal */}
|
|
1446
|
+
{craftPublishName && (
|
|
1447
|
+
<Suspense fallback={null}>
|
|
1448
|
+
<CraftPublishModalLazy
|
|
1449
|
+
projectPath={projectPath}
|
|
1450
|
+
craftName={craftPublishName}
|
|
1451
|
+
onClose={() => setCraftPublishName(null)}
|
|
1452
|
+
/>
|
|
1453
|
+
</Suspense>
|
|
1454
|
+
)}
|
|
1455
|
+
|
|
1456
|
+
{/* Craft Manifest editor */}
|
|
1457
|
+
{craftManifestName && (
|
|
1458
|
+
<Suspense fallback={null}>
|
|
1459
|
+
<CraftManifestEditorLazy
|
|
1460
|
+
projectPath={projectPath}
|
|
1461
|
+
craftName={craftManifestName}
|
|
1462
|
+
onClose={() => setCraftManifestName(null)}
|
|
1463
|
+
/>
|
|
1464
|
+
</Suspense>
|
|
1465
|
+
)}
|
|
1466
|
+
|
|
1467
|
+
{/* Craft Builder modal */}
|
|
1468
|
+
{craftBuilder && (
|
|
1469
|
+
<Suspense fallback={null}>
|
|
1470
|
+
<CraftBuilderModalLazy
|
|
1471
|
+
projectPath={projectPath}
|
|
1472
|
+
projectName={projectName}
|
|
1473
|
+
refineCraftName={craftBuilder.refineName}
|
|
1474
|
+
onClose={() => setCraftBuilder(null)}
|
|
1475
|
+
onCreated={() => { refreshCrafts(); setCraftBuilder(null); }}
|
|
1476
|
+
/>
|
|
1477
|
+
</Suspense>
|
|
1478
|
+
)}
|
|
1479
|
+
|
|
1376
1480
|
{/* Git panel — bottom (code tab only) */}
|
|
1377
1481
|
{projectTab === 'code' && gitInfo && (
|
|
1378
1482
|
<div className="border-t border-[var(--border)] shrink-0">
|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
|
|
4
4
|
import { useSidebarResize } from '@/hooks/useSidebarResize';
|
|
5
5
|
|
|
6
6
|
const PluginsPanel = lazy(() => import('./PluginsPanel'));
|
|
7
|
+
const CraftsMarketplacePanelLazy = lazy(() => import('./CraftsMarketplacePanel'));
|
|
7
8
|
|
|
8
9
|
type ItemType = 'skill' | 'command';
|
|
9
10
|
|
|
@@ -139,7 +140,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
139
140
|
const [syncing, setSyncing] = useState(false);
|
|
140
141
|
const [loading, setLoading] = useState(true);
|
|
141
142
|
const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
|
|
142
|
-
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins'>('all');
|
|
143
|
+
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'crafts'>('all');
|
|
143
144
|
const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
|
|
144
145
|
// Rules (CLAUDE.md templates)
|
|
145
146
|
const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
|
|
@@ -372,7 +373,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
372
373
|
<div className="flex items-center gap-2">
|
|
373
374
|
<span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
|
|
374
375
|
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
375
|
-
{([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules'], ['plugins', 'Plugins']] as const).map(([value, label]) => (
|
|
376
|
+
{([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules'], ['plugins', 'Plugins'], ['crafts', 'Crafts']] as const).map(([value, label]) => (
|
|
376
377
|
<button
|
|
377
378
|
key={value}
|
|
378
379
|
onClick={() => setTypeFilter(value)}
|
|
@@ -397,7 +398,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
397
398
|
</button>
|
|
398
399
|
</div>
|
|
399
400
|
{/* Search — hide on rules tab */}
|
|
400
|
-
{typeFilter !== 'rules' && typeFilter !== 'plugins' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
|
|
401
|
+
{typeFilter !== 'rules' && typeFilter !== 'plugins' && typeFilter !== 'crafts' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
|
|
401
402
|
<input
|
|
402
403
|
type="text"
|
|
403
404
|
value={searchQuery}
|
|
@@ -407,7 +408,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
407
408
|
/>
|
|
408
409
|
</div>}
|
|
409
410
|
|
|
410
|
-
{typeFilter === 'rules' || typeFilter === 'plugins' ? null : skills.length === 0 ? (
|
|
411
|
+
{typeFilter === 'rules' || typeFilter === 'plugins' || typeFilter === 'crafts' ? null : skills.length === 0 ? (
|
|
411
412
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
412
413
|
<p className="text-xs">No skills yet</p>
|
|
413
414
|
<button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
|
|
@@ -984,6 +985,13 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
984
985
|
<PluginsPanel />
|
|
985
986
|
</Suspense>
|
|
986
987
|
)}
|
|
988
|
+
|
|
989
|
+
{/* Crafts — registry browse view */}
|
|
990
|
+
{typeFilter === 'crafts' && (
|
|
991
|
+
<Suspense fallback={<div className="p-4 text-xs text-[var(--text-secondary)]">Loading...</div>}>
|
|
992
|
+
<CraftsMarketplacePanelLazy searchQuery={searchQuery} />
|
|
993
|
+
</Suspense>
|
|
994
|
+
)}
|
|
987
995
|
</div>
|
|
988
996
|
);
|
|
989
997
|
}
|
|
@@ -184,7 +184,8 @@ export default function TaskDetail({
|
|
|
184
184
|
</button>
|
|
185
185
|
</div>
|
|
186
186
|
</div>
|
|
187
|
-
<
|
|
187
|
+
<TaskPromptPreview prompt={task.prompt} />
|
|
188
|
+
|
|
188
189
|
<div className="flex items-center gap-3 text-[10px] text-[var(--text-secondary)]">
|
|
189
190
|
<span>Created: {new Date(task.createdAt).toLocaleString()}</span>
|
|
190
191
|
{task.startedAt && <span>Started: {new Date(task.startedAt).toLocaleString()}</span>}
|
|
@@ -520,3 +521,50 @@ function formatToolContent(content: string): string {
|
|
|
520
521
|
return content;
|
|
521
522
|
}
|
|
522
523
|
}
|
|
524
|
+
|
|
525
|
+
// ─── Task prompt preview with click-to-expand ──────────────
|
|
526
|
+
const PROMPT_PREVIEW_MAX = 240;
|
|
527
|
+
|
|
528
|
+
function TaskPromptPreview({ prompt }: { prompt: string }) {
|
|
529
|
+
const [open, setOpen] = useState(false);
|
|
530
|
+
const long = prompt.length > PROMPT_PREVIEW_MAX || prompt.includes('\n');
|
|
531
|
+
const preview = long ? prompt.slice(0, PROMPT_PREVIEW_MAX).split('\n')[0] : prompt;
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<>
|
|
535
|
+
<div className="flex items-start gap-2 mb-2">
|
|
536
|
+
<p className="text-xs text-[var(--text-secondary)] flex-1 break-words" title={long ? 'Click to view full prompt' : undefined}>
|
|
537
|
+
{preview}
|
|
538
|
+
{long && <span className="text-[var(--text-secondary)] opacity-60">…</span>}
|
|
539
|
+
</p>
|
|
540
|
+
{long && (
|
|
541
|
+
<button
|
|
542
|
+
onClick={() => setOpen(true)}
|
|
543
|
+
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--accent)]/20 hover:text-[var(--accent)] shrink-0"
|
|
544
|
+
title={`${prompt.length} chars · ${prompt.split('\n').length} lines`}
|
|
545
|
+
>
|
|
546
|
+
View full
|
|
547
|
+
</button>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
{open && (
|
|
552
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
|
|
553
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-2xl w-[800px] max-w-[95vw] max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
554
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-2">
|
|
555
|
+
<span className="text-xs font-semibold text-[var(--text-primary)]">Task prompt</span>
|
|
556
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{prompt.length.toLocaleString()} chars · {prompt.split('\n').length} lines</span>
|
|
557
|
+
<div className="flex-1" />
|
|
558
|
+
<button
|
|
559
|
+
onClick={() => { navigator.clipboard.writeText(prompt).catch(() => {}); }}
|
|
560
|
+
className="text-[10px] px-2 py-1 rounded text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
|
|
561
|
+
>Copy</button>
|
|
562
|
+
<button onClick={() => setOpen(false)} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
563
|
+
</div>
|
|
564
|
+
<pre className="flex-1 overflow-auto p-4 text-[11px] font-mono whitespace-pre-wrap break-words text-[var(--text-primary)]">{prompt}</pre>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
)}
|
|
568
|
+
</>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Client-side SDK exposed to crafts. Lives behind a React context so each craft
|
|
4
|
+
// gets its project + craft scope automatically.
|
|
5
|
+
|
|
6
|
+
import React, { createContext, useContext, useCallback, useEffect, useState, useRef } from 'react';
|
|
7
|
+
|
|
8
|
+
interface CraftContextValue {
|
|
9
|
+
projectPath: string;
|
|
10
|
+
projectName: string;
|
|
11
|
+
craftName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CraftContext = createContext<CraftContextValue | null>(null);
|
|
15
|
+
|
|
16
|
+
export function CraftSDKProvider({ projectPath, projectName, craftName, children }: {
|
|
17
|
+
projectPath: string; projectName: string; craftName: string; children: React.ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
return <CraftContext.Provider value={{ projectPath, projectName, craftName }}>{children}</CraftContext.Provider>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function useCraftCtx(): CraftContextValue {
|
|
23
|
+
const v = useContext(CraftContext);
|
|
24
|
+
if (!v) throw new Error('Craft SDK hook used outside CraftSDKProvider');
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── 1. useProject ────────────────────────────────────────
|
|
29
|
+
export interface ProjectInfo {
|
|
30
|
+
projectPath: string;
|
|
31
|
+
projectName: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useProject(): ProjectInfo {
|
|
35
|
+
const { projectPath, projectName } = useCraftCtx();
|
|
36
|
+
return { projectPath, projectName };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── 2. useForgeFetch ─────────────────────────────────────
|
|
40
|
+
export interface FetchState<T> {
|
|
41
|
+
data: T | null;
|
|
42
|
+
loading: boolean;
|
|
43
|
+
error: string | null;
|
|
44
|
+
refetch: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useForgeFetch<T = any>(path: string, opts: { auto?: boolean; init?: RequestInit } = {}): FetchState<T> {
|
|
48
|
+
const { projectPath } = useCraftCtx();
|
|
49
|
+
const [data, setData] = useState<T | null>(null);
|
|
50
|
+
const [loading, setLoading] = useState(false);
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
const [tick, setTick] = useState(0);
|
|
53
|
+
|
|
54
|
+
const fullPath = path.includes('?') ? `${path}&projectPath=${encodeURIComponent(projectPath)}` : `${path}?projectPath=${encodeURIComponent(projectPath)}`;
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (opts.auto === false) return;
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
setLoading(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
fetch(fullPath, opts.init)
|
|
62
|
+
.then(async r => {
|
|
63
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
64
|
+
return r.json();
|
|
65
|
+
})
|
|
66
|
+
.then(j => { if (!cancelled) { setData(j); setLoading(false); } })
|
|
67
|
+
.catch(e => { if (!cancelled) { setError(e?.message || String(e)); setLoading(false); } });
|
|
68
|
+
return () => { cancelled = true; };
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [fullPath, tick]);
|
|
71
|
+
|
|
72
|
+
return { data, loading, error, refetch: () => setTick(t => t + 1) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 3. useInject ─────────────────────────────────────────
|
|
76
|
+
export function useInject(): (text: string, opts?: { sessionName?: string }) => Promise<{ ok: boolean; sessionName?: string }> {
|
|
77
|
+
const { projectPath, projectName, craftName } = useCraftCtx();
|
|
78
|
+
return useCallback(async (text: string, opts = {}) => {
|
|
79
|
+
const r = await fetch('/api/craft-system/inject', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ projectPath, text, sessionName: opts.sessionName }),
|
|
83
|
+
});
|
|
84
|
+
const j = await r.json();
|
|
85
|
+
return { ok: !!j.ok, sessionName: j.sessionName };
|
|
86
|
+
}, [projectPath, projectName, craftName]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 4. useTask ───────────────────────────────────────────
|
|
90
|
+
export interface TaskHandle {
|
|
91
|
+
id: string;
|
|
92
|
+
watch: (onLog: (entry: any) => void, onDone?: (task: any) => void) => () => void;
|
|
93
|
+
cancel: () => Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useTask(): (prompt: string, opts?: { agent?: string }) => Promise<TaskHandle> {
|
|
97
|
+
const { projectPath, projectName } = useCraftCtx();
|
|
98
|
+
return useCallback(async (prompt: string, opts = {}) => {
|
|
99
|
+
const r = await fetch('/api/tasks', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ projectName, projectPath, prompt, agent: opts.agent }),
|
|
103
|
+
});
|
|
104
|
+
const t = await r.json();
|
|
105
|
+
if (!t?.id) throw new Error(t?.error || 'failed to create task');
|
|
106
|
+
return {
|
|
107
|
+
id: t.id,
|
|
108
|
+
watch: (onLog, onDone) => {
|
|
109
|
+
const es = new EventSource(`/api/tasks/${t.id}/stream`);
|
|
110
|
+
es.onmessage = (ev) => {
|
|
111
|
+
try {
|
|
112
|
+
const data = JSON.parse(ev.data);
|
|
113
|
+
if (data.type === 'log') onLog(data.entry);
|
|
114
|
+
else if (data.type === 'complete' && onDone) { onDone(data.task); es.close(); }
|
|
115
|
+
} catch {}
|
|
116
|
+
};
|
|
117
|
+
es.onerror = () => es.close();
|
|
118
|
+
return () => es.close();
|
|
119
|
+
},
|
|
120
|
+
cancel: async () => { await fetch(`/api/tasks/${t.id}/cancel`, { method: 'POST' }); },
|
|
121
|
+
};
|
|
122
|
+
}, [projectName, projectPath]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 5. useStore ──────────────────────────────────────────
|
|
126
|
+
// Stores at <project>/.forge/crafts/<name>/data/<file>.json via /api/crafts/<name>/_store
|
|
127
|
+
export function useStore<T = any>(file: string, defaultValue?: T): [T | null, (next: T) => Promise<void>, { loading: boolean; error: string | null; reload: () => void }] {
|
|
128
|
+
const { projectPath, craftName } = useCraftCtx();
|
|
129
|
+
const [value, setValue] = useState<T | null>(defaultValue ?? null);
|
|
130
|
+
const [loading, setLoading] = useState(true);
|
|
131
|
+
const [error, setError] = useState<string | null>(null);
|
|
132
|
+
const [tick, setTick] = useState(0);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
setLoading(true);
|
|
137
|
+
fetch(`/api/craft-system/storage?projectPath=${encodeURIComponent(projectPath)}&craft=${craftName}&file=${encodeURIComponent(file)}`)
|
|
138
|
+
.then(async r => r.ok ? r.json() : { value: null })
|
|
139
|
+
.then(j => { if (!cancelled) { setValue(j.value ?? defaultValue ?? null); setLoading(false); } })
|
|
140
|
+
.catch(e => { if (!cancelled) { setError(e?.message); setLoading(false); } });
|
|
141
|
+
return () => { cancelled = true; };
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
143
|
+
}, [projectPath, craftName, file, tick]);
|
|
144
|
+
|
|
145
|
+
const save = useCallback(async (next: T) => {
|
|
146
|
+
setValue(next);
|
|
147
|
+
await fetch(`/api/craft-system/storage?projectPath=${encodeURIComponent(projectPath)}&craft=${craftName}&file=${encodeURIComponent(file)}`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify({ value: next }),
|
|
151
|
+
});
|
|
152
|
+
}, [projectPath, craftName, file]);
|
|
153
|
+
|
|
154
|
+
return [value, save, { loading, error, reload: () => setTick(t => t + 1) }];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 6. useOpenAPI — load + parse an OpenAPI spec from the project ──
|
|
158
|
+
export interface OpenAPIData {
|
|
159
|
+
spec: any | null;
|
|
160
|
+
paths: string[];
|
|
161
|
+
schemas: Record<string, any>;
|
|
162
|
+
loading: boolean;
|
|
163
|
+
error: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function useOpenAPI(specPath: string): OpenAPIData {
|
|
167
|
+
const { projectPath } = useCraftCtx();
|
|
168
|
+
const [state, setState] = useState<OpenAPIData>({ spec: null, paths: [], schemas: {}, loading: true, error: null });
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
let cancelled = false;
|
|
171
|
+
setState(s => ({ ...s, loading: true }));
|
|
172
|
+
fetch(`/api/craft-system/helpers/openapi?projectPath=${encodeURIComponent(projectPath)}&path=${encodeURIComponent(specPath)}`)
|
|
173
|
+
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
|
|
174
|
+
.then(j => { if (!cancelled) setState({ spec: j.spec, paths: j.paths || [], schemas: j.schemas || {}, loading: false, error: null }); })
|
|
175
|
+
.catch(e => { if (!cancelled) setState(s => ({ ...s, loading: false, error: e.message })); });
|
|
176
|
+
return () => { cancelled = true; };
|
|
177
|
+
}, [projectPath, specPath]);
|
|
178
|
+
return state;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 7. useFile — read a file from the project (optional polling) ──
|
|
182
|
+
export function useFile(path: string, opts: { watch?: number } = {}): { content: string | null; loading: boolean; error: string | null; reload: () => void } {
|
|
183
|
+
const { projectPath } = useCraftCtx();
|
|
184
|
+
const [content, setContent] = useState<string | null>(null);
|
|
185
|
+
const [loading, setLoading] = useState(true);
|
|
186
|
+
const [error, setError] = useState<string | null>(null);
|
|
187
|
+
const [tick, setTick] = useState(0);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
let cancelled = false;
|
|
190
|
+
setLoading(true);
|
|
191
|
+
fetch(`/api/craft-system/helpers/file?projectPath=${encodeURIComponent(projectPath)}&path=${encodeURIComponent(path)}`)
|
|
192
|
+
.then(r => r.ok ? r.text() : Promise.reject(new Error(`${r.status}`)))
|
|
193
|
+
.then(t => { if (!cancelled) { setContent(t); setLoading(false); } })
|
|
194
|
+
.catch(e => { if (!cancelled) { setError(e.message); setLoading(false); } });
|
|
195
|
+
return () => { cancelled = true; };
|
|
196
|
+
}, [projectPath, path, tick]);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!opts.watch) return;
|
|
199
|
+
const id = setInterval(() => setTick(t => t + 1), opts.watch);
|
|
200
|
+
return () => clearInterval(id);
|
|
201
|
+
}, [opts.watch]);
|
|
202
|
+
return { content, loading, error, reload: () => setTick(t => t + 1) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 8. useShell — run a shell command in the project cwd ──
|
|
206
|
+
export function useShell(): (cmd: string, opts?: { timeout?: number }) => Promise<{ stdout: string; stderr: string; code: number }> {
|
|
207
|
+
const { projectPath } = useCraftCtx();
|
|
208
|
+
return useCallback(async (cmd, opts = {}) => {
|
|
209
|
+
const r = await fetch(`/api/craft-system/helpers/shell?projectPath=${encodeURIComponent(projectPath)}`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ cmd, timeout: opts.timeout }),
|
|
213
|
+
});
|
|
214
|
+
return r.json();
|
|
215
|
+
}, [projectPath]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── 9. useGit — quick git status / log helpers ──
|
|
219
|
+
export interface GitInfo {
|
|
220
|
+
branch?: string;
|
|
221
|
+
changes: { status: string; path: string }[];
|
|
222
|
+
ahead: number;
|
|
223
|
+
behind: number;
|
|
224
|
+
log: { hash: string; message: string; author: string; date: string }[];
|
|
225
|
+
}
|
|
226
|
+
export function useGit(): { info: GitInfo | null; loading: boolean; reload: () => void } {
|
|
227
|
+
const { projectPath } = useCraftCtx();
|
|
228
|
+
const [info, setInfo] = useState<GitInfo | null>(null);
|
|
229
|
+
const [loading, setLoading] = useState(true);
|
|
230
|
+
const [tick, setTick] = useState(0);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
let cancelled = false;
|
|
233
|
+
setLoading(true);
|
|
234
|
+
fetch(`/api/git?dir=${encodeURIComponent(projectPath)}`)
|
|
235
|
+
.then(r => r.ok ? r.json() : null)
|
|
236
|
+
.then(j => { if (!cancelled) { setInfo(j); setLoading(false); } })
|
|
237
|
+
.catch(() => { if (!cancelled) setLoading(false); });
|
|
238
|
+
return () => { cancelled = true; };
|
|
239
|
+
}, [projectPath, tick]);
|
|
240
|
+
return { info, loading, reload: () => setTick(t => t + 1) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── 10. useToast — quick notification ──
|
|
244
|
+
type ToastFn = (msg: string, kind?: 'info' | 'success' | 'error') => void;
|
|
245
|
+
let globalToast: ToastFn | null = null;
|
|
246
|
+
export function setGlobalToast(fn: ToastFn) { globalToast = fn; }
|
|
247
|
+
export function useToast(): ToastFn {
|
|
248
|
+
return useCallback((msg, kind = 'info') => {
|
|
249
|
+
if (globalToast) globalToast(msg, kind);
|
|
250
|
+
else console.log(`[toast:${kind}]`, msg);
|
|
251
|
+
}, []);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Bundle exports for the runtime shim
|
|
255
|
+
export function getSDK() {
|
|
256
|
+
return {
|
|
257
|
+
useProject, useForgeFetch, useInject, useTask, useStore,
|
|
258
|
+
useOpenAPI, useFile, useShell, useGit, useToast,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Server-side SDK — used by craft authors in their `server.ts` file.
|
|
2
|
+
|
|
3
|
+
import type { CraftServerDef, CraftRouteHandler } from '@/lib/crafts/types';
|
|
4
|
+
|
|
5
|
+
export function defineCraftServer(def: CraftServerDef): CraftServerDef {
|
|
6
|
+
// Identity wrapper for type checking + future validation hooks.
|
|
7
|
+
if (!def || typeof def !== 'object' || !def.routes) {
|
|
8
|
+
throw new Error('defineCraftServer: routes is required');
|
|
9
|
+
}
|
|
10
|
+
return def;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Re-export types so authors can `import type { ... } from '@forge/craft/server'`.
|
|
14
|
+
export type { CraftServerDef, CraftRouteHandler, ForgeServerApi, CraftRouteHandlerCtx } from '@/lib/crafts/types';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Discovers crafts in a project's .forge/crafts/ + builtins shipped with Forge.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import * as YAML from 'yaml';
|
|
6
|
+
import type { CraftDescriptor, CraftManifest } from './types';
|
|
7
|
+
|
|
8
|
+
const BUILTIN_DIR = resolve(process.cwd(), 'lib/builtin-crafts');
|
|
9
|
+
|
|
10
|
+
function readManifest(dir: string): CraftManifest | null {
|
|
11
|
+
const yml = join(dir, 'craft.yaml');
|
|
12
|
+
if (!existsSync(yml)) return null;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = YAML.parse(readFileSync(yml, 'utf8')) as CraftManifest;
|
|
15
|
+
if (!parsed?.name) return null;
|
|
16
|
+
return parsed;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function describe(dir: string, scope: 'builtin' | 'project'): CraftDescriptor | null {
|
|
23
|
+
const m = readManifest(dir);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const uiFile = m.ui?.tab || 'ui.tsx';
|
|
26
|
+
const serverFile = m.server?.entry || 'server.ts';
|
|
27
|
+
return {
|
|
28
|
+
...m,
|
|
29
|
+
__dir: dir,
|
|
30
|
+
__scope: scope,
|
|
31
|
+
hasUi: existsSync(join(dir, uiFile)),
|
|
32
|
+
hasServer: existsSync(join(dir, serverFile)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listChildren(dir: string): string[] {
|
|
37
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
38
|
+
return readdirSync(dir)
|
|
39
|
+
.filter(n => !n.startsWith('.') && !n.startsWith('_'))
|
|
40
|
+
.map(n => join(dir, n))
|
|
41
|
+
.filter(p => statSync(p).isDirectory());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listProjectCrafts(projectPath: string): CraftDescriptor[] {
|
|
45
|
+
const out: CraftDescriptor[] = [];
|
|
46
|
+
// Builtins first (open-source samples shipped with Forge)
|
|
47
|
+
for (const dir of listChildren(BUILTIN_DIR)) {
|
|
48
|
+
const d = describe(dir, 'builtin');
|
|
49
|
+
if (d) out.push(d);
|
|
50
|
+
}
|
|
51
|
+
// Project-local crafts override / extend builtins by name
|
|
52
|
+
const projDir = join(projectPath, '.forge', 'crafts');
|
|
53
|
+
for (const dir of listChildren(projDir)) {
|
|
54
|
+
const d = describe(dir, 'project');
|
|
55
|
+
if (!d) continue;
|
|
56
|
+
const idx = out.findIndex(x => x.name === d.name);
|
|
57
|
+
if (idx >= 0) out[idx] = d; else out.push(d);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getCraft(projectPath: string, name: string): CraftDescriptor | null {
|
|
63
|
+
return listProjectCrafts(projectPath).find(c => c.name === name) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Best-effort condition evaluator for showWhen expressions. Tiny DSL only.
|
|
67
|
+
// hasFile("path/relative/to/project")
|
|
68
|
+
// always
|
|
69
|
+
export function shouldShow(craft: CraftDescriptor, projectPath: string): boolean {
|
|
70
|
+
// First gate: requirements (project-type compatibility). If any are declared,
|
|
71
|
+
// at least one matcher must match. This is the same gate the marketplace uses.
|
|
72
|
+
if (craft.requires) {
|
|
73
|
+
if (!matchesRequirements(craft.requires, projectPath)) return false;
|
|
74
|
+
}
|
|
75
|
+
// Second gate: explicit ui.showWhen expression
|
|
76
|
+
const expr = craft.ui?.showWhen;
|
|
77
|
+
if (!expr || expr.trim() === 'always') return true;
|
|
78
|
+
const m = expr.match(/^hasFile\(\s*["']([^"']+)["']\s*\)$/);
|
|
79
|
+
if (m) return existsSync(join(projectPath, m[1]));
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Evaluate craft's requires field against a project path. Returns true when
|
|
84
|
+
// the project satisfies at least one of the declared requirements (OR logic).
|
|
85
|
+
// An empty requires object means "no constraint" → true.
|
|
86
|
+
export function matchesRequirements(req: NonNullable<CraftDescriptor['requires']>, projectPath: string): boolean {
|
|
87
|
+
const files = req.hasFile || [];
|
|
88
|
+
const globs = req.hasGlob || [];
|
|
89
|
+
if (files.length === 0 && globs.length === 0) return true;
|
|
90
|
+
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
if (existsSync(join(projectPath, f))) return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Cheap glob match via shell. Bounded; runs once per craft, not per file.
|
|
96
|
+
for (const g of globs) {
|
|
97
|
+
try {
|
|
98
|
+
const r = require('node:child_process').execSync(
|
|
99
|
+
`find "${projectPath}" -path "${projectPath}/node_modules" -prune -o -path "${projectPath}/.git" -prune -o -name '*' -print 2>/dev/null | head -200 | grep -q -E "${globToRegex(g)}"`,
|
|
100
|
+
{ timeout: 3000, stdio: 'pipe' }
|
|
101
|
+
);
|
|
102
|
+
if (r) return true;
|
|
103
|
+
} catch {
|
|
104
|
+
// grep -q returns 1 when no match, but execSync throws — ignore
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function globToRegex(glob: string): string {
|
|
111
|
+
// Tiny glob → regex: ** → .*, * → [^/]*, . escaped
|
|
112
|
+
return glob
|
|
113
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
114
|
+
.replace(/\*\*/g, '§§')
|
|
115
|
+
.replace(/\*/g, '[^/]*')
|
|
116
|
+
.replace(/§§/g, '.*');
|
|
117
|
+
}
|