aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,140 lines (969 loc) • 38.3 kB
JavaScript
/**
* IDE Config Generator
*
* Story 1.4: IDE Selection
* Generates IDE-specific configuration files with validation and rollback
*
* @module wizard/ide-config-generator
*/
const fs = require('fs-extra');
const path = require('path');
const yaml = require('js-yaml');
const inquirer = require('inquirer');
const ora = require('ora');
const { spawnSync } = require('child_process');
const { getIDEConfig } = require('../config/ide-configs');
const { validateProjectName } = require('./validators');
const { getMergeStrategy, hasMergeStrategy } = require('../merger/index.js');
/**
* Render template with variables
* @param {string} template - Template string
* @param {Object} variables - Variables to interpolate
* @returns {string} Rendered template
*/
function renderTemplate(template, variables) {
let rendered = template;
// Replace all {{variable}} patterns
for (const [key, value] of Object.entries(variables)) {
const regex = new RegExp(`{{${key}}}`, 'g');
rendered = rendered.replace(regex, value);
}
return rendered;
}
/**
* Validate config content based on format
* @param {string} content - Config file content
* @param {string} format - Format: 'json', 'yaml', or 'text'
* @throws {Error} If validation fails
*/
function validateConfigContent(content, format) {
if (format === 'json') {
try {
JSON.parse(content);
} catch (error) {
throw new Error(`Invalid JSON: ${error.message}`);
}
} else if (format === 'yaml') {
try {
yaml.load(content);
} catch (error) {
throw new Error(`Invalid YAML: ${error.message}`);
}
}
// Text format doesn't need validation
}
/**
* Create backup of existing file
* @param {string} filePath - Path to file to backup
* @returns {Promise<string>} Backup file path
*/
async function backupFile(filePath) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${filePath}.backup.${timestamp}`;
await fs.copy(filePath, backupPath);
return backupPath;
}
/**
* Prompt user for action when file exists
* @param {string} filePath - Path to existing file
* @param {Object} options - Options
* @param {string} options.projectType - 'BROWNFIELD' | 'GREENFIELD' | 'EXISTING_AIOS'
* @param {boolean} options.forceMerge - If true, auto-select merge without prompting
* @param {boolean} options.noMerge - If true, don't offer merge option
* @returns {Promise<string>} Action: 'merge', 'overwrite', 'skip', or 'backup'
*/
async function promptFileExists(filePath, options = {}) {
const { projectType, forceMerge, noMerge } = options;
const canMerge = !noMerge && hasMergeStrategy(filePath);
const isBrownfield = projectType === 'BROWNFIELD' || projectType === 'EXISTING_AIOS';
// If force merge is set and merge is available, return merge directly
if (forceMerge && canMerge) {
return 'merge';
}
// Build choices based on available options
const choices = [];
if (canMerge) {
choices.push({
name: 'Merge (complement existing)',
value: 'merge',
});
}
choices.push(
{ name: 'Overwrite completely', value: 'overwrite' },
{ name: 'Create backup and overwrite', value: 'backup' },
{ name: 'Skip', value: 'skip' },
);
// Default to merge for brownfield if available, otherwise backup
const defaultChoice = isBrownfield && canMerge ? 'merge' : 'backup';
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: `File ${path.basename(filePath)} already exists. What would you like to do?`,
choices,
default: defaultChoice,
},
]);
return action;
}
/**
* Sanitize and validate a candidate project name
* Converts unsafe directory names to safe project names
*
* @param {string} candidate - Candidate project name (e.g., from path.basename)
* @returns {string} Safe, validated project name
*/
function sanitizeProjectName(candidate) {
if (!candidate || typeof candidate !== 'string') {
return 'my-project';
}
// Step 1: Convert to lowercase and replace spaces/special chars with dashes
let sanitized = candidate
.toLowerCase()
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '-') // Replace non-alphanumeric (except dash/underscore) with dash
.replace(/[-_]+/g, '-') // Collapse multiple dashes/underscores into single dash
.replace(/^[-_]+|[-_]+$/g, ''); // Remove leading/trailing dashes/underscores
// Step 2: Ensure it starts with alphanumeric
sanitized = sanitized.replace(/^[^a-zA-Z0-9]+/, '');
// Step 3: Limit length (validateProjectName allows up to 100)
if (sanitized.length > 100) {
sanitized = sanitized.substring(0, 100);
// Remove trailing dash if truncation created one
sanitized = sanitized.replace(/-+$/, '');
}
// Step 4: Validate the sanitized name
const validation = validateProjectName(sanitized);
if (validation === true && sanitized.length > 0) {
return sanitized;
}
// Step 5: If validation fails, generate a safe alphanumeric slug
// Use first alphanumeric chars from original, or generate default
const alphanumericOnly = candidate.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
if (alphanumericOnly.length > 0 && alphanumericOnly.length <= 100) {
const fallbackValidation = validateProjectName(alphanumericOnly);
if (fallbackValidation === true) {
return alphanumericOnly;
}
}
// Step 6: Ultimate fallback - safe default
return 'my-project';
}
/**
* Generate template variables from wizard state
* @param {Object} wizardState - Current wizard state
* @returns {Object} Template variables
*/
function generateTemplateVariables(wizardState) {
const timestamp = new Date().toISOString();
// Safely get project name with validation
// If provided, validate it; otherwise sanitize fallback from directory name
let projectName;
if (wizardState.projectName) {
const validation = validateProjectName(wizardState.projectName);
if (validation === true) {
projectName = wizardState.projectName;
} else {
// If provided name is invalid, sanitize it
projectName = sanitizeProjectName(wizardState.projectName);
}
} else {
// No project name provided, sanitize fallback from directory name
projectName = sanitizeProjectName(path.basename(process.cwd()));
}
return {
projectName,
projectType: wizardState.projectType || 'greenfield',
timestamp,
aiosVersion: '2.1.0', // From package.json in real implementation
};
}
/**
* Copy agent files from .aios-core/development/agents to IDE-specific agent folder
* v4 modular structure: agents are now in development/ module
* @param {string} projectRoot - Project root directory
* @param {string} agentFolder - Target folder for agent files (IDE-specific)
* @param {Object} ideConfig - IDE configuration object (optional, for special handling)
* @returns {Promise<string[]>} List of copied files
*/
async function copyAgentFiles(projectRoot, agentFolder, ideConfig = null) {
// v4: Agents are in development/agents/ (not root agents/)
const sourceDir = path.join(__dirname, '..', '..', '..', '..', '.aios-core', 'development', 'agents');
const targetDir = path.join(projectRoot, agentFolder);
const copiedFiles = [];
// Ensure target directory exists
await fs.ensureDir(targetDir);
// Get all agent files (excluding backup files)
const files = await fs.readdir(sourceDir);
const agentFiles = files.filter(file =>
file.endsWith('.md') &&
!file.includes('.backup') &&
!file.startsWith('test-'), // Exclude test agents
);
// Check if this is AntiGravity - needs workflow files instead of direct copy
const isAntiGravity = ideConfig && ideConfig.specialConfig && ideConfig.specialConfig.type === 'antigravity';
for (const file of agentFiles) {
const sourcePath = path.join(sourceDir, file);
const agentName = file.replace('.md', '');
// Only copy if source is a file (not directory)
const stat = await fs.stat(sourcePath);
if (stat.isFile()) {
if (isAntiGravity) {
// For AntiGravity: create workflow activation files
const workflowContent = generateAntiGravityWorkflow(agentName);
const targetPath = path.join(targetDir, file);
await fs.writeFile(targetPath, workflowContent, 'utf8');
copiedFiles.push(targetPath);
// Also copy the actual agent to .antigravity/agents
const agentsDir = path.join(projectRoot, ideConfig.specialConfig.agentsFolder);
await fs.ensureDir(agentsDir);
const agentTargetPath = path.join(agentsDir, file);
await fs.copy(sourcePath, agentTargetPath);
copiedFiles.push(agentTargetPath);
} else if (ideConfig && ideConfig.agentFolder && ideConfig.agentFolder.includes('.github')) {
// GitHub Copilot: apply transformer for .agent.md format with YAML frontmatter
try {
const agentParser = require('../../../../.aios-core/infrastructure/scripts/ide-sync/agent-parser');
const copilotTransformer = require('../../../../.aios-core/infrastructure/scripts/ide-sync/transformers/github-copilot');
const agentData = agentParser.parseAgentFile(sourcePath);
const content = copilotTransformer.transform(agentData);
const filename = copilotTransformer.getFilename(agentData);
const targetPath = path.join(targetDir, filename);
await fs.writeFile(targetPath, content, 'utf8');
copiedFiles.push(targetPath);
} catch (transformError) {
// Fallback: copy raw file with .agent.md extension
const targetPath = path.join(targetDir, `${agentName}.agent.md`);
await fs.copy(sourcePath, targetPath);
copiedFiles.push(targetPath);
}
} else {
// Normal copy for other IDEs
const targetPath = path.join(targetDir, file);
await fs.copy(sourcePath, targetPath);
copiedFiles.push(targetPath);
}
}
}
return copiedFiles;
}
/**
* Copy .claude/rules folder for Claude Code IDE
* @param {string} projectRoot - Project root directory
* @returns {Promise<string[]>} List of copied files
*/
async function copyClaudeRulesFolder(projectRoot) {
const sourceDir = path.join(__dirname, '..', '..', '..', '..', '.claude', 'rules');
const targetDir = path.join(projectRoot, '.claude', 'rules');
const copiedFiles = [];
// Check if source exists
if (!await fs.pathExists(sourceDir)) {
return copiedFiles;
}
// Ensure target directory exists
await fs.ensureDir(targetDir);
// Get all files in rules folder
const files = await fs.readdir(sourceDir);
for (const file of files) {
const sourcePath = path.join(sourceDir, file);
const targetPath = path.join(targetDir, file);
const stat = await fs.stat(sourcePath);
if (stat.isFile()) {
await fs.copy(sourcePath, targetPath);
copiedFiles.push(targetPath);
}
}
return copiedFiles;
}
/**
* Generate AntiGravity workflow activation file content
* @param {string} agentName - Name of the agent (e.g., 'dev', 'architect')
* @returns {string} Workflow file content
*/
function generateAntiGravityWorkflow(agentName) {
// Capitalize first letter for display
const displayName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
return `---
description: Ativa o agente ${displayName}
---
# Ativação do Agente ${displayName}
**INSTRUÇÕES CRÍTICAS PARA O ANTIGRAVITY:**
1. Leia COMPLETAMENTE o arquivo \`.antigravity/agents/${agentName}.md\`
2. Siga EXATAMENTE as \`activation-instructions\` definidas no bloco YAML do agente
3. Adote a persona conforme definido no agente
4. Execute a saudação conforme \`greeting_levels\` definido no agente
5. **MANTENHA esta persona até receber o comando \`*exit\`**
6. Responda aos comandos com prefixo \`*\` conforme definido no agente
7. Siga as regras globais do projeto em \`.antigravity/rules.md\`
**Comandos disponíveis:** Use \`*help\` para ver todos os comandos do agente.
`;
}
/**
* Create AntiGravity configuration JSON file
* @param {string} projectRoot - Project root directory
* @param {Object} ideConfig - AntiGravity IDE config
* @returns {Promise<string>} Path to created file
*/
async function createAntiGravityConfigJson(projectRoot, ideConfig) {
const configPath = path.join(projectRoot, ideConfig.specialConfig.configJsonPath);
const projectName = path.basename(projectRoot);
const config = {
version: '1.0',
project: projectName,
workspace: projectRoot.replace(/\\/g, '/'),
agents: {
enabled: true,
directory: ideConfig.specialConfig.agentsFolder,
default: 'aios-master',
},
rules: {
enabled: true,
file: ideConfig.configFile,
},
features: {
storyDrivenDevelopment: true,
agentActivation: true,
workflowAutomation: true,
},
paths: {
stories: 'docs/stories',
prd: 'docs/prd',
architecture: 'docs/architecture',
tasks: '.aios-core/tasks',
workflows: '.aios-core/workflows',
},
};
await fs.ensureDir(path.dirname(configPath));
await fs.writeFile(configPath, JSON.stringify(config, null, 4), 'utf8');
return configPath;
}
/**
* Generate IDE configuration files
*
* AC2: Creates appropriate config file for each selected IDE
* AC3: Validates config content before writing
* AC4: Handles existing files with user prompt
* AC5: Shows progress feedback
*
* @param {string[]} selectedIDEs - Array of IDE keys
* @param {Object} wizardState - Current wizard state
* @param {Object} options - Options
* @param {string} options.projectRoot - Project root directory (defaults to cwd)
* @returns {Promise<{success: boolean, files: string[], errors: Array}>}
*
* @example
* const result = await generateIDEConfigs(['cursor', 'github-copilot'], wizardState);
* console.log(result.files); // ['.cursorrules', '.github/copilot-instructions.md']
*/
async function generateIDEConfigs(selectedIDEs, wizardState, options = {}) {
const projectRoot = options.projectRoot || process.cwd();
const createdFiles = [];
const createdFolders = [];
const backupFiles = [];
const errors = [];
// Generate template variables
const templateVars = generateTemplateVariables(wizardState);
const spinner = ora();
try {
for (const ideKey of selectedIDEs) {
const ide = getIDEConfig(ideKey);
if (!ide) {
errors.push({ ide: ideKey, error: 'IDE configuration not found' });
continue;
}
spinner.start(`Configuring ${ide.name}...`);
try {
// Create directory if needed
const configPath = path.join(projectRoot, ide.configFile);
const configDir = path.dirname(configPath);
if (ide.requiresDirectory) {
await fs.ensureDir(configDir);
}
// Check if file exists
const exists = await fs.pathExists(configPath);
let userAction = null;
if (exists) {
spinner.stop();
userAction = await promptFileExists(configPath, {
projectType: wizardState.projectType,
forceMerge: options.forceMerge,
noMerge: options.noMerge,
});
if (userAction === 'skip') {
spinner.succeed(`Skipped ${ide.name} (file exists)`);
continue;
}
if (userAction === 'backup') {
const backupPath = await backupFile(configPath);
backupFiles.push(backupPath);
spinner.info(`Created backup: ${path.basename(backupPath)}`);
}
spinner.start(`Configuring ${ide.name}...`);
}
// Load template from .aios-core/product/templates/
const templatePath = path.join(__dirname, '..', '..', '..', '..', '.aios-core', 'product', 'templates', ide.template);
if (!await fs.pathExists(templatePath)) {
throw new Error(`Template file not found: ${ide.template}`);
}
const template = await fs.readFile(templatePath, 'utf8');
// Render template
const rendered = renderTemplate(template, templateVars);
// Validate content
validateConfigContent(rendered, ide.format);
// Handle merge vs overwrite
let finalContent = rendered;
if (userAction === 'merge' && exists) {
// Merge existing content with new template
spinner.text = `Merging ${ide.configFile}...`;
const existingContent = await fs.readFile(configPath, 'utf8');
const merger = getMergeStrategy(configPath);
const mergeResult = await merger.merge(existingContent, rendered);
finalContent = mergeResult.content;
// Show merge summary
spinner.succeed(`Merged ${ide.configFile}`);
console.log(` 📋 Preserved: ${mergeResult.stats.preserved}, Updated: ${mergeResult.stats.updated}, Added: ${mergeResult.stats.added}`);
if (mergeResult.stats.conflicts > 0) {
console.log(` ⚠️ Suggestions: ${mergeResult.stats.conflicts} (see comments in file)`);
}
spinner.start(`Finishing ${ide.name}...`);
}
// Write file
await fs.writeFile(configPath, finalContent, 'utf8');
createdFiles.push(configPath);
spinner.succeed(`Created ${ide.configFile}`);
// Copy agent files to IDE-specific agent folder
if (ide.agentFolder) {
spinner.start(`Copying agents to ${ide.agentFolder}...`);
const agentFiles = await copyAgentFiles(projectRoot, ide.agentFolder, ide);
createdFiles.push(...agentFiles);
createdFolders.push(path.join(projectRoot, ide.agentFolder));
// For AntiGravity, also create the antigravity.json config file
if (ide.specialConfig && ide.specialConfig.type === 'antigravity') {
const configJsonPath = await createAntiGravityConfigJson(projectRoot, ide);
createdFiles.push(configJsonPath);
spinner.succeed(`Created AntiGravity config and ${agentFiles.length} workflow files`);
} else {
spinner.succeed(`Copied ${agentFiles.length} agent files to ${ide.agentFolder}`);
}
}
// For Claude Code, also copy .claude/rules folder, hooks, and settings
if (ideKey === 'claude-code') {
spinner.start('Copying Claude Code rules...');
const rulesFiles = await copyClaudeRulesFolder(projectRoot);
createdFiles.push(...rulesFiles);
if (rulesFiles.length > 0) {
createdFolders.push(path.join(projectRoot, '.claude', 'rules'));
spinner.succeed(`Copied ${rulesFiles.length} rule file(s) to .claude/rules`);
} else {
spinner.info('No rule files to copy');
}
// BUG-3 fix (INS-1): Copy .claude/hooks/ folder (SYNAPSE engine + precompact)
spinner.start('Copying Claude Code hooks...');
const hookFiles = await copyClaudeHooksFolder(projectRoot);
createdFiles.push(...hookFiles);
if (hookFiles.length > 0) {
createdFolders.push(path.join(projectRoot, '.claude', 'hooks'));
spinner.succeed(`Copied ${hookFiles.length} hook file(s) to .claude/hooks`);
} else {
spinner.info('No hook files to copy (SYNAPSE hooks not found in source)');
}
// BUG-4 fix (INS-1): Create .claude/settings.local.json with hook registration
spinner.start('Configuring Claude Code settings...');
const settingsFile = await createClaudeSettingsLocal(projectRoot);
if (settingsFile) {
createdFiles.push(settingsFile);
spinner.succeed('Created .claude/settings.local.json with registered hooks');
} else {
spinner.info('Skipped settings.local.json (no hooks to register)');
}
}
// Gemini parity with Claude Code: copy hooks and configure settings
if (ideKey === 'gemini') {
spinner.start('Copying Gemini CLI hooks...');
const hookFiles = await copyGeminiHooksFolder(projectRoot);
createdFiles.push(...hookFiles);
if (hookFiles.length > 0) {
createdFolders.push(path.join(projectRoot, '.gemini', 'hooks'));
spinner.succeed(`Copied ${hookFiles.length} hook file(s) to .gemini/hooks`);
} else {
spinner.info('No Gemini hook files to copy');
}
spinner.start('Configuring Gemini CLI settings...');
const settingsFile = await createGeminiSettings(projectRoot);
if (settingsFile) {
createdFiles.push(settingsFile);
spinner.succeed('Created .gemini/settings.json with AIOS hooks');
} else {
spinner.info('Skipped .gemini/settings.json (no hooks to register)');
}
spinner.start('Linking Gemini AIOS extension...');
const extensionResult = await linkGeminiExtension(projectRoot);
if (extensionResult.status === 'linked') {
spinner.succeed('Gemini extension "aios" linked and enabled');
} else if (extensionResult.status === 'already-linked') {
spinner.succeed('Gemini extension "aios" already linked');
} else {
spinner.info(`Skipped Gemini extension linking (${extensionResult.reason})`);
}
}
} catch (error) {
spinner.fail(`Failed to configure ${ide.name}`);
errors.push({ ide: ide.name, error: error.message });
// Rollback: Delete all created files
for (const file of createdFiles) {
await fs.remove(file).catch(() => {});
}
// Rollback: Delete created folders
for (const folder of createdFolders) {
await fs.remove(folder).catch(() => {});
}
// Restore backups
for (const backup of backupFiles) {
const original = backup.replace(/\.backup\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/, '');
await fs.move(backup, original, { overwrite: true }).catch(() => {});
}
throw new Error(`IDE config generation failed for ${ide.name}: ${error.message}`);
}
}
return {
success: true,
files: createdFiles,
errors: errors.length > 0 ? errors : undefined,
};
} catch (error) {
return {
success: false,
files: [],
errors: [{ error: error.message }],
};
}
}
/**
* Show success summary after config generation
* @param {Object} result - Result from generateIDEConfigs
*/
function showSuccessSummary(result) {
if (result.files.length === 0) {
console.log('\nNo IDE configurations created.');
return;
}
console.log(`\n✅ Created ${result.files.length} IDE configuration(s):`);
for (const file of result.files) {
console.log(` - ${path.basename(file)}`);
}
console.log('\n📋 Next Steps:');
console.log(' 1. Open your project in your selected IDE(s)');
console.log(' 2. The IDE should automatically recognize AIOS configuration');
console.log(' 3. Try activating an agent with @agent-name');
console.log(' 4. Use * commands to interact with agents\n');
}
/**
* BUG-3 fix (INS-1): Copy .claude/hooks/ folder during installation
* Only copies JS hooks that work without external dependencies (Python, etc.)
* @param {string} projectRoot - Project root directory
* @returns {Promise<string[]>} List of copied files
*/
async function copyClaudeHooksFolder(projectRoot) {
const sourceDir = path.join(__dirname, '..', '..', '..', '..', '.claude', 'hooks');
const targetDir = path.join(projectRoot, '.claude', 'hooks');
const copiedFiles = [];
if (!await fs.pathExists(sourceDir)) {
return copiedFiles;
}
// QA-C2 fix: Guard source === dest (framework-dev mode)
if (path.resolve(sourceDir) === path.resolve(targetDir)) {
return copiedFiles;
}
await fs.ensureDir(targetDir);
// Only copy JS hooks that work standalone (no Python/shell deps)
const HOOKS_TO_COPY = [
'synapse-engine.cjs',
'precompact-session-digest.cjs',
'README.md',
];
const files = await fs.readdir(sourceDir);
for (const file of files) {
if (!HOOKS_TO_COPY.includes(file)) {
continue;
}
const sourcePath = path.join(sourceDir, file);
const targetPath = path.join(targetDir, file);
const stat = await fs.stat(sourcePath);
if (stat.isFile()) {
await fs.copy(sourcePath, targetPath);
copiedFiles.push(targetPath);
}
}
return copiedFiles;
}
/**
* BUG-4 fix (INS-1): Create .claude/settings.local.json with hook registration
* Creates or merges hook entries into settings.local.json
* @param {string} projectRoot - Project root directory
* @returns {Promise<string|null>} Path to created/updated file, or null if skipped
*/
async function createClaudeSettingsLocal(projectRoot) {
const settingsPath = path.join(projectRoot, '.claude', 'settings.local.json');
const hooksDir = path.join(projectRoot, '.claude', 'hooks');
// Only create if hooks directory exists
if (!await fs.pathExists(hooksDir)) {
return null;
}
// Find all .cjs hook files dynamically (Story INS-4.3, Gap #13)
const allFiles = await fs.readdir(hooksDir);
const hookFiles = allFiles.filter(f => f.endsWith('.cjs'));
if (hookFiles.length === 0) {
return null;
}
const isWindows = process.platform === 'win32';
let settings = {};
// Merge with existing settings if present
if (await fs.pathExists(settingsPath)) {
try {
const existing = await fs.readFile(settingsPath, 'utf8');
settings = JSON.parse(existing);
} catch (parseError) {
// Corrupted file — log and overwrite with fresh settings
console.error(` ⚠️ Could not parse ${settingsPath}: ${parseError.message}`);
settings = {};
}
}
// Ensure hooks.UserPromptSubmit structure exists
if (!settings.hooks) {
settings.hooks = {};
}
if (!Array.isArray(settings.hooks.UserPromptSubmit)) {
settings.hooks.UserPromptSubmit = [];
}
// Register each .cjs hook file
for (const hookFileName of hookFiles) {
const hookFilePath = path.join(hooksDir, hookFileName);
// QA-C1 fix: Use correct Claude Code nested hook format
// Windows workaround: $CLAUDE_PROJECT_DIR has known bug on Windows (GH #6023/#5814)
const hookCommand = isWindows
? `node "${hookFilePath.replace(/\\/g, '\\\\')}"` // Absolute path with escaped backslashes
: `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${hookFileName}"`;
// Check if this hook is already registered (supports both nested and flat formats)
const hookBaseName = hookFileName.replace('.cjs', '');
const alreadyRegistered = settings.hooks.UserPromptSubmit.some(entry => {
if (Array.isArray(entry.hooks)) {
return entry.hooks.some(h => h.command && h.command.includes(hookBaseName));
}
return entry.command && entry.command.includes(hookBaseName);
});
if (!alreadyRegistered) {
settings.hooks.UserPromptSubmit.push({
hooks: [
{
type: 'command',
command: hookCommand,
timeout: 10,
},
],
});
}
}
try {
await fs.ensureDir(path.dirname(settingsPath));
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
} catch (writeError) {
console.error(` ⚠️ Failed to write ${settingsPath}: ${writeError.message}`);
return null;
}
return settingsPath;
}
/**
* Copy .aios-core/hooks/gemini folder into .gemini/hooks during installation
* @param {string} projectRoot - Project root directory
* @returns {Promise<string[]>} List of copied files
*/
async function copyGeminiHooksFolder(projectRoot) {
const sourceDir = path.join(__dirname, '..', '..', '..', '..', '.aios-core', 'hooks', 'gemini');
const targetDir = path.join(projectRoot, '.gemini', 'hooks');
const copiedFiles = [];
if (!await fs.pathExists(sourceDir)) {
return copiedFiles;
}
if (path.resolve(sourceDir) === path.resolve(targetDir)) {
return copiedFiles;
}
await fs.ensureDir(targetDir);
const files = await fs.readdir(sourceDir);
for (const file of files) {
if (!file.endsWith('.js')) continue;
const sourcePath = path.join(sourceDir, file);
const targetPath = path.join(targetDir, file);
const stat = await fs.stat(sourcePath);
if (stat.isFile()) {
await fs.copy(sourcePath, targetPath);
copiedFiles.push(targetPath);
}
}
return copiedFiles;
}
/**
* Create/merge .gemini/settings.json and register AIOS hooks as enabled.
* @param {string} projectRoot - Project root directory
* @returns {Promise<string|null>} Path to settings file or null if skipped
*/
async function createGeminiSettings(projectRoot) {
const settingsPath = path.join(projectRoot, '.gemini', 'settings.json');
const hooksDir = path.join(projectRoot, '.gemini', 'hooks');
if (!await fs.pathExists(hooksDir)) {
return null;
}
const hookEntries = [
{
event: 'SessionStart',
matcher: '*',
hook: {
name: 'aios-session-init',
type: 'command',
command: 'node ".gemini/hooks/session-start.js"',
timeout: 5000,
enabled: true,
},
},
{
event: 'BeforeAgent',
matcher: '*',
hook: {
name: 'aios-context-inject',
type: 'command',
command: 'node ".gemini/hooks/before-agent.js"',
timeout: 3000,
enabled: true,
},
},
{
event: 'BeforeTool',
matcher: 'write_file|replace|shell|bash|execute',
hook: {
name: 'aios-security-check',
type: 'command',
command: 'node ".gemini/hooks/before-tool.js"',
timeout: 2000,
enabled: true,
},
},
{
event: 'AfterTool',
matcher: '*',
hook: {
name: 'aios-audit-log',
type: 'command',
command: 'node ".gemini/hooks/after-tool.js"',
timeout: 2000,
enabled: true,
},
},
{
event: 'SessionEnd',
matcher: '*',
hook: {
name: 'aios-session-persist',
type: 'command',
command: 'node ".gemini/hooks/session-end.js"',
timeout: 5000,
enabled: true,
},
},
];
let settings = {};
if (await fs.pathExists(settingsPath)) {
try {
settings = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
} catch (error) {
console.error(` ⚠️ Could not parse ${settingsPath}: ${error.message}`);
settings = {};
}
}
settings.previewFeatures = true;
settings.folderTrust = settings.folderTrust || { enabled: true };
settings.hooks = settings.hooks || {};
for (const entry of hookEntries) {
if (!Array.isArray(settings.hooks[entry.event])) {
settings.hooks[entry.event] = [];
}
const alreadyRegistered = settings.hooks[entry.event].some((wrapper) => {
if (wrapper && Array.isArray(wrapper.hooks)) {
return wrapper.hooks.some((h) => h && h.name === entry.hook.name);
}
return false;
});
if (!alreadyRegistered) {
settings.hooks[entry.event].push({
matcher: entry.matcher,
hooks: [entry.hook],
});
}
}
await fs.ensureDir(path.dirname(settingsPath));
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
return settingsPath;
}
/**
* Best-effort Gemini extension linking for AIOS project.
* Does not fail installation when auth/CLI is unavailable.
* @param {string} projectRoot
* @returns {Promise<{status: 'linked'|'already-linked'|'skipped', reason?: string}>}
*/
async function linkGeminiExtension(projectRoot) {
const extensionDir = path.join(projectRoot, 'packages', 'gemini-aios-extension');
const manifestPath = path.join(extensionDir, 'gemini-extension.json');
const legacyManifestPath = path.join(extensionDir, 'extension.json');
if (!await fs.pathExists(extensionDir)) {
return { status: 'skipped', reason: 'extension-dir-not-found' };
}
// Gemini CLI >=0.28 expects gemini-extension.json
if (!await fs.pathExists(manifestPath) && await fs.pathExists(legacyManifestPath)) {
await fs.copy(legacyManifestPath, manifestPath);
}
if (!await fs.pathExists(manifestPath)) {
return { status: 'skipped', reason: 'manifest-not-found' };
}
const versionCheck = spawnSync('gemini', ['--version'], { encoding: 'utf8' });
if (versionCheck.status !== 0) {
return { status: 'skipped', reason: 'gemini-cli-not-available' };
}
let linkResult = spawnSync('gemini', ['extensions', 'link', extensionDir, '--consent'], {
cwd: projectRoot,
encoding: 'utf8',
timeout: 30000,
});
if (linkResult.status === 0) {
return { status: 'linked' };
}
const output = `${linkResult.stdout || ''}\n${linkResult.stderr || ''}`;
// When already installed, perform idempotent relink.
if (output.includes('already installed')) {
const uninstall = spawnSync('gemini', ['extensions', 'uninstall', 'aios'], {
cwd: projectRoot,
encoding: 'utf8',
timeout: 30000,
});
if (uninstall.status !== 0) {
return { status: 'skipped', reason: 'uninstall-failed' };
}
linkResult = spawnSync('gemini', ['extensions', 'link', extensionDir, '--consent'], {
cwd: projectRoot,
encoding: 'utf8',
timeout: 30000,
});
if (linkResult.status === 0) {
return { status: 'linked' };
}
return { status: 'skipped', reason: 'relink-failed' };
}
if (output.toLowerCase().includes('authentication')) {
return { status: 'skipped', reason: 'authentication-required' };
}
return { status: 'skipped', reason: 'link-failed' };
}
/**
* Copy .claude/skills/ directories during installation (Story INS-4.3, Gap #11)
* @param {string} projectRoot - Project root directory
* @param {string} [_sourceRoot] - Override source root for testing (default: __dirname-relative)
* @returns {Promise<{count: number, skipped: boolean}>} Copy result
*/
async function copySkillFiles(projectRoot, _sourceRoot) {
const sourceDir = _sourceRoot
? path.join(_sourceRoot, '.claude', 'skills')
: path.join(__dirname, '..', '..', '..', '..', '.claude', 'skills');
const targetDir = path.join(projectRoot, '.claude', 'skills');
if (!await fs.pathExists(sourceDir)) {
return { count: 0, skipped: true };
}
// Guard source === dest (framework-dev mode)
if (path.resolve(sourceDir) === path.resolve(targetDir)) {
return { count: 0, skipped: true };
}
await fs.ensureDir(targetDir);
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
const skillDirs = entries.filter(d => d.isDirectory());
let count = 0;
for (const dir of skillDirs) {
const sourcePath = path.join(sourceDir, dir.name);
const targetPath = path.join(targetDir, dir.name);
await fs.copy(sourcePath, targetPath, { overwrite: true });
count++;
}
return { count, skipped: false };
}
/**
* Copy extra .claude/commands/ files during installation (Story INS-4.3, Gap #12)
* Uses an allowlist of distributable top-level directories to prevent leaking
* private squads or project-specific content into installed projects.
* @param {string} projectRoot - Project root directory
* @param {string} [_sourceRoot] - Override source root for testing (default: __dirname-relative)
* @returns {Promise<{count: number, skipped: boolean}>} Copy result
*/
async function copyExtraCommandFiles(projectRoot, _sourceRoot) {
const sourceDir = _sourceRoot
? path.join(_sourceRoot, '.claude', 'commands')
: path.join(__dirname, '..', '..', '..', '..', '.claude', 'commands');
const targetDir = path.join(projectRoot, '.claude', 'commands');
if (!await fs.pathExists(sourceDir)) {
return { count: 0, skipped: true };
}
// Guard source === dest (framework-dev mode)
if (path.resolve(sourceDir) === path.resolve(targetDir)) {
return { count: 0, skipped: true };
}
// Allowlist: only these top-level entries are distributable.
// Squad commands (cohort-squad/, design-system/, squad-creator-pro/, etc.)
// are private and must NOT be copied to installed projects.
const DISTRIBUTABLE_ENTRIES = new Set([
'AIOS', // Core agent/script commands (agents/ sub-dir excluded below)
'synapse', // SYNAPSE context engine commands
'greet.md', // Greeting skill
]);
// Within AIOS/, these sub-dirs are excluded (private or handled separately)
const AIOS_EXCLUDED = new Set([
'AIOS/agents', // Already handled by copyAgentFiles()
'AIOS/stories', // Project-specific story skills, not distributable
]);
await fs.ensureDir(targetDir);
let count = 0;
async function copyRecursive(src, dest, relativePath) {
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name;
// At top level, only copy distributable entries
if (!relativePath && !DISTRIBUTABLE_ENTRIES.has(entry.name)) {
continue;
}
// Within AIOS/, skip excluded sub-directories
if (AIOS_EXCLUDED.has(entryRelative) || [...AIOS_EXCLUDED].some(ex => entryRelative.startsWith(ex + '/'))) {
continue;
}
const sourcePath = path.join(src, entry.name);
const targetPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await fs.ensureDir(targetPath);
await copyRecursive(sourcePath, targetPath, entryRelative);
} else if (entry.name.endsWith('.md')) {
await fs.copy(sourcePath, targetPath, { overwrite: true });
count++;
}
}
}
await copyRecursive(sourceDir, targetDir, '');
return { count, skipped: false };
}
module.exports = {
generateIDEConfigs,
showSuccessSummary,
renderTemplate,
validateConfigContent,
backupFile,
promptFileExists,
generateTemplateVariables,
copyClaudeHooksFolder,
createClaudeSettingsLocal,
copySkillFiles,
copyExtraCommandFiles,
copyGeminiHooksFolder,
createGeminiSettings,
linkGeminiExtension,
};