@aion0/forge 0.3.0 → 0.3.2
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 +9 -2
- package/README.md +9 -9
- package/app/api/claude-templates/route.ts +145 -0
- package/app/api/docs/sessions/route.ts +3 -3
- package/app/api/monitor/route.ts +2 -2
- package/app/api/pipelines/route.ts +2 -2
- package/app/api/preview/[...path]/route.ts +2 -2
- package/app/api/preview/route.ts +3 -4
- package/app/api/projects/route.ts +19 -0
- package/app/api/skills/local/route.ts +228 -0
- package/app/api/skills/route.ts +36 -11
- package/app/api/terminal-state/route.ts +2 -2
- package/app/login/page.tsx +1 -1
- package/bin/forge-server.mjs +83 -33
- package/cli/mw.ts +27 -9
- package/components/Dashboard.tsx +14 -0
- package/components/ProjectManager.tsx +581 -42
- package/components/SessionView.tsx +1 -1
- package/components/SettingsModal.tsx +18 -1
- package/components/SkillsPanel.tsx +515 -29
- package/components/WebTerminal.tsx +50 -5
- package/instrumentation.ts +2 -2
- package/lib/claude-sessions.ts +2 -2
- package/lib/claude-templates.ts +227 -0
- package/lib/cloudflared.ts +4 -3
- package/lib/crypto.ts +3 -4
- package/lib/dirs.ts +99 -0
- package/lib/flows.ts +2 -2
- package/lib/init.ts +5 -2
- package/lib/password.ts +2 -2
- package/lib/pipeline.ts +3 -3
- package/lib/session-watcher.ts +2 -2
- package/lib/settings.ts +4 -2
- package/lib/skills.ts +444 -79
- package/lib/telegram-bot.ts +12 -5
- package/lib/terminal-standalone.ts +3 -2
- package/package.json +1 -1
- package/src/config/index.ts +6 -7
- package/src/core/db/database.ts +17 -5
package/CLAUDE.md
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
./start.sh dev # development (hot-reload)
|
|
8
8
|
forge server start # production via npm link/install
|
|
9
9
|
forge server start --dev # dev mode
|
|
10
|
-
forge server start
|
|
10
|
+
forge server start # background by default, logs to ~/.forge/forge.log
|
|
11
|
+
forge server start --foreground # foreground mode
|
|
11
12
|
forge server stop # stop background server
|
|
12
13
|
forge server restart # stop + start (safe for remote)
|
|
13
14
|
forge server rebuild # force rebuild
|
|
@@ -34,7 +35,7 @@ npm login && npm publish --access public --otp=<code>
|
|
|
34
35
|
# ── CLI ──
|
|
35
36
|
forge # help
|
|
36
37
|
forge --version # show version
|
|
37
|
-
forge
|
|
38
|
+
forge tcode # show tunnel URL + session code
|
|
38
39
|
forge tasks # list tasks
|
|
39
40
|
forge task <project> "prompt" # submit task
|
|
40
41
|
forge watch <id> # live stream task output
|
|
@@ -54,3 +55,9 @@ forge watch <id> # live stream task output
|
|
|
54
55
|
Location: /Users/zliu/MyDocuments/obsidian-project/Projects/Bastion
|
|
55
56
|
When I ask about my notes, use bash to search and read files from this directory.
|
|
56
57
|
Example: find /Users/zliu/MyDocuments/obsidian-project -name "*.md" | head -20
|
|
58
|
+
|
|
59
|
+
<!-- forge:template:obsidian-vault -->
|
|
60
|
+
## Obsidian Vault
|
|
61
|
+
When I ask about my notes, use bash to search and read files from the vault directory.
|
|
62
|
+
Example: find <vault_path> -name "*.md" | head -20
|
|
63
|
+
<!-- /forge:template:obsidian-vault -->
|
package/README.md
CHANGED
|
@@ -58,19 +58,19 @@ No API keys required. Uses your existing Claude Code subscription. Code never le
|
|
|
58
58
|
| **Pipelines** | YAML DAG workflows with parallel execution & visual editor |
|
|
59
59
|
| **Remote Access** | Cloudflare Tunnel with 2FA (password + session code) |
|
|
60
60
|
| **Docs Viewer** | Obsidian / markdown rendering with AI assistant |
|
|
61
|
-
| **Projects** | File browser, git operations, code viewer |
|
|
62
|
-
| **Skills** |
|
|
61
|
+
| **Projects** | File browser, git operations, code viewer with syntax highlighting, diff view |
|
|
62
|
+
| **Skills** | Marketplace for skills & commands — browse, install, update, version tracking |
|
|
63
63
|
| **Telegram** | Tasks, sessions, notes, tunnel control from mobile |
|
|
64
64
|
| **CLI** | `forge task`, `forge watch`, `forge status`, and more |
|
|
65
65
|
|
|
66
66
|
## Quick Start
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
forge server start
|
|
70
|
-
forge server start --
|
|
71
|
-
forge server start --
|
|
72
|
-
forge server stop
|
|
73
|
-
forge server restart
|
|
69
|
+
forge server start # start (background by default)
|
|
70
|
+
forge server start --foreground # run in foreground
|
|
71
|
+
forge server start --dev # dev mode with hot-reload
|
|
72
|
+
forge server stop # stop
|
|
73
|
+
forge server restart # restart
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
### From source
|
|
@@ -89,11 +89,11 @@ forge task <project> <prompt> # submit a task
|
|
|
89
89
|
forge tasks # list tasks
|
|
90
90
|
forge watch <id> # live stream output
|
|
91
91
|
forge status # process status
|
|
92
|
-
forge
|
|
92
|
+
forge tcode # show tunnel URL + session code
|
|
93
93
|
forge projects # list projects
|
|
94
94
|
forge flows # list workflows
|
|
95
95
|
forge run <flow> # run a workflow
|
|
96
|
-
forge
|
|
96
|
+
forge --reset-password # reset admin password
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
## Telegram Bot
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
listTemplates,
|
|
4
|
+
getTemplate,
|
|
5
|
+
saveTemplate,
|
|
6
|
+
deleteTemplate,
|
|
7
|
+
isInjected,
|
|
8
|
+
getInjectedTemplates,
|
|
9
|
+
injectTemplate,
|
|
10
|
+
removeTemplate,
|
|
11
|
+
setTemplateDefault,
|
|
12
|
+
applyDefaultTemplates,
|
|
13
|
+
} from '@/lib/claude-templates';
|
|
14
|
+
import { loadSettings } from '@/lib/settings';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
function getProjectPaths(): { name: string; path: string }[] {
|
|
20
|
+
const settings = loadSettings();
|
|
21
|
+
const roots = (settings.projectRoots || []).map((r: string) => r.replace(/^~/, homedir()));
|
|
22
|
+
const projects: { name: string; path: string }[] = [];
|
|
23
|
+
for (const root of roots) {
|
|
24
|
+
try {
|
|
25
|
+
const { readdirSync, statSync } = require('node:fs');
|
|
26
|
+
for (const name of readdirSync(root)) {
|
|
27
|
+
const p = join(root, name);
|
|
28
|
+
try { if (statSync(p).isDirectory() && !name.startsWith('.')) projects.push({ name, path: p }); } catch {}
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// GET /api/claude-templates
|
|
36
|
+
// ?action=list — list all templates
|
|
37
|
+
// ?action=status&project=PATH — get injection status for a project
|
|
38
|
+
// ?action=read-claude-md&project=PATH — read project's CLAUDE.md content
|
|
39
|
+
export async function GET(req: Request) {
|
|
40
|
+
const { searchParams } = new URL(req.url);
|
|
41
|
+
const action = searchParams.get('action') || 'list';
|
|
42
|
+
|
|
43
|
+
if (action === 'list') {
|
|
44
|
+
const templates = listTemplates();
|
|
45
|
+
const projects = getProjectPaths();
|
|
46
|
+
return NextResponse.json({ templates, projects });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (action === 'status') {
|
|
50
|
+
const projectPath = searchParams.get('project');
|
|
51
|
+
if (!projectPath) return NextResponse.json({ error: 'project required' }, { status: 400 });
|
|
52
|
+
const claudeMd = join(projectPath, 'CLAUDE.md');
|
|
53
|
+
const injected = getInjectedTemplates(claudeMd);
|
|
54
|
+
const templates = listTemplates();
|
|
55
|
+
const status = templates.map(t => ({
|
|
56
|
+
id: t.id,
|
|
57
|
+
name: t.name,
|
|
58
|
+
injected: injected.includes(t.id),
|
|
59
|
+
}));
|
|
60
|
+
return NextResponse.json({ status, hasClaudeMd: existsSync(claudeMd) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (action === 'read-claude-md') {
|
|
64
|
+
const projectPath = searchParams.get('project');
|
|
65
|
+
if (!projectPath) return NextResponse.json({ error: 'project required' }, { status: 400 });
|
|
66
|
+
const claudeMd = join(projectPath, 'CLAUDE.md');
|
|
67
|
+
if (!existsSync(claudeMd)) return NextResponse.json({ content: '', exists: false });
|
|
68
|
+
return NextResponse.json({ content: readFileSync(claudeMd, 'utf-8'), exists: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// POST /api/claude-templates
|
|
75
|
+
export async function POST(req: Request) {
|
|
76
|
+
const body = await req.json();
|
|
77
|
+
|
|
78
|
+
// Save/create template
|
|
79
|
+
if (body.action === 'save') {
|
|
80
|
+
const { id, name, description, tags, content, isDefault } = body;
|
|
81
|
+
if (!id || !name || !content) return NextResponse.json({ error: 'id, name, content required' }, { status: 400 });
|
|
82
|
+
saveTemplate(id, name, description || '', tags || [], content, isDefault);
|
|
83
|
+
return NextResponse.json({ ok: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Toggle default flag
|
|
87
|
+
if (body.action === 'set-default') {
|
|
88
|
+
const { id, isDefault } = body;
|
|
89
|
+
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
|
|
90
|
+
const ok = setTemplateDefault(id, !!isDefault);
|
|
91
|
+
return NextResponse.json({ ok });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Apply default templates to a project
|
|
95
|
+
if (body.action === 'apply-defaults') {
|
|
96
|
+
const { project } = body;
|
|
97
|
+
if (!project) return NextResponse.json({ error: 'project required' }, { status: 400 });
|
|
98
|
+
const injected = applyDefaultTemplates(project);
|
|
99
|
+
return NextResponse.json({ ok: true, injected });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Delete template
|
|
103
|
+
if (body.action === 'delete') {
|
|
104
|
+
const ok = deleteTemplate(body.id);
|
|
105
|
+
if (!ok) return NextResponse.json({ error: 'Cannot delete (builtin or not found)' }, { status: 400 });
|
|
106
|
+
return NextResponse.json({ ok: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Inject template into project(s)
|
|
110
|
+
if (body.action === 'inject') {
|
|
111
|
+
const { templateId, projects } = body; // projects: string[] of paths
|
|
112
|
+
if (!templateId || !projects?.length) return NextResponse.json({ error: 'templateId and projects required' }, { status: 400 });
|
|
113
|
+
const results: { project: string; injected: boolean; reason?: string }[] = [];
|
|
114
|
+
for (const projectPath of projects) {
|
|
115
|
+
const claudeMd = join(projectPath, 'CLAUDE.md');
|
|
116
|
+
if (isInjected(claudeMd, templateId)) {
|
|
117
|
+
results.push({ project: projectPath, injected: false, reason: 'already exists' });
|
|
118
|
+
} else {
|
|
119
|
+
const ok = injectTemplate(claudeMd, templateId);
|
|
120
|
+
results.push({ project: projectPath, injected: ok });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return NextResponse.json({ ok: true, results });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Remove template from project
|
|
127
|
+
if (body.action === 'remove') {
|
|
128
|
+
const { templateId, project } = body;
|
|
129
|
+
if (!templateId || !project) return NextResponse.json({ error: 'templateId and project required' }, { status: 400 });
|
|
130
|
+
const claudeMd = join(project, 'CLAUDE.md');
|
|
131
|
+
const ok = removeTemplate(claudeMd, templateId);
|
|
132
|
+
return NextResponse.json({ ok });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Save CLAUDE.md content directly
|
|
136
|
+
if (body.action === 'save-claude-md') {
|
|
137
|
+
const { project, content } = body;
|
|
138
|
+
if (!project) return NextResponse.json({ error: 'project required' }, { status: 400 });
|
|
139
|
+
const { writeFileSync: wf } = require('node:fs');
|
|
140
|
+
wf(join(project, 'CLAUDE.md'), content, 'utf-8');
|
|
141
|
+
return NextResponse.json({ ok: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
145
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { getClaudeDir } from '@/lib/dirs';
|
|
5
5
|
|
|
6
6
|
export async function GET(req: Request) {
|
|
7
7
|
const { searchParams } = new URL(req.url);
|
|
8
8
|
const dir = searchParams.get('dir');
|
|
9
9
|
if (!dir) return NextResponse.json({ sessions: [] });
|
|
10
10
|
|
|
11
|
-
// Claude stores sessions at
|
|
11
|
+
// Claude stores sessions at <claudeDir>/projects/<path-with-dashes>/
|
|
12
12
|
const hash = dir.replace(/\//g, '-');
|
|
13
|
-
const claudeDir = join(
|
|
13
|
+
const claudeDir = join(getClaudeDir(), 'projects', hash);
|
|
14
14
|
|
|
15
15
|
if (!existsSync(claudeDir)) {
|
|
16
16
|
return NextResponse.json({ sessions: [] });
|
package/app/api/monitor/route.ts
CHANGED
|
@@ -26,8 +26,8 @@ export async function GET() {
|
|
|
26
26
|
try {
|
|
27
27
|
const { readFileSync } = require('fs');
|
|
28
28
|
const { join } = require('path');
|
|
29
|
-
const {
|
|
30
|
-
const state = JSON.parse(readFileSync(join(
|
|
29
|
+
const { getDataDir: _gdd } = require('@/lib/dirs');
|
|
30
|
+
const state = JSON.parse(readFileSync(join(_gdd(), 'tunnel-state.json'), 'utf-8'));
|
|
31
31
|
tunnelUrl = state.url || '';
|
|
32
32
|
} catch {}
|
|
33
33
|
|
|
@@ -2,10 +2,10 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
import { listPipelines, listWorkflows, startPipeline } from '@/lib/pipeline';
|
|
3
3
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { homedir } from 'node:os';
|
|
6
5
|
import YAML from 'yaml';
|
|
6
|
+
import { getDataDir } from '@/lib/dirs';
|
|
7
7
|
|
|
8
|
-
const FLOWS_DIR = join(
|
|
8
|
+
const FLOWS_DIR = join(getDataDir(), 'flows');
|
|
9
9
|
|
|
10
10
|
// GET /api/pipelines — list all pipelines + available workflows
|
|
11
11
|
export async function GET(req: Request) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { getDataDir } from '@/lib/dirs';
|
|
5
5
|
|
|
6
|
-
const CONFIG_FILE = join(
|
|
6
|
+
const CONFIG_FILE = join(getDataDir(), 'preview.json');
|
|
7
7
|
|
|
8
8
|
function getPort(): number {
|
|
9
9
|
try {
|
package/app/api/preview/route.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
4
|
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
|
5
|
+
import { getDataDir, getConfigDir } from '@/lib/dirs';
|
|
6
6
|
|
|
7
|
-
const CONFIG_FILE = join(
|
|
7
|
+
const CONFIG_FILE = join(getDataDir(), 'preview.json');
|
|
8
8
|
|
|
9
9
|
interface PreviewEntry {
|
|
10
10
|
port: number;
|
|
@@ -35,8 +35,7 @@ function saveConfig(entries: PreviewEntry[]) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function getCloudflaredPath(): string | null {
|
|
38
|
-
const
|
|
39
|
-
const binPath = join(dataDir, 'bin', 'cloudflared');
|
|
38
|
+
const binPath = join(getConfigDir(), 'bin', 'cloudflared');
|
|
40
39
|
if (existsSync(binPath)) return binPath;
|
|
41
40
|
try {
|
|
42
41
|
return execSync('which cloudflared', { encoding: 'utf-8' }).trim();
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { scanProjects } from '@/lib/projects';
|
|
3
|
+
import { applyDefaultTemplates, listTemplates } from '@/lib/claude-templates';
|
|
4
|
+
|
|
5
|
+
// Track known projects to detect new ones
|
|
6
|
+
const knownProjects = new Set<string>();
|
|
3
7
|
|
|
4
8
|
export async function GET() {
|
|
5
9
|
const projects = scanProjects();
|
|
10
|
+
|
|
11
|
+
// Auto-apply default templates to newly detected projects
|
|
12
|
+
const hasDefaults = listTemplates().some(t => t.isDefault);
|
|
13
|
+
if (hasDefaults) {
|
|
14
|
+
for (const p of projects) {
|
|
15
|
+
if (!knownProjects.has(p.path)) {
|
|
16
|
+
knownProjects.add(p.path);
|
|
17
|
+
try { applyDefaultTemplates(p.path); } catch {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
} else {
|
|
21
|
+
// Still track projects even without defaults
|
|
22
|
+
for (const p of projects) knownProjects.add(p.path);
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
return NextResponse.json(projects);
|
|
7
26
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { getClaudeDir } from '@/lib/dirs';
|
|
7
|
+
|
|
8
|
+
const SKILLS_DIR = join(getClaudeDir(), 'skills');
|
|
9
|
+
const COMMANDS_DIR = join(getClaudeDir(), 'commands');
|
|
10
|
+
|
|
11
|
+
function md5(content: string): string {
|
|
12
|
+
return createHash('md5').update(content).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Recursively list files in a directory */
|
|
16
|
+
function listFiles(dir: string, prefix = ''): { path: string; size: number }[] {
|
|
17
|
+
const files: { path: string; size: number }[] = [];
|
|
18
|
+
if (!existsSync(dir)) return files;
|
|
19
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
20
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
21
|
+
const fullPath = join(dir, entry.name);
|
|
22
|
+
if (entry.isFile()) {
|
|
23
|
+
files.push({ path: relPath, size: statSync(fullPath).size });
|
|
24
|
+
} else if (entry.isDirectory()) {
|
|
25
|
+
files.push(...listFiles(fullPath, relPath));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return files;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveDir(name: string, type: string, projectPath?: string): string {
|
|
32
|
+
if (type === 'skill') {
|
|
33
|
+
return projectPath
|
|
34
|
+
? join(projectPath, '.claude', 'skills', name)
|
|
35
|
+
: join(SKILLS_DIR, name);
|
|
36
|
+
}
|
|
37
|
+
// Command: could be single file or directory
|
|
38
|
+
const base = projectPath ? join(projectPath, '.claude', 'commands') : COMMANDS_DIR;
|
|
39
|
+
const dirPath = join(base, name);
|
|
40
|
+
if (existsSync(dirPath) && statSync(dirPath).isDirectory()) return dirPath;
|
|
41
|
+
// Single file — return parent dir
|
|
42
|
+
return base;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Scan a directory for all installed skills/commands */
|
|
46
|
+
function scanLocalItems(projectPath?: string): { name: string; type: 'skill' | 'command'; scope: string; fileCount: number; projectPath?: string }[] {
|
|
47
|
+
const items: { name: string; type: 'skill' | 'command'; scope: string; fileCount: number; projectPath?: string }[] = [];
|
|
48
|
+
|
|
49
|
+
// Scan skills directories
|
|
50
|
+
const skillsDirs = [
|
|
51
|
+
{ dir: SKILLS_DIR, scope: 'global' as const },
|
|
52
|
+
...(projectPath ? [{ dir: join(projectPath, '.claude', 'skills'), scope: 'project' as const }] : []),
|
|
53
|
+
];
|
|
54
|
+
for (const { dir, scope } of skillsDirs) {
|
|
55
|
+
if (!existsSync(dir)) continue;
|
|
56
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
57
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
58
|
+
const files = listFiles(join(dir, entry.name));
|
|
59
|
+
items.push({ name: entry.name, type: 'skill', scope, fileCount: files.length });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Scan commands directories
|
|
65
|
+
const cmdDirs = [
|
|
66
|
+
{ dir: COMMANDS_DIR, scope: 'global' as const },
|
|
67
|
+
...(projectPath ? [{ dir: join(projectPath, '.claude', 'commands'), scope: 'project' as const }] : []),
|
|
68
|
+
];
|
|
69
|
+
for (const { dir, scope } of cmdDirs) {
|
|
70
|
+
if (!existsSync(dir)) continue;
|
|
71
|
+
// Collect names, merge file + dir with same name
|
|
72
|
+
const seen = new Map<string, { name: string; type: 'command'; scope: typeof scope; fileCount: number }>();
|
|
73
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
if (entry.name.startsWith('.')) continue;
|
|
75
|
+
const cmdName = entry.isFile() ? entry.name.replace(/\.md$/, '') : entry.name;
|
|
76
|
+
if (entry.isFile() && !entry.name.endsWith('.md')) continue;
|
|
77
|
+
const existing = seen.get(cmdName);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
const files = listFiles(join(dir, entry.name));
|
|
80
|
+
const count = files.length + (existing?.fileCount || 0);
|
|
81
|
+
seen.set(cmdName, { name: cmdName, type: 'command', scope, fileCount: count });
|
|
82
|
+
} else if (entry.isFile()) {
|
|
83
|
+
if (existing) {
|
|
84
|
+
existing.fileCount += 1;
|
|
85
|
+
} else {
|
|
86
|
+
seen.set(cmdName, { name: cmdName, type: 'command', scope, fileCount: 1 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
items.push(...seen.values());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return items;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// GET /api/skills/local?name=X&type=skill|command&project=PATH
|
|
97
|
+
// action=scan → list ALL locally installed skills/commands
|
|
98
|
+
// action=files → list installed files for a specific item
|
|
99
|
+
// action=read&path=FILE → read file content + hash
|
|
100
|
+
export async function GET(req: Request) {
|
|
101
|
+
const { searchParams } = new URL(req.url);
|
|
102
|
+
const action = searchParams.get('action') || 'files';
|
|
103
|
+
const name = searchParams.get('name') || '';
|
|
104
|
+
const type = searchParams.get('type') || 'command';
|
|
105
|
+
const projectPath = searchParams.get('project') || '';
|
|
106
|
+
|
|
107
|
+
if (action === 'scan') {
|
|
108
|
+
const scanAll = searchParams.get('all') === '1';
|
|
109
|
+
if (scanAll) {
|
|
110
|
+
// Scan global + all configured projects
|
|
111
|
+
const { loadSettings } = require('@/lib/settings');
|
|
112
|
+
const settings = loadSettings();
|
|
113
|
+
const allItems: any[] = [];
|
|
114
|
+
// Global
|
|
115
|
+
allItems.push(...scanLocalItems());
|
|
116
|
+
// All projects
|
|
117
|
+
for (const root of (settings.projectRoots || [])) {
|
|
118
|
+
const resolvedRoot = root.replace(/^~/, homedir());
|
|
119
|
+
if (!existsSync(resolvedRoot)) continue;
|
|
120
|
+
for (const entry of readdirSync(resolvedRoot, { withFileTypes: true })) {
|
|
121
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
122
|
+
const pp = join(resolvedRoot, entry.name);
|
|
123
|
+
const projItems = scanLocalItems(pp)
|
|
124
|
+
.filter(i => i.scope === 'project')
|
|
125
|
+
.map(i => ({ ...i, projectPath: pp, scope: entry.name }));
|
|
126
|
+
allItems.push(...projItems);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return NextResponse.json({ items: allItems });
|
|
130
|
+
}
|
|
131
|
+
const items = scanLocalItems(projectPath || undefined);
|
|
132
|
+
return NextResponse.json({ items });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (action === 'files') {
|
|
136
|
+
if (type === 'skill') {
|
|
137
|
+
const dir = resolveDir(name, type, projectPath || undefined);
|
|
138
|
+
return NextResponse.json({ files: listFiles(dir) });
|
|
139
|
+
} else {
|
|
140
|
+
// Command: collect both single .md file and directory contents
|
|
141
|
+
const base = projectPath ? join(projectPath, '.claude', 'commands') : COMMANDS_DIR;
|
|
142
|
+
const singleFile = join(base, `${name}.md`);
|
|
143
|
+
const dirPath = join(base, name);
|
|
144
|
+
const files: { path: string; size: number }[] = [];
|
|
145
|
+
// Single .md file at root
|
|
146
|
+
if (existsSync(singleFile)) {
|
|
147
|
+
files.push({ path: `${name}.md`, size: statSync(singleFile).size });
|
|
148
|
+
}
|
|
149
|
+
// Directory contents
|
|
150
|
+
if (existsSync(dirPath) && statSync(dirPath).isDirectory()) {
|
|
151
|
+
files.push(...listFiles(dirPath).map(f => ({ path: `${name}/${f.path}`, size: f.size })));
|
|
152
|
+
}
|
|
153
|
+
return NextResponse.json({ files });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (action === 'read') {
|
|
158
|
+
const filePath = searchParams.get('path') || '';
|
|
159
|
+
let fullPath: string;
|
|
160
|
+
if (type === 'skill') {
|
|
161
|
+
const dir = resolveDir(name, type, projectPath || undefined);
|
|
162
|
+
fullPath = join(dir, filePath);
|
|
163
|
+
} else {
|
|
164
|
+
// filePath from files action is like "name.md" or "name/sub/file.md"
|
|
165
|
+
// Resolve relative to commands base directory
|
|
166
|
+
const base = projectPath ? join(projectPath, '.claude', 'commands') : COMMANDS_DIR;
|
|
167
|
+
fullPath = join(base, filePath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!existsSync(fullPath)) return NextResponse.json({ content: '', hash: '' });
|
|
171
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
172
|
+
return NextResponse.json({ content, hash: md5(content) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// POST /api/skills/local — save, install-local, delete-local
|
|
179
|
+
export async function POST(req: Request) {
|
|
180
|
+
const body = await req.json();
|
|
181
|
+
const action = body.action || 'save';
|
|
182
|
+
|
|
183
|
+
// Install local skill/command to another project or global
|
|
184
|
+
if (action === 'install-local') {
|
|
185
|
+
const { installLocal } = require('@/lib/skills');
|
|
186
|
+
const { name, type, sourceProject, target, force } = body;
|
|
187
|
+
const result = installLocal(name, type, sourceProject || undefined, target, !!force);
|
|
188
|
+
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Delete local skill/command
|
|
192
|
+
if (action === 'delete-local') {
|
|
193
|
+
const { deleteLocal } = require('@/lib/skills');
|
|
194
|
+
const { name, type, project } = body;
|
|
195
|
+
const ok = deleteLocal(name, type, project || undefined);
|
|
196
|
+
return NextResponse.json({ ok });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Save edited file (default action)
|
|
200
|
+
const { name, type, project, path: filePath, content, expectedHash } = body;
|
|
201
|
+
|
|
202
|
+
let fullPath: string;
|
|
203
|
+
if (type === 'skill') {
|
|
204
|
+
const dir = project
|
|
205
|
+
? join(project, '.claude', 'skills', name)
|
|
206
|
+
: join(SKILLS_DIR, name);
|
|
207
|
+
fullPath = join(dir, filePath);
|
|
208
|
+
} else {
|
|
209
|
+
const base = project ? join(project, '.claude', 'commands') : COMMANDS_DIR;
|
|
210
|
+
const dirPath = join(base, name);
|
|
211
|
+
if (existsSync(dirPath) && statSync(dirPath).isDirectory()) {
|
|
212
|
+
fullPath = join(dirPath, filePath);
|
|
213
|
+
} else {
|
|
214
|
+
fullPath = join(base, filePath);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check for concurrent modification
|
|
219
|
+
if (expectedHash && existsSync(fullPath)) {
|
|
220
|
+
const current = readFileSync(fullPath, 'utf-8');
|
|
221
|
+
if (md5(current) !== expectedHash) {
|
|
222
|
+
return NextResponse.json({ ok: false, error: 'File was modified externally. Reload and try again.' }, { status: 409 });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
227
|
+
return NextResponse.json({ ok: true, hash: md5(content) });
|
|
228
|
+
}
|
package/app/api/skills/route.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
uninstallGlobal,
|
|
8
8
|
uninstallProject,
|
|
9
9
|
refreshInstallState,
|
|
10
|
+
checkLocalModified,
|
|
10
11
|
} from '@/lib/skills';
|
|
11
12
|
import { loadSettings } from '@/lib/settings';
|
|
12
13
|
import { homedir } from 'node:os';
|
|
@@ -34,18 +35,23 @@ export async function GET(req: Request) {
|
|
|
34
35
|
const action = searchParams.get('action');
|
|
35
36
|
const name = searchParams.get('name');
|
|
36
37
|
|
|
37
|
-
// List files in a skill directory
|
|
38
|
+
// List files in a skill/command directory
|
|
38
39
|
if (action === 'files' && name) {
|
|
39
40
|
try {
|
|
40
41
|
const settings = loadSettings();
|
|
41
42
|
const repoUrl = settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const repo = match ? match[1] : 'aiwatching/forge-skills';
|
|
43
|
+
const matchRepo = repoUrl.match(/github\.com\/([^/]+\/[^/]+)/);
|
|
44
|
+
const repo = matchRepo ? matchRepo[1] : 'aiwatching/forge-skills';
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Try skills/ first, then commands/ (repo may not have commands/ dir)
|
|
47
|
+
let res = await fetch(`https://api.github.com/repos/${repo}/contents/skills/${name}`, {
|
|
47
48
|
headers: { 'Accept': 'application/vnd.github.v3+json' },
|
|
48
49
|
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
res = await fetch(`https://api.github.com/repos/${repo}/contents/commands/${name}`, {
|
|
52
|
+
headers: { 'Accept': 'application/vnd.github.v3+json' },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
49
55
|
if (!res.ok) return NextResponse.json({ files: [] });
|
|
50
56
|
|
|
51
57
|
const items = await res.json();
|
|
@@ -80,7 +86,11 @@ export async function GET(req: Request) {
|
|
|
80
86
|
try {
|
|
81
87
|
const settings = loadSettings();
|
|
82
88
|
const baseUrl = settings.skillsRepoUrl || 'https://raw.githubusercontent.com/aiwatching/forge-skills/main';
|
|
83
|
-
|
|
89
|
+
// Try skills/ first, then commands/
|
|
90
|
+
let res = await fetch(`${baseUrl}/skills/${name}/${filePath}`);
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
res = await fetch(`${baseUrl}/commands/${name}/${filePath}`);
|
|
93
|
+
}
|
|
84
94
|
if (!res.ok) return NextResponse.json({ content: '(Not found)' });
|
|
85
95
|
const content = await res.text();
|
|
86
96
|
return NextResponse.json({ content });
|
|
@@ -107,8 +117,18 @@ export async function POST(req: Request) {
|
|
|
107
117
|
return NextResponse.json(result);
|
|
108
118
|
}
|
|
109
119
|
|
|
120
|
+
if (body.action === 'check-modified') {
|
|
121
|
+
try {
|
|
122
|
+
const modified = await checkLocalModified(body.name);
|
|
123
|
+
return NextResponse.json({ modified });
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return NextResponse.json({ modified: false, error: String(e) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
110
129
|
if (body.action === 'install') {
|
|
111
130
|
const { name, target } = body; // target: 'global' | projectPath
|
|
131
|
+
if (!name || !target) return NextResponse.json({ ok: false, error: 'name and target required' }, { status: 400 });
|
|
112
132
|
try {
|
|
113
133
|
if (target === 'global') {
|
|
114
134
|
await installGlobal(name);
|
|
@@ -123,12 +143,17 @@ export async function POST(req: Request) {
|
|
|
123
143
|
|
|
124
144
|
if (body.action === 'uninstall') {
|
|
125
145
|
const { name, target } = body;
|
|
126
|
-
if (target
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
if (!name || !target) return NextResponse.json({ ok: false, error: 'name and target required' }, { status: 400 });
|
|
147
|
+
try {
|
|
148
|
+
if (target === 'global') {
|
|
149
|
+
uninstallGlobal(name);
|
|
150
|
+
} else {
|
|
151
|
+
uninstallProject(name, target);
|
|
152
|
+
}
|
|
153
|
+
return NextResponse.json({ ok: true });
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return NextResponse.json({ ok: false, error: String(e) }, { status: 500 });
|
|
130
156
|
}
|
|
131
|
-
return NextResponse.json({ ok: true });
|
|
132
157
|
}
|
|
133
158
|
|
|
134
159
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { NextResponse } from 'next/server';
|
|
4
|
+
import { getDataDir } from '@/lib/dirs';
|
|
5
5
|
|
|
6
|
-
const STATE_FILE = join(
|
|
6
|
+
const STATE_FILE = join(getDataDir(), 'terminal-state.json');
|
|
7
7
|
|
|
8
8
|
export async function GET() {
|
|
9
9
|
try {
|
package/app/login/page.tsx
CHANGED
|
@@ -88,7 +88,7 @@ export default function LoginPage() {
|
|
|
88
88
|
{showHelp && (
|
|
89
89
|
<p className="text-[10px] text-[var(--text-secondary)] mt-1 bg-[var(--bg-tertiary)] rounded p-2">
|
|
90
90
|
Run in terminal:<br />
|
|
91
|
-
<code className="text-[var(--accent)]">forge
|
|
91
|
+
<code className="text-[var(--accent)]">forge --reset-password</code>
|
|
92
92
|
</p>
|
|
93
93
|
)}
|
|
94
94
|
</div>
|