@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 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 --background # background, logs to ~/.forge/forge.log
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 password # show today's login password
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** | Browse & install Claude Code skills from registry |
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 # start
70
- forge server start --dev # dev mode with hot-reload
71
- forge server start --background # run in background
72
- forge server stop # stop
73
- forge server restart # 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 password # show login password
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 server start --reset-password # reset admin password
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 { homedir } from 'node:os';
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 ~/.claude/projects/<path-with-dashes>/
11
+ // Claude stores sessions at <claudeDir>/projects/<path-with-dashes>/
12
12
  const hash = dir.replace(/\//g, '-');
13
- const claudeDir = join(homedir(), '.claude', 'projects', hash);
13
+ const claudeDir = join(getClaudeDir(), 'projects', hash);
14
14
 
15
15
  if (!existsSync(claudeDir)) {
16
16
  return NextResponse.json({ sessions: [] });
@@ -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 { homedir } = require('os');
30
- const state = JSON.parse(readFileSync(join(homedir(), '.forge', 'tunnel-state.json'), 'utf-8'));
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(homedir(), '.forge', 'flows');
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 { homedir } from 'node:os';
4
+ import { getDataDir } from '@/lib/dirs';
5
5
 
6
- const CONFIG_FILE = join(homedir(), '.forge', 'preview.json');
6
+ const CONFIG_FILE = join(getDataDir(), 'preview.json');
7
7
 
8
8
  function getPort(): number {
9
9
  try {
@@ -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(process.env.FORGE_DATA_DIR || join(homedir(), '.forge'), 'preview.json');
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 dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
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
+ }
@@ -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
- // Extract owner/repo from raw URL
43
- const match = repoUrl.match(/github\.com\/([^/]+\/[^/]+)/);
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
- const res = await fetch(`https://api.github.com/repos/${repo}/contents/skills/${name}`, {
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
- const res = await fetch(`${baseUrl}/skills/${name}/${filePath}`);
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 === 'global') {
127
- uninstallGlobal(name);
128
- } else {
129
- uninstallProject(name, target);
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(homedir(), '.forge', 'terminal-state.json');
6
+ const STATE_FILE = join(getDataDir(), 'terminal-state.json');
7
7
 
8
8
  export async function GET() {
9
9
  try {
@@ -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 server start --reset-password</code>
91
+ <code className="text-[var(--accent)]">forge --reset-password</code>
92
92
  </p>
93
93
  )}
94
94
  </div>