@aion0/forge 0.10.6 → 0.10.17
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 +5 -5
- package/app/api/public-info/[resource]/route.ts +40 -0
- package/app/api/skills/install-local/route.ts +2 -1
- package/cli/mw.mjs +11 -21
- package/components/SettingsModal.tsx +42 -33
- package/components/WorkspaceView.tsx +5 -3
- package/lib/agents/index.ts +8 -9
- package/lib/agents/known-models.ts +75 -0
- package/lib/agents/migrate.ts +14 -3
- package/lib/dirs.ts +6 -26
- package/lib/public-info/fetch.ts +116 -0
- package/lib/public-info/types.ts +38 -0
- package/lib/public-info/use-models-registry.ts +66 -0
- package/lib/settings.ts +34 -4
- package/lib/skills.ts +2 -2
- package/lib/workspace/watch-manager.ts +5 -1
- package/package.json +1 -1
- package/lib/__tests__/foreach-batch-yaml.test.ts +0 -33
- package/lib/__tests__/foreach-before.test.ts +0 -201
- package/lib/__tests__/foreach-parse.test.ts +0 -114
- package/lib/__tests__/foreach-snapshot.test.ts +0 -112
- package/lib/__tests__/foreach-source.test.ts +0 -105
- package/lib/__tests__/foreach-template.test.ts +0 -112
- package/lib/workspace/__tests__/state-machine.test.ts +0 -388
- package/lib/workspace/__tests__/workspace.test.ts +0 -311
- package/scripts/bench/README.md +0 -66
- package/scripts/bench/results/.gitignore +0 -2
- package/scripts/bench/run.ts +0 -635
- package/scripts/bench/tasks/01-text-utils/task.md +0 -26
- package/scripts/bench/tasks/01-text-utils/validator.sh +0 -46
- package/scripts/bench/tasks/02-pagination/setup.sh +0 -19
- package/scripts/bench/tasks/02-pagination/task.md +0 -48
- package/scripts/bench/tasks/02-pagination/validator.sh +0 -69
- package/scripts/bench/tasks/03-bug-fix/setup.sh +0 -82
- package/scripts/bench/tasks/03-bug-fix/task.md +0 -30
- package/scripts/bench/tasks/03-bug-fix/validator.sh +0 -29
- package/scripts/test-agents-migrate.ts +0 -149
- package/scripts/test-mantis.ts +0 -223
- package/scripts/test-memory-local.ts +0 -139
- package/scripts/test-memory-upsert.ts +0 -106
- package/scripts/verify-usage.ts +0 -178
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.17
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-30
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.16
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
- feat
|
|
7
|
+
### Features
|
|
8
|
+
- feat: API profile model picker uses registry providers
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.16...v0.10.17
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/public-info/<resource>?refresh=1
|
|
3
|
+
*
|
|
4
|
+
* Server-side proxy + cache for files under the public-info repo. UI
|
|
5
|
+
* never hits GitHub directly — avoids CORS / rate-limit headaches and
|
|
6
|
+
* gives us a single cache shared across all browser tabs.
|
|
7
|
+
*
|
|
8
|
+
* Supported resources (extend by adding to RESOURCE_MAP):
|
|
9
|
+
* - `models` → `models/registry.json`
|
|
10
|
+
*
|
|
11
|
+
* The UI passes the short name (`models`), this route translates to the
|
|
12
|
+
* actual path under the repo and pairs it with the right fallback.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
16
|
+
import { fetchPublicInfo } from '@/lib/public-info/fetch';
|
|
17
|
+
import { KNOWN_MODELS_FALLBACK } from '@/lib/agents/known-models';
|
|
18
|
+
|
|
19
|
+
interface ResourceSpec {
|
|
20
|
+
path: string;
|
|
21
|
+
fallback: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RESOURCE_MAP: Record<string, ResourceSpec> = {
|
|
25
|
+
models: { path: 'models/registry.json', fallback: KNOWN_MODELS_FALLBACK },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function GET(
|
|
29
|
+
req: NextRequest,
|
|
30
|
+
{ params }: { params: Promise<{ resource: string }> },
|
|
31
|
+
): Promise<NextResponse> {
|
|
32
|
+
const { resource } = await params;
|
|
33
|
+
const spec = RESOURCE_MAP[resource];
|
|
34
|
+
if (!spec) {
|
|
35
|
+
return NextResponse.json({ error: `unknown resource: ${resource}` }, { status: 404 });
|
|
36
|
+
}
|
|
37
|
+
const forceRefresh = req.nextUrl.searchParams.get('refresh') === '1';
|
|
38
|
+
const data = await fetchPublicInfo(spec.path, spec.fallback, { forceRefresh });
|
|
39
|
+
return NextResponse.json(data);
|
|
40
|
+
}
|
|
@@ -29,6 +29,7 @@ import AdmZip from 'adm-zip';
|
|
|
29
29
|
import { homedir } from 'node:os';
|
|
30
30
|
import { getDb } from '@/src/core/db/database';
|
|
31
31
|
import { getDbPath } from '@/src/config';
|
|
32
|
+
import { getClaudeDir } from '@/lib/dirs';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Refuse to clobber an existing skill (registry-synced or previously
|
|
@@ -52,7 +53,7 @@ function db() { return getDb(getDbPath()); }
|
|
|
52
53
|
// ─── Helpers ──────────────────────────────────────────────
|
|
53
54
|
|
|
54
55
|
function getClaudeHome(): string {
|
|
55
|
-
return
|
|
56
|
+
return getClaudeDir();
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function isValidName(s: string): boolean {
|
package/cli/mw.mjs
CHANGED
|
@@ -745,7 +745,7 @@ __export(dirs_exports, {
|
|
|
745
745
|
});
|
|
746
746
|
import { homedir as homedir2 } from "node:os";
|
|
747
747
|
import { join as join3 } from "node:path";
|
|
748
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync2, renameSync, copyFileSync
|
|
748
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, renameSync, copyFileSync } from "node:fs";
|
|
749
749
|
function getConfigDir() {
|
|
750
750
|
return join3(homedir2(), ".forge");
|
|
751
751
|
}
|
|
@@ -797,17 +797,7 @@ function migrateDataDir() {
|
|
|
797
797
|
console.log("[forge] Migration complete. Old files kept as backup.");
|
|
798
798
|
}
|
|
799
799
|
function getClaudeDir() {
|
|
800
|
-
|
|
801
|
-
try {
|
|
802
|
-
const settingsFile = join3(getDataDir(), "settings.yaml");
|
|
803
|
-
if (existsSync3(settingsFile)) {
|
|
804
|
-
const raw = readFileSync2(settingsFile, "utf-8");
|
|
805
|
-
const m = raw.match(/^\s*claudeHome:\s*["']?(.+?)["']?\s*$/m);
|
|
806
|
-
if (m?.[1]) return m[1].replace(/^~/, homedir2());
|
|
807
|
-
}
|
|
808
|
-
} catch {
|
|
809
|
-
}
|
|
810
|
-
return join3(homedir2(), ".claude");
|
|
800
|
+
return process.env.CLAUDE_HOME || join3(homedir2(), ".claude");
|
|
811
801
|
}
|
|
812
802
|
var MIGRATE_FILES, MIGRATE_DIRS, migrated;
|
|
813
803
|
var init_dirs = __esm({
|
|
@@ -835,10 +825,10 @@ var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "3000"}`;
|
|
|
835
825
|
var [, , cmd, ...args] = process.argv;
|
|
836
826
|
async function checkForUpdate() {
|
|
837
827
|
try {
|
|
838
|
-
const { readFileSync:
|
|
828
|
+
const { readFileSync: readFileSync2 } = await import("node:fs");
|
|
839
829
|
const { join: join4, dirname } = await import("node:path");
|
|
840
830
|
const { fileURLToPath } = await import("node:url");
|
|
841
|
-
const pkg = JSON.parse(
|
|
831
|
+
const pkg = JSON.parse(readFileSync2(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
|
|
842
832
|
const current = pkg.version;
|
|
843
833
|
const controller = new AbortController();
|
|
844
834
|
const timeout = setTimeout(() => controller.abort(), 3e3);
|
|
@@ -872,11 +862,11 @@ async function api3(path, opts) {
|
|
|
872
862
|
}
|
|
873
863
|
async function main() {
|
|
874
864
|
if (cmd === "--version" || cmd === "-v") {
|
|
875
|
-
const { readFileSync:
|
|
865
|
+
const { readFileSync: readFileSync2 } = await import("node:fs");
|
|
876
866
|
const { join: join4, dirname } = await import("node:path");
|
|
877
867
|
const { fileURLToPath } = await import("node:url");
|
|
878
868
|
try {
|
|
879
|
-
const pkg = JSON.parse(
|
|
869
|
+
const pkg = JSON.parse(readFileSync2(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
|
|
880
870
|
console.log(`@aion0/forge v${pkg.version}`);
|
|
881
871
|
} catch {
|
|
882
872
|
console.log("forge (version unknown)");
|
|
@@ -1193,14 +1183,14 @@ Resume in CLI:`);
|
|
|
1193
1183
|
}
|
|
1194
1184
|
case "tunnel_code":
|
|
1195
1185
|
case "tcode": {
|
|
1196
|
-
const { readFileSync:
|
|
1186
|
+
const { readFileSync: readFileSync2, existsSync: existsSync4 } = await import("node:fs");
|
|
1197
1187
|
const { join: join4 } = await import("node:path");
|
|
1198
1188
|
const { getDataDir: _gdd } = await Promise.resolve().then(() => (init_dirs(), dirs_exports));
|
|
1199
1189
|
const dataDir2 = _gdd();
|
|
1200
1190
|
const codeFile = join4(dataDir2, "session-code.json");
|
|
1201
1191
|
try {
|
|
1202
1192
|
if (existsSync4(codeFile)) {
|
|
1203
|
-
const data = JSON.parse(
|
|
1193
|
+
const data = JSON.parse(readFileSync2(codeFile, "utf-8"));
|
|
1204
1194
|
if (data.code) {
|
|
1205
1195
|
console.log(`Session code: ${data.code}`);
|
|
1206
1196
|
} else {
|
|
@@ -1212,7 +1202,7 @@ Resume in CLI:`);
|
|
|
1212
1202
|
} catch {
|
|
1213
1203
|
}
|
|
1214
1204
|
try {
|
|
1215
|
-
const tunnelState = JSON.parse(
|
|
1205
|
+
const tunnelState = JSON.parse(readFileSync2(join4(dataDir2, "tunnel-state.json"), "utf-8"));
|
|
1216
1206
|
if (tunnelState.url) console.log(`Tunnel URL: ${tunnelState.url}`);
|
|
1217
1207
|
} catch {
|
|
1218
1208
|
}
|
|
@@ -1356,9 +1346,9 @@ ${task.gitDiff.slice(0, 2e3)}`);
|
|
|
1356
1346
|
cwd: homedir3()
|
|
1357
1347
|
});
|
|
1358
1348
|
try {
|
|
1359
|
-
const { readFileSync:
|
|
1349
|
+
const { readFileSync: readFileSync2 } = await import("node:fs");
|
|
1360
1350
|
const globalRoot = execSync2("npm root -g", { encoding: "utf-8", cwd: homedir3() }).trim();
|
|
1361
|
-
const pkg = JSON.parse(
|
|
1351
|
+
const pkg = JSON.parse(readFileSync2(join4(globalRoot, "@aion0", "forge", "package.json"), "utf-8"));
|
|
1362
1352
|
console.log(`[forge] Upgraded to v${pkg.version}. Run: forge server restart`);
|
|
1363
1353
|
} catch {
|
|
1364
1354
|
console.log("[forge] Upgraded. Run: forge server restart");
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import { FolderPicker } from './FolderPicker';
|
|
5
|
+
import { useModelsRegistry, pickerOptions } from '@/lib/public-info/use-models-registry';
|
|
5
6
|
|
|
6
7
|
function SecretInput({ value, onChange, placeholder, className }: {
|
|
7
8
|
value: string;
|
|
@@ -917,6 +918,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
|
|
|
917
918
|
onUpdate: (cfg: any) => void; onDelete: () => void;
|
|
918
919
|
isApi?: boolean;
|
|
919
920
|
}) {
|
|
921
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
920
922
|
const [expanded, setExpanded] = useState(false);
|
|
921
923
|
const [testing, setTesting] = useState(false);
|
|
922
924
|
const [testResult, setTestResult] = useState<{ ok: boolean; message?: string; error?: string; duration_ms?: number } | null>(null);
|
|
@@ -975,20 +977,23 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete, isApi: isApiProp
|
|
|
975
977
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
976
978
|
<input value={cfg.model || ''} onChange={e => onUpdate({ ...cfg, model: e.target.value })}
|
|
977
979
|
list={`profile-model-${id}`} className={inputClass} />
|
|
978
|
-
{
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
980
|
+
{/* Datalist sourced from forge-public-info registry — adding a new
|
|
981
|
+
model id over there shows up here within 24h, no forge release.
|
|
982
|
+
API profiles look up by provider; CLI profiles look up by cliType. */}
|
|
983
|
+
<datalist id={`profile-model-${id}`}>
|
|
984
|
+
{(() => {
|
|
985
|
+
const models = isApi
|
|
986
|
+
? modelsRegistry.providers?.[cfg.provider || 'anthropic']?.models
|
|
987
|
+
: modelsRegistry.agents[
|
|
988
|
+
cfg.cliType === 'codex' || cfg.base === 'codex' ? 'codex'
|
|
989
|
+
: cfg.cliType === 'aider' || cfg.base === 'aider' ? 'aider'
|
|
990
|
+
: 'claude-code'
|
|
991
|
+
]?.models;
|
|
992
|
+
return (models || []).map(m => (
|
|
993
|
+
<option key={m.id} value={m.id} />
|
|
994
|
+
));
|
|
995
|
+
})()}
|
|
996
|
+
</datalist>
|
|
992
997
|
</div>
|
|
993
998
|
</div>
|
|
994
999
|
{isApi ? (
|
|
@@ -1127,6 +1132,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1127
1132
|
baseAgents: AgentEntry[];
|
|
1128
1133
|
onAdd: (id: string, cfg: any) => void;
|
|
1129
1134
|
}) {
|
|
1135
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1130
1136
|
const [open, setOpen] = useState(false);
|
|
1131
1137
|
const [id, setId] = useState('');
|
|
1132
1138
|
const [name, setName] = useState('');
|
|
@@ -1228,22 +1234,17 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1228
1234
|
<div className="flex-1">
|
|
1229
1235
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
1230
1236
|
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1231
|
-
placeholder={
|
|
1237
|
+
placeholder={
|
|
1238
|
+
base === 'claude' ? modelsRegistry.agents['claude-code']?.default || ''
|
|
1239
|
+
: base === 'codex' ? modelsRegistry.agents['codex']?.default || ''
|
|
1240
|
+
: ''
|
|
1241
|
+
}
|
|
1232
1242
|
list={`model-list-${base}`} className={inputClass} />
|
|
1233
|
-
{base
|
|
1234
|
-
|
|
1235
|
-
<option value=
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
</datalist>
|
|
1239
|
-
)}
|
|
1240
|
-
{base === 'codex' && (
|
|
1241
|
-
<datalist id="model-list-codex">
|
|
1242
|
-
<option value="codex-mini" />
|
|
1243
|
-
<option value="o4-mini" />
|
|
1244
|
-
<option value="gpt-4o" />
|
|
1245
|
-
</datalist>
|
|
1246
|
-
)}
|
|
1243
|
+
<datalist id={`model-list-${base}`}>
|
|
1244
|
+
{(modelsRegistry.agents[base === 'claude' ? 'claude-code' : base]?.models || []).map(m => (
|
|
1245
|
+
<option key={m.id} value={m.id} />
|
|
1246
|
+
))}
|
|
1247
|
+
</datalist>
|
|
1247
1248
|
</div>
|
|
1248
1249
|
</div>
|
|
1249
1250
|
<div>
|
|
@@ -1275,7 +1276,14 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1275
1276
|
</div>
|
|
1276
1277
|
<div className="flex-1">
|
|
1277
1278
|
<label className="text-[8px] text-[var(--text-secondary)]">Model</label>
|
|
1278
|
-
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1279
|
+
<input value={model} onChange={e => setModel(e.target.value)}
|
|
1280
|
+
placeholder={modelsRegistry.providers?.[provider]?.default || ''}
|
|
1281
|
+
list={`api-model-list-${provider}`} className={inputClass} />
|
|
1282
|
+
<datalist id={`api-model-list-${provider}`}>
|
|
1283
|
+
{(modelsRegistry.providers?.[provider]?.models || []).map(m => (
|
|
1284
|
+
<option key={m.id} value={m.id} />
|
|
1285
|
+
))}
|
|
1286
|
+
</datalist>
|
|
1279
1287
|
</div>
|
|
1280
1288
|
</div>
|
|
1281
1289
|
<div>
|
|
@@ -1314,6 +1322,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
|
|
|
1314
1322
|
}
|
|
1315
1323
|
|
|
1316
1324
|
function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
|
|
1325
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1317
1326
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
1318
1327
|
const [loading, setLoading] = useState(true);
|
|
1319
1328
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
@@ -1649,9 +1658,9 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1649
1658
|
<span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
|
|
1650
1659
|
{((() => {
|
|
1651
1660
|
const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
return
|
|
1661
|
+
// Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
|
|
1662
|
+
// Add new IDs there and they show up here within 24h with no forge release.
|
|
1663
|
+
return pickerOptions(modelsRegistry, ct);
|
|
1655
1664
|
})()).map(preset => (
|
|
1656
1665
|
<button
|
|
1657
1666
|
key={preset}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
|
|
4
4
|
import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
|
|
5
|
+
import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
|
|
5
6
|
import {
|
|
6
7
|
ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
|
|
7
8
|
type Node, type NodeProps, MarkerType, type NodeChange,
|
|
@@ -715,6 +716,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
715
716
|
onConfirm: (cfg: Omit<AgentConfig, 'id'>) => void;
|
|
716
717
|
onCancel: () => void;
|
|
717
718
|
}) {
|
|
719
|
+
const { registry: modelsRegistry } = useModelsRegistry();
|
|
718
720
|
const [label, setLabel] = useState(initial.label || '');
|
|
719
721
|
const [icon, setIcon] = useState(initial.icon || '🤖');
|
|
720
722
|
const [role, setRole] = useState(initial.role || '');
|
|
@@ -1160,9 +1162,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
|
|
|
1160
1162
|
list="workspace-model-list"
|
|
1161
1163
|
className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] font-mono" />
|
|
1162
1164
|
<datalist id="workspace-model-list">
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1165
|
+
{(modelsRegistry.agents['claude-code']?.models || []).map(m => (
|
|
1166
|
+
<option key={m.id} value={m.id} />
|
|
1167
|
+
))}
|
|
1166
1168
|
</datalist>
|
|
1167
1169
|
</div>
|
|
1168
1170
|
);
|
package/lib/agents/index.ts
CHANGED
|
@@ -91,7 +91,7 @@ export function listAgents(): AgentConfig[] {
|
|
|
91
91
|
enabled: cfg.enabled !== false,
|
|
92
92
|
isProfile: true,
|
|
93
93
|
backendType: 'cli',
|
|
94
|
-
model: cfg.
|
|
94
|
+
model: cfg.models?.task,
|
|
95
95
|
skipPermissionsFlag: cfg.skipPermissionsFlag || baseAgent?.skipPermissionsFlag,
|
|
96
96
|
env: cfg.env,
|
|
97
97
|
cliType: (baseAgent as any)?.cliType || 'generic',
|
|
@@ -117,7 +117,7 @@ export function listAgents(): AgentConfig[] {
|
|
|
117
117
|
base: cfg.base,
|
|
118
118
|
isProfile: true,
|
|
119
119
|
backendType: 'cli',
|
|
120
|
-
model: cfg.
|
|
120
|
+
model: cfg.models?.task,
|
|
121
121
|
skipPermissionsFlag: cfg.skipPermissionsFlag || baseAgent?.skipPermissionsFlag,
|
|
122
122
|
env: cfg.env,
|
|
123
123
|
cliType: cfg.cliType || (baseAgent as any)?.cliType || 'generic',
|
|
@@ -262,20 +262,19 @@ export function resolveTerminalLaunch(agentId?: string): TerminalLaunchInfo {
|
|
|
262
262
|
|| (cliType === 'codex' ? probeCodexSkipFlag(agentCfg.path || 'codex') : cli.skip)
|
|
263
263
|
|| undefined;
|
|
264
264
|
|
|
265
|
-
// Resolve env/model
|
|
265
|
+
// Resolve env/model from this agent's per-use models sub-table.
|
|
266
|
+
// 顶层 model 字段已在 0.10 起被 migrate.ts 清理,这里只读 models.terminal。
|
|
266
267
|
let env: Record<string, string> | undefined;
|
|
267
268
|
let model: string | undefined;
|
|
268
|
-
if (agentCfg.base || agentCfg.env || agentCfg.
|
|
269
|
-
// This agent has profile-like config — read env/model directly
|
|
269
|
+
if (agentCfg.base || agentCfg.env || agentCfg.models) {
|
|
270
270
|
if (agentCfg.env) env = { ...agentCfg.env };
|
|
271
|
-
model = agentCfg.
|
|
272
|
-
if (model === 'default') model = undefined; // 'default'
|
|
271
|
+
model = agentCfg.models?.terminal;
|
|
272
|
+
if (model === 'default') model = undefined; // 'default' = 不加 --model
|
|
273
273
|
} else if (agentCfg.profile) {
|
|
274
|
-
// Agent links to a separate profile — read from that
|
|
275
274
|
const profileCfg = settings.agents?.[agentCfg.profile];
|
|
276
275
|
if (profileCfg) {
|
|
277
276
|
if (profileCfg.env) env = { ...profileCfg.env };
|
|
278
|
-
model = profileCfg.
|
|
277
|
+
model = profileCfg.models?.terminal;
|
|
279
278
|
if (model === 'default') model = undefined;
|
|
280
279
|
}
|
|
281
280
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in fallback model list. Forge prefers the public-info repo's
|
|
3
|
+
* registry (see `lib/public-info/fetch.ts`), but falls back to this
|
|
4
|
+
* when the network is unreachable, the file is malformed, or the
|
|
5
|
+
* user is on first-run before the cache is populated.
|
|
6
|
+
*
|
|
7
|
+
* Keep this list reasonably current — if the registry is permanently
|
|
8
|
+
* unreachable, this is what users see. New models in the wild should
|
|
9
|
+
* land in the public-info repo first (no code change required), and
|
|
10
|
+
* trickle into this fallback whenever the next forge release happens.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelsRegistry } from '../public-info/types';
|
|
14
|
+
|
|
15
|
+
export const KNOWN_MODELS_FALLBACK: ModelsRegistry = {
|
|
16
|
+
version: 1,
|
|
17
|
+
updatedAt: '2026-05-30',
|
|
18
|
+
note: 'Bundled fallback — actual current list lives in forge-public-info/models/registry.json',
|
|
19
|
+
agents: {
|
|
20
|
+
'claude-code': {
|
|
21
|
+
displayName: 'Claude Code',
|
|
22
|
+
default: 'claude-sonnet-4-6',
|
|
23
|
+
aliases: [
|
|
24
|
+
{ id: 'default', label: 'default (CLI decides)' },
|
|
25
|
+
{ id: 'sonnet', label: 'sonnet (alias)' },
|
|
26
|
+
{ id: 'opus', label: 'opus (alias)' },
|
|
27
|
+
{ id: 'haiku', label: 'haiku (alias)' },
|
|
28
|
+
],
|
|
29
|
+
models: [
|
|
30
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
|
|
31
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
|
|
32
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
codex: {
|
|
36
|
+
displayName: 'OpenAI Codex',
|
|
37
|
+
default: 'o4-mini',
|
|
38
|
+
aliases: [{ id: 'default', label: 'default (CLI decides)' }],
|
|
39
|
+
models: [
|
|
40
|
+
{ id: 'o4-mini', label: 'o4-mini', tier: 'fast', default: true },
|
|
41
|
+
{ id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
|
|
42
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1', tier: 'standard' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
aider: {
|
|
46
|
+
displayName: 'Aider',
|
|
47
|
+
default: 'default',
|
|
48
|
+
aliases: [{ id: 'default', label: 'default (CLI decides)' }],
|
|
49
|
+
models: [],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
providers: {
|
|
53
|
+
anthropic: {
|
|
54
|
+
displayName: 'Anthropic API',
|
|
55
|
+
default: 'claude-sonnet-4-6',
|
|
56
|
+
aliases: [],
|
|
57
|
+
models: [
|
|
58
|
+
{ id: 'claude-opus-4-8', label: 'Opus 4.8', tier: 'premium' },
|
|
59
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'standard', default: true },
|
|
60
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'fast' },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
openai: {
|
|
64
|
+
displayName: 'OpenAI API',
|
|
65
|
+
default: 'gpt-4.1',
|
|
66
|
+
aliases: [],
|
|
67
|
+
models: [
|
|
68
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1', tier: 'premium', default: true },
|
|
69
|
+
{ id: 'gpt-4o', label: 'GPT-4o', tier: 'standard' },
|
|
70
|
+
{ id: 'o4-mini', label: 'o4-mini', tier: 'fast' },
|
|
71
|
+
{ id: 'o3-mini', label: 'o3-mini', tier: 'fast' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
package/lib/agents/migrate.ts
CHANGED
|
@@ -74,9 +74,19 @@ export function migrateAgentsFlatten(settings: Settings): boolean {
|
|
|
74
74
|
}
|
|
75
75
|
if (raw.type !== undefined) { delete raw.type; mutated = true; }
|
|
76
76
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
// 顶层 model 字段已被 per-use models 子表完整取代(terminal / task /
|
|
78
|
+
// telegram / help / mobile 各自可配)。0.10 之前的迁移逻辑反方向复制了
|
|
79
|
+
// models.task → model,导致 resolveTerminalLaunch 错把"task 模型"
|
|
80
|
+
// 当成"全场景兜底",新建项目终端被强插 --model。
|
|
81
|
+
//
|
|
82
|
+
// 这里反向收尾:把残留的 model 搬回 models.task(只在 task 为空或
|
|
83
|
+
// 'default' 时填,避免覆盖用户更近期的选择),然后彻底删字段。
|
|
84
|
+
if (raw.model !== undefined) {
|
|
85
|
+
if (!raw.models) raw.models = {};
|
|
86
|
+
if ((!raw.models.task || raw.models.task === 'default') && raw.model !== 'default') {
|
|
87
|
+
raw.models.task = raw.model;
|
|
88
|
+
}
|
|
89
|
+
delete raw.model;
|
|
80
90
|
mutated = true;
|
|
81
91
|
}
|
|
82
92
|
}
|
|
@@ -116,6 +126,7 @@ function detectsLegacyShape(settings: Settings): boolean {
|
|
|
116
126
|
if (cfg.type === 'api') return true;
|
|
117
127
|
if (cfg.base !== undefined) return true;
|
|
118
128
|
if (cfg.cliType !== undefined) return true;
|
|
129
|
+
if (cfg.model !== undefined) return true; // 顶层 model 已废,改 models.task
|
|
119
130
|
// 没有 tool + 是 builtin id → 也算需要迁移(补 tool 字段)
|
|
120
131
|
}
|
|
121
132
|
// 内置 agent 没有 tool 字段也需要补
|
package/lib/dirs.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
-
import { existsSync, mkdirSync, renameSync, copyFileSync
|
|
12
|
+
import { existsSync, mkdirSync, renameSync, copyFileSync } from 'node:fs';
|
|
13
13
|
|
|
14
14
|
/** Shared config directory — only binaries, fixed at ~/.forge/ */
|
|
15
15
|
export function getConfigDir(): string {
|
|
@@ -85,30 +85,10 @@ export function migrateDataDir(): void {
|
|
|
85
85
|
console.log('[forge] Migration complete. Old files kept as backup.');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** Claude
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* fires ReferenceError under ESM concurrent loads, and dirs is on
|
|
93
|
-
* the hot path for every task / connector / pipeline node.
|
|
94
|
-
*
|
|
95
|
-
* Cut the cycle by reading the YAML directly here. No parser needed:
|
|
96
|
-
* we only need one field (`claudeHome`), which is plain "key: value".
|
|
97
|
-
* Falls back to ~/.claude if anything goes sideways.
|
|
98
|
-
*/
|
|
88
|
+
/** Claude config directory — skills, commands, sessions, etc. Override
|
|
89
|
+
* via the `CLAUDE_HOME` env var (same name claude CLI uses); defaults
|
|
90
|
+
* to `~/.claude`. The settings.yaml `claudeHome` field was removed in
|
|
91
|
+
* 0.10.10 — set the env var if you need a non-default location. */
|
|
99
92
|
export function getClaudeDir(): string {
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
const settingsFile = join(getDataDir(), 'settings.yaml');
|
|
103
|
-
if (existsSync(settingsFile)) {
|
|
104
|
-
const raw = readFileSync(settingsFile, 'utf-8');
|
|
105
|
-
// Match a top-level `claudeHome: <value>` line. Tolerates quoted /
|
|
106
|
-
// unquoted strings and stray whitespace. Not a full YAML parser
|
|
107
|
-
// because we explicitly want to avoid loading the YAML module
|
|
108
|
-
// (and settings.ts) from this hot path.
|
|
109
|
-
const m = raw.match(/^\s*claudeHome:\s*["']?(.+?)["']?\s*$/m);
|
|
110
|
-
if (m?.[1]) return m[1].replace(/^~/, homedir());
|
|
111
|
-
}
|
|
112
|
-
} catch {}
|
|
113
|
-
return join(homedir(), '.claude');
|
|
93
|
+
return process.env.CLAUDE_HOME || join(homedir(), '.claude');
|
|
114
94
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic fetch+cache helper for `forge-public-info` resources.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: GitHub-hosted JSON files live under
|
|
5
|
+
* `<publicInfoRepoUrl>/<resourceDir>/<file>.json`
|
|
6
|
+
*
|
|
7
|
+
* On read:
|
|
8
|
+
* 1. If a fresh cached copy exists (< TTL), return it.
|
|
9
|
+
* 2. Otherwise fetch from the configured `publicInfoRepoUrl`.
|
|
10
|
+
* 3. On any error (offline, 4xx, malformed JSON), return the bundled
|
|
11
|
+
* fallback so the UI never breaks.
|
|
12
|
+
*
|
|
13
|
+
* Cache lives at `<dataDir>/public-info-cache/<resource>.json`. Each
|
|
14
|
+
* file has a sidecar `<resource>.meta.json` carrying the fetch
|
|
15
|
+
* timestamp; we don't trust HTTP `Last-Modified` because GitHub raw
|
|
16
|
+
* serves through a CDN that strips it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { getDataDir } from '../dirs';
|
|
22
|
+
import { loadSettings } from '../settings';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_REPO_URL = 'https://raw.githubusercontent.com/aiwatching/forge-public-info/main';
|
|
25
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
27
|
+
|
|
28
|
+
interface CacheMeta { fetchedAt: number; etag?: string }
|
|
29
|
+
|
|
30
|
+
function cacheDir(): string {
|
|
31
|
+
const dir = join(getDataDir(), 'public-info-cache');
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function repoUrl(): string {
|
|
37
|
+
return (loadSettings() as any).publicInfoRepoUrl || DEFAULT_REPO_URL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cacheKey(resourcePath: string): string {
|
|
41
|
+
// public/info/models/registry.json → models-registry
|
|
42
|
+
return resourcePath.replace(/\.json$/, '').replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+|-+$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCache(resourcePath: string): { data: unknown; meta: CacheMeta } | null {
|
|
46
|
+
const key = cacheKey(resourcePath);
|
|
47
|
+
const dataPath = join(cacheDir(), `${key}.json`);
|
|
48
|
+
const metaPath = join(cacheDir(), `${key}.meta.json`);
|
|
49
|
+
if (!existsSync(dataPath) || !existsSync(metaPath)) return null;
|
|
50
|
+
try {
|
|
51
|
+
return {
|
|
52
|
+
data: JSON.parse(readFileSync(dataPath, 'utf-8')),
|
|
53
|
+
meta: JSON.parse(readFileSync(metaPath, 'utf-8')),
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeCache(resourcePath: string, data: unknown): void {
|
|
61
|
+
const key = cacheKey(resourcePath);
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(join(cacheDir(), `${key}.json`), JSON.stringify(data, null, 2), 'utf-8');
|
|
64
|
+
writeFileSync(
|
|
65
|
+
join(cacheDir(), `${key}.meta.json`),
|
|
66
|
+
JSON.stringify({ fetchedAt: Date.now() } satisfies CacheMeta, null, 2),
|
|
67
|
+
'utf-8',
|
|
68
|
+
);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn('[public-info] cache write failed:', err instanceof Error ? err.message : err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch a public-info resource with built-in cache + fallback.
|
|
76
|
+
*
|
|
77
|
+
* @param resourcePath Path under the repo, e.g. `"models/registry.json"`
|
|
78
|
+
* @param fallback Used when network + cache both fail
|
|
79
|
+
* @param opts.forceRefresh Skip cache TTL check (e.g., user-triggered refresh)
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchPublicInfo<T>(
|
|
82
|
+
resourcePath: string,
|
|
83
|
+
fallback: T,
|
|
84
|
+
opts: { forceRefresh?: boolean } = {},
|
|
85
|
+
): Promise<T> {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
|
|
88
|
+
// 1. Cache hit (fresh)
|
|
89
|
+
if (!opts.forceRefresh) {
|
|
90
|
+
const cached = readCache(resourcePath);
|
|
91
|
+
if (cached && now - cached.meta.fetchedAt < CACHE_TTL_MS) {
|
|
92
|
+
return cached.data as T;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Network fetch
|
|
97
|
+
const url = `${repoUrl()}/${resourcePath}`;
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
100
|
+
if (res.ok) {
|
|
101
|
+
const data = await res.json();
|
|
102
|
+
writeCache(resourcePath, data);
|
|
103
|
+
return data as T;
|
|
104
|
+
}
|
|
105
|
+
console.warn(`[public-info] ${url} returned HTTP ${res.status}, using fallback`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`[public-info] ${url} fetch failed:`, err instanceof Error ? err.message : err);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Stale cache (better than fallback)
|
|
111
|
+
const cached = readCache(resourcePath);
|
|
112
|
+
if (cached) return cached.data as T;
|
|
113
|
+
|
|
114
|
+
// 4. Bundled fallback
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|