@aion0/forge 0.1.10 → 0.2.1

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 CHANGED
@@ -9,6 +9,7 @@ pnpm dev
9
9
  pnpm build && pnpm start
10
10
 
11
11
  # Publish to npm (bump version in package.json first)
12
+ npm login
12
13
  npm publish --access public --otp=<code>
13
14
 
14
15
  # Install globally from local source (for testing)
@@ -0,0 +1,194 @@
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: scan for git repos (could be root dir or subdirectories)
142
+ interface GitRepo {
143
+ name: string; // repo dir name (or '.' for root)
144
+ branch: string;
145
+ remote: string; // remote URL
146
+ changes: { path: string; status: string }[];
147
+ }
148
+ const gitRepos: GitRepo[] = [];
149
+
150
+ function scanGitStatus(dir: string, repoName: string, pathPrefix: string) {
151
+ try {
152
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf-8', timeout: 3000 }).trim();
153
+ const statusOut = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 5000 });
154
+ const changes = statusOut.replace(/\n$/, '').split('\n').filter(Boolean)
155
+ .map(line => {
156
+ if (line.length < 4) return null;
157
+ return {
158
+ status: line.substring(0, 2).trim() || 'M',
159
+ path: pathPrefix ? `${pathPrefix}/${line.substring(3).replace(/\/$/, '')}` : line.substring(3).replace(/\/$/, ''),
160
+ };
161
+ })
162
+ .filter((g): g is { status: string; path: string } => g !== null && !!g.path && !g.path.includes(' -> '));
163
+ let remote = '';
164
+ try { remote = execSync('git remote get-url origin', { cwd: dir, encoding: 'utf-8', timeout: 2000 }).trim(); } catch {}
165
+ if (branch || changes.length > 0) {
166
+ gitRepos.push({ name: repoName, branch, remote, changes });
167
+ }
168
+ } catch {}
169
+ }
170
+
171
+ // Check if root is a git repo
172
+ try {
173
+ execSync('git rev-parse --git-dir', { cwd: resolvedDir, encoding: 'utf-8', timeout: 2000 });
174
+ scanGitStatus(resolvedDir, '.', '');
175
+ } catch {
176
+ // Root is not a git repo — scan subdirectories
177
+ try {
178
+ for (const entry of readdirSync(resolvedDir, { withFileTypes: true })) {
179
+ if (!entry.isDirectory() || entry.name.startsWith('.') || IGNORE.has(entry.name)) continue;
180
+ const subDir = join(resolvedDir, entry.name);
181
+ try {
182
+ execSync('git rev-parse --git-dir', { cwd: subDir, encoding: 'utf-8', timeout: 2000 });
183
+ scanGitStatus(subDir, entry.name, entry.name);
184
+ } catch {}
185
+ }
186
+ } catch {}
187
+ }
188
+
189
+ // Flatten for backward compat
190
+ const gitChanges = gitRepos.flatMap(r => r.changes);
191
+ const gitBranch = gitRepos.length === 1 ? gitRepos[0].branch : '';
192
+
193
+ return NextResponse.json({ tree, dirName, dirPath: resolvedDir, gitBranch, gitChanges, gitRepos });
194
+ }
@@ -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
+ }