@aion0/forge 0.2.16 → 0.2.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/app/api/code/route.ts +37 -1
- package/app/api/detect-cli/route.ts +46 -0
- package/app/api/docs/route.ts +32 -1
- package/app/api/upgrade/route.ts +8 -0
- package/bin/forge-server.mjs +13 -3
- package/components/CodeViewer.tsx +63 -1
- package/components/Dashboard.tsx +0 -10
- package/components/DocsViewer.tsx +51 -1
- package/components/SessionView.tsx +73 -3
- package/components/SettingsModal.tsx +31 -8
- package/lib/init.ts +18 -0
- package/package.json +1 -1
package/app/api/code/route.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, relative, extname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { execSync } from 'node:child_process';
|
|
@@ -221,3 +221,39 @@ export async function GET(req: Request) {
|
|
|
221
221
|
|
|
222
222
|
return NextResponse.json({ tree, dirName, dirPath: resolvedDir, gitBranch, gitChanges, gitRepos });
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
// PUT /api/code — save file content
|
|
226
|
+
export async function PUT(req: Request) {
|
|
227
|
+
const { dir, file, content } = await req.json() as {
|
|
228
|
+
dir: string;
|
|
229
|
+
file: string;
|
|
230
|
+
content: string;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (!dir || !file) {
|
|
234
|
+
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const resolvedDir = dir.replace(/^~/, homedir());
|
|
238
|
+
const fullPath = join(resolvedDir, file);
|
|
239
|
+
|
|
240
|
+
// Security: ensure path is within the directory
|
|
241
|
+
if (!fullPath.startsWith(resolvedDir)) {
|
|
242
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Verify dir is under a configured project root
|
|
246
|
+
const settings = loadSettings();
|
|
247
|
+
const roots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
|
|
248
|
+
const allowed = roots.some(r => resolvedDir.startsWith(r));
|
|
249
|
+
if (!allowed) {
|
|
250
|
+
return NextResponse.json({ error: 'Directory not in project roots' }, { status: 403 });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
255
|
+
return NextResponse.json({ ok: true });
|
|
256
|
+
} catch {
|
|
257
|
+
return NextResponse.json({ error: 'Failed to save' }, { status: 500 });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
|
|
5
|
+
interface CliInfo {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
version: string;
|
|
9
|
+
installHint: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function detect(name: string, installHint: string): CliInfo {
|
|
13
|
+
try {
|
|
14
|
+
const path = execSync(`which ${name}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
15
|
+
let version = '';
|
|
16
|
+
try {
|
|
17
|
+
const out = execSync(`${path} --version`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
18
|
+
// Extract version number from output (e.g. "@anthropic-ai/claude-code v1.2.3" or "codex 0.1.0")
|
|
19
|
+
const match = out.match(/v?(\d+\.\d+\.\d+)/);
|
|
20
|
+
version = match ? match[1] : out.slice(0, 50);
|
|
21
|
+
} catch {}
|
|
22
|
+
return { name, path, version, installHint };
|
|
23
|
+
} catch {
|
|
24
|
+
return { name, path: '', version: '', installHint };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function GET() {
|
|
29
|
+
const os = platform();
|
|
30
|
+
const isLinux = os === 'linux';
|
|
31
|
+
const isMac = os === 'darwin';
|
|
32
|
+
|
|
33
|
+
const results = [
|
|
34
|
+
detect('claude', isMac
|
|
35
|
+
? 'npm install -g @anthropic-ai/claude-code'
|
|
36
|
+
: isLinux
|
|
37
|
+
? 'npm install -g @anthropic-ai/claude-code'
|
|
38
|
+
: 'npm install -g @anthropic-ai/claude-code'),
|
|
39
|
+
detect('codex', 'npm install -g @openai/codex'),
|
|
40
|
+
detect('aider', isMac
|
|
41
|
+
? 'brew install aider or pip install aider-chat'
|
|
42
|
+
: 'pip install aider-chat'),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({ os, tools: results });
|
|
46
|
+
}
|
package/app/api/docs/route.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { readdirSync, statSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, relative, extname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { loadSettings } from '@/lib/settings';
|
|
@@ -128,3 +128,34 @@ export async function GET(req: Request) {
|
|
|
128
128
|
|
|
129
129
|
return NextResponse.json({ roots: rootNames, rootPaths: docRoots, tree });
|
|
130
130
|
}
|
|
131
|
+
|
|
132
|
+
// PUT /api/docs — save file content
|
|
133
|
+
export async function PUT(req: Request) {
|
|
134
|
+
const { root: rootIdx, file: filePath, content } = await req.json() as {
|
|
135
|
+
root: number;
|
|
136
|
+
file: string;
|
|
137
|
+
content: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const settings = loadSettings();
|
|
141
|
+
const docRoots = (settings.docRoots || []).map(r => r.replace(/^~/, homedir()));
|
|
142
|
+
|
|
143
|
+
if (rootIdx >= docRoots.length || !filePath) {
|
|
144
|
+
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const root = docRoots[rootIdx];
|
|
148
|
+
const fullPath = join(root, filePath);
|
|
149
|
+
|
|
150
|
+
// Security: ensure path is within root
|
|
151
|
+
if (!fullPath.startsWith(root)) {
|
|
152
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
157
|
+
return NextResponse.json({ ok: true });
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return NextResponse.json({ error: 'Failed to save' }, { status: 500 });
|
|
160
|
+
}
|
|
161
|
+
}
|
package/app/api/upgrade/route.ts
CHANGED
|
@@ -16,8 +16,16 @@ export async function POST() {
|
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Upgrade from npm
|
|
19
20
|
execSync('cd /tmp && npm install -g @aion0/forge', { timeout: 120000 });
|
|
20
21
|
|
|
22
|
+
// Install devDependencies for build (npm -g doesn't install them)
|
|
23
|
+
const pkgRoot = execSync('npm root -g', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
24
|
+
const forgeRoot = join(pkgRoot, '@aion0', 'forge');
|
|
25
|
+
try {
|
|
26
|
+
execSync('npm install --include=dev', { cwd: forgeRoot, timeout: 120000 });
|
|
27
|
+
} catch {}
|
|
28
|
+
|
|
21
29
|
return NextResponse.json({
|
|
22
30
|
ok: true,
|
|
23
31
|
message: 'Upgraded. Restart server to apply.',
|
package/bin/forge-server.mjs
CHANGED
|
@@ -28,6 +28,16 @@ import { homedir } from 'node:os';
|
|
|
28
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
29
|
const ROOT = join(__dirname, '..');
|
|
30
30
|
|
|
31
|
+
/** Build Next.js — install devDependencies first if missing */
|
|
32
|
+
function buildNext() {
|
|
33
|
+
// Check if devDependencies are installed (e.g. @tailwindcss/postcss)
|
|
34
|
+
if (!existsSync(join(ROOT, 'node_modules', '@tailwindcss', 'postcss'))) {
|
|
35
|
+
console.log('[forge] Installing dependencies...');
|
|
36
|
+
execSync('npm install --include=dev', { cwd: ROOT, stdio: 'inherit' });
|
|
37
|
+
}
|
|
38
|
+
execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// ── Parse arguments ──
|
|
32
42
|
|
|
33
43
|
function getArg(name) {
|
|
@@ -173,7 +183,7 @@ function stopServer() {
|
|
|
173
183
|
function startBackground() {
|
|
174
184
|
if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
|
|
175
185
|
console.log('[forge] Building...');
|
|
176
|
-
|
|
186
|
+
buildNext();
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
const logFd = openSync(LOG_FILE, 'a');
|
|
@@ -221,7 +231,7 @@ if (isRebuild || existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
|
|
|
221
231
|
if (isRebuild || lastBuiltVersion !== pkgVersion) {
|
|
222
232
|
console.log(`[forge] Rebuilding (v${pkgVersion})...`);
|
|
223
233
|
execSync('rm -rf .next', { cwd: ROOT });
|
|
224
|
-
|
|
234
|
+
buildNext();
|
|
225
235
|
writeFileSync(versionFile, pkgVersion);
|
|
226
236
|
if (isRebuild) {
|
|
227
237
|
console.log('[forge] Rebuild complete');
|
|
@@ -254,7 +264,7 @@ if (isDev) {
|
|
|
254
264
|
} else {
|
|
255
265
|
if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) {
|
|
256
266
|
console.log('[forge] Building...');
|
|
257
|
-
|
|
267
|
+
buildNext();
|
|
258
268
|
}
|
|
259
269
|
console.log(`[forge] Starting server (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
|
|
260
270
|
startServices();
|
|
@@ -185,6 +185,9 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
185
185
|
const [viewMode, setViewMode] = useState<'file' | 'diff'>('file');
|
|
186
186
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
187
187
|
const [codeOpen, setCodeOpen] = useState(false);
|
|
188
|
+
const [editing, setEditing] = useState(false);
|
|
189
|
+
const [editContent, setEditContent] = useState('');
|
|
190
|
+
const [saving, setSaving] = useState(false);
|
|
188
191
|
|
|
189
192
|
const handleCodeOpenChange = useCallback((open: boolean) => {
|
|
190
193
|
setCodeOpen(open);
|
|
@@ -626,7 +629,43 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
626
629
|
<>
|
|
627
630
|
<span className="text-xs font-semibold text-[var(--text-primary)] truncate">{selectedFile}</span>
|
|
628
631
|
{language && (
|
|
629
|
-
<span className="text-[9px] text-[var(--text-secondary)] ml-auto">{LANG_MAP[language] || language}</span>
|
|
632
|
+
<span className="text-[9px] text-[var(--text-secondary)] ml-auto mr-2">{LANG_MAP[language] || language}</span>
|
|
633
|
+
)}
|
|
634
|
+
{content !== null && !editing && (
|
|
635
|
+
<button
|
|
636
|
+
onClick={() => { setEditing(true); setEditContent(content); }}
|
|
637
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] shrink-0 ml-auto"
|
|
638
|
+
>
|
|
639
|
+
Edit
|
|
640
|
+
</button>
|
|
641
|
+
)}
|
|
642
|
+
{editing && (
|
|
643
|
+
<>
|
|
644
|
+
<button
|
|
645
|
+
disabled={saving}
|
|
646
|
+
onClick={async () => {
|
|
647
|
+
if (!currentDir || !selectedFile) return;
|
|
648
|
+
setSaving(true);
|
|
649
|
+
await fetch('/api/code', {
|
|
650
|
+
method: 'PUT',
|
|
651
|
+
headers: { 'Content-Type': 'application/json' },
|
|
652
|
+
body: JSON.stringify({ dir: currentDir, file: selectedFile, content: editContent }),
|
|
653
|
+
});
|
|
654
|
+
setContent(editContent);
|
|
655
|
+
setEditing(false);
|
|
656
|
+
setSaving(false);
|
|
657
|
+
}}
|
|
658
|
+
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0 ml-auto"
|
|
659
|
+
>
|
|
660
|
+
{saving ? 'Saving...' : 'Save'}
|
|
661
|
+
</button>
|
|
662
|
+
<button
|
|
663
|
+
onClick={() => setEditing(false)}
|
|
664
|
+
className="text-[9px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
665
|
+
>
|
|
666
|
+
Cancel
|
|
667
|
+
</button>
|
|
668
|
+
</>
|
|
630
669
|
)}
|
|
631
670
|
</>
|
|
632
671
|
) : (
|
|
@@ -688,6 +727,28 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
688
727
|
</pre>
|
|
689
728
|
</div>
|
|
690
729
|
) : selectedFile && content !== null ? (
|
|
730
|
+
editing ? (
|
|
731
|
+
<div className="flex-1 overflow-hidden flex flex-col">
|
|
732
|
+
<textarea
|
|
733
|
+
value={editContent}
|
|
734
|
+
onChange={e => setEditContent(e.target.value)}
|
|
735
|
+
onKeyDown={e => {
|
|
736
|
+
// Tab key inserts 2 spaces
|
|
737
|
+
if (e.key === 'Tab') {
|
|
738
|
+
e.preventDefault();
|
|
739
|
+
const ta = e.target as HTMLTextAreaElement;
|
|
740
|
+
const start = ta.selectionStart;
|
|
741
|
+
const end = ta.selectionEnd;
|
|
742
|
+
setEditContent(editContent.slice(0, start) + ' ' + editContent.slice(end));
|
|
743
|
+
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + 2; }, 0);
|
|
744
|
+
}
|
|
745
|
+
}}
|
|
746
|
+
className="flex-1 w-full p-4 bg-[var(--bg-primary)] text-[var(--text-primary)] text-[12px] leading-[1.5] font-mono resize-none focus:outline-none"
|
|
747
|
+
style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}
|
|
748
|
+
spellCheck={false}
|
|
749
|
+
/>
|
|
750
|
+
</div>
|
|
751
|
+
) : (
|
|
691
752
|
<div className="flex-1 overflow-auto bg-[var(--bg-primary)]">
|
|
692
753
|
<pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2, overflow: 'auto', maxWidth: 0, minWidth: '100%' }}>
|
|
693
754
|
{content.split('\n').map((line, i) => (
|
|
@@ -698,6 +759,7 @@ export default function CodeViewer({ terminalRef }: { terminalRef: React.RefObje
|
|
|
698
759
|
))}
|
|
699
760
|
</pre>
|
|
700
761
|
</div>
|
|
762
|
+
)
|
|
701
763
|
) : (
|
|
702
764
|
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
703
765
|
<p className="text-xs">{currentDir ? 'Select a file to view' : 'Terminal will show files for its working directory'}</p>
|
package/components/Dashboard.tsx
CHANGED
|
@@ -8,7 +8,6 @@ import SessionView from './SessionView';
|
|
|
8
8
|
import NewTaskModal from './NewTaskModal';
|
|
9
9
|
import SettingsModal from './SettingsModal';
|
|
10
10
|
import TunnelToggle from './TunnelToggle';
|
|
11
|
-
import MonitorPanel from './MonitorPanel';
|
|
12
11
|
import type { Task } from '@/src/types';
|
|
13
12
|
import type { WebTerminalHandle } from './WebTerminal';
|
|
14
13
|
|
|
@@ -45,7 +44,6 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
45
44
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
46
45
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
47
46
|
const [showSettings, setShowSettings] = useState(false);
|
|
48
|
-
const [showMonitor, setShowMonitor] = useState(false);
|
|
49
47
|
const [usage, setUsage] = useState<UsageSummary[]>([]);
|
|
50
48
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
51
49
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
@@ -236,12 +234,6 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
236
234
|
)}
|
|
237
235
|
</span>
|
|
238
236
|
)}
|
|
239
|
-
<button
|
|
240
|
-
onClick={() => setShowMonitor(true)}
|
|
241
|
-
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
242
|
-
>
|
|
243
|
-
Monitor
|
|
244
|
-
</button>
|
|
245
237
|
<button
|
|
246
238
|
onClick={() => setShowSettings(true)}
|
|
247
239
|
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
@@ -413,8 +405,6 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
413
405
|
/>
|
|
414
406
|
)}
|
|
415
407
|
|
|
416
|
-
{showMonitor && <MonitorPanel onClose={() => setShowMonitor(false)} />}
|
|
417
|
-
|
|
418
408
|
{showSettings && (
|
|
419
409
|
<SettingsModal onClose={() => { setShowSettings(false); fetchData(); }} />
|
|
420
410
|
)}
|
|
@@ -84,6 +84,9 @@ export default function DocsViewer() {
|
|
|
84
84
|
const [search, setSearch] = useState('');
|
|
85
85
|
const [terminalHeight, setTerminalHeight] = useState(250);
|
|
86
86
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
87
|
+
const [editing, setEditing] = useState(false);
|
|
88
|
+
const [editContent, setEditContent] = useState('');
|
|
89
|
+
const [saving, setSaving] = useState(false);
|
|
87
90
|
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
|
88
91
|
|
|
89
92
|
// Fetch tree
|
|
@@ -254,7 +257,42 @@ export default function DocsViewer() {
|
|
|
254
257
|
{selectedFile ? (
|
|
255
258
|
<>
|
|
256
259
|
<span className="text-xs font-semibold text-[var(--text-primary)] truncate">{selectedFile.replace(/\.md$/, '')}</span>
|
|
257
|
-
<span className="text-[9px] text-[var(--text-secondary)] ml-auto">{selectedFile}</span>
|
|
260
|
+
<span className="text-[9px] text-[var(--text-secondary)] ml-auto mr-2">{selectedFile}</span>
|
|
261
|
+
{content && !isImageFile(selectedFile) && !editing && (
|
|
262
|
+
<button
|
|
263
|
+
onClick={() => { setEditing(true); setEditContent(content); }}
|
|
264
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] shrink-0"
|
|
265
|
+
>
|
|
266
|
+
Edit
|
|
267
|
+
</button>
|
|
268
|
+
)}
|
|
269
|
+
{editing && (
|
|
270
|
+
<>
|
|
271
|
+
<button
|
|
272
|
+
disabled={saving}
|
|
273
|
+
onClick={async () => {
|
|
274
|
+
setSaving(true);
|
|
275
|
+
await fetch('/api/docs', {
|
|
276
|
+
method: 'PUT',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({ root: activeRoot, file: selectedFile, content: editContent }),
|
|
279
|
+
});
|
|
280
|
+
setContent(editContent);
|
|
281
|
+
setEditing(false);
|
|
282
|
+
setSaving(false);
|
|
283
|
+
}}
|
|
284
|
+
className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
|
|
285
|
+
>
|
|
286
|
+
{saving ? 'Saving...' : 'Save'}
|
|
287
|
+
</button>
|
|
288
|
+
<button
|
|
289
|
+
onClick={() => setEditing(false)}
|
|
290
|
+
className="text-[9px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
|
|
291
|
+
>
|
|
292
|
+
Cancel
|
|
293
|
+
</button>
|
|
294
|
+
</>
|
|
295
|
+
)}
|
|
258
296
|
</>
|
|
259
297
|
) : (
|
|
260
298
|
<span className="text-xs text-[var(--text-secondary)]">{roots[activeRoot] || 'Docs'}</span>
|
|
@@ -278,6 +316,17 @@ export default function DocsViewer() {
|
|
|
278
316
|
/>
|
|
279
317
|
</div>
|
|
280
318
|
) : selectedFile && content ? (
|
|
319
|
+
editing ? (
|
|
320
|
+
<div className="flex-1 overflow-hidden flex flex-col">
|
|
321
|
+
<textarea
|
|
322
|
+
value={editContent}
|
|
323
|
+
onChange={e => setEditContent(e.target.value)}
|
|
324
|
+
className="flex-1 w-full p-4 bg-[var(--bg-primary)] text-[var(--text-primary)] text-[13px] font-mono leading-relaxed resize-none focus:outline-none"
|
|
325
|
+
style={{ tabSize: 2 }}
|
|
326
|
+
spellCheck={false}
|
|
327
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
) : (
|
|
281
330
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
|
282
331
|
{loading ? (
|
|
283
332
|
<div className="text-xs text-[var(--text-secondary)]">Loading...</div>
|
|
@@ -287,6 +336,7 @@ export default function DocsViewer() {
|
|
|
287
336
|
</div>
|
|
288
337
|
)}
|
|
289
338
|
</div>
|
|
339
|
+
)
|
|
290
340
|
) : (
|
|
291
341
|
<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
|
|
292
342
|
<p className="text-xs">Select a document to view</p>
|
|
@@ -32,6 +32,17 @@ interface Watcher {
|
|
|
32
32
|
createdAt: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
interface MonitorData {
|
|
36
|
+
processes: {
|
|
37
|
+
nextjs: { running: boolean; pid: string };
|
|
38
|
+
terminal: { running: boolean; pid: string };
|
|
39
|
+
telegram: { running: boolean; pid: string };
|
|
40
|
+
tunnel: { running: boolean; pid: string; url: string };
|
|
41
|
+
};
|
|
42
|
+
sessions: { name: string; created: string; attached: boolean; windows: number }[];
|
|
43
|
+
uptime: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
export default function SessionView({
|
|
36
47
|
projectName,
|
|
37
48
|
projects,
|
|
@@ -51,7 +62,9 @@ export default function SessionView({
|
|
|
51
62
|
const [syncing, setSyncing] = useState(false);
|
|
52
63
|
const [watchers, setWatchers] = useState<Watcher[]>([]);
|
|
53
64
|
const [batchMode, setBatchMode] = useState(false);
|
|
54
|
-
const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map());
|
|
65
|
+
const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map());
|
|
66
|
+
const [monitor, setMonitor] = useState<MonitorData | null>(null);
|
|
67
|
+
const [monitorOpen, setMonitorOpen] = useState(true);
|
|
55
68
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
56
69
|
|
|
57
70
|
// Load cached sessions tree
|
|
@@ -79,10 +92,17 @@ export default function SessionView({
|
|
|
79
92
|
} catch {}
|
|
80
93
|
}, []);
|
|
81
94
|
|
|
95
|
+
const refreshMonitor = useCallback(() => {
|
|
96
|
+
fetch('/api/monitor').then(r => r.json()).then(setMonitor).catch(() => {});
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
82
99
|
useEffect(() => {
|
|
83
|
-
loadTree(true);
|
|
100
|
+
loadTree(true);
|
|
84
101
|
loadWatchers();
|
|
85
|
-
|
|
102
|
+
refreshMonitor();
|
|
103
|
+
const timer = setInterval(refreshMonitor, 5000);
|
|
104
|
+
return () => clearInterval(timer);
|
|
105
|
+
}, [loadTree, loadWatchers, refreshMonitor]);
|
|
86
106
|
|
|
87
107
|
// Auto-expand project if only one or if pre-selected
|
|
88
108
|
useEffect(() => {
|
|
@@ -328,6 +348,56 @@ export default function SessionView({
|
|
|
328
348
|
</div>
|
|
329
349
|
)}
|
|
330
350
|
|
|
351
|
+
{/* Monitor */}
|
|
352
|
+
{monitor && (
|
|
353
|
+
<div className="border-b border-[var(--border)]">
|
|
354
|
+
<button
|
|
355
|
+
onClick={() => setMonitorOpen(v => !v)}
|
|
356
|
+
className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-[var(--bg-tertiary)] transition-colors"
|
|
357
|
+
>
|
|
358
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{monitorOpen ? '▼' : '▶'}</span>
|
|
359
|
+
<span className="text-[9px] font-semibold text-[var(--text-secondary)] uppercase">Monitor</span>
|
|
360
|
+
{monitor.uptime && (
|
|
361
|
+
<span className="text-[8px] text-[var(--text-secondary)] ml-auto">{monitor.uptime}</span>
|
|
362
|
+
)}
|
|
363
|
+
</button>
|
|
364
|
+
{monitorOpen && (
|
|
365
|
+
<div className="px-2 pb-2 space-y-1.5">
|
|
366
|
+
{/* Processes */}
|
|
367
|
+
{[
|
|
368
|
+
{ label: 'Next.js', ...monitor.processes.nextjs },
|
|
369
|
+
{ label: 'Terminal', ...monitor.processes.terminal },
|
|
370
|
+
{ label: 'Telegram', ...monitor.processes.telegram },
|
|
371
|
+
{ label: 'Tunnel', ...monitor.processes.tunnel },
|
|
372
|
+
].map(p => (
|
|
373
|
+
<div key={p.label} className="flex items-center gap-1.5 text-[10px]">
|
|
374
|
+
<span className={p.running ? 'text-green-400' : 'text-gray-500'}>●</span>
|
|
375
|
+
<span className="text-[var(--text-primary)]">{p.label}</span>
|
|
376
|
+
<span className="text-[var(--text-secondary)] font-mono ml-auto">{p.running ? `pid:${p.pid}` : 'stopped'}</span>
|
|
377
|
+
</div>
|
|
378
|
+
))}
|
|
379
|
+
{monitor.processes.tunnel.running && monitor.processes.tunnel.url && (
|
|
380
|
+
<div className="text-[9px] text-[var(--accent)] truncate pl-4">{monitor.processes.tunnel.url}</div>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{/* Tmux sessions */}
|
|
384
|
+
{monitor.sessions.length > 0 && (
|
|
385
|
+
<div className="pt-1">
|
|
386
|
+
<span className="text-[8px] font-semibold text-[var(--text-secondary)] uppercase">Tmux ({monitor.sessions.length})</span>
|
|
387
|
+
{monitor.sessions.map(s => (
|
|
388
|
+
<div key={s.name} className="flex items-center gap-1.5 text-[10px] mt-0.5">
|
|
389
|
+
<span className={s.attached ? 'text-green-400' : 'text-yellow-500'}>●</span>
|
|
390
|
+
<span className="font-mono text-[var(--text-primary)] truncate flex-1">{s.name}</span>
|
|
391
|
+
<span className="text-[8px] text-[var(--text-secondary)]">{s.attached ? 'attached' : 'detached'}</span>
|
|
392
|
+
</div>
|
|
393
|
+
))}
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
|
|
331
401
|
{/* Tree */}
|
|
332
402
|
<div className="flex-1 overflow-y-auto">
|
|
333
403
|
{Object.keys(sessionTree).length === 0 && (
|
|
@@ -410,15 +410,38 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
410
410
|
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
411
411
|
Claude Code Path
|
|
412
412
|
</label>
|
|
413
|
-
<
|
|
414
|
-
|
|
413
|
+
<div className="flex gap-2">
|
|
414
|
+
<input
|
|
415
|
+
value={settings.claudePath}
|
|
416
|
+
onChange={e => setSettings({ ...settings, claudePath: e.target.value })}
|
|
417
|
+
placeholder="Auto-detect or enter path manually"
|
|
418
|
+
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)]"
|
|
419
|
+
/>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={async () => {
|
|
423
|
+
try {
|
|
424
|
+
const res = await fetch('/api/detect-cli');
|
|
425
|
+
const data = await res.json();
|
|
426
|
+
const claude = data.tools?.find((t: any) => t.name === 'claude');
|
|
427
|
+
if (claude?.path) {
|
|
428
|
+
setSettings({ ...settings, claudePath: claude.path });
|
|
429
|
+
} else {
|
|
430
|
+
const hint = claude?.installHint || 'npm install -g @anthropic-ai/claude-code';
|
|
431
|
+
alert(`Claude Code not found.\n\nInstall:\n ${hint}`);
|
|
432
|
+
}
|
|
433
|
+
} catch { alert('Detection failed'); }
|
|
434
|
+
}}
|
|
435
|
+
className="text-[10px] px-2 py-1.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors shrink-0"
|
|
436
|
+
>
|
|
437
|
+
Detect
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
<p className={`text-[9px] ${settings.claudePath ? 'text-[var(--text-secondary)]' : 'text-[var(--yellow)]'}`}>
|
|
441
|
+
{settings.claudePath
|
|
442
|
+
? 'Click Detect to re-scan, or edit manually.'
|
|
443
|
+
: 'Not configured. Click Detect or run `which claude` in terminal to find the path.'}
|
|
415
444
|
</p>
|
|
416
|
-
<input
|
|
417
|
-
value={settings.claudePath}
|
|
418
|
-
onChange={e => setSettings({ ...settings, claudePath: e.target.value })}
|
|
419
|
-
placeholder="/usr/local/bin/claude"
|
|
420
|
-
className="w-full 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)]"
|
|
421
|
-
/>
|
|
422
445
|
</div>
|
|
423
446
|
|
|
424
447
|
{/* Telegram Notifications */}
|
package/lib/init.ts
CHANGED
|
@@ -45,6 +45,21 @@ function migrateSecrets() {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Auto-detect claude binary path if not configured */
|
|
49
|
+
function autoDetectClaude() {
|
|
50
|
+
try {
|
|
51
|
+
const settings = loadSettings();
|
|
52
|
+
if (settings.claudePath) return; // already configured
|
|
53
|
+
const { execSync } = require('node:child_process');
|
|
54
|
+
const path = execSync('which claude', { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
55
|
+
if (path) {
|
|
56
|
+
settings.claudePath = path;
|
|
57
|
+
saveSettings(settings);
|
|
58
|
+
console.log(`[init] Auto-detected claude: ${path}`);
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
export function ensureInitialized() {
|
|
49
64
|
if (gInit[initKey]) return;
|
|
50
65
|
gInit[initKey] = true;
|
|
@@ -52,6 +67,9 @@ export function ensureInitialized() {
|
|
|
52
67
|
// Migrate plaintext secrets on startup
|
|
53
68
|
migrateSecrets();
|
|
54
69
|
|
|
70
|
+
// Auto-detect claude path if not configured
|
|
71
|
+
autoDetectClaude();
|
|
72
|
+
|
|
55
73
|
// Task runner is safe in every worker (DB-level coordination)
|
|
56
74
|
ensureRunnerStarted();
|
|
57
75
|
|
package/package.json
CHANGED