@aion0/forge 0.8.5 → 0.8.7
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 +7 -4
- package/app/api/agents/route.ts +11 -1
- package/app/api/jobs/preview/route.ts +54 -5
- package/app/api/jobs/recipes/route.ts +59 -0
- package/app/api/workflows/marketplace/route.ts +52 -0
- package/bin/forge-server.mjs +21 -0
- package/components/PipelineView.tsx +255 -7
- package/components/SettingsModal.tsx +45 -10
- package/components/SkillsPanel.tsx +151 -17
- package/components/WorkspaceView.tsx +3 -1
- package/install.sh +28 -0
- package/lib/agents/index.ts +6 -1
- package/lib/chat/agent-loop.ts +37 -3
- package/lib/chat/llm/anthropic.ts +22 -4
- package/lib/chat/protocols/http.ts +46 -2
- package/lib/chat/tool-dispatcher.ts +21 -3
- package/lib/jobs/recipes.ts +260 -0
- package/lib/jobs/scheduler.ts +17 -2
- package/lib/pipeline.ts +5 -610
- package/lib/settings.ts +6 -0
- package/lib/workflow-marketplace.ts +275 -0
- package/package.json +1 -1
|
@@ -860,9 +860,10 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
|
|
|
860
860
|
<select value={cfg.provider || 'anthropic'} onChange={e => onUpdate({ ...cfg, provider: e.target.value })} className={inputClass}>
|
|
861
861
|
<option value="anthropic">Anthropic</option>
|
|
862
862
|
<option value="openai">OpenAI</option>
|
|
863
|
+
<option value="grok">Grok / xAI</option>
|
|
864
|
+
<option value="google">Google / Gemini</option>
|
|
865
|
+
<option value="deepseek">DeepSeek</option>
|
|
863
866
|
<option value="litellm">LiteLLM (OpenAI-compatible proxy)</option>
|
|
864
|
-
<option value="grok">Grok</option>
|
|
865
|
-
<option value="google">Google</option>
|
|
866
867
|
</select>
|
|
867
868
|
</div>
|
|
868
869
|
<div className="flex-1">
|
|
@@ -871,11 +872,28 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
|
|
|
871
872
|
</div>
|
|
872
873
|
</div>
|
|
873
874
|
<div>
|
|
874
|
-
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
875
|
+
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
876
|
+
Base URL {(() => {
|
|
877
|
+
const p = (cfg.provider || '').toLowerCase();
|
|
878
|
+
if (p === 'litellm') return '— required (LiteLLM / proxy)';
|
|
879
|
+
if (p === '' || p === 'other') return '— optional';
|
|
880
|
+
return '— optional, leave empty to use the provider default below';
|
|
881
|
+
})()}
|
|
882
|
+
</label>
|
|
875
883
|
<input
|
|
876
884
|
value={cfg.baseUrl || ''}
|
|
877
885
|
onChange={e => onUpdate({ ...cfg, baseUrl: e.target.value })}
|
|
878
|
-
placeholder={
|
|
886
|
+
placeholder={(() => {
|
|
887
|
+
switch ((cfg.provider || '').toLowerCase()) {
|
|
888
|
+
case 'anthropic': return 'https://api.anthropic.com (default)';
|
|
889
|
+
case 'openai': return 'https://api.openai.com/v1 (default)';
|
|
890
|
+
case 'grok': case 'xai': return 'https://api.x.ai/v1 (default)';
|
|
891
|
+
case 'google': case 'gemini': return 'https://generativelanguage.googleapis.com/v1beta/openai (default)';
|
|
892
|
+
case 'deepseek': return 'https://api.deepseek.com (default)';
|
|
893
|
+
case 'litellm': return 'http://127.0.0.1:4000/v1';
|
|
894
|
+
default: return 'https://...';
|
|
895
|
+
}
|
|
896
|
+
})()}
|
|
879
897
|
className={inputClass + ' font-mono'}
|
|
880
898
|
/>
|
|
881
899
|
</div>
|
|
@@ -1085,9 +1103,10 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1085
1103
|
<select value={provider} onChange={e => setProvider(e.target.value)} className={inputClass}>
|
|
1086
1104
|
<option value="anthropic">Anthropic</option>
|
|
1087
1105
|
<option value="openai">OpenAI</option>
|
|
1106
|
+
<option value="grok">Grok / xAI</option>
|
|
1107
|
+
<option value="google">Google / Gemini</option>
|
|
1108
|
+
<option value="deepseek">DeepSeek</option>
|
|
1088
1109
|
<option value="litellm">LiteLLM (OpenAI-compatible proxy)</option>
|
|
1089
|
-
<option value="grok">Grok</option>
|
|
1090
|
-
<option value="google">Google</option>
|
|
1091
1110
|
</select>
|
|
1092
1111
|
</div>
|
|
1093
1112
|
<div className="flex-1">
|
|
@@ -1100,11 +1119,23 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1100
1119
|
<input type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder="sk-..." className={inputClass} />
|
|
1101
1120
|
</div>
|
|
1102
1121
|
<div>
|
|
1103
|
-
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
1122
|
+
<label className="text-[8px] text-[var(--text-secondary)]">
|
|
1123
|
+
Base URL {provider === 'litellm' ? '(required)' : '(optional — leave empty for provider default)'}
|
|
1124
|
+
</label>
|
|
1104
1125
|
<input
|
|
1105
1126
|
value={baseUrl}
|
|
1106
1127
|
onChange={e => setBaseUrl(e.target.value)}
|
|
1107
|
-
placeholder={
|
|
1128
|
+
placeholder={(() => {
|
|
1129
|
+
switch (provider) {
|
|
1130
|
+
case 'anthropic': return 'https://api.anthropic.com (default)';
|
|
1131
|
+
case 'openai': return 'https://api.openai.com/v1 (default)';
|
|
1132
|
+
case 'grok': return 'https://api.x.ai/v1 (default)';
|
|
1133
|
+
case 'google': return 'https://generativelanguage.googleapis.com/v1beta/openai (default)';
|
|
1134
|
+
case 'deepseek': return 'https://api.deepseek.com (default)';
|
|
1135
|
+
case 'litellm': return 'http://127.0.0.1:4000/v1';
|
|
1136
|
+
default: return 'https://...';
|
|
1137
|
+
}
|
|
1138
|
+
})()}
|
|
1108
1139
|
className={inputClass + ' font-mono'}
|
|
1109
1140
|
/>
|
|
1110
1141
|
</div>
|
|
@@ -1175,9 +1206,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1175
1206
|
});
|
|
1176
1207
|
}
|
|
1177
1208
|
|
|
1178
|
-
// Add configured but not detected agents
|
|
1209
|
+
// Add configured but not detected agents. Skip rows that are
|
|
1210
|
+
// profiles (CLI profile with `base`, or API profile with
|
|
1211
|
+
// `type: 'api'`) — those have their own Profiles section below
|
|
1212
|
+
// and don't belong in the Agents list.
|
|
1179
1213
|
for (const [id, cfg] of Object.entries(configured) as [string, any][]) {
|
|
1180
1214
|
if (merged.find(a => a.id === id)) continue;
|
|
1215
|
+
if (cfg.type === 'api' || cfg.base) continue;
|
|
1181
1216
|
merged.push({
|
|
1182
1217
|
id,
|
|
1183
1218
|
name: cfg.name ?? id,
|
|
@@ -1751,7 +1786,7 @@ function ChatAgentSelect({ settings, setSettings }: { settings: any; setSettings
|
|
|
1751
1786
|
<div className="mt-4 pt-3 border-t border-[var(--border)]">
|
|
1752
1787
|
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase mb-2 block">Chat backend</label>
|
|
1753
1788
|
<div className="flex items-center gap-2">
|
|
1754
|
-
<span className="text-[9px] text-[var(--text-secondary)]">Default chat
|
|
1789
|
+
<span className="text-[9px] text-[var(--text-secondary)]">Default chat profile:</span>
|
|
1755
1790
|
<select
|
|
1756
1791
|
value={settings.chatAgent || ''}
|
|
1757
1792
|
onChange={e => setSettings({ ...settings, chatAgent: e.target.value })}
|
|
@@ -142,7 +142,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
142
142
|
const [syncing, setSyncing] = useState(false);
|
|
143
143
|
const [loading, setLoading] = useState(true);
|
|
144
144
|
const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
|
|
145
|
-
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts'>('all');
|
|
145
|
+
const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins' | 'connectors' | 'crafts' | 'recipes' | 'pipelines'>('all');
|
|
146
146
|
const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
|
|
147
147
|
// Rules (CLAUDE.md templates)
|
|
148
148
|
const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
|
|
@@ -414,21 +414,31 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
414
414
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
415
415
|
<div className="flex items-center gap-2">
|
|
416
416
|
<span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
>
|
|
428
|
-
|
|
429
|
-
</
|
|
430
|
-
|
|
431
|
-
|
|
417
|
+
{/* Grouped category dropdown — replaces the long inline tab bar.
|
|
418
|
+
Native <optgroup> gives free keyboard nav + a coherent layout
|
|
419
|
+
regardless of category count. */}
|
|
420
|
+
<select
|
|
421
|
+
value={typeFilter}
|
|
422
|
+
onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)}
|
|
423
|
+
className="text-[10px] px-2 py-1 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
424
|
+
>
|
|
425
|
+
<optgroup label="Catalog">
|
|
426
|
+
<option value="all">All ({skills.length})</option>
|
|
427
|
+
<option value="skill">Skills ({skillCount})</option>
|
|
428
|
+
<option value="command">Commands ({commandCount})</option>
|
|
429
|
+
<option value="local">Local ({localCount})</option>
|
|
430
|
+
<option value="rules">Rules</option>
|
|
431
|
+
</optgroup>
|
|
432
|
+
<optgroup label="Extensions">
|
|
433
|
+
<option value="plugins">Plugins</option>
|
|
434
|
+
<option value="connectors">Connectors</option>
|
|
435
|
+
<option value="crafts">Crafts</option>
|
|
436
|
+
</optgroup>
|
|
437
|
+
<optgroup label="Templates">
|
|
438
|
+
<option value="recipes">Recipes</option>
|
|
439
|
+
<option value="pipelines">Pipelines</option>
|
|
440
|
+
</optgroup>
|
|
441
|
+
</select>
|
|
432
442
|
</div>
|
|
433
443
|
<span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
|
|
434
444
|
<input
|
|
@@ -470,7 +480,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
470
480
|
/>
|
|
471
481
|
</div>}
|
|
472
482
|
|
|
473
|
-
{typeFilter === 'rules' || typeFilter === 'plugins' || typeFilter === 'connectors' || typeFilter === 'crafts' ? null : skills.length === 0 ? (
|
|
483
|
+
{typeFilter === 'rules' || typeFilter === 'plugins' || typeFilter === 'connectors' || typeFilter === 'crafts' || typeFilter === 'recipes' || typeFilter === 'pipelines' ? null : skills.length === 0 ? (
|
|
474
484
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
|
|
475
485
|
<p className="text-xs">No skills yet</p>
|
|
476
486
|
<button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
|
|
@@ -1063,6 +1073,130 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
1063
1073
|
<CraftsMarketplacePanelLazy searchQuery={searchQuery} />
|
|
1064
1074
|
</Suspense>
|
|
1065
1075
|
)}
|
|
1076
|
+
|
|
1077
|
+
{/* Recipes / Pipelines — read-only marketplace browser. Actual
|
|
1078
|
+
use lives in extension's Jobs tab (recipes) and Pipelines tab
|
|
1079
|
+
(pipelines): both show the same registry data with a "create
|
|
1080
|
+
local copy / instantiate" action. This page exists for
|
|
1081
|
+
discovery only. */}
|
|
1082
|
+
{(typeFilter === 'recipes' || typeFilter === 'pipelines') && (
|
|
1083
|
+
<WorkflowMarketplaceBrowser kind={typeFilter === 'recipes' ? 'recipe' : 'pipeline'} searchQuery={searchQuery} />
|
|
1084
|
+
)}
|
|
1085
|
+
</div>
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// ─── Workflow Marketplace browser (read-only) ────────────────
|
|
1090
|
+
// Pulls /api/workflows/marketplace and renders recipes or pipelines.
|
|
1091
|
+
// Each row links to where you'd actually use it; no Install button here
|
|
1092
|
+
// because the use flow (Jobs/Pipelines tab) handles install implicitly.
|
|
1093
|
+
|
|
1094
|
+
interface MarketRow {
|
|
1095
|
+
kind: 'recipe' | 'pipeline';
|
|
1096
|
+
name: string;
|
|
1097
|
+
display_name: string;
|
|
1098
|
+
description?: string;
|
|
1099
|
+
version: string;
|
|
1100
|
+
author?: string;
|
|
1101
|
+
tags?: string[];
|
|
1102
|
+
source: 'registry' | 'local';
|
|
1103
|
+
installed: boolean;
|
|
1104
|
+
installed_version?: string;
|
|
1105
|
+
has_update?: boolean;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function WorkflowMarketplaceBrowser({ kind, searchQuery }: { kind: 'recipe' | 'pipeline'; searchQuery: string }) {
|
|
1109
|
+
const [rows, setRows] = useState<MarketRow[] | null>(null);
|
|
1110
|
+
const [busy, setBusy] = useState(false);
|
|
1111
|
+
const [err, setErr] = useState<string>('');
|
|
1112
|
+
|
|
1113
|
+
const useInLocation = kind === 'recipe' ? 'Jobs tab' : 'Pipelines tab';
|
|
1114
|
+
|
|
1115
|
+
const load = async () => {
|
|
1116
|
+
try {
|
|
1117
|
+
const res = await fetch('/api/workflows/marketplace');
|
|
1118
|
+
const data = await res.json();
|
|
1119
|
+
setRows((kind === 'recipe' ? data.recipes : data.pipelines) || []);
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
1122
|
+
setRows([]);
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const sync = async () => {
|
|
1127
|
+
setBusy(true);
|
|
1128
|
+
setErr('');
|
|
1129
|
+
try {
|
|
1130
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
1131
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1132
|
+
body: JSON.stringify({ action: 'sync' }),
|
|
1133
|
+
});
|
|
1134
|
+
const data = await res.json();
|
|
1135
|
+
if (!data.ok) setErr(data.error || 'sync failed');
|
|
1136
|
+
await load();
|
|
1137
|
+
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
|
1138
|
+
finally { setBusy(false); }
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
useEffect(() => { void load(); }, [kind]);
|
|
1142
|
+
|
|
1143
|
+
const q = searchQuery.toLowerCase();
|
|
1144
|
+
const filtered = (rows || []).filter(r => !q
|
|
1145
|
+
|| r.name.toLowerCase().includes(q)
|
|
1146
|
+
|| r.display_name.toLowerCase().includes(q)
|
|
1147
|
+
|| (r.description || '').toLowerCase().includes(q)
|
|
1148
|
+
|| (r.tags || []).some(t => t.toLowerCase().includes(q)));
|
|
1149
|
+
|
|
1150
|
+
return (
|
|
1151
|
+
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
|
1152
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-2 shrink-0">
|
|
1153
|
+
<span className="text-[10px] text-[var(--text-secondary)] flex-1">
|
|
1154
|
+
{kind === 'recipe' ? 'Job recipes' : 'Pipeline templates'} from <code className="font-mono">aiwatching/forge-workflow</code>
|
|
1155
|
+
{' · '}use them from the {useInLocation}
|
|
1156
|
+
</span>
|
|
1157
|
+
<button
|
|
1158
|
+
onClick={() => void sync()}
|
|
1159
|
+
disabled={busy}
|
|
1160
|
+
className="text-[9px] px-2 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
1161
|
+
>{busy ? 'Syncing…' : 'Sync'}</button>
|
|
1162
|
+
</div>
|
|
1163
|
+
{err && (
|
|
1164
|
+
<div className="px-4 py-2 text-[10px] text-[var(--red)] border-b border-[var(--border)]">{err}</div>
|
|
1165
|
+
)}
|
|
1166
|
+
<div className="p-3 space-y-2">
|
|
1167
|
+
{rows == null ? (
|
|
1168
|
+
<div className="text-[11px] text-[var(--text-secondary)] text-center py-8">Loading…</div>
|
|
1169
|
+
) : filtered.length === 0 ? (
|
|
1170
|
+
<div className="text-[11px] text-[var(--text-secondary)] text-center py-8">
|
|
1171
|
+
{rows.length === 0 ? <>Nothing in the registry yet — click <b>Sync</b>.</> : 'No matches for your search.'}
|
|
1172
|
+
</div>
|
|
1173
|
+
) : (
|
|
1174
|
+
filtered.map((r) => (
|
|
1175
|
+
<div key={r.name} className="border border-[var(--border)] rounded p-3 bg-[var(--bg-tertiary)]/30">
|
|
1176
|
+
<div className="flex items-baseline gap-2">
|
|
1177
|
+
<span className="text-[11px] font-semibold text-[var(--text-primary)] flex-1">{r.display_name}</span>
|
|
1178
|
+
<span className="text-[8px] text-[var(--text-secondary)] font-mono">v{r.version}</span>
|
|
1179
|
+
{/* Read-only catalog — no install/uninstall here; usage
|
|
1180
|
+
happens in Jobs / Pipelines tab and that's where the
|
|
1181
|
+
install state matters. */}
|
|
1182
|
+
{r.source === 'local' && <span className="text-[8px] text-[var(--text-secondary)]" title="Only on this machine — not in the marketplace registry">· local-only</span>}
|
|
1183
|
+
</div>
|
|
1184
|
+
{r.description && (
|
|
1185
|
+
<p className="text-[10px] text-[var(--text-secondary)] mt-1.5 leading-relaxed whitespace-pre-wrap">{r.description}</p>
|
|
1186
|
+
)}
|
|
1187
|
+
<div className="mt-2 flex items-center gap-2">
|
|
1188
|
+
<code className="text-[9px] text-[var(--text-secondary)] font-mono">{r.name}</code>
|
|
1189
|
+
{r.author && <span className="text-[9px] text-[var(--text-secondary)]">· {r.author}</span>}
|
|
1190
|
+
{r.tags && r.tags.length > 0 && (
|
|
1191
|
+
<span className="text-[9px] text-[var(--text-secondary)]">{r.tags.map(t => `#${t}`).join(' ')}</span>
|
|
1192
|
+
)}
|
|
1193
|
+
<span className="flex-1" />
|
|
1194
|
+
<span className="text-[9px] text-[var(--text-secondary)] italic">Use in {useInLocation} →</span>
|
|
1195
|
+
</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
))
|
|
1198
|
+
)}
|
|
1199
|
+
</div>
|
|
1066
1200
|
</div>
|
|
1067
1201
|
);
|
|
1068
1202
|
}
|
|
@@ -726,7 +726,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
726
726
|
const [pluginDefs, setPluginDefs] = useState<{ id: string; name: string; icon: string }[]>([]);
|
|
727
727
|
|
|
728
728
|
useEffect(() => {
|
|
729
|
-
|
|
729
|
+
// WorkspaceView shows BOTH CLI agents and API profiles (workspace
|
|
730
|
+
// agents can run in either backend), so opt into ?include=all.
|
|
731
|
+
fetch('/api/agents?include=all').then(r => r.json()).then(data => {
|
|
730
732
|
const list = (data.agents || data || []).map((a: any) => ({
|
|
731
733
|
id: a.id, name: a.name || a.id,
|
|
732
734
|
isProfile: a.isProfile || a.base,
|
package/install.sh
CHANGED
|
@@ -47,6 +47,34 @@ if ! command -v claude >/dev/null 2>&1 \
|
|
|
47
47
|
echo ""
|
|
48
48
|
fi
|
|
49
49
|
|
|
50
|
+
# Optional helpers used by built-in workflows. Warn-only — Forge installs
|
|
51
|
+
# fine without them, but specific pipelines will fail at the shell step
|
|
52
|
+
# if they're missing (e.g. mr-review-fix calls glab + jq).
|
|
53
|
+
missing_opts=()
|
|
54
|
+
command -v jq >/dev/null 2>&1 || missing_opts+=("jq")
|
|
55
|
+
command -v glab >/dev/null 2>&1 || missing_opts+=("glab")
|
|
56
|
+
if [ ${#missing_opts[@]} -gt 0 ]; then
|
|
57
|
+
echo "[forge] ⚠️ Optional CLIs missing: ${missing_opts[*]}"
|
|
58
|
+
echo "[forge] jq — needed by shell pipelines that parse JSON"
|
|
59
|
+
echo "[forge] glab — needed by the mr-review-fix workflow (GitLab API)"
|
|
60
|
+
case "$(uname -s)" in
|
|
61
|
+
Darwin)
|
|
62
|
+
echo "[forge] Install: brew install ${missing_opts[*]}"
|
|
63
|
+
if command -v brew >/dev/null 2>&1; then
|
|
64
|
+
read -r -p "[forge] Install now? [y/N] " reply
|
|
65
|
+
case "$reply" in [yY]*) brew install "${missing_opts[@]}" ;; esac
|
|
66
|
+
fi
|
|
67
|
+
;;
|
|
68
|
+
Linux)
|
|
69
|
+
echo "[forge] Install via your distro's package manager."
|
|
70
|
+
case " ${missing_opts[*]} " in
|
|
71
|
+
*" glab "*) echo "[forge] glab — see https://gitlab.com/gitlab-org/cli#installation" ;;
|
|
72
|
+
esac
|
|
73
|
+
;;
|
|
74
|
+
esac
|
|
75
|
+
echo ""
|
|
76
|
+
fi
|
|
77
|
+
|
|
50
78
|
if [ "$1" = "local" ] || [ "$1" = "--local" ]; then
|
|
51
79
|
echo "[forge] Installing from local source..."
|
|
52
80
|
npm uninstall -g @aion0/forge 2>/dev/null || true
|
package/lib/agents/index.ts
CHANGED
|
@@ -78,7 +78,12 @@ export function listAgents(): AgentConfig[] {
|
|
|
78
78
|
for (const [id, cfg] of Object.entries(settings.agents)) {
|
|
79
79
|
if (['claude', 'codex', 'aider'].includes(id)) continue;
|
|
80
80
|
|
|
81
|
-
// API profile — no CLI detection needed
|
|
81
|
+
// API profile — no CLI detection needed. Tagged with
|
|
82
|
+
// backendType='api' so CLI-only dropdowns (Terminal launcher,
|
|
83
|
+
// NewTaskModal etc.) can filter them out via
|
|
84
|
+
// `agents.filter(a => a.backendType !== 'api')`. WorkspaceView's
|
|
85
|
+
// API mode keeps them visible. Chat backend doesn't go through
|
|
86
|
+
// this path at all — it reads settings.agents directly.
|
|
82
87
|
if (cfg.type === 'api') {
|
|
83
88
|
agents.push({
|
|
84
89
|
id,
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -78,11 +78,45 @@ function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
|
|
|
78
78
|
return 'openai';
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Provider → default public API URL. Saves the user from having to know
|
|
83
|
+
* the right host for first-party providers; only LiteLLM (which is by
|
|
84
|
+
* definition a self-hosted proxy) requires an explicit URL.
|
|
85
|
+
*
|
|
86
|
+
* Resolution priority: profile.baseUrl > env.*_BASE_URL > provider default.
|
|
87
|
+
*/
|
|
88
|
+
export function defaultBaseUrl(provider: string | undefined): string {
|
|
89
|
+
switch ((provider || '').toLowerCase()) {
|
|
90
|
+
case 'anthropic':
|
|
91
|
+
case 'claude':
|
|
92
|
+
return 'https://api.anthropic.com';
|
|
93
|
+
case 'openai':
|
|
94
|
+
return 'https://api.openai.com/v1';
|
|
95
|
+
case 'grok':
|
|
96
|
+
case 'xai':
|
|
97
|
+
return 'https://api.x.ai/v1';
|
|
98
|
+
case 'google':
|
|
99
|
+
case 'gemini':
|
|
100
|
+
// Google's Gemini exposes an OpenAI-compatible shim at this path.
|
|
101
|
+
return 'https://generativelanguage.googleapis.com/v1beta/openai';
|
|
102
|
+
case 'deepseek':
|
|
103
|
+
return 'https://api.deepseek.com';
|
|
104
|
+
default:
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pickBaseUrl(
|
|
110
|
+
profile: { baseUrl?: string; env?: Record<string, string>; provider?: string },
|
|
111
|
+
adapter: 'anthropic' | 'openai',
|
|
112
|
+
): string {
|
|
82
113
|
if (profile.baseUrl) return profile.baseUrl;
|
|
83
114
|
const env = profile.env || {};
|
|
84
|
-
|
|
85
|
-
|
|
115
|
+
const envOverride = adapter === 'anthropic'
|
|
116
|
+
? env.ANTHROPIC_BASE_URL
|
|
117
|
+
: (env.OPENAI_BASE_URL || env.OPENAI_API_BASE);
|
|
118
|
+
if (envOverride) return envOverride;
|
|
119
|
+
return defaultBaseUrl(profile.provider);
|
|
86
120
|
}
|
|
87
121
|
|
|
88
122
|
function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
|
|
@@ -16,10 +16,13 @@ function historyToApi(history: Message[]): Anthropic.MessageParam[] {
|
|
|
16
16
|
if (b.type === 'text') {
|
|
17
17
|
if (b.text.length > 0) content.push({ type: 'text', text: b.text });
|
|
18
18
|
} else if (b.type === 'tool_use') {
|
|
19
|
+
// Same encoding as tool definitions — names with `.` must
|
|
20
|
+
// round-trip through Anthropic as `__` to satisfy its tool name
|
|
21
|
+
// regex when this turn's history is re-sent next turn.
|
|
19
22
|
content.push({
|
|
20
23
|
type: 'tool_use',
|
|
21
24
|
id: b.id,
|
|
22
|
-
name: b.name,
|
|
25
|
+
name: b.name.replace(/\./g, '__'),
|
|
23
26
|
input: (b.input ?? {}) as Record<string, unknown>,
|
|
24
27
|
});
|
|
25
28
|
} else if (b.type === 'tool_result') {
|
|
@@ -64,6 +67,20 @@ export function makeAnthropicClient(apiKey: string, baseUrl: string): Anthropic
|
|
|
64
67
|
return new Anthropic(opts);
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Anthropic restricts tool names to `^[a-zA-Z0-9_-]{1,128}$`. Forge's
|
|
72
|
+
* connector tools use a `connector.tool` namespace (e.g.
|
|
73
|
+
* `gitlab.list_my_mrs`), so we encode the dot as `__` on the way out and
|
|
74
|
+
* decode it back when Anthropic returns a tool_use. Internal dispatcher
|
|
75
|
+
* still sees the canonical dotted form.
|
|
76
|
+
*/
|
|
77
|
+
function encodeToolName(name: string): string {
|
|
78
|
+
return name.replace(/\./g, '__');
|
|
79
|
+
}
|
|
80
|
+
function decodeToolName(name: string): string {
|
|
81
|
+
return name.replace(/__/g, '.');
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
export const anthropicAdapter: LlmAdapter = {
|
|
68
85
|
async stream(req: LlmRequest, cb: LlmCallbacks): Promise<LlmTurnResult> {
|
|
69
86
|
const client = makeAnthropicClient(req.apiKey, req.baseUrl);
|
|
@@ -73,7 +90,7 @@ export const anthropicAdapter: LlmAdapter = {
|
|
|
73
90
|
max_tokens: req.maxTokens,
|
|
74
91
|
system: req.system,
|
|
75
92
|
tools: req.tools.map((t) => ({
|
|
76
|
-
name: t.name,
|
|
93
|
+
name: encodeToolName(t.name),
|
|
77
94
|
description: t.description,
|
|
78
95
|
input_schema: t.input_schema as Anthropic.Tool.InputSchema,
|
|
79
96
|
})),
|
|
@@ -89,8 +106,9 @@ export const anthropicAdapter: LlmAdapter = {
|
|
|
89
106
|
if (block.type === 'text') {
|
|
90
107
|
content.push({ type: 'text', text: block.text });
|
|
91
108
|
} else if (block.type === 'tool_use') {
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
const decodedName = decodeToolName(block.name);
|
|
110
|
+
cb.onToolUse({ id: block.id, name: decodedName, input: block.input });
|
|
111
|
+
content.push({ type: 'tool_use', id: block.id, name: decodedName, input: block.input });
|
|
94
112
|
}
|
|
95
113
|
}
|
|
96
114
|
|
|
@@ -22,6 +22,13 @@ export interface HttpProtocolArgs {
|
|
|
22
22
|
tool: ConnectorTool;
|
|
23
23
|
settings: Record<string, any>;
|
|
24
24
|
args: Record<string, any>;
|
|
25
|
+
/**
|
|
26
|
+
* When true, return the full response body without the 8KB cap. Used by
|
|
27
|
+
* the Jobs scheduler — it parses JSON, not feeds the response into an
|
|
28
|
+
* LLM context, so truncation breaks `JSON.parse` for any response
|
|
29
|
+
* larger than 8KB (Todos / search responses routinely exceed this).
|
|
30
|
+
*/
|
|
31
|
+
noTruncation?: boolean;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface HttpProtocolResult {
|
|
@@ -45,8 +52,39 @@ function expandObjectLeaves(obj: any, settings: Record<string, any>, args: Recor
|
|
|
45
52
|
return obj;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Expand `{args.X}` placeholders in a URL path with the value URL-
|
|
57
|
+
* encoded. `{settings.X}` is NOT encoded — `{settings.base_url}` is the
|
|
58
|
+
* scheme + host (with its own `://` and `/`), which must stay literal.
|
|
59
|
+
*
|
|
60
|
+
* Why: GitLab and many REST APIs accept either a numeric id or a
|
|
61
|
+
* URL-encoded namespace path (`fortinac%2FFortiNAC`) as the project
|
|
62
|
+
* identifier in the path. Without encoding, `args.project_id =
|
|
63
|
+
* "fortinac/FortiNAC"` interpolates as a raw `/` and turns
|
|
64
|
+
* `/projects/{args.project_id}/...` into `/projects/fortinac/FortiNAC/...`
|
|
65
|
+
* (extra path segment), which the API can't parse. Numeric ids encode
|
|
66
|
+
* to themselves — no regression.
|
|
67
|
+
*/
|
|
68
|
+
function expandUrlPath(template: string, settings: Record<string, any>, args: Record<string, any>): string {
|
|
69
|
+
// First handle settings.* with raw substitution (keeps base_url intact).
|
|
70
|
+
let out = expandAllTokens(template, settings, {});
|
|
71
|
+
// Then handle args.* with URL-encoding.
|
|
72
|
+
out = out.replace(/\{args\.([^{}]+)\}/g, (full, rawKey) => {
|
|
73
|
+
const path = String(rawKey).trim().split('.');
|
|
74
|
+
let v: any = args;
|
|
75
|
+
for (const p of path) {
|
|
76
|
+
if (v == null || typeof v !== 'object') { v = undefined; break; }
|
|
77
|
+
v = v[p];
|
|
78
|
+
}
|
|
79
|
+
if (v == null) return full;
|
|
80
|
+
const s = typeof v === 'string' ? v : (typeof v === 'number' || typeof v === 'boolean' ? String(v) : JSON.stringify(v));
|
|
81
|
+
return encodeURIComponent(s);
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): string {
|
|
49
|
-
const base =
|
|
87
|
+
const base = expandUrlPath(spec.url, settings, args);
|
|
50
88
|
if (!spec.query) return base;
|
|
51
89
|
const url = new URL(base);
|
|
52
90
|
for (const [k, raw] of Object.entries(spec.query)) {
|
|
@@ -86,7 +124,7 @@ function truncate(s: string): { text: string; truncated: boolean; totalBytes: nu
|
|
|
86
124
|
return { text: slice, truncated: true, totalBytes: buf.byteLength };
|
|
87
125
|
}
|
|
88
126
|
|
|
89
|
-
export async function runHttp({ tool, settings, args }: HttpProtocolArgs): Promise<HttpProtocolResult> {
|
|
127
|
+
export async function runHttp({ tool, settings, args, noTruncation }: HttpProtocolArgs): Promise<HttpProtocolResult> {
|
|
90
128
|
const spec = tool.request;
|
|
91
129
|
if (!spec || !spec.url) {
|
|
92
130
|
return { content: 'http tool missing `request.url`', is_error: true };
|
|
@@ -125,6 +163,12 @@ export async function runHttp({ tool, settings, args }: HttpProtocolArgs): Promi
|
|
|
125
163
|
clearTimeout(timer);
|
|
126
164
|
|
|
127
165
|
const text = await res.text().catch(() => '');
|
|
166
|
+
if (noTruncation) {
|
|
167
|
+
// Jobs scheduler / preview path — return the body verbatim so
|
|
168
|
+
// JSON.parse works on responses larger than MAX_BODY_BYTES. No
|
|
169
|
+
// preamble either; callers parse content directly.
|
|
170
|
+
return { content: text, is_error: !res.ok };
|
|
171
|
+
}
|
|
128
172
|
const { text: shown, truncated, totalBytes } = truncate(text);
|
|
129
173
|
const preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n` +
|
|
130
174
|
(truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes — truncated)\n\n` : '\n');
|
|
@@ -130,11 +130,29 @@ function buildConnectorPayload(
|
|
|
130
130
|
|
|
131
131
|
// ─── Public entry ─────────────────────────────────────────
|
|
132
132
|
|
|
133
|
+
export interface DispatchOptions {
|
|
134
|
+
/** Per-turn extra builtins (e.g. memory_* when Temper is configured). */
|
|
135
|
+
extraBuiltins?: Record<string, BuiltinHandler>;
|
|
136
|
+
/**
|
|
137
|
+
* Skip the HTTP-protocol 8KB body cap. Set by callers that parse the
|
|
138
|
+
* response programmatically (Jobs scheduler, /api/jobs/preview) and
|
|
139
|
+
* therefore don't need an LLM-friendly truncation.
|
|
140
|
+
*/
|
|
141
|
+
noTruncation?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
export async function dispatchTool(
|
|
134
145
|
call: ToolCall,
|
|
135
|
-
|
|
136
|
-
extraBuiltins?: Record<string, BuiltinHandler>,
|
|
146
|
+
optsOrExtraBuiltins?: DispatchOptions | Record<string, BuiltinHandler>,
|
|
137
147
|
): Promise<ToolResult> {
|
|
148
|
+
// Back-compat: accept either DispatchOptions or a bare extraBuiltins map
|
|
149
|
+
// so older callers (chat agent-loop etc.) keep working without edits.
|
|
150
|
+
const opts: DispatchOptions = optsOrExtraBuiltins && 'extraBuiltins' in (optsOrExtraBuiltins as any)
|
|
151
|
+
? (optsOrExtraBuiltins as DispatchOptions)
|
|
152
|
+
: optsOrExtraBuiltins && 'noTruncation' in (optsOrExtraBuiltins as any)
|
|
153
|
+
? (optsOrExtraBuiltins as DispatchOptions)
|
|
154
|
+
: { extraBuiltins: optsOrExtraBuiltins as Record<string, BuiltinHandler> | undefined };
|
|
155
|
+
const extraBuiltins = opts.extraBuiltins;
|
|
138
156
|
// Per-turn builtins first (memory tools), then global builtins.
|
|
139
157
|
const dynBuiltin = extraBuiltins?.[call.name];
|
|
140
158
|
if (dynBuiltin) {
|
|
@@ -164,7 +182,7 @@ export async function dispatchTool(
|
|
|
164
182
|
try {
|
|
165
183
|
switch (protocol) {
|
|
166
184
|
case 'http':
|
|
167
|
-
return await runHttp({ tool: located.tool, settings: located.settings, args: argInput });
|
|
185
|
+
return await runHttp({ tool: located.tool, settings: located.settings, args: argInput, noTruncation: opts.noTruncation });
|
|
168
186
|
case 'shell':
|
|
169
187
|
return await runShell({ tool: located.tool, settings: located.settings, args: argInput });
|
|
170
188
|
case 'browser': {
|