Skip to content

Commit 688fa6f

Browse files
Merge pull request #3 from phuthuycoding/refactor/split-install-modules
refactor(install): split install.ts into focused modules
2 parents 9416fe2 + 871d116 commit 688fa6f

10 files changed

Lines changed: 645 additions & 832 deletions

File tree

src/commands/install.ts

Lines changed: 1 addition & 831 deletions
Large diffs are not rendered by default.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import chalk from 'chalk';
2+
import path from 'path';
3+
import fs from 'fs';
4+
import type { EditorTarget, FileResult, Scope } from '../../types.js';
5+
import {
6+
ASSETS_DIR,
7+
ensureDir,
8+
copyFile,
9+
getEditorConfig,
10+
getEditorDir,
11+
getFiles,
12+
mergeAgentsToFile,
13+
} from '../../utils/symlink.js';
14+
import { printSummary } from './print.js';
15+
16+
/**
17+
* Rules-file editors (Cursor, Windsurf). These do not support discrete
18+
* agents/commands/skills — agent personas are merged into a single rules file
19+
* (AGENTS.md / global_rules.md), and architecture docs are copied alongside.
20+
*/
21+
22+
const installArchitectureForEditor = (targetDir: string): FileResult[] => {
23+
const archDir = path.join(ASSETS_DIR, 'architecture');
24+
const targetArchDir = path.join(targetDir, 'architecture');
25+
ensureDir(targetArchDir);
26+
27+
if (!fs.existsSync(archDir)) {
28+
return [];
29+
}
30+
31+
return getFiles(archDir).map((file) =>
32+
copyFile(file, path.join(targetArchDir, path.basename(file)))
33+
);
34+
};
35+
36+
export const installForOtherEditor = async (target: EditorTarget, scope: Scope): Promise<FileResult[]> => {
37+
const config = getEditorConfig(target);
38+
const targetDir = getEditorDir(target, scope);
39+
const results: FileResult[] = [];
40+
41+
console.log('');
42+
console.log(chalk.cyan(`>>> ${config.name} Installation`));
43+
console.log(chalk.gray(` Target: ${targetDir}`));
44+
console.log('');
45+
46+
ensureDir(targetDir);
47+
48+
results.push(...installArchitectureForEditor(targetDir));
49+
console.log(chalk.green(` ✓ Architecture installed to ${chalk.cyan(path.join(targetDir, 'architecture'))}`));
50+
51+
if (config.rulesFile) {
52+
const result = mergeAgentsToFile(path.join(targetDir, config.rulesFile), target);
53+
results.push(result);
54+
console.log(chalk.green(` ✓ Agents merged to ${chalk.cyan(config.rulesFile)}`));
55+
}
56+
57+
printSummary(results);
58+
59+
console.log('');
60+
console.log(chalk.green(`✓ ${config.name} installation complete!`));
61+
62+
return results;
63+
};

src/commands/install/index.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import chalk from 'chalk';
2+
import fs from 'fs';
3+
import type { CommandOptions, EditorTarget, Scope } from '../../types.js';
4+
import { ASSETS_DIR, isSymlinkSupported } from '../../utils/symlink.js';
5+
import { addTarget } from '../../utils/config.js';
6+
import { printHeader } from './print.js';
7+
import { installScope } from './native.js';
8+
import { installSkillEditorScope } from './skill-editor.js';
9+
import { installForOtherEditor } from './generic-editor.js';
10+
import { showTargetMenu, showInteractiveMenu } from './prompts.js';
11+
import { printUsage } from './usage.js';
12+
13+
/** Editors that branch into a global/project/all scope flow. */
14+
type ScopedTarget = 'claude' | 'codex' | 'antigravity';
15+
const isScopedTarget = (target: EditorTarget): target is ScopedTarget =>
16+
target === 'claude' || target === 'codex' || target === 'antigravity';
17+
18+
const resolveStrategy = (options: CommandOptions): boolean => {
19+
if (options.symlink === true) return true;
20+
if (options.symlink === false) return false;
21+
return isSymlinkSupported();
22+
};
23+
24+
const resolveInstallType = async (
25+
options: CommandOptions,
26+
target: ScopedTarget
27+
): Promise<'global' | 'project' | 'all'> => {
28+
if (options.global) return 'global';
29+
if (options.project) return 'project';
30+
if (options.all) return 'all';
31+
return showInteractiveMenu(target);
32+
};
33+
34+
/** Install a scoped target (claude/codex/antigravity) into one scope. */
35+
const installScopedTarget = async (
36+
target: ScopedTarget,
37+
scope: Scope,
38+
useSymlink: boolean
39+
): Promise<void> => {
40+
if (target === 'claude') {
41+
// Symlinks only make sense for the global, shared install.
42+
await installScope(scope, scope === 'global' ? useSymlink : false);
43+
} else {
44+
await installSkillEditorScope(scope, target);
45+
}
46+
};
47+
48+
export const installCommand = async (options: CommandOptions): Promise<void> => {
49+
printHeader();
50+
51+
if (!fs.existsSync(ASSETS_DIR)) {
52+
console.log(chalk.red('Error: Assets directory not found.'));
53+
console.log(chalk.gray(`Expected: ${ASSETS_DIR}`));
54+
process.exit(1);
55+
}
56+
57+
const useSymlink = resolveStrategy(options);
58+
const strategyLabel = useSymlink ? 'symlinks' : 'file copy';
59+
if (options.symlink === undefined) {
60+
console.log(chalk.gray(` Auto-detected file strategy: ${strategyLabel} (${process.platform})`));
61+
} else {
62+
console.log(chalk.gray(` File strategy: ${strategyLabel} (user override)`));
63+
}
64+
console.log('');
65+
66+
const targets: EditorTarget[] = options.target ? [options.target] : [await showTargetMenu()];
67+
68+
for (const target of targets) {
69+
addTarget(target);
70+
71+
if (!isScopedTarget(target)) {
72+
await installForOtherEditor(target, 'global');
73+
continue;
74+
}
75+
76+
const installType = await resolveInstallType(options, target);
77+
if (installType === 'global' || installType === 'all') {
78+
await installScopedTarget(target, 'global', useSymlink);
79+
}
80+
if (installType === 'project' || installType === 'all') {
81+
await installScopedTarget(target, 'project', useSymlink);
82+
}
83+
}
84+
85+
printUsage(targets);
86+
};

src/commands/install/native.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import chalk from 'chalk';
2+
import path from 'path';
3+
import fs from 'fs';
4+
import type { FileResult, Scope } from '../../types.js';
5+
import {
6+
ASSETS_DIR,
7+
ensureDir,
8+
createSymlink,
9+
copyFile,
10+
copyDir,
11+
getAgentsDir,
12+
getCommandsDir,
13+
getSkillsDir,
14+
getArchitectureDir,
15+
getClaudeDir,
16+
getFiles,
17+
getDirs,
18+
} from '../../utils/symlink.js';
19+
import { printInstalled } from './print.js';
20+
21+
/**
22+
* Claude native install: assets are symlinked (or copied) verbatim into
23+
* ~/.claude/{agents,commands,skills,architecture}. This is the only target
24+
* that supports symlinks and the full agents/commands/skills layout.
25+
*/
26+
27+
/** Symlink or copy every file from a source dir into the target dir. */
28+
const linkFiles = (sourceDir: string, targetDir: string, useSymlink: boolean): FileResult[] => {
29+
if (!fs.existsSync(sourceDir)) {
30+
return [];
31+
}
32+
return getFiles(sourceDir).map((file) => {
33+
const target = path.join(targetDir, path.basename(file));
34+
return useSymlink ? createSymlink(file, target) : copyFile(file, target);
35+
});
36+
};
37+
38+
const installAgents = (targetDir: string, useSymlink: boolean): FileResult[] => {
39+
ensureDir(targetDir);
40+
const results = [
41+
...linkFiles(path.join(ASSETS_DIR, 'agents', 'developers'), targetDir, useSymlink),
42+
...linkFiles(path.join(ASSETS_DIR, 'agents', 'utilities'), targetDir, useSymlink),
43+
];
44+
printInstalled('Agents', targetDir, results);
45+
return results;
46+
};
47+
48+
const installCommands = (targetDir: string, useSymlink: boolean): FileResult[] => {
49+
ensureDir(targetDir);
50+
const results = linkFiles(path.join(ASSETS_DIR, 'commands'), targetDir, useSymlink);
51+
printInstalled('Commands', targetDir, results);
52+
return results;
53+
};
54+
55+
const installSkills = (targetDir: string, useSymlink: boolean): FileResult[] => {
56+
ensureDir(targetDir);
57+
const skillsDir = path.join(ASSETS_DIR, 'skills');
58+
const results = fs.existsSync(skillsDir)
59+
? getDirs(skillsDir).map((dir) => {
60+
const target = path.join(targetDir, path.basename(dir));
61+
return useSymlink ? createSymlink(dir, target) : copyDir(dir, target);
62+
})
63+
: [];
64+
printInstalled('Skills', targetDir, results);
65+
return results;
66+
};
67+
68+
const installArchitecture = (targetDir: string, useSymlink: boolean): FileResult[] => {
69+
ensureDir(targetDir);
70+
const results = linkFiles(path.join(ASSETS_DIR, 'architecture'), targetDir, useSymlink);
71+
printInstalled('Architecture', targetDir, results);
72+
return results;
73+
};
74+
75+
export const installScope = async (scope: Scope, useSymlink: boolean): Promise<void> => {
76+
const isGlobal = scope === 'global';
77+
const label = isGlobal ? 'Global' : 'Project';
78+
const targetPath = isGlobal ? '~/.claude/' : `${process.cwd()}/.claude/`;
79+
80+
console.log('');
81+
console.log(chalk.cyan(`>>> ${label} Installation`));
82+
console.log(chalk.gray(` Target: ${targetPath}`));
83+
console.log('');
84+
85+
ensureDir(getClaudeDir(scope));
86+
87+
installAgents(getAgentsDir(scope), useSymlink);
88+
if (isGlobal) {
89+
installCommands(getCommandsDir(scope), useSymlink);
90+
}
91+
installSkills(getSkillsDir(scope), useSymlink);
92+
installArchitecture(getArchitectureDir(scope), useSymlink);
93+
94+
if (!isGlobal) {
95+
console.log(chalk.gray(' Note: Commands are installed globally only'));
96+
}
97+
98+
console.log('');
99+
console.log(chalk.green(`✓ ${label} installation complete!`));
100+
};

src/commands/install/print.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import chalk from 'chalk';
2+
import type { FileResult } from '../../types.js';
3+
4+
export const printHeader = (): void => {
5+
console.log('');
6+
console.log(chalk.cyan('════════════════════════════════════════'));
7+
console.log(chalk.cyan(' MoiCle Installer'));
8+
console.log(chalk.cyan('════════════════════════════════════════'));
9+
console.log('');
10+
};
11+
12+
export const printSummary = (results: FileResult[]): void => {
13+
const created = results.filter((r) => r.status === 'created').length;
14+
const updated = results.filter((r) => r.status === 'updated').length;
15+
const exists = results.filter((r) => r.status === 'exists').length;
16+
const skipped = results.filter((r) => r.status === 'skipped').length;
17+
const errors = results.filter((r) => r.status === 'error').length;
18+
19+
if (created > 0) console.log(chalk.green(` Created: ${created}`));
20+
if (updated > 0) console.log(chalk.yellow(` Updated: ${updated}`));
21+
if (exists > 0) console.log(chalk.gray(` Already exists: ${exists}`));
22+
if (skipped > 0) console.log(chalk.gray(` Skipped: ${skipped}`));
23+
if (errors > 0) console.log(chalk.red(` Errors: ${errors}`));
24+
};
25+
26+
/** Print "✓ <what> installed to <dir>" followed by the status summary. */
27+
export const printInstalled = (what: string, targetDir: string, results: FileResult[]): void => {
28+
console.log(chalk.green(` ✓ ${what} installed to ${chalk.cyan(targetDir)}`));
29+
printSummary(results);
30+
};

src/commands/install/prompts.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import inquirer from 'inquirer';
2+
import type { EditorTarget } from '../../types.js';
3+
4+
export const showTargetMenu = async (): Promise<EditorTarget> => {
5+
const { target } = await inquirer.prompt([
6+
{
7+
type: 'list',
8+
name: 'target',
9+
message: 'Which editor would you like to configure?',
10+
choices: [
11+
{ name: 'Claude Code', value: 'claude' },
12+
{ name: 'Codex CLI', value: 'codex' },
13+
{ name: 'Antigravity', value: 'antigravity' },
14+
{ name: 'Cursor', value: 'cursor' },
15+
{ name: 'Windsurf', value: 'windsurf' },
16+
],
17+
},
18+
]);
19+
20+
return target;
21+
};
22+
23+
const SCOPE_PATHS: Record<'claude' | 'codex' | 'antigravity', { global: string; project: string }> = {
24+
claude: { global: '~/.claude/', project: './.claude/' },
25+
codex: { global: '~/.codex/', project: './.codex/' },
26+
antigravity: { global: '~/.gemini/', project: './.gemini/' },
27+
};
28+
29+
export const showInteractiveMenu = async (
30+
target: 'claude' | 'codex' | 'antigravity'
31+
): Promise<'global' | 'project' | 'all'> => {
32+
const { global: globalPath, project: projectPath } = SCOPE_PATHS[target];
33+
34+
const { installType } = await inquirer.prompt([
35+
{
36+
type: 'list',
37+
name: 'installType',
38+
message: 'Where would you like to install?',
39+
choices: [
40+
{ name: `Global (${globalPath}) - Available for all projects`, value: 'global' },
41+
{ name: `Project (${projectPath}) - This project only`, value: 'project' },
42+
{ name: 'Both - Global and current project', value: 'all' },
43+
],
44+
},
45+
]);
46+
47+
return installType;
48+
};

0 commit comments

Comments
 (0)