@aion0/forge 0.1.9 → 0.2.0
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/CLAUDE.md +44 -0
- package/app/api/code/route.ts +160 -0
- package/app/api/docs/route.ts +85 -0
- package/app/api/docs/sessions/route.ts +54 -0
- package/app/api/terminal-cwd/route.ts +19 -0
- package/components/CodeViewer.tsx +474 -0
- package/components/Dashboard.tsx +34 -14
- package/components/DocTerminal.tsx +168 -0
- package/components/DocsViewer.tsx +254 -0
- package/components/MarkdownContent.tsx +24 -8
- package/components/SettingsModal.tsx +55 -0
- package/components/WebTerminal.tsx +32 -7
- package/lib/settings.ts +2 -0
- package/lib/telegram-bot.ts +469 -49
- package/lib/terminal-standalone.ts +35 -3
- package/next-env.d.ts +1 -1
- package/package.json +2 -1
package/CLAUDE.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
+
## Project: Forge (@aion0/forge)
|
|
2
|
+
|
|
3
|
+
### Dev Commands
|
|
4
|
+
```bash
|
|
5
|
+
# Development (hot-reload)
|
|
6
|
+
pnpm dev
|
|
7
|
+
|
|
8
|
+
# Production (local)
|
|
9
|
+
pnpm build && pnpm start
|
|
10
|
+
|
|
11
|
+
# Publish to npm (bump version in package.json first)
|
|
12
|
+
npm login
|
|
13
|
+
npm publish --access public --otp=<code>
|
|
14
|
+
|
|
15
|
+
# Install globally from local source (for testing)
|
|
16
|
+
npm install -g /Users/zliu/IdeaProjects/my-workflow
|
|
17
|
+
|
|
18
|
+
# Install from npm
|
|
19
|
+
npm install -g @aion0/forge
|
|
20
|
+
|
|
21
|
+
# Run via npm global install
|
|
22
|
+
forge-server # foreground (auto-builds if needed)
|
|
23
|
+
forge-server --dev # dev mode
|
|
24
|
+
forge-server --background # background, logs to ~/.forge/forge.log
|
|
25
|
+
forge-server --stop # stop background server
|
|
26
|
+
forge-server --rebuild # force rebuild
|
|
27
|
+
|
|
28
|
+
# CLI
|
|
29
|
+
forge # help
|
|
30
|
+
forge password # show today's login password
|
|
31
|
+
forge tasks # list tasks
|
|
32
|
+
forge task <project> "prompt" # submit task
|
|
33
|
+
|
|
34
|
+
# Terminal server runs on port 3001 (auto-started by Next.js)
|
|
35
|
+
# Data directory: ~/.forge/
|
|
36
|
+
# Config: ~/.forge/settings.yaml
|
|
37
|
+
# Env: ~/.forge/.env.local
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Key Paths
|
|
41
|
+
- Data: `~/.forge/` (settings, db, password, terminal-state, flows, bin)
|
|
42
|
+
- npm package: `@aion0/forge`
|
|
43
|
+
- GitHub: `github.com/aiwatching/forge`
|
|
44
|
+
|
|
1
45
|
## Obsidian Vault
|
|
2
46
|
Location: /Users/zliu/MyDocuments/obsidian-project/Projects/Bastion
|
|
3
47
|
When I ask about my notes, use bash to search and read files from this directory.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, relative, extname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { loadSettings } from '@/lib/settings';
|
|
7
|
+
|
|
8
|
+
interface FileNode {
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
type: 'file' | 'dir';
|
|
12
|
+
children?: FileNode[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const IGNORE = new Set([
|
|
16
|
+
'node_modules', '.git', '.next', 'dist', 'build', '.idea', '.vscode',
|
|
17
|
+
'.DS_Store', 'coverage', '__pycache__', '.cache', '.output', 'target',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const CODE_EXTS = new Set([
|
|
21
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
22
|
+
'.py', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.cpp', '.h',
|
|
23
|
+
'.css', '.scss', '.html', '.json', '.yaml', '.yml', '.toml',
|
|
24
|
+
'.md', '.txt', '.sh', '.bash', '.zsh', '.fish',
|
|
25
|
+
'.sql', '.graphql', '.proto', '.env', '.gitignore',
|
|
26
|
+
'.xml', '.csv', '.lock',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function isCodeFile(name: string): boolean {
|
|
30
|
+
if (name.startsWith('.') && !name.startsWith('.env') && !name.startsWith('.git')) return false;
|
|
31
|
+
const ext = extname(name);
|
|
32
|
+
if (!ext) return !name.includes('.'); // files like Makefile, Dockerfile
|
|
33
|
+
return CODE_EXTS.has(ext);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
|
|
37
|
+
if (depth > 5) return [];
|
|
38
|
+
try {
|
|
39
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
const nodes: FileNode[] = [];
|
|
41
|
+
|
|
42
|
+
const sorted = entries
|
|
43
|
+
.filter(e => !IGNORE.has(e.name) && !e.name.startsWith('.'))
|
|
44
|
+
.sort((a, b) => {
|
|
45
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
46
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
47
|
+
return a.name.localeCompare(b.name);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
for (const entry of sorted) {
|
|
51
|
+
const fullPath = join(dir, entry.name);
|
|
52
|
+
const relPath = relative(base, fullPath);
|
|
53
|
+
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
const children = scanDir(fullPath, base, depth + 1);
|
|
56
|
+
if (children.length > 0) {
|
|
57
|
+
nodes.push({ name: entry.name, path: relPath, type: 'dir', children });
|
|
58
|
+
}
|
|
59
|
+
} else if (isCodeFile(entry.name)) {
|
|
60
|
+
nodes.push({ name: entry.name, path: relPath, type: 'file' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return nodes;
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// GET /api/code?dir=<absolute-path>&file=<relative-path>
|
|
70
|
+
// dir mode: returns file tree for the given directory
|
|
71
|
+
// file mode: returns file content
|
|
72
|
+
export async function GET(req: Request) {
|
|
73
|
+
const { searchParams } = new URL(req.url);
|
|
74
|
+
const dir = searchParams.get('dir');
|
|
75
|
+
const filePath = searchParams.get('file');
|
|
76
|
+
|
|
77
|
+
if (!dir) {
|
|
78
|
+
return NextResponse.json({ tree: [], dirName: '' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const resolvedDir = dir.replace(/^~/, homedir());
|
|
82
|
+
|
|
83
|
+
// Security: dir must be under a projectRoot
|
|
84
|
+
const settings = loadSettings();
|
|
85
|
+
const projectRoots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
|
|
86
|
+
const allowed = projectRoots.some(root => resolvedDir.startsWith(root) || resolvedDir === root);
|
|
87
|
+
if (!allowed) {
|
|
88
|
+
return NextResponse.json({ error: 'Directory not under any project root' }, { status: 403 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Git diff for a specific file
|
|
92
|
+
const diffFile = searchParams.get('diff');
|
|
93
|
+
if (diffFile) {
|
|
94
|
+
const fullPath = join(resolvedDir, diffFile);
|
|
95
|
+
if (!fullPath.startsWith(resolvedDir)) {
|
|
96
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
// Try staged + unstaged diff
|
|
100
|
+
let diff = '';
|
|
101
|
+
try { diff = execSync(`git diff -- "${diffFile}"`, { cwd: resolvedDir, encoding: 'utf-8', timeout: 5000 }); } catch {}
|
|
102
|
+
if (!diff) {
|
|
103
|
+
try { diff = execSync(`git diff HEAD -- "${diffFile}"`, { cwd: resolvedDir, encoding: 'utf-8', timeout: 5000 }); } catch {}
|
|
104
|
+
}
|
|
105
|
+
if (!diff) {
|
|
106
|
+
// Untracked file — show entire content as added
|
|
107
|
+
try {
|
|
108
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
109
|
+
diff = content.split('\n').map(l => `+${l}`).join('\n');
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
return NextResponse.json({ diff: diff || 'No changes' });
|
|
113
|
+
} catch {
|
|
114
|
+
return NextResponse.json({ diff: 'Failed to get diff' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read file content
|
|
119
|
+
if (filePath) {
|
|
120
|
+
const fullPath = join(resolvedDir, filePath);
|
|
121
|
+
if (!fullPath.startsWith(resolvedDir)) {
|
|
122
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const stat = statSync(fullPath);
|
|
126
|
+
if (stat.size > 500_000) {
|
|
127
|
+
return NextResponse.json({ content: '// File too large to display', language: 'text' });
|
|
128
|
+
}
|
|
129
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
130
|
+
const ext = extname(fullPath).replace('.', '') || 'text';
|
|
131
|
+
return NextResponse.json({ content, language: ext });
|
|
132
|
+
} catch {
|
|
133
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Return file tree
|
|
138
|
+
const tree = scanDir(resolvedDir, resolvedDir);
|
|
139
|
+
const dirName = resolvedDir.split('/').pop() || resolvedDir;
|
|
140
|
+
|
|
141
|
+
// Git status: changed files
|
|
142
|
+
let gitChanges: { path: string; status: string }[] = [];
|
|
143
|
+
let gitBranch = '';
|
|
144
|
+
try {
|
|
145
|
+
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: resolvedDir, encoding: 'utf-8', timeout: 3000 }).trim();
|
|
146
|
+
const statusOut = execSync('git status --porcelain -u', { cwd: resolvedDir, encoding: 'utf-8', timeout: 5000 });
|
|
147
|
+
gitChanges = statusOut.replace(/\n$/, '').split('\n').filter(Boolean)
|
|
148
|
+
.map(line => {
|
|
149
|
+
// Format: XY<space>path — first 2 chars are status, char 3 is space, rest is path
|
|
150
|
+
if (line.length < 4) return null;
|
|
151
|
+
return {
|
|
152
|
+
status: line.substring(0, 2).trim() || 'M',
|
|
153
|
+
path: line.substring(3).replace(/\/$/, ''),
|
|
154
|
+
};
|
|
155
|
+
})
|
|
156
|
+
.filter((g): g is { status: string; path: string } => g !== null && !!g.path && !g.path.includes(' -> '));
|
|
157
|
+
} catch {}
|
|
158
|
+
|
|
159
|
+
return NextResponse.json({ tree, dirName, dirPath: resolvedDir, gitBranch, gitChanges });
|
|
160
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { readdirSync, statSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative, extname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { loadSettings } from '@/lib/settings';
|
|
6
|
+
|
|
7
|
+
interface FileNode {
|
|
8
|
+
name: string;
|
|
9
|
+
path: string; // relative to docRoot
|
|
10
|
+
type: 'file' | 'dir';
|
|
11
|
+
children?: FileNode[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
|
|
15
|
+
if (depth > 6) return [];
|
|
16
|
+
try {
|
|
17
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
18
|
+
const nodes: FileNode[] = [];
|
|
19
|
+
|
|
20
|
+
// Sort: dirs first, then files, alphabetical
|
|
21
|
+
const sorted = entries
|
|
22
|
+
.filter(e => !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
23
|
+
.sort((a, b) => {
|
|
24
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
25
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
26
|
+
return a.name.localeCompare(b.name);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
for (const entry of sorted) {
|
|
30
|
+
const fullPath = join(dir, entry.name);
|
|
31
|
+
const relPath = relative(base, fullPath);
|
|
32
|
+
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
const children = scanDir(fullPath, base, depth + 1);
|
|
35
|
+
if (children.length > 0) {
|
|
36
|
+
nodes.push({ name: entry.name, path: relPath, type: 'dir', children });
|
|
37
|
+
}
|
|
38
|
+
} else if (extname(entry.name) === '.md') {
|
|
39
|
+
nodes.push({ name: entry.name, path: relPath, type: 'file' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return nodes;
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// GET /api/docs — list doc roots and their file trees
|
|
49
|
+
export async function GET(req: Request) {
|
|
50
|
+
const { searchParams } = new URL(req.url);
|
|
51
|
+
const filePath = searchParams.get('file');
|
|
52
|
+
const rootIdx = parseInt(searchParams.get('root') || '0');
|
|
53
|
+
|
|
54
|
+
const settings = loadSettings();
|
|
55
|
+
const docRoots = (settings.docRoots || []).map(r => r.replace(/^~/, homedir()));
|
|
56
|
+
|
|
57
|
+
if (docRoots.length === 0) {
|
|
58
|
+
return NextResponse.json({ roots: [], tree: [], content: null });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rootNames = docRoots.map(r => r.split('/').pop() || r);
|
|
62
|
+
|
|
63
|
+
// Read file content
|
|
64
|
+
if (filePath && rootIdx < docRoots.length) {
|
|
65
|
+
const root = docRoots[rootIdx];
|
|
66
|
+
const fullPath = join(root, filePath);
|
|
67
|
+
// Security: ensure path doesn't escape root
|
|
68
|
+
if (!fullPath.startsWith(root)) {
|
|
69
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
73
|
+
return NextResponse.json({ content });
|
|
74
|
+
} catch {
|
|
75
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Return tree for selected root
|
|
80
|
+
const idx = Math.min(rootIdx, docRoots.length - 1);
|
|
81
|
+
const root = docRoots[idx];
|
|
82
|
+
const tree = scanDir(root, root);
|
|
83
|
+
|
|
84
|
+
return NextResponse.json({ roots: rootNames, rootPaths: docRoots, tree });
|
|
85
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
export async function GET(req: Request) {
|
|
7
|
+
const { searchParams } = new URL(req.url);
|
|
8
|
+
const dir = searchParams.get('dir');
|
|
9
|
+
if (!dir) return NextResponse.json({ sessions: [] });
|
|
10
|
+
|
|
11
|
+
// Claude stores sessions at ~/.claude/projects/<path-with-dashes>/
|
|
12
|
+
const hash = dir.replace(/\//g, '-');
|
|
13
|
+
const claudeDir = join(homedir(), '.claude', 'projects', hash);
|
|
14
|
+
|
|
15
|
+
if (!existsSync(claudeDir)) {
|
|
16
|
+
return NextResponse.json({ sessions: [] });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const files = readdirSync(claudeDir).filter(f => f.endsWith('.jsonl'));
|
|
21
|
+
const sessions = files.map(f => {
|
|
22
|
+
const sessionId = f.replace('.jsonl', '');
|
|
23
|
+
const filePath = join(claudeDir, f);
|
|
24
|
+
const stat = statSync(filePath);
|
|
25
|
+
|
|
26
|
+
// Read first line to get first prompt
|
|
27
|
+
let firstPrompt = '';
|
|
28
|
+
try {
|
|
29
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
30
|
+
const lines = content.split('\n').filter(Boolean);
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
try {
|
|
33
|
+
const entry = JSON.parse(line);
|
|
34
|
+
if (entry.type === 'human' || entry.role === 'user') {
|
|
35
|
+
const text = typeof entry.message === 'string' ? entry.message
|
|
36
|
+
: entry.message?.content?.[0]?.text || '';
|
|
37
|
+
if (text) { firstPrompt = text.slice(0, 80); break; }
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
sessionId,
|
|
45
|
+
firstPrompt,
|
|
46
|
+
modified: stat.mtime.toISOString(),
|
|
47
|
+
};
|
|
48
|
+
}).sort((a, b) => b.modified.localeCompare(a.modified));
|
|
49
|
+
|
|
50
|
+
return NextResponse.json({ sessions });
|
|
51
|
+
} catch {
|
|
52
|
+
return NextResponse.json({ sessions: [] });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
export async function GET(req: Request) {
|
|
5
|
+
const { searchParams } = new URL(req.url);
|
|
6
|
+
const session = searchParams.get('session');
|
|
7
|
+
if (!session || !session.startsWith('mw-')) {
|
|
8
|
+
return NextResponse.json({ path: null });
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const cwd = execSync(`tmux display-message -p -t ${session} '#{pane_current_path}'`, {
|
|
12
|
+
encoding: 'utf-8',
|
|
13
|
+
timeout: 3000,
|
|
14
|
+
}).trim();
|
|
15
|
+
return NextResponse.json({ path: cwd || null });
|
|
16
|
+
} catch {
|
|
17
|
+
return NextResponse.json({ path: null });
|
|
18
|
+
}
|
|
19
|
+
}
|