@aion0/forge 0.10.5 → 0.10.12
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 +3 -6
- package/app/api/fs/browse/route.ts +37 -0
- package/app/api/skills/install-local/route.ts +2 -1
- package/cli/mw.mjs +11 -21
- package/components/FolderPicker.tsx +86 -0
- package/components/SettingsModal.tsx +30 -0
- package/lib/agents/index.ts +8 -9
- package/lib/agents/migrate.ts +14 -3
- package/lib/dirs.ts +6 -26
- package/lib/settings.ts +25 -4
- package/lib/skills.ts +2 -2
- package/lib/workspace/watch-manager.ts +5 -1
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
- package/scripts/test-agents-migrate.ts +2 -2
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.12
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-30
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.11
|
|
6
6
|
|
|
7
|
-
### Other
|
|
8
|
-
- fix(chat/anthropic): normalize baseURL to include /v1 (fix Not Found regression)
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.4...v0.10.5
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.11...v0.10.12
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/fs/browse?path=/some/dir
|
|
3
|
+
* Returns immediate subdirectories of `path` (defaults to $HOME).
|
|
4
|
+
* Used by the Add Project folder picker so users don't have to type
|
|
5
|
+
* paths. Auth-gated by middleware like the rest of /api/*.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { join, resolve, dirname } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
|
|
13
|
+
export async function GET(req: Request) {
|
|
14
|
+
const url = new URL(req.url);
|
|
15
|
+
const requested = url.searchParams.get('path') || homedir();
|
|
16
|
+
const path = resolve(requested);
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const stat = statSync(path);
|
|
20
|
+
if (!stat.isDirectory()) {
|
|
21
|
+
return NextResponse.json({ ok: false, error: 'not a directory', path }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
const entries = readdirSync(path, { withFileTypes: true })
|
|
24
|
+
.filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith('.'))
|
|
25
|
+
.map(e => ({ name: e.name, path: join(path, e.name) }))
|
|
26
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
27
|
+
return NextResponse.json({
|
|
28
|
+
ok: true,
|
|
29
|
+
path,
|
|
30
|
+
parent: path === '/' ? null : dirname(path),
|
|
31
|
+
home: homedir(),
|
|
32
|
+
entries,
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return NextResponse.json({ ok: false, error: (err as Error).message, path }, { status: 404 });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -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");
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Entry { name: string; path: string }
|
|
6
|
+
|
|
7
|
+
export function FolderPicker({ onSelect, onClose }: {
|
|
8
|
+
onSelect: (path: string) => void;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}) {
|
|
11
|
+
const [path, setPath] = useState<string>('');
|
|
12
|
+
const [parent, setParent] = useState<string | null>(null);
|
|
13
|
+
const [home, setHome] = useState<string>('');
|
|
14
|
+
const [entries, setEntries] = useState<Entry[]>([]);
|
|
15
|
+
const [error, setError] = useState<string>('');
|
|
16
|
+
|
|
17
|
+
const load = (p?: string) => {
|
|
18
|
+
setError('');
|
|
19
|
+
const q = p ? `?path=${encodeURIComponent(p)}` : '';
|
|
20
|
+
fetch(`/api/fs/browse${q}`).then(r => r.json()).then(d => {
|
|
21
|
+
if (!d.ok) { setError(d.error || 'failed'); return; }
|
|
22
|
+
setPath(d.path);
|
|
23
|
+
setParent(d.parent);
|
|
24
|
+
setHome(d.home);
|
|
25
|
+
setEntries(d.entries);
|
|
26
|
+
}).catch(e => setError(e.message));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
useEffect(() => { load(); }, []);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[60]" onClick={onClose}>
|
|
33
|
+
<div
|
|
34
|
+
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[520px] max-h-[70vh] flex flex-col"
|
|
35
|
+
onClick={e => e.stopPropagation()}
|
|
36
|
+
>
|
|
37
|
+
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center gap-2">
|
|
38
|
+
<span className="text-xs font-semibold">Select Folder</span>
|
|
39
|
+
<button onClick={() => load(home)} className="text-[10px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--bg-primary)]">~ Home</button>
|
|
40
|
+
<button onClick={onClose} className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]">✕</button>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div className="px-4 py-2 border-b border-[var(--border)] flex items-center gap-2">
|
|
44
|
+
<button
|
|
45
|
+
onClick={() => parent && load(parent)}
|
|
46
|
+
disabled={!parent}
|
|
47
|
+
className="text-[10px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--bg-primary)] disabled:opacity-40"
|
|
48
|
+
>↑ Up</button>
|
|
49
|
+
<input
|
|
50
|
+
value={path}
|
|
51
|
+
onChange={e => setPath(e.target.value)}
|
|
52
|
+
onKeyDown={e => e.key === 'Enter' && load(path)}
|
|
53
|
+
className="flex-1 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[11px] font-mono"
|
|
54
|
+
/>
|
|
55
|
+
<button onClick={() => load(path)} className="text-[10px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] hover:bg-[var(--bg-primary)]">Go</button>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div className="flex-1 overflow-y-auto px-2 py-2 min-h-[200px]">
|
|
59
|
+
{error && <div className="text-[11px] text-red-400 px-2 py-1">{error}</div>}
|
|
60
|
+
{entries.map(e => (
|
|
61
|
+
<button
|
|
62
|
+
key={e.path}
|
|
63
|
+
onClick={() => load(e.path)}
|
|
64
|
+
className="w-full text-left px-2 py-1 rounded text-[11px] font-mono hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
65
|
+
>
|
|
66
|
+
<span className="text-[var(--text-secondary)]">📁</span>
|
|
67
|
+
<span>{e.name}</span>
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
{!error && entries.length === 0 && (
|
|
71
|
+
<div className="text-[11px] text-[var(--text-secondary)] px-2 py-4 text-center">(no subdirectories)</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="px-4 py-3 border-t border-[var(--border)] flex items-center justify-between gap-2">
|
|
76
|
+
<span className="text-[10px] text-[var(--text-secondary)] font-mono truncate flex-1" title={path}>{path}</span>
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => { onSelect(path); onClose(); }}
|
|
79
|
+
disabled={!path}
|
|
80
|
+
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded disabled:opacity-50"
|
|
81
|
+
>Select this folder</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import { FolderPicker } from './FolderPicker';
|
|
4
5
|
|
|
5
6
|
function SecretInput({ value, onChange, placeholder, className }: {
|
|
6
7
|
value: string;
|
|
@@ -236,6 +237,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
236
237
|
const [secretStatus, setSecretStatus] = useState<Record<string, boolean>>({});
|
|
237
238
|
const [newRoot, setNewRoot] = useState('');
|
|
238
239
|
const [newDocRoot, setNewDocRoot] = useState('');
|
|
240
|
+
const [pickerFor, setPickerFor] = useState<null | 'project' | 'doc'>(null);
|
|
239
241
|
const [saved, setSaved] = useState(false);
|
|
240
242
|
const [tunnel, setTunnel] = useState<TunnelStatus>({
|
|
241
243
|
status: 'stopped', url: null, error: null, installed: false, log: [],
|
|
@@ -322,6 +324,23 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
322
324
|
};
|
|
323
325
|
|
|
324
326
|
return (
|
|
327
|
+
<>
|
|
328
|
+
{pickerFor && (
|
|
329
|
+
<FolderPicker
|
|
330
|
+
onClose={() => setPickerFor(null)}
|
|
331
|
+
onSelect={picked => {
|
|
332
|
+
if (pickerFor === 'project') {
|
|
333
|
+
if (!settings.projectRoots.includes(picked)) {
|
|
334
|
+
setSettings({ ...settings, projectRoots: [...settings.projectRoots, picked] });
|
|
335
|
+
}
|
|
336
|
+
} else if (pickerFor === 'doc') {
|
|
337
|
+
if (!(settings.docRoots || []).includes(picked)) {
|
|
338
|
+
setSettings({ ...settings, docRoots: [...(settings.docRoots || []), picked] });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}}
|
|
342
|
+
/>
|
|
343
|
+
)}
|
|
325
344
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => {
|
|
326
345
|
if (hasUnsaved && !confirm('You have unsaved changes. Close anyway?')) return;
|
|
327
346
|
onClose();
|
|
@@ -363,6 +382,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
363
382
|
placeholder="/Users/you/projects"
|
|
364
383
|
className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
365
384
|
/>
|
|
385
|
+
<button
|
|
386
|
+
onClick={() => setPickerFor('project')}
|
|
387
|
+
className="text-[10px] px-2 py-1.5 border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)]"
|
|
388
|
+
title="Browse local folders"
|
|
389
|
+
>📁</button>
|
|
366
390
|
<button
|
|
367
391
|
onClick={addRoot}
|
|
368
392
|
className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
@@ -410,6 +434,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
410
434
|
placeholder="/Users/you/obsidian-vault"
|
|
411
435
|
className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
|
|
412
436
|
/>
|
|
437
|
+
<button
|
|
438
|
+
onClick={() => setPickerFor('doc')}
|
|
439
|
+
className="text-[10px] px-2 py-1.5 border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)]"
|
|
440
|
+
title="Browse local folders"
|
|
441
|
+
>📁</button>
|
|
413
442
|
<button
|
|
414
443
|
onClick={() => {
|
|
415
444
|
if (newDocRoot.trim() && !settings.docRoots.includes(newDocRoot.trim())) {
|
|
@@ -858,6 +887,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
858
887
|
/>
|
|
859
888
|
)}
|
|
860
889
|
</div>
|
|
890
|
+
</>
|
|
861
891
|
);
|
|
862
892
|
}
|
|
863
893
|
|
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
|
}
|
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
|
}
|
package/lib/settings.ts
CHANGED
|
@@ -27,10 +27,11 @@ export interface AgentEntry {
|
|
|
27
27
|
models?: { terminal?: string; task?: string; telegram?: string; help?: string; mobile?: string };
|
|
28
28
|
skipPermissionsFlag?: string;
|
|
29
29
|
requiresTTY?: boolean;
|
|
30
|
-
model?: string; // flat model override
|
|
31
30
|
env?: Record<string, string>; // environment variables injected when spawning CLI
|
|
32
31
|
|
|
33
32
|
// === Deprecated (kept for migration compat; removed in a later release) ===
|
|
33
|
+
/** @deprecated 顶层 model 被 models.terminal/task/... 子表替代,migrate.ts 自动清掉 */
|
|
34
|
+
model?: string;
|
|
34
35
|
/** @deprecated migrated to `tool` */
|
|
35
36
|
base?: string;
|
|
36
37
|
/** @deprecated migrated to `tool` */
|
|
@@ -84,7 +85,9 @@ export interface Settings {
|
|
|
84
85
|
projectRoots: string[];
|
|
85
86
|
docRoots: string[];
|
|
86
87
|
claudePath: string;
|
|
87
|
-
claudeHome
|
|
88
|
+
// claudeHome removed in 0.10.10 — was a never-UI-exposed override that
|
|
89
|
+
// mostly served to leak `claudeHome: ""` into every yaml. Set the
|
|
90
|
+
// CLAUDE_HOME env var if you need to point at a non-default ~/.claude.
|
|
88
91
|
telegramBotToken: string;
|
|
89
92
|
telegramChatId: string;
|
|
90
93
|
notifyOnComplete: boolean;
|
|
@@ -184,7 +187,11 @@ const defaults: Settings = {
|
|
|
184
187
|
projectRoots: [],
|
|
185
188
|
docRoots: [],
|
|
186
189
|
claudePath: '',
|
|
187
|
-
claudeHome
|
|
190
|
+
// claudeHome intentionally omitted — it's an optional override (most users
|
|
191
|
+
// have claude at ~/.claude). Keeping it as a default empty string used to
|
|
192
|
+
// leak `claudeHome: ""` into every saved settings.yaml, which the dirs.ts
|
|
193
|
+
// regex then mis-parsed as the value `"`. See loadSettings normalizer +
|
|
194
|
+
// dirs.ts:getClaudeDir for the read-side handling.
|
|
188
195
|
telegramBotToken: '',
|
|
189
196
|
telegramChatId: '',
|
|
190
197
|
notifyOnComplete: true,
|
|
@@ -279,7 +286,21 @@ export function loadSettings(): Settings {
|
|
|
279
286
|
if (!existsSync(SETTINGS_FILE)) return { ...defaults };
|
|
280
287
|
try {
|
|
281
288
|
const raw = readFileSync(SETTINGS_FILE, 'utf-8');
|
|
282
|
-
const parsed = { ...defaults, ...YAML.parse(raw) };
|
|
289
|
+
const parsed: any = { ...defaults, ...YAML.parse(raw) };
|
|
290
|
+
// claudeHome was removed from the schema in 0.10.10. Drop any leftover
|
|
291
|
+
// value so it stops round-tripping through saveSettings. If a user had
|
|
292
|
+
// set a real override (rare — UI never exposed the field), surface a
|
|
293
|
+
// one-time warning pointing them at the CLAUDE_HOME env var.
|
|
294
|
+
if ('claudeHome' in parsed) {
|
|
295
|
+
const old = parsed.claudeHome;
|
|
296
|
+
delete parsed.claudeHome;
|
|
297
|
+
if (old && old !== '') {
|
|
298
|
+
console.warn(
|
|
299
|
+
`[settings] claudeHome="${old}" is no longer read. ` +
|
|
300
|
+
`If you need to override the claude config dir, set CLAUDE_HOME env var instead.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
283
304
|
// Decrypt top-level secret fields
|
|
284
305
|
for (const field of SECRET_FIELDS) {
|
|
285
306
|
if (parsed[field] && isEncrypted(parsed[field])) {
|
package/lib/skills.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
|
|
|
8
8
|
import { getDb } from '../src/core/db/database';
|
|
9
9
|
import { getDbPath } from '../src/config';
|
|
10
10
|
import { loadSettings } from './settings';
|
|
11
|
+
import { getClaudeDir } from './dirs';
|
|
11
12
|
|
|
12
13
|
type ItemType = 'skill' | 'command';
|
|
13
14
|
|
|
@@ -271,8 +272,7 @@ async function downloadFile(url: string): Promise<string> {
|
|
|
271
272
|
// ─── Install ─────────────────────────────────────────────────
|
|
272
273
|
|
|
273
274
|
function getClaudeHome(): string {
|
|
274
|
-
|
|
275
|
-
return settings.claudeHome || join(homedir(), '.claude');
|
|
275
|
+
return getClaudeDir();
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
function getSkillDir(name: string, type: ItemType, projectPath?: string): string {
|
|
@@ -13,6 +13,7 @@ import { homedir } from 'node:os';
|
|
|
13
13
|
import { execSync } from 'node:child_process';
|
|
14
14
|
import type { WorkspaceAgentConfig, WatchTarget, WatchConfig } from './types';
|
|
15
15
|
import { appendAgentLog } from './persistence';
|
|
16
|
+
import { getClaudeDir } from '../dirs';
|
|
16
17
|
|
|
17
18
|
// ─── Snapshot types ──────────────────────────────────────
|
|
18
19
|
|
|
@@ -219,7 +220,10 @@ function detectAgentLogChanges(workspaceId: string, targetAgentId: string, patte
|
|
|
219
220
|
const lastSessionFile = new Map<string, string>();
|
|
220
221
|
|
|
221
222
|
function detectSessionChanges(projectPath: string, pattern: string | undefined, prevLineCount: number, contextChars = 500, sessionId?: string): { changes: WatchChange | null; lineCount: number } {
|
|
222
|
-
|
|
223
|
+
// Honor CLAUDE_HOME env var so watch-manager looks in the same place as
|
|
224
|
+
// session detection, skills installer, etc. Was previously hardcoded to
|
|
225
|
+
// ~/.claude, which silently broke for users with a custom claude dir.
|
|
226
|
+
const claudeHome = join(getClaudeDir(), 'projects');
|
|
223
227
|
const encoded = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
224
228
|
const sessionDir = join(claudeHome, encoded);
|
|
225
229
|
if (!existsSync(sessionDir)) return { changes: null, lineCount: prevLineCount };
|
package/next-env.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ const pass = (msg: string) => console.log(` ✓ ${msg}`);
|
|
|
15
15
|
|
|
16
16
|
function baseSettings(): Settings {
|
|
17
17
|
return {
|
|
18
|
-
projectRoots: [], docRoots: [], claudePath: '',
|
|
18
|
+
projectRoots: [], docRoots: [], claudePath: '',
|
|
19
19
|
telegramBotToken: '', telegramChatId: '',
|
|
20
20
|
notifyOnComplete: true, notifyOnFailure: true,
|
|
21
21
|
tunnelAutoStart: false, telegramTunnelPassword: '',
|
|
@@ -114,7 +114,7 @@ function baseSettings(): Settings {
|
|
|
114
114
|
const s = baseSettings();
|
|
115
115
|
s.agents = {
|
|
116
116
|
claude: { tool: 'claude', enabled: true, path: '' },
|
|
117
|
-
'forti-coder': { tool: 'claude', enabled: true,
|
|
117
|
+
'forti-coder': { tool: 'claude', enabled: true, models: { task: 'sonnet' }, env: {} },
|
|
118
118
|
};
|
|
119
119
|
(s as any).apiProfiles = {
|
|
120
120
|
'forti-api': { provider: 'openai-compatible', model: 'X', apiKey: 'k', enabled: true },
|