@aion0/forge 0.10.4 → 0.10.6

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 CHANGED
@@ -1,11 +1,11 @@
1
- # Forge v0.10.4
1
+ # Forge v0.10.6
2
2
 
3
3
  Released: 2026-05-30
4
4
 
5
- ## Changes since v0.10.3
5
+ ## Changes since v0.10.5
6
6
 
7
7
  ### Other
8
- - fix(migrate): backup raw settings.yaml bytes (don't leak plaintext secrets)
8
+ - feat(settings): folder picker for Add Project / Add Document Directory
9
9
 
10
10
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.3...v0.10.4
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.5...v0.10.6
@@ -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
+ }
@@ -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
 
@@ -50,9 +50,16 @@ function decodeToolName(name: string): string { return name.replace(/__/g, '.');
50
50
 
51
51
  function makeClient(apiKey: string, baseUrl: string) {
52
52
  const oauth = isOauthToken(apiKey);
53
+ // @ai-sdk/anthropic appends `/messages` directly onto the baseURL (unlike
54
+ // @anthropic-ai/sdk which inserts `/v1/`). If the URL doesn't end in /v1,
55
+ // the request goes to `${host}/messages` and 404s. Forge's defaultBaseUrl
56
+ // returns the host without /v1 to match the other SDK — normalize here.
57
+ const normalized = baseUrl
58
+ ? baseUrl.replace(/\/+$/, '') + (/\/v\d+$/.test(baseUrl.replace(/\/+$/, '')) ? '' : '/v1')
59
+ : undefined;
53
60
  return createAnthropic({
54
61
  apiKey: oauth ? '' : apiKey,
55
- baseURL: baseUrl || undefined,
62
+ baseURL: normalized,
56
63
  headers: oauth
57
64
  ? {
58
65
  authorization: `Bearer ${apiKey}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {