feat(phase3): add remote Claude spawner and project registry
Phase 3 adds the ability to spawn Claude Code instances remotely from Telegram in registered projects. New Features: - Project registry system (~/.innervoice/projects.json) - Remote Claude spawner with process management - Auto-spawn capability for projects - Full CRUD operations for projects via Telegram Telegram Bot Commands: - /projects - List all registered projects - /register ProjectName /path [--auto-spawn] - /unregister ProjectName - /spawn ProjectName [initial prompt] - /spawned - List running spawned processes - /kill ProjectName API Endpoints: - GET /projects - List registered projects - POST /projects/register - Register new project - DELETE /projects/:name - Unregister project - GET /projects/:name - Get project details - POST /spawn - Spawn Claude in project - POST /kill/:projectName - Kill spawned process - GET /spawned - List all spawned processes - GET /spawned/:projectName - Check if project running Files Added: - src/project-registry.ts - Project registry management - src/claude-spawner.ts - Claude process spawning Files Modified: - src/index.ts - Added spawner integration, bot commands, API endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
147
src/claude-spawner.ts
Normal file
147
src/claude-spawner.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Claude Spawner for InnerVoice
|
||||
// Spawns Claude Code instances remotely from Telegram
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { findProject, touchProject } from './project-registry.js';
|
||||
|
||||
interface SpawnedProcess {
|
||||
projectName: string;
|
||||
process: ChildProcess;
|
||||
startTime: Date;
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
const activeProcesses = new Map<string, SpawnedProcess>();
|
||||
|
||||
// Spawn Claude in a project
|
||||
export async function spawnClaude(
|
||||
projectName: string,
|
||||
initialPrompt?: string
|
||||
): Promise<{ success: boolean; message: string; pid?: number }> {
|
||||
// Check if already running
|
||||
if (activeProcesses.has(projectName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Claude is already running in ${projectName}`
|
||||
};
|
||||
}
|
||||
|
||||
// Find project in registry
|
||||
const project = await findProject(projectName);
|
||||
if (!project) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Project "${projectName}" not found in registry. Register it first with: /register ProjectName /path/to/project`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Spawn Claude Code
|
||||
const claudeProcess = spawn('claude', initialPrompt ? [initialPrompt] : [], {
|
||||
cwd: project.path,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
INNERVOICE_SPAWNED: '1' // Mark as spawned by InnerVoice
|
||||
}
|
||||
});
|
||||
|
||||
// Store process
|
||||
activeProcesses.set(projectName, {
|
||||
projectName,
|
||||
process: claudeProcess,
|
||||
startTime: new Date(),
|
||||
initialPrompt
|
||||
});
|
||||
|
||||
// Update last accessed
|
||||
await touchProject(projectName);
|
||||
|
||||
// Log output (for debugging)
|
||||
if (claudeProcess.stdout) {
|
||||
claudeProcess.stdout.on('data', (data) => {
|
||||
console.log(`[${projectName}] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (claudeProcess.stderr) {
|
||||
claudeProcess.stderr.on('data', (data) => {
|
||||
console.error(`[${projectName}] ${data.toString().trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle exit
|
||||
claudeProcess.on('exit', (code) => {
|
||||
console.log(`🛑 Claude exited in ${projectName} (code: ${code})`);
|
||||
activeProcesses.delete(projectName);
|
||||
});
|
||||
|
||||
claudeProcess.on('error', (error) => {
|
||||
console.error(`❌ Error spawning Claude in ${projectName}:`, error);
|
||||
activeProcesses.delete(projectName);
|
||||
});
|
||||
|
||||
// Unref so it doesn't keep Node running
|
||||
claudeProcess.unref();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Claude started in ${projectName}${initialPrompt ? ` with prompt: "${initialPrompt}"` : ''}`,
|
||||
pid: claudeProcess.pid
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to spawn Claude: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Kill a spawned Claude process
|
||||
export function killClaude(projectName: string): { success: boolean; message: string } {
|
||||
const spawned = activeProcesses.get(projectName);
|
||||
|
||||
if (!spawned) {
|
||||
return {
|
||||
success: false,
|
||||
message: `No active Claude process found for ${projectName}`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
spawned.process.kill('SIGTERM');
|
||||
activeProcesses.delete(projectName);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Claude process terminated in ${projectName}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to kill process: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// List all spawned processes
|
||||
export function listSpawnedProcesses(): Array<{
|
||||
projectName: string;
|
||||
pid?: number;
|
||||
startTime: Date;
|
||||
initialPrompt?: string;
|
||||
runningMinutes: number;
|
||||
}> {
|
||||
return Array.from(activeProcesses.values()).map(sp => ({
|
||||
projectName: sp.projectName,
|
||||
pid: sp.process.pid,
|
||||
startTime: sp.startTime,
|
||||
initialPrompt: sp.initialPrompt,
|
||||
runningMinutes: Math.floor((Date.now() - sp.startTime.getTime()) / 60000)
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if Claude is running in a project
|
||||
export function isClaudeRunning(projectName: string): boolean {
|
||||
return activeProcesses.has(projectName);
|
||||
}
|
||||
335
src/index.ts
335
src/index.ts
@@ -10,6 +10,19 @@ import {
|
||||
getQueueSummary,
|
||||
cleanupOldTasks
|
||||
} from './queue-manager.js';
|
||||
import {
|
||||
spawnClaude,
|
||||
killClaude,
|
||||
listSpawnedProcesses,
|
||||
isClaudeRunning
|
||||
} from './claude-spawner.js';
|
||||
import {
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
findProject,
|
||||
loadProjects,
|
||||
validateProjectPath
|
||||
} from './project-registry.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -109,23 +122,25 @@ bot.command('status', async (ctx) => {
|
||||
bot.command('help', async (ctx) => {
|
||||
await ctx.reply(
|
||||
'*Claude Telegram Bridge - Commands*\n\n' +
|
||||
'*Bot Commands:*\n' +
|
||||
'`/start` - Initialize and connect\n' +
|
||||
'`/help` - Show this help message\n' +
|
||||
'`/status` - Check bridge status\n' +
|
||||
'*Session Management:*\n' +
|
||||
'`/sessions` - List active Claude sessions\n' +
|
||||
'`/queue` - View queued messages\n\n' +
|
||||
'*Project Management:*\n' +
|
||||
'`/projects` - List registered projects\n' +
|
||||
'`/register` ProjectName /path [--auto-spawn]\n' +
|
||||
'`/unregister` ProjectName\n' +
|
||||
'`/spawn` ProjectName [prompt]\n' +
|
||||
'`/spawned` - List spawned processes\n' +
|
||||
'`/kill` ProjectName\n\n' +
|
||||
'*Bot Control:*\n' +
|
||||
'`/status` - Check bridge status\n' +
|
||||
'`/test` - Send test notification\n\n' +
|
||||
'*How it works:*\n' +
|
||||
'• Send me any message - I forward it to Claude\n' +
|
||||
'• Claude processes it and replies back\n' +
|
||||
'• When Claude asks a question, your next message answers it\n' +
|
||||
'• Messages show project context: 📁 ProjectName [#abc1234]\n\n' +
|
||||
'*Features:*\n' +
|
||||
'✅ Multi-project session tracking\n' +
|
||||
'✅ Two-way communication\n' +
|
||||
'✅ Question/Answer flow\n' +
|
||||
'✅ Progress notifications\n' +
|
||||
'✅ Error alerts\n\n' +
|
||||
'• Send any message - forwards to active Claude\n' +
|
||||
'• Target specific project: `ProjectName: message`\n' +
|
||||
'• Messages show context: 📁 ProjectName [#abc1234]\n' +
|
||||
'• Register projects for remote spawning\n' +
|
||||
'• Messages queue when projects are offline\n\n' +
|
||||
'More info: See README in bridge folder',
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
@@ -178,6 +193,178 @@ bot.command('queue', async (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
bot.command('projects', async (ctx) => {
|
||||
try {
|
||||
const projects = await loadProjects();
|
||||
|
||||
if (projects.length === 0) {
|
||||
await ctx.reply('📭 No registered projects\n\nRegister with: `/register ProjectName /path/to/project`', { parse_mode: 'Markdown' });
|
||||
return;
|
||||
}
|
||||
|
||||
const projectList = projects.map((p, i) => {
|
||||
const autoSpawnEmoji = p.autoSpawn ? '🔄' : '⏸️';
|
||||
const lastAccessed = new Date(p.lastAccessed).toLocaleDateString();
|
||||
const running = isClaudeRunning(p.name) ? '🟢' : '⚪';
|
||||
return `${i + 1}. ${running} *${p.name}* ${autoSpawnEmoji}\n 📍 ${p.path}\n 🕐 Last: ${lastAccessed}`;
|
||||
}).join('\n\n');
|
||||
|
||||
await ctx.reply(
|
||||
`*Registered Projects* (${projects.length})\n\n${projectList}\n\n🟢 Running ⚪ Offline 🔄 Auto-spawn ⏸️ Manual`,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} catch (error: any) {
|
||||
await ctx.reply(`❌ Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command('register', async (ctx) => {
|
||||
const args = ctx.message.text.split(' ').slice(1);
|
||||
|
||||
if (args.length < 2) {
|
||||
await ctx.reply(
|
||||
'📝 *Register a Project*\n\n' +
|
||||
'Usage: `/register ProjectName /path/to/project [--auto-spawn]`\n\n' +
|
||||
'Example: `/register MyApp ~/code/myapp --auto-spawn`\n\n' +
|
||||
'Options:\n' +
|
||||
'• `--auto-spawn`: Auto-start Claude when messages arrive',
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = args[0];
|
||||
const projectPath = args[1].replace('~', process.env.HOME || '~');
|
||||
const autoSpawn = args.includes('--auto-spawn');
|
||||
|
||||
try {
|
||||
// Validate path exists
|
||||
const isValid = await validateProjectPath(projectPath);
|
||||
if (!isValid) {
|
||||
await ctx.reply(`❌ Path does not exist or is not a directory: ${projectPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await registerProject(projectName, projectPath, { autoSpawn });
|
||||
await ctx.reply(
|
||||
`✅ Project registered successfully!\n\n` +
|
||||
`📁 *${projectName}*\n` +
|
||||
`📍 ${projectPath}\n` +
|
||||
`${autoSpawn ? '🔄 Auto-spawn enabled' : '⏸️ Manual spawn only'}\n\n` +
|
||||
`Spawn with: \`/spawn ${projectName}\``,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} catch (error: any) {
|
||||
await ctx.reply(`❌ Registration failed: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command('unregister', async (ctx) => {
|
||||
const args = ctx.message.text.split(' ').slice(1);
|
||||
|
||||
if (args.length === 0) {
|
||||
await ctx.reply('Usage: `/unregister ProjectName`', { parse_mode: 'Markdown' });
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = args[0];
|
||||
|
||||
try {
|
||||
const success = await unregisterProject(projectName);
|
||||
if (success) {
|
||||
await ctx.reply(`✅ Project *${projectName}* unregistered`, { parse_mode: 'Markdown' });
|
||||
} else {
|
||||
await ctx.reply(`❌ Project *${projectName}* not found`, { parse_mode: 'Markdown' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
await ctx.reply(`❌ Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command('spawn', async (ctx) => {
|
||||
const args = ctx.message.text.split(' ').slice(1);
|
||||
|
||||
if (args.length === 0) {
|
||||
await ctx.reply(
|
||||
'🚀 *Spawn Claude in a Project*\n\n' +
|
||||
'Usage: `/spawn ProjectName [prompt]`\n\n' +
|
||||
'Example:\n' +
|
||||
'`/spawn MyApp`\n' +
|
||||
'`/spawn MyApp "Fix the login bug"`',
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = args[0];
|
||||
const initialPrompt = args.slice(1).join(' ') || undefined;
|
||||
|
||||
try {
|
||||
await ctx.reply(`⏳ Starting Claude in *${projectName}*...`, { parse_mode: 'Markdown' });
|
||||
|
||||
const result = await spawnClaude(projectName, initialPrompt);
|
||||
|
||||
if (result.success) {
|
||||
await ctx.reply(
|
||||
`${result.message}\n\n` +
|
||||
`PID: ${result.pid}\n\n` +
|
||||
`You can now send messages to it: \`${projectName}: your message\``,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} else {
|
||||
await ctx.reply(`❌ ${result.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
await ctx.reply(`❌ Spawn failed: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command('kill', async (ctx) => {
|
||||
const args = ctx.message.text.split(' ').slice(1);
|
||||
|
||||
if (args.length === 0) {
|
||||
await ctx.reply('Usage: `/kill ProjectName`', { parse_mode: 'Markdown' });
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = args[0];
|
||||
|
||||
try {
|
||||
const result = killClaude(projectName);
|
||||
|
||||
if (result.success) {
|
||||
await ctx.reply(`🛑 ${result.message}`);
|
||||
} else {
|
||||
await ctx.reply(`❌ ${result.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
await ctx.reply(`❌ Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command('spawned', async (ctx) => {
|
||||
try {
|
||||
const spawned = listSpawnedProcesses();
|
||||
|
||||
if (spawned.length === 0) {
|
||||
await ctx.reply('📭 No spawned Claude processes');
|
||||
return;
|
||||
}
|
||||
|
||||
const spawnedList = spawned.map((s, i) => {
|
||||
const prompt = s.initialPrompt ? `\n 💬 "${s.initialPrompt.substring(0, 50)}${s.initialPrompt.length > 50 ? '...' : ''}"` : '';
|
||||
return `${i + 1}. *${s.projectName}*\n 🆔 PID: ${s.pid}\n ⏱️ Running: ${s.runningMinutes}m${prompt}`;
|
||||
}).join('\n\n');
|
||||
|
||||
await ctx.reply(
|
||||
`*Spawned Claude Processes* (${spawned.length})\n\n${spawnedList}\n\n_Kill with: /kill ProjectName_`,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
} catch (error: any) {
|
||||
await ctx.reply(`❌ Error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for any text messages from user
|
||||
bot.on('text', async (ctx) => {
|
||||
const message = ctx.message.text;
|
||||
@@ -545,6 +732,126 @@ app.get('/status', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Project registry endpoints
|
||||
app.get('/projects', async (req, res) => {
|
||||
try {
|
||||
const projects = await loadProjects();
|
||||
res.json({ projects, count: projects.length });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/projects/register', async (req, res) => {
|
||||
const { name, path: projectPath, autoSpawn, description, tags } = req.body;
|
||||
|
||||
if (!name || !projectPath) {
|
||||
return res.status(400).json({ error: 'name and path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await validateProjectPath(projectPath);
|
||||
if (!isValid) {
|
||||
return res.status(400).json({ error: 'Invalid path or not a directory' });
|
||||
}
|
||||
|
||||
const project = await registerProject(name, projectPath, { autoSpawn, description, tags });
|
||||
res.json({ success: true, project });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/projects/:name', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
|
||||
try {
|
||||
const success = await unregisterProject(name);
|
||||
if (success) {
|
||||
res.json({ success: true, message: `Project ${name} unregistered` });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/projects/:name', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
|
||||
try {
|
||||
const project = await findProject(name);
|
||||
if (project) {
|
||||
res.json({ project });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Claude spawner endpoints
|
||||
app.post('/spawn', async (req, res) => {
|
||||
const { projectName, initialPrompt } = req.body;
|
||||
|
||||
if (!projectName) {
|
||||
return res.status(400).json({ error: 'projectName is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnClaude(projectName, initialPrompt);
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(400).json(result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/kill/:projectName', (req, res) => {
|
||||
const { projectName } = req.params;
|
||||
|
||||
try {
|
||||
const result = killClaude(projectName);
|
||||
if (result.success) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(404).json(result);
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/spawned', (req, res) => {
|
||||
try {
|
||||
const spawned = listSpawnedProcesses();
|
||||
res.json({ processes: spawned, count: spawned.length });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/spawned/:projectName', (req, res) => {
|
||||
const { projectName } = req.params;
|
||||
|
||||
try {
|
||||
const running = isClaudeRunning(projectName);
|
||||
if (running) {
|
||||
const spawned = listSpawnedProcesses().find(p => p.projectName === projectName);
|
||||
res.json({ running: true, process: spawned });
|
||||
} else {
|
||||
res.json({ running: false });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Start bot
|
||||
bot.launch().then(() => {
|
||||
console.log('🤖 Telegram bot started');
|
||||
|
||||
148
src/project-registry.ts
Normal file
148
src/project-registry.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Project Registry for InnerVoice
|
||||
// Manages known projects and their locations for remote spawning
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
const REGISTRY_PATH = path.join(process.env.HOME || '~', '.innervoice', 'projects.json');
|
||||
|
||||
export interface RegisteredProject {
|
||||
name: string;
|
||||
path: string;
|
||||
lastAccessed: Date;
|
||||
autoSpawn: boolean; // Auto-spawn when message received
|
||||
metadata?: {
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure registry file exists
|
||||
async function ensureRegistry(): Promise<void> {
|
||||
const dir = path.dirname(REGISTRY_PATH);
|
||||
if (!existsSync(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!existsSync(REGISTRY_PATH)) {
|
||||
await fs.writeFile(REGISTRY_PATH, JSON.stringify([], null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Load all registered projects
|
||||
export async function loadProjects(): Promise<RegisteredProject[]> {
|
||||
await ensureRegistry();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(REGISTRY_PATH, 'utf-8');
|
||||
const projects = JSON.parse(content);
|
||||
// Convert date strings back to Date objects
|
||||
return projects.map((p: any) => ({
|
||||
...p,
|
||||
lastAccessed: new Date(p.lastAccessed)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading project registry:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save projects to registry
|
||||
export async function saveProjects(projects: RegisteredProject[]): Promise<void> {
|
||||
await ensureRegistry();
|
||||
await fs.writeFile(REGISTRY_PATH, JSON.stringify(projects, null, 2));
|
||||
}
|
||||
|
||||
// Register a new project
|
||||
export async function registerProject(
|
||||
name: string,
|
||||
projectPath: string,
|
||||
options?: { autoSpawn?: boolean; description?: string; tags?: string[] }
|
||||
): Promise<RegisteredProject> {
|
||||
const projects = await loadProjects();
|
||||
|
||||
// Check if already exists
|
||||
const existing = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
|
||||
if (existing) {
|
||||
// Update existing
|
||||
existing.path = projectPath;
|
||||
existing.lastAccessed = new Date();
|
||||
if (options?.autoSpawn !== undefined) existing.autoSpawn = options.autoSpawn;
|
||||
if (options?.description) {
|
||||
existing.metadata = existing.metadata || {};
|
||||
existing.metadata.description = options.description;
|
||||
}
|
||||
if (options?.tags) {
|
||||
existing.metadata = existing.metadata || {};
|
||||
existing.metadata.tags = options.tags;
|
||||
}
|
||||
await saveProjects(projects);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new
|
||||
const newProject: RegisteredProject = {
|
||||
name,
|
||||
path: projectPath,
|
||||
lastAccessed: new Date(),
|
||||
autoSpawn: options?.autoSpawn ?? false,
|
||||
metadata: {
|
||||
description: options?.description,
|
||||
tags: options?.tags
|
||||
}
|
||||
};
|
||||
|
||||
projects.push(newProject);
|
||||
await saveProjects(projects);
|
||||
|
||||
console.log(`📝 Registered project: ${name} at ${projectPath}`);
|
||||
return newProject;
|
||||
}
|
||||
|
||||
// Unregister a project
|
||||
export async function unregisterProject(name: string): Promise<boolean> {
|
||||
const projects = await loadProjects();
|
||||
const filtered = projects.filter(p => p.name.toLowerCase() !== name.toLowerCase());
|
||||
|
||||
if (filtered.length === projects.length) {
|
||||
return false; // Not found
|
||||
}
|
||||
|
||||
await saveProjects(filtered);
|
||||
console.log(`🗑️ Unregistered project: ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find a project by name
|
||||
export async function findProject(name: string): Promise<RegisteredProject | null> {
|
||||
const projects = await loadProjects();
|
||||
return projects.find(p => p.name.toLowerCase() === name.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
// Update last accessed time
|
||||
export async function touchProject(name: string): Promise<void> {
|
||||
const projects = await loadProjects();
|
||||
const project = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
|
||||
|
||||
if (project) {
|
||||
project.lastAccessed = new Date();
|
||||
await saveProjects(projects);
|
||||
}
|
||||
}
|
||||
|
||||
// Get projects eligible for auto-spawn
|
||||
export async function getAutoSpawnProjects(): Promise<RegisteredProject[]> {
|
||||
const projects = await loadProjects();
|
||||
return projects.filter(p => p.autoSpawn);
|
||||
}
|
||||
|
||||
// Check if a project path exists
|
||||
export async function validateProjectPath(projectPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.stat(projectPath);
|
||||
return stats.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user