Files
ccs/src/utils/shell-completion.ts
T
Tam Nhu Tran 60d6bbd027 fix(config): migrate all hardcoded paths to getCcsDir() and improve validation
- Replace os.homedir() + '.ccs' with getCcsDir() across 14 instances in 13 files:
  version-command, model-config, openrouter-catalog, config-checks, disk-cache,
  aggregator, auth-middleware, shell-completion, shared-manager, recovery-manager,
  auto-repair, delegation-validator, claude-dir-installer, cliproxy executor
- Add isDirectory() validation for --config-dir argument in ccs.ts
- Make detectCloudSyncPath() case-insensitive for cloud provider matching
- Fix help-command.ts to use dynamic dirDisplay for all path references
- Add 5 new tests: setGlobalConfigDir precedence, relative path resolution,
  reset behavior, getCcsDirSource with --config-dir, case-insensitive detection
- Clean up unused os imports after migration
2026-02-11 11:15:08 +07:00

292 lines
8.2 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getCcsDir } from './config-manager';
// import { execSync } from 'child_process'; // Currently unused
type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' | null;
interface InstallResult {
success: boolean;
alreadyInstalled?: boolean;
message?: string;
reload?: string;
}
/**
* Shell Completion Installer
* Auto-configures shell completion for bash, zsh, fish, PowerShell
*/
export class ShellCompletionInstaller {
private homeDir: string;
private ccsDir: string;
private completionDir: string;
private scriptsDir: string;
constructor() {
this.homeDir = os.homedir();
this.ccsDir = getCcsDir();
this.completionDir = path.join(this.ccsDir, 'completions');
this.scriptsDir = path.join(__dirname, '../../scripts/completion');
}
/**
* Detect current shell
*/
detectShell(): ShellType {
const shell = process.env.SHELL || '';
if (shell.includes('bash')) return 'bash';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('fish')) return 'fish';
if (process.platform === 'win32') return 'powershell';
return null;
}
/**
* Ensure completion files are in ~/.ccs/completions/
*/
ensureCompletionFiles(): void {
if (!fs.existsSync(this.completionDir)) {
fs.mkdirSync(this.completionDir, { recursive: true });
}
// Copy completion scripts
const files = ['ccs.bash', 'ccs.zsh', 'ccs.fish', 'ccs.ps1'];
files.forEach((file) => {
const src = path.join(this.scriptsDir, file);
const dest = path.join(this.completionDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
}
});
}
/**
* Safely create directory, checking for file conflicts
*/
private ensureDirectory(dirPath: string): void {
if (fs.existsSync(dirPath)) {
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
throw new Error(
`Cannot create directory: ${dirPath} exists but is a file.\n` +
`Please remove or rename this file and try again.`
);
}
// Directory exists, nothing to do
return;
}
// Check parent directories recursively
const parentDir = path.dirname(dirPath);
if (parentDir !== dirPath) {
this.ensureDirectory(parentDir);
}
// Create the directory
fs.mkdirSync(dirPath);
}
/**
* Install bash completion
*/
private installBash(force = false): InstallResult {
const rcFile = path.join(this.homeDir, '.bashrc');
const completionPath = path.join(this.completionDir, 'ccs.bash');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
const marker = '# CCS shell completion';
const sourceCmd = `source "${completionPath}"`;
const block = `\n${marker}\n${sourceCmd}\n`;
// Check if already installed
if (fs.existsSync(rcFile)) {
const content = fs.readFileSync(rcFile, 'utf8');
if (content.includes(marker)) {
if (!force) {
return { success: true, alreadyInstalled: true };
}
// Force: completion files already updated by ensureCompletionFiles()
return {
success: true,
message: `Updated completion files in ${this.completionDir}`,
reload: 'source ~/.bashrc',
};
}
}
// Append to .bashrc
fs.appendFileSync(rcFile, block);
return {
success: true,
message: `Added to ${rcFile}`,
reload: 'source ~/.bashrc',
};
}
/**
* Install zsh completion
*/
private installZsh(force = false): InstallResult {
const rcFile = path.join(this.homeDir, '.zshrc');
const completionPath = path.join(this.completionDir, 'ccs.zsh');
const zshCompDir = path.join(this.homeDir, '.zsh', 'completion');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
// Create zsh completion directory (with file conflict checking)
this.ensureDirectory(zshCompDir);
// Copy to zsh completion directory (always update for force)
const destFile = path.join(zshCompDir, '_ccs');
fs.copyFileSync(completionPath, destFile);
const marker = '# CCS shell completion';
const setupCmds = ['fpath=(~/.zsh/completion $fpath)', 'autoload -Uz compinit && compinit'];
const block = `\n${marker}\n${setupCmds.join('\n')}\n`;
// Check if already installed
if (fs.existsSync(rcFile)) {
const content = fs.readFileSync(rcFile, 'utf8');
if (content.includes(marker)) {
if (!force) {
return { success: true, alreadyInstalled: true };
}
// Force: files already updated above
return {
success: true,
message: `Updated ${destFile}`,
reload: 'source ~/.zshrc',
};
}
}
// Append to .zshrc
fs.appendFileSync(rcFile, block);
return {
success: true,
message: `Added to ${rcFile}`,
reload: 'source ~/.zshrc',
};
}
/**
* Install fish completion
*/
private installFish(force = false): InstallResult {
const completionPath = path.join(this.completionDir, 'ccs.fish');
const fishCompDir = path.join(this.homeDir, '.config', 'fish', 'completions');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
// Create fish completion directory (with file conflict checking)
this.ensureDirectory(fishCompDir);
// Copy to fish completion directory (fish auto-loads from here)
const destFile = path.join(fishCompDir, 'ccs.fish');
// Check if already installed
if (fs.existsSync(destFile) && !force) {
return { success: true, alreadyInstalled: true };
}
fs.copyFileSync(completionPath, destFile);
return {
success: true,
message: `Installed to ${destFile}`,
reload: 'Fish auto-loads completions (no reload needed)',
};
}
/**
* Install PowerShell completion
*/
private installPowerShell(force = false): InstallResult {
const profilePath =
process.env.PROFILE ||
path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
const completionPath = path.join(this.completionDir, 'ccs.ps1');
if (!fs.existsSync(completionPath)) {
throw new Error('Completion file not found. Please reinstall CCS.');
}
const marker = '# CCS shell completion';
const sourceCmd = `. "${completionPath.replace(/\\/g, '\\\\')}"`;
const block = `\n${marker}\n${sourceCmd}\n`;
// Create profile directory if needed (with file conflict checking)
const profileDir = path.dirname(profilePath);
this.ensureDirectory(profileDir);
// Check if already installed
if (fs.existsSync(profilePath)) {
const content = fs.readFileSync(profilePath, 'utf8');
if (content.includes(marker)) {
if (!force) {
return { success: true, alreadyInstalled: true };
}
// Force: completion files already updated by ensureCompletionFiles()
return {
success: true,
message: `Updated completion files in ${this.completionDir}`,
reload: '. $PROFILE',
};
}
}
// Append to PowerShell profile
fs.appendFileSync(profilePath, block);
return {
success: true,
message: `Added to ${profilePath}`,
reload: '. $PROFILE',
};
}
/**
* Install for detected or specified shell
*/
install(shell: ShellType = null, options: { force?: boolean } = {}): InstallResult {
const targetShell = shell || this.detectShell();
const { force = false } = options;
if (!targetShell) {
throw new Error(
'Could not detect shell. Please specify: --bash, --zsh, --fish, or --powershell'
);
}
// Ensure completion files exist (always copy latest)
this.ensureCompletionFiles();
// Install for target shell
switch (targetShell) {
case 'bash':
return this.installBash(force);
case 'zsh':
return this.installZsh(force);
case 'fish':
return this.installFish(force);
case 'powershell':
return this.installPowerShell(force);
default:
throw new Error(`Unsupported shell: ${targetShell}`);
}
}
}