Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
832 changes: 1 addition & 831 deletions src/commands/install.ts

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions src/commands/install/generic-editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import chalk from 'chalk';
import path from 'path';
import fs from 'fs';
import type { EditorTarget, FileResult, Scope } from '../../types.js';
import {
ASSETS_DIR,
ensureDir,
copyFile,
getEditorConfig,
getEditorDir,
getFiles,
mergeAgentsToFile,
} from '../../utils/symlink.js';
import { printSummary } from './print.js';

/**
* Rules-file editors (Cursor, Windsurf). These do not support discrete
* agents/commands/skills — agent personas are merged into a single rules file
* (AGENTS.md / global_rules.md), and architecture docs are copied alongside.
*/

const installArchitectureForEditor = (targetDir: string): FileResult[] => {
const archDir = path.join(ASSETS_DIR, 'architecture');
const targetArchDir = path.join(targetDir, 'architecture');
ensureDir(targetArchDir);

if (!fs.existsSync(archDir)) {
return [];
}

return getFiles(archDir).map((file) =>
copyFile(file, path.join(targetArchDir, path.basename(file)))
);
};

export const installForOtherEditor = async (target: EditorTarget, scope: Scope): Promise<FileResult[]> => {
const config = getEditorConfig(target);
const targetDir = getEditorDir(target, scope);
const results: FileResult[] = [];

console.log('');
console.log(chalk.cyan(`>>> ${config.name} Installation`));
console.log(chalk.gray(` Target: ${targetDir}`));
console.log('');

ensureDir(targetDir);

results.push(...installArchitectureForEditor(targetDir));
console.log(chalk.green(` ✓ Architecture installed to ${chalk.cyan(path.join(targetDir, 'architecture'))}`));

if (config.rulesFile) {
const result = mergeAgentsToFile(path.join(targetDir, config.rulesFile), target);
results.push(result);
console.log(chalk.green(` ✓ Agents merged to ${chalk.cyan(config.rulesFile)}`));
}

printSummary(results);

console.log('');
console.log(chalk.green(`✓ ${config.name} installation complete!`));

return results;
};
86 changes: 86 additions & 0 deletions src/commands/install/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import chalk from 'chalk';
import fs from 'fs';
import type { CommandOptions, EditorTarget, Scope } from '../../types.js';
import { ASSETS_DIR, isSymlinkSupported } from '../../utils/symlink.js';
import { addTarget } from '../../utils/config.js';
import { printHeader } from './print.js';
import { installScope } from './native.js';
import { installSkillEditorScope } from './skill-editor.js';
import { installForOtherEditor } from './generic-editor.js';
import { showTargetMenu, showInteractiveMenu } from './prompts.js';
import { printUsage } from './usage.js';

/** Editors that branch into a global/project/all scope flow. */
type ScopedTarget = 'claude' | 'codex' | 'antigravity';
const isScopedTarget = (target: EditorTarget): target is ScopedTarget =>
target === 'claude' || target === 'codex' || target === 'antigravity';

const resolveStrategy = (options: CommandOptions): boolean => {
if (options.symlink === true) return true;
if (options.symlink === false) return false;
return isSymlinkSupported();
};

const resolveInstallType = async (
options: CommandOptions,
target: ScopedTarget
): Promise<'global' | 'project' | 'all'> => {
if (options.global) return 'global';
if (options.project) return 'project';
if (options.all) return 'all';
return showInteractiveMenu(target);
};

/** Install a scoped target (claude/codex/antigravity) into one scope. */
const installScopedTarget = async (
target: ScopedTarget,
scope: Scope,
useSymlink: boolean
): Promise<void> => {
if (target === 'claude') {
// Symlinks only make sense for the global, shared install.
await installScope(scope, scope === 'global' ? useSymlink : false);
} else {
await installSkillEditorScope(scope, target);
}
};

export const installCommand = async (options: CommandOptions): Promise<void> => {
printHeader();

if (!fs.existsSync(ASSETS_DIR)) {
console.log(chalk.red('Error: Assets directory not found.'));
console.log(chalk.gray(`Expected: ${ASSETS_DIR}`));
process.exit(1);
}

const useSymlink = resolveStrategy(options);
const strategyLabel = useSymlink ? 'symlinks' : 'file copy';
if (options.symlink === undefined) {
console.log(chalk.gray(` Auto-detected file strategy: ${strategyLabel} (${process.platform})`));
} else {
console.log(chalk.gray(` File strategy: ${strategyLabel} (user override)`));
}
console.log('');

const targets: EditorTarget[] = options.target ? [options.target] : [await showTargetMenu()];

for (const target of targets) {
addTarget(target);

if (!isScopedTarget(target)) {
await installForOtherEditor(target, 'global');
continue;
}

const installType = await resolveInstallType(options, target);
if (installType === 'global' || installType === 'all') {
await installScopedTarget(target, 'global', useSymlink);
}
if (installType === 'project' || installType === 'all') {
await installScopedTarget(target, 'project', useSymlink);
}
}

printUsage(targets);
};
100 changes: 100 additions & 0 deletions src/commands/install/native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import chalk from 'chalk';
import path from 'path';
import fs from 'fs';
import type { FileResult, Scope } from '../../types.js';
import {
ASSETS_DIR,
ensureDir,
createSymlink,
copyFile,
copyDir,
getAgentsDir,
getCommandsDir,
getSkillsDir,
getArchitectureDir,
getClaudeDir,
getFiles,
getDirs,
} from '../../utils/symlink.js';
import { printInstalled } from './print.js';

/**
* Claude native install: assets are symlinked (or copied) verbatim into
* ~/.claude/{agents,commands,skills,architecture}. This is the only target
* that supports symlinks and the full agents/commands/skills layout.
*/

/** Symlink or copy every file from a source dir into the target dir. */
const linkFiles = (sourceDir: string, targetDir: string, useSymlink: boolean): FileResult[] => {
if (!fs.existsSync(sourceDir)) {
return [];
}
return getFiles(sourceDir).map((file) => {
const target = path.join(targetDir, path.basename(file));
return useSymlink ? createSymlink(file, target) : copyFile(file, target);
});
};

const installAgents = (targetDir: string, useSymlink: boolean): FileResult[] => {
ensureDir(targetDir);
const results = [
...linkFiles(path.join(ASSETS_DIR, 'agents', 'developers'), targetDir, useSymlink),
...linkFiles(path.join(ASSETS_DIR, 'agents', 'utilities'), targetDir, useSymlink),
];
printInstalled('Agents', targetDir, results);
return results;
};

const installCommands = (targetDir: string, useSymlink: boolean): FileResult[] => {
ensureDir(targetDir);
const results = linkFiles(path.join(ASSETS_DIR, 'commands'), targetDir, useSymlink);
printInstalled('Commands', targetDir, results);
return results;
};

const installSkills = (targetDir: string, useSymlink: boolean): FileResult[] => {
ensureDir(targetDir);
const skillsDir = path.join(ASSETS_DIR, 'skills');
const results = fs.existsSync(skillsDir)
? getDirs(skillsDir).map((dir) => {
const target = path.join(targetDir, path.basename(dir));
return useSymlink ? createSymlink(dir, target) : copyDir(dir, target);
})
: [];
printInstalled('Skills', targetDir, results);
return results;
};

const installArchitecture = (targetDir: string, useSymlink: boolean): FileResult[] => {
ensureDir(targetDir);
const results = linkFiles(path.join(ASSETS_DIR, 'architecture'), targetDir, useSymlink);
printInstalled('Architecture', targetDir, results);
return results;
};

export const installScope = async (scope: Scope, useSymlink: boolean): Promise<void> => {
const isGlobal = scope === 'global';
const label = isGlobal ? 'Global' : 'Project';
const targetPath = isGlobal ? '~/.claude/' : `${process.cwd()}/.claude/`;

console.log('');
console.log(chalk.cyan(`>>> ${label} Installation`));
console.log(chalk.gray(` Target: ${targetPath}`));
console.log('');

ensureDir(getClaudeDir(scope));

installAgents(getAgentsDir(scope), useSymlink);
if (isGlobal) {
installCommands(getCommandsDir(scope), useSymlink);
}
installSkills(getSkillsDir(scope), useSymlink);
installArchitecture(getArchitectureDir(scope), useSymlink);

if (!isGlobal) {
console.log(chalk.gray(' Note: Commands are installed globally only'));
}

console.log('');
console.log(chalk.green(`✓ ${label} installation complete!`));
};
30 changes: 30 additions & 0 deletions src/commands/install/print.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import chalk from 'chalk';
import type { FileResult } from '../../types.js';

export const printHeader = (): void => {
console.log('');
console.log(chalk.cyan('════════════════════════════════════════'));
console.log(chalk.cyan(' MoiCle Installer'));
console.log(chalk.cyan('════════════════════════════════════════'));
console.log('');
};

export const printSummary = (results: FileResult[]): void => {
const created = results.filter((r) => r.status === 'created').length;
const updated = results.filter((r) => r.status === 'updated').length;
const exists = results.filter((r) => r.status === 'exists').length;
const skipped = results.filter((r) => r.status === 'skipped').length;
const errors = results.filter((r) => r.status === 'error').length;

if (created > 0) console.log(chalk.green(` Created: ${created}`));
if (updated > 0) console.log(chalk.yellow(` Updated: ${updated}`));
if (exists > 0) console.log(chalk.gray(` Already exists: ${exists}`));
if (skipped > 0) console.log(chalk.gray(` Skipped: ${skipped}`));
if (errors > 0) console.log(chalk.red(` Errors: ${errors}`));
};

/** Print "✓ <what> installed to <dir>" followed by the status summary. */
export const printInstalled = (what: string, targetDir: string, results: FileResult[]): void => {
console.log(chalk.green(` ✓ ${what} installed to ${chalk.cyan(targetDir)}`));
printSummary(results);
};
48 changes: 48 additions & 0 deletions src/commands/install/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import inquirer from 'inquirer';
import type { EditorTarget } from '../../types.js';

export const showTargetMenu = async (): Promise<EditorTarget> => {
const { target } = await inquirer.prompt([
{
type: 'list',
name: 'target',
message: 'Which editor would you like to configure?',
choices: [
{ name: 'Claude Code', value: 'claude' },
{ name: 'Codex CLI', value: 'codex' },
{ name: 'Antigravity', value: 'antigravity' },
{ name: 'Cursor', value: 'cursor' },
{ name: 'Windsurf', value: 'windsurf' },
],
},
]);

return target;
};

const SCOPE_PATHS: Record<'claude' | 'codex' | 'antigravity', { global: string; project: string }> = {
claude: { global: '~/.claude/', project: './.claude/' },
codex: { global: '~/.codex/', project: './.codex/' },
antigravity: { global: '~/.gemini/', project: './.gemini/' },
};

export const showInteractiveMenu = async (
target: 'claude' | 'codex' | 'antigravity'
): Promise<'global' | 'project' | 'all'> => {
const { global: globalPath, project: projectPath } = SCOPE_PATHS[target];

const { installType } = await inquirer.prompt([
{
type: 'list',
name: 'installType',
message: 'Where would you like to install?',
choices: [
{ name: `Global (${globalPath}) - Available for all projects`, value: 'global' },
{ name: `Project (${projectPath}) - This project only`, value: 'project' },
{ name: 'Both - Global and current project', value: 'all' },
],
},
]);

return installType;
};
Loading
Loading