@aion0/forge 0.1.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.
Files changed (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. package/tsconfig.json +41 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Cloudflare Tunnel (cloudflared) integration.
3
+ * Zero-config mode: no account needed, gives a temporary public URL.
4
+ */
5
+
6
+ import { spawn, execSync, type ChildProcess } from 'node:child_process';
7
+ import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync } from 'node:fs';
8
+ import { homedir, platform, arch } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import https from 'node:https';
11
+ import http from 'node:http';
12
+
13
+ const BIN_DIR = join(homedir(), '.my-workflow', 'bin');
14
+ const BIN_NAME = platform() === 'win32' ? 'cloudflared.exe' : 'cloudflared';
15
+ const BIN_PATH = join(BIN_DIR, BIN_NAME);
16
+
17
+ // ─── Download URL resolution ────────────────────────────────────
18
+
19
+ function getDownloadUrl(): string {
20
+ const os = platform();
21
+ const cpu = arch();
22
+ const base = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
23
+
24
+ if (os === 'darwin') {
25
+ // macOS universal binary
26
+ return `${base}/cloudflared-darwin-amd64.tgz`;
27
+ }
28
+ if (os === 'linux') {
29
+ if (cpu === 'arm64') return `${base}/cloudflared-linux-arm64`;
30
+ return `${base}/cloudflared-linux-amd64`;
31
+ }
32
+ if (os === 'win32') {
33
+ return `${base}/cloudflared-windows-amd64.exe`;
34
+ }
35
+ throw new Error(`Unsupported platform: ${os}/${cpu}`);
36
+ }
37
+
38
+ // ─── Download helper ────────────────────────────────────────────
39
+
40
+ function followRedirects(url: string, dest: string): Promise<void> {
41
+ return new Promise((resolve, reject) => {
42
+ const client = url.startsWith('https') ? https : http;
43
+ client.get(url, { headers: { 'User-Agent': 'my-workflow' } }, (res) => {
44
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
45
+ followRedirects(res.headers.location, dest).then(resolve, reject);
46
+ return;
47
+ }
48
+ if (res.statusCode !== 200) {
49
+ reject(new Error(`Download failed: HTTP ${res.statusCode}`));
50
+ return;
51
+ }
52
+ const file = createWriteStream(dest);
53
+ res.pipe(file);
54
+ file.on('finish', () => file.close(() => resolve()));
55
+ file.on('error', reject);
56
+ }).on('error', reject);
57
+ });
58
+ }
59
+
60
+ export async function downloadCloudflared(): Promise<string> {
61
+ if (existsSync(BIN_PATH)) return BIN_PATH;
62
+
63
+ mkdirSync(BIN_DIR, { recursive: true });
64
+ const url = getDownloadUrl();
65
+ const isTgz = url.endsWith('.tgz');
66
+ const tmpPath = isTgz ? `${BIN_PATH}.tgz` : BIN_PATH;
67
+
68
+ console.log(`[cloudflared] Downloading from ${url}...`);
69
+ await followRedirects(url, tmpPath);
70
+
71
+ if (isTgz) {
72
+ // Extract tgz (macOS)
73
+ execSync(`tar -xzf "${tmpPath}" -C "${BIN_DIR}"`, { encoding: 'utf-8' });
74
+ try { unlinkSync(tmpPath); } catch {}
75
+ }
76
+
77
+ if (platform() !== 'win32') {
78
+ chmodSync(BIN_PATH, 0o755);
79
+ }
80
+
81
+ console.log(`[cloudflared] Installed to ${BIN_PATH}`);
82
+ return BIN_PATH;
83
+ }
84
+
85
+ export function isInstalled(): boolean {
86
+ return existsSync(BIN_PATH);
87
+ }
88
+
89
+ // ─── Tunnel process management ──────────────────────────────────
90
+ // Use globalThis to persist state across hot-reloads
91
+
92
+ interface TunnelState {
93
+ process: ChildProcess | null;
94
+ url: string | null;
95
+ status: 'stopped' | 'starting' | 'running' | 'error';
96
+ error: string | null;
97
+ log: string[];
98
+ }
99
+
100
+ const stateKey = Symbol.for('mw-tunnel-state');
101
+ const gAny = globalThis as any;
102
+ if (!gAny[stateKey]) {
103
+ gAny[stateKey] = { process: null, url: null, status: 'stopped', error: null, log: [] } as TunnelState;
104
+ }
105
+ const state: TunnelState = gAny[stateKey];
106
+
107
+ const MAX_LOG_LINES = 100;
108
+
109
+ function pushLog(line: string) {
110
+ state.log.push(line);
111
+ if (state.log.length > MAX_LOG_LINES) state.log.shift();
112
+ }
113
+
114
+ export async function startTunnel(localPort: number = 3000): Promise<{ url?: string; error?: string }> {
115
+ if (state.process) {
116
+ return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
117
+ }
118
+
119
+ state.status = 'starting';
120
+ state.url = null;
121
+ state.error = null;
122
+ state.log = [];
123
+
124
+ let binPath: string;
125
+ try {
126
+ binPath = await downloadCloudflared();
127
+ } catch (e) {
128
+ const msg = e instanceof Error ? e.message : String(e);
129
+ state.status = 'error';
130
+ state.error = msg;
131
+ return { error: msg };
132
+ }
133
+
134
+ return new Promise((resolve) => {
135
+ let resolved = false;
136
+
137
+ state.process = spawn(binPath, ['tunnel', '--url', `http://localhost:${localPort}`], {
138
+ stdio: ['ignore', 'pipe', 'pipe'],
139
+ env: { ...process.env },
140
+ });
141
+
142
+ const handleOutput = (data: Buffer) => {
143
+ const text = data.toString();
144
+ for (const line of text.split('\n')) {
145
+ if (!line.trim()) continue;
146
+ pushLog(line);
147
+
148
+ const urlMatch = line.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
149
+ if (urlMatch && !state.url) {
150
+ state.url = urlMatch[1];
151
+ state.status = 'running';
152
+ console.log(`[cloudflared] Tunnel URL: ${state.url}`);
153
+ if (!resolved) {
154
+ resolved = true;
155
+ resolve({ url: state.url });
156
+ }
157
+ }
158
+ }
159
+ };
160
+
161
+ state.process.stdout?.on('data', handleOutput);
162
+ state.process.stderr?.on('data', handleOutput);
163
+
164
+ state.process.on('error', (err) => {
165
+ state.status = 'error';
166
+ state.error = err.message;
167
+ pushLog(`[error] ${err.message}`);
168
+ if (!resolved) {
169
+ resolved = true;
170
+ resolve({ error: err.message });
171
+ }
172
+ });
173
+
174
+ state.process.on('exit', (code) => {
175
+ state.process = null;
176
+ if (state.status !== 'error') {
177
+ state.status = 'stopped';
178
+ }
179
+ state.url = null;
180
+ pushLog(`[exit] cloudflared exited with code ${code}`);
181
+ if (!resolved) {
182
+ resolved = true;
183
+ resolve({ error: `cloudflared exited with code ${code}` });
184
+ }
185
+ });
186
+
187
+ setTimeout(() => {
188
+ if (!resolved) {
189
+ resolved = true;
190
+ if (!state.url) {
191
+ state.status = 'error';
192
+ state.error = 'Timeout waiting for tunnel URL';
193
+ resolve({ error: 'Timeout waiting for tunnel URL (30s)' });
194
+ }
195
+ }
196
+ }, 30000);
197
+ });
198
+ }
199
+
200
+ export function stopTunnel() {
201
+ if (state.process) {
202
+ state.process.kill('SIGTERM');
203
+ state.process = null;
204
+ }
205
+ state.url = null;
206
+ state.status = 'stopped';
207
+ state.error = null;
208
+ }
209
+
210
+ export function getTunnelStatus() {
211
+ return {
212
+ status: state.status,
213
+ url: state.url,
214
+ error: state.error,
215
+ installed: isInstalled(),
216
+ log: state.log.slice(-20),
217
+ };
218
+ }
package/lib/flows.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Workflow (Flow) engine — loads YAML flow definitions and executes them.
3
+ *
4
+ * Flow files live in ~/.my-workflow/flows/*.yaml
5
+ */
6
+
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import YAML from 'yaml';
11
+ import { createTask } from './task-manager';
12
+ import { getProjectInfo } from './projects';
13
+ import type { Task } from '@/src/types';
14
+
15
+ const FLOWS_DIR = join(homedir(), '.my-workflow', 'flows');
16
+
17
+ export interface FlowStep {
18
+ project: string;
19
+ prompt: string;
20
+ priority?: number;
21
+ dependsOn?: string; // step name to wait for
22
+ }
23
+
24
+ export interface Flow {
25
+ name: string;
26
+ description?: string;
27
+ schedule?: string; // cron expression for auto-trigger
28
+ steps: FlowStep[];
29
+ }
30
+
31
+ export function listFlows(): Flow[] {
32
+ if (!existsSync(FLOWS_DIR)) return [];
33
+
34
+ return readdirSync(FLOWS_DIR)
35
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
36
+ .map(f => {
37
+ try {
38
+ const raw = readFileSync(join(FLOWS_DIR, f), 'utf-8');
39
+ const parsed = YAML.parse(raw);
40
+ return {
41
+ name: parsed.name || f.replace(/\.ya?ml$/, ''),
42
+ description: parsed.description,
43
+ schedule: parsed.schedule,
44
+ steps: parsed.steps || [],
45
+ } as Flow;
46
+ } catch {
47
+ return null;
48
+ }
49
+ })
50
+ .filter(Boolean) as Flow[];
51
+ }
52
+
53
+ export function getFlow(name: string): Flow | null {
54
+ return listFlows().find(f => f.name === name) || null;
55
+ }
56
+
57
+ /**
58
+ * Run a flow — creates tasks for each step.
59
+ * Steps without dependsOn run immediately (queued).
60
+ * Steps with dependsOn will be handled by a follow-up mechanism.
61
+ */
62
+ export function runFlow(name: string): { flow: Flow; tasks: Task[] } {
63
+ const flow = getFlow(name);
64
+ if (!flow) throw new Error(`Flow not found: ${name}`);
65
+
66
+ const tasks: Task[] = [];
67
+
68
+ for (const step of flow.steps) {
69
+ const project = getProjectInfo(step.project);
70
+ if (!project) {
71
+ console.error(`[flow] Project not found: ${step.project}, skipping step`);
72
+ continue;
73
+ }
74
+
75
+ const task = createTask({
76
+ projectName: project.name,
77
+ projectPath: project.path,
78
+ prompt: step.prompt,
79
+ priority: step.priority || 0,
80
+ });
81
+
82
+ tasks.push(task);
83
+ }
84
+
85
+ return { flow, tasks };
86
+ }
package/lib/init.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Server-side initialization — called once on first API request.
3
+ * Starts background services: task runner, Telegram bot.
4
+ */
5
+
6
+ import { ensureRunnerStarted } from './task-manager';
7
+ import { startTelegramBot, stopTelegramBot } from './telegram-bot';
8
+ import { startWatcherLoop } from './session-watcher';
9
+ import { getPassword } from './password';
10
+ import { loadSettings } from './settings';
11
+ import { startTunnel } from './cloudflared';
12
+ import { spawn } from 'node:child_process';
13
+ import { join } from 'node:path';
14
+
15
+ const initKey = Symbol.for('mw-initialized');
16
+ const gInit = globalThis as any;
17
+
18
+ export function ensureInitialized() {
19
+ if (gInit[initKey]) return;
20
+ gInit[initKey] = true;
21
+
22
+ // Display login password (auto-generated, rotates daily)
23
+ const password = getPassword();
24
+ console.log(`[init] Login password: ${password} (valid today)`);
25
+ console.log('[init] Forgot? Run: mw password');
26
+
27
+ // Start background task runner
28
+ ensureRunnerStarted();
29
+
30
+ // Start Telegram bot if configured
31
+ startTelegramBot();
32
+
33
+ // Start terminal WebSocket server as separate process (node-pty needs native module)
34
+ startTerminalProcess();
35
+
36
+ // Start session watcher loop
37
+ startWatcherLoop();
38
+
39
+ // Auto-start tunnel if configured
40
+ const settings = loadSettings();
41
+ if (settings.tunnelAutoStart) {
42
+ startTunnel().then(result => {
43
+ if (result.url) console.log(`[init] Tunnel started: ${result.url}`);
44
+ else if (result.error) console.log(`[init] Tunnel failed: ${result.error}`);
45
+ });
46
+ }
47
+
48
+ console.log('[init] Background services started');
49
+ }
50
+
51
+ /** Restart Telegram bot (e.g. after settings change) */
52
+ export function restartTelegramBot() {
53
+ stopTelegramBot();
54
+ startTelegramBot();
55
+ }
56
+
57
+ let terminalChild: ReturnType<typeof spawn> | null = null;
58
+
59
+ function startTerminalProcess() {
60
+ if (terminalChild) return;
61
+
62
+ // Check if port 3001 is already in use
63
+ const net = require('node:net');
64
+ const tester = net.createServer();
65
+ tester.once('error', () => {
66
+ // Port in use — terminal server already running
67
+ console.log('[terminal] Port 3001 already in use, skipping');
68
+ });
69
+ tester.once('listening', () => {
70
+ tester.close();
71
+ // Port free — start terminal server
72
+ const script = join(process.cwd(), 'lib', 'terminal-standalone.ts');
73
+ terminalChild = spawn('npx', ['tsx', script], {
74
+ stdio: ['ignore', 'inherit', 'inherit'],
75
+ env: { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CLAUDECODE')) } as NodeJS.ProcessEnv,
76
+ detached: false,
77
+ });
78
+ terminalChild.on('exit', () => { terminalChild = null; });
79
+ console.log('[terminal] Started standalone server (pid:', terminalChild.pid, ')');
80
+ });
81
+ tester.listen(3001);
82
+ }
package/lib/notify.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Notification module — sends task updates via Telegram.
3
+ */
4
+
5
+ import { loadSettings } from './settings';
6
+ import type { Task } from '@/src/types';
7
+
8
+ export async function notifyTaskComplete(task: Task) {
9
+ const settings = loadSettings();
10
+ if (!settings.notifyOnComplete) return;
11
+
12
+ const cost = task.costUSD != null ? `$${task.costUSD.toFixed(4)}` : 'unknown';
13
+ const duration = task.startedAt && task.completedAt
14
+ ? formatDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime())
15
+ : 'unknown';
16
+
17
+ await sendTelegram(
18
+ `✅ *Task Done*\n\n` +
19
+ `*Project:* ${esc(task.projectName)}\n` +
20
+ `*Task:* ${esc(task.prompt.slice(0, 200))}\n` +
21
+ `*Duration:* ${duration}\n` +
22
+ `*Cost:* ${cost}\n\n` +
23
+ `${task.resultSummary ? `*Result:*\n${esc(task.resultSummary.slice(0, 500))}` : '_No summary_'}`
24
+ );
25
+ }
26
+
27
+ export async function notifyTaskFailed(task: Task) {
28
+ const settings = loadSettings();
29
+ if (!settings.notifyOnFailure) return;
30
+
31
+ await sendTelegram(
32
+ `❌ *Task Failed*\n\n` +
33
+ `*Project:* ${esc(task.projectName)}\n` +
34
+ `*Task:* ${esc(task.prompt.slice(0, 200))}\n` +
35
+ `*Error:* ${esc(task.error || 'Unknown error')}`
36
+ );
37
+ }
38
+
39
+ async function sendTelegram(text: string) {
40
+ const settings = loadSettings();
41
+ const { telegramBotToken, telegramChatId } = settings;
42
+
43
+ if (!telegramBotToken || !telegramChatId) return;
44
+
45
+ try {
46
+ const url = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
47
+ const res = await fetch(url, {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({
51
+ chat_id: telegramChatId,
52
+ text,
53
+ parse_mode: 'Markdown',
54
+ disable_web_page_preview: true,
55
+ }),
56
+ });
57
+
58
+ if (!res.ok) {
59
+ console.error('[notify] Telegram error:', res.status, await res.text());
60
+ }
61
+ } catch (err) {
62
+ console.error('[notify] Telegram send failed:', err);
63
+ }
64
+ }
65
+
66
+ // Escape Markdown special characters
67
+ function esc(s: string): string {
68
+ return s.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&');
69
+ }
70
+
71
+ function formatDuration(ms: number): string {
72
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
73
+ if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
74
+ return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`;
75
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Auto-generated login password.
3
+ * Rotates daily. Saved to ~/.my-workflow/password.json with date.
4
+ * CLI can read it via `mw password`.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, dirname } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+
12
+ const PASSWORD_FILE = join(homedir(), '.my-workflow', 'password.json');
13
+
14
+ function generatePassword(): string {
15
+ // 8-char alphanumeric, easy to type
16
+ return randomBytes(6).toString('base64url').slice(0, 8);
17
+ }
18
+
19
+ function todayStr(): string {
20
+ return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
21
+ }
22
+
23
+ interface PasswordData {
24
+ password: string;
25
+ date: string;
26
+ }
27
+
28
+ function readPasswordData(): PasswordData | null {
29
+ try {
30
+ if (!existsSync(PASSWORD_FILE)) return null;
31
+ return JSON.parse(readFileSync(PASSWORD_FILE, 'utf-8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function savePasswordData(data: PasswordData) {
38
+ const dir = dirname(PASSWORD_FILE);
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
+ writeFileSync(PASSWORD_FILE, JSON.stringify(data), { mode: 0o600 });
41
+ }
42
+
43
+ /**
44
+ * Get the current password. Priority:
45
+ * 1. MW_PASSWORD env var (user explicitly set, never rotates)
46
+ * 2. Saved password file if still valid today
47
+ * 3. Generate new one, save with today's date
48
+ */
49
+ export function getPassword(): string {
50
+ // If user explicitly set MW_PASSWORD, use it (no rotation)
51
+ if (process.env.MW_PASSWORD && process.env.MW_PASSWORD !== 'auto') {
52
+ return process.env.MW_PASSWORD;
53
+ }
54
+
55
+ const today = todayStr();
56
+ const saved = readPasswordData();
57
+
58
+ // Valid for today
59
+ if (saved && saved.date === today && saved.password) {
60
+ return saved.password;
61
+ }
62
+
63
+ // Expired or missing — generate new
64
+ const password = generatePassword();
65
+ savePasswordData({ password, date: today });
66
+ console.log(`[password] New daily password generated for ${today}`);
67
+ return password;
68
+ }
69
+
70
+ /** Read password from file (for CLI use) */
71
+ export function readPasswordFile(): string | null {
72
+ const data = readPasswordData();
73
+ if (!data) return null;
74
+ // Only return if still valid today
75
+ if (data.date !== todayStr()) return null;
76
+ return data.password;
77
+ }
@@ -0,0 +1,86 @@
1
+ import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { loadSettings } from './settings';
4
+
5
+ export interface LocalProject {
6
+ name: string;
7
+ path: string;
8
+ root: string; // Which project root it came from
9
+ hasGit: boolean;
10
+ hasClaudeMd: boolean;
11
+ language: string | null;
12
+ lastModified: string;
13
+ }
14
+
15
+ export function scanProjects(): LocalProject[] {
16
+ const settings = loadSettings();
17
+ const roots = settings.projectRoots;
18
+
19
+ if (roots.length === 0) return [];
20
+
21
+ const projects: LocalProject[] = [];
22
+
23
+ for (const root of roots) {
24
+ if (!existsSync(root)) continue;
25
+
26
+ const entries = readdirSync(root, { withFileTypes: true });
27
+
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory()) continue;
30
+ if (entry.name.startsWith('.')) continue;
31
+
32
+ const projectPath = join(root, entry.name);
33
+
34
+ try {
35
+ const hasGit = existsSync(join(projectPath, '.git'));
36
+ const hasClaudeMd = existsSync(join(projectPath, 'CLAUDE.md'));
37
+ const language = detectLanguage(projectPath);
38
+ const stat = statSync(projectPath);
39
+
40
+ projects.push({
41
+ name: entry.name,
42
+ path: projectPath,
43
+ root,
44
+ hasGit,
45
+ hasClaudeMd,
46
+ language,
47
+ lastModified: stat.mtime.toISOString(),
48
+ });
49
+ } catch {
50
+ // Skip inaccessible directories
51
+ }
52
+ }
53
+ }
54
+
55
+ return projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
56
+ }
57
+
58
+ function detectLanguage(projectPath: string): string | null {
59
+ const markers: [string, string][] = [
60
+ ['pom.xml', 'java'],
61
+ ['build.gradle', 'java'],
62
+ ['build.gradle.kts', 'kotlin'],
63
+ ['package.json', 'typescript'],
64
+ ['tsconfig.json', 'typescript'],
65
+ ['requirements.txt', 'python'],
66
+ ['pyproject.toml', 'python'],
67
+ ['go.mod', 'go'],
68
+ ['Cargo.toml', 'rust'],
69
+ ];
70
+
71
+ for (const [file, lang] of markers) {
72
+ if (existsSync(join(projectPath, file))) return lang;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ export function getProjectInfo(name: string): LocalProject | null {
78
+ const projects = scanProjects();
79
+ return projects.find(p => p.name === name) || null;
80
+ }
81
+
82
+ export function getProjectClaudeMd(projectPath: string): string | null {
83
+ const claudeMdPath = join(projectPath, 'CLAUDE.md');
84
+ if (!existsSync(claudeMdPath)) return null;
85
+ return readFileSync(claudeMdPath, 'utf-8');
86
+ }