diff --git a/hlyr/README.md b/hlyr/README.md index 4490379a8..bf3949a79 100644 --- a/hlyr/README.md +++ b/hlyr/README.md @@ -199,24 +199,61 @@ humanlayer thoughts - `sync` - Manually sync thoughts and update searchable index - `status` - Check the status of your thoughts setup - `config` - View or edit thoughts configuration +- `uninit` - Remove thoughts setup from current repository +- `profile` - Manage thoughts profiles (for multiple thoughts repositories) + +**Profile Management:** + +The thoughts system supports multiple profiles, allowing you to maintain separate thoughts repositories for different organizational contexts (e.g., personal projects, different clients). + +```bash +# Create a new profile +humanlayer thoughts profile create [--repo ] [--repos-dir ] [--global-dir ] + +# List all profiles +humanlayer thoughts profile list [--json] + +# Show profile details +humanlayer thoughts profile show [--json] + +# Delete a profile +humanlayer thoughts profile delete [--force] +``` **Examples:** ```bash -# Initialize thoughts for a new project +# Initialize thoughts for a new project (default profile) humanlayer thoughts init -# Sync thoughts after making changes +# Create a profile for personal projects +humanlayer thoughts profile create personal --repo ~/thoughts-personal + +# Initialize a repo with a specific profile +cd ~/projects/personal-app +humanlayer thoughts init --profile personal + +# Sync thoughts (automatically uses the correct profile's repository) humanlayer thoughts sync -m "Updated architecture notes" -# Check status +# Check status (shows which profile is active) humanlayer thoughts status -# View configuration -humanlayer thoughts config --json +# View all profiles and configuration +humanlayer thoughts config + +# List all configured profiles +humanlayer thoughts profile list ``` -The thoughts system keeps your notes separate from code while making them easily accessible to AI assistants. See the [Thoughts documentation](./THOUGHTS.md) for detailed information. +**Profile Features:** + +- **Multiple Repositories**: Each profile can have its own thoughts repository location +- **Automatic Resolution**: Commands automatically use the correct profile based on repository mappings +- **Backward Compatible**: Existing configurations without profiles continue to work unchanged +- **Per-Repository Profiles**: Different repositories can use different profiles, even worktrees of the same repo + +The thoughts system keeps your notes separate from code while making them easily accessible to AI assistants. ### `claude` diff --git a/hlyr/src/commands/thoughts.ts b/hlyr/src/commands/thoughts.ts index 23749868c..dafe1ae58 100644 --- a/hlyr/src/commands/thoughts.ts +++ b/hlyr/src/commands/thoughts.ts @@ -4,6 +4,10 @@ import { thoughtsUninitCommand } from './thoughts/uninit.js' import { thoughtsSyncCommand } from './thoughts/sync.js' import { thoughtsStatusCommand } from './thoughts/status.js' import { thoughtsConfigCommand } from './thoughts/config.js' +import { profileCreateCommand } from './thoughts/profile/create.js' +import { profileListCommand } from './thoughts/profile/list.js' +import { profileShowCommand } from './thoughts/profile/show.js' +import { profileDeleteCommand } from './thoughts/profile/delete.js' export function thoughtsCommand(program: Command): void { const thoughts = program.command('thoughts').description('Manage developer thoughts and notes') @@ -14,6 +18,7 @@ export function thoughtsCommand(program: Command): void { .option('--force', 'Force reconfiguration even if already set up') .option('--config-file ', 'Path to config file') .option('--directory ', 'Specify the repository directory name (skips interactive prompt)') + .option('--profile ', 'Use a specific thoughts profile') .action(thoughtsInitCommand) thoughts @@ -43,4 +48,37 @@ export function thoughtsCommand(program: Command): void { .option('--json', 'Output configuration as JSON') .option('--config-file ', 'Path to config file') .action(thoughtsConfigCommand) + + // Profile management commands + const profile = thoughts.command('profile').description('Manage thoughts profiles') + + profile + .command('create ') + .description('Create a new thoughts profile') + .option('--repo ', 'Thoughts repository path') + .option('--repos-dir ', 'Repos directory name') + .option('--global-dir ', 'Global directory name') + .option('--config-file ', 'Path to config file') + .action(profileCreateCommand) + + profile + .command('list') + .description('List all thoughts profiles') + .option('--json', 'Output as JSON') + .option('--config-file ', 'Path to config file') + .action(profileListCommand) + + profile + .command('show ') + .description('Show details of a specific profile') + .option('--json', 'Output as JSON') + .option('--config-file ', 'Path to config file') + .action(profileShowCommand) + + profile + .command('delete ') + .description('Delete a thoughts profile') + .option('--force', 'Force deletion even if in use') + .option('--config-file ', 'Path to config file') + .action(profileDeleteCommand) } diff --git a/hlyr/src/commands/thoughts/config.ts b/hlyr/src/commands/thoughts/config.ts index d1fdcf3ab..4746d3d6c 100644 --- a/hlyr/src/commands/thoughts/config.ts +++ b/hlyr/src/commands/thoughts/config.ts @@ -1,6 +1,10 @@ import { spawn } from 'child_process' import chalk from 'chalk' -import { loadThoughtsConfig } from '../../thoughtsConfig.js' +import { + loadThoughtsConfig, + getRepoNameFromMapping, + getProfileNameFromMapping, +} from '../../thoughtsConfig.js' import { getDefaultConfigPath } from '../../config.js' interface ConfigOptions { @@ -54,9 +58,30 @@ export async function thoughtsConfigCommand(options: ConfigOptions): Promise { + mappings.forEach(([repo, mapping]) => { + const repoName = getRepoNameFromMapping(mapping) + const profileName = getProfileNameFromMapping(mapping) + console.log(` ${chalk.cyan(repo)}`) - console.log(` → ${chalk.green(`${config.reposDir}/${thoughtsDir}`)}`) + console.log(` → ${chalk.green(`${config.reposDir}/${repoName}`)}`) + + if (profileName) { + console.log(` Profile: ${chalk.yellow(profileName)}`) + } else { + console.log(` Profile: ${chalk.gray('(default)')}`) + } + }) + } + + console.log('') + + // Add profiles section + console.log(chalk.yellow('Profiles:')) + if (!config.profiles || Object.keys(config.profiles).length === 0) { + console.log(chalk.gray(' No profiles configured')) + } else { + Object.keys(config.profiles).forEach(name => { + console.log(` ${chalk.cyan(name)}`) }) } diff --git a/hlyr/src/commands/thoughts/init.ts b/hlyr/src/commands/thoughts/init.ts index 022cb8eab..fceb98123 100644 --- a/hlyr/src/commands/thoughts/init.ts +++ b/hlyr/src/commands/thoughts/init.ts @@ -17,12 +17,15 @@ import { getRepoThoughtsPath, getGlobalThoughtsPath, updateSymlinksForNewUsers, + validateProfile, + resolveProfileForRepo, } from '../../thoughtsConfig.js' interface InitOptions { force?: boolean configFile?: string directory?: string + profile?: string } function sanitizeDirectoryName(name: string): string { @@ -394,6 +397,43 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { console.log('') } + // Validate profile if specified + if (options.profile) { + if (!validateProfile(config, options.profile)) { + console.error(chalk.red(`Error: Profile "${options.profile}" does not exist.`)) + console.error('') + console.error(chalk.gray('Available profiles:')) + if (config.profiles) { + Object.keys(config.profiles).forEach(name => { + console.error(chalk.gray(` - ${name}`)) + }) + } else { + console.error(chalk.gray(' (none)')) + } + console.error('') + console.error(chalk.yellow('Create a profile first:')) + console.error(chalk.gray(` humanlayer thoughts profile create ${options.profile}`)) + process.exit(1) + } + } + + // Resolve profile config early so we use the right thoughtsRepo throughout + // Create a temporary mapping to resolve the profile (will be updated later with actual mapping) + const tempProfileConfig = + options.profile && config.profiles && config.profiles[options.profile] + ? { + thoughtsRepo: config.profiles[options.profile].thoughtsRepo, + reposDir: config.profiles[options.profile].reposDir, + globalDir: config.profiles[options.profile].globalDir, + profileName: options.profile, + } + : { + thoughtsRepo: config.thoughtsRepo, + reposDir: config.reposDir, + globalDir: config.globalDir, + profileName: undefined, + } + // Now check for existing setup in current repo const setupStatus = checkExistingSetup(config) @@ -425,20 +465,26 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { } // Ensure thoughts repo still exists (might have been deleted) - const expandedRepo = expandPath(config.thoughtsRepo) + const expandedRepo = expandPath(tempProfileConfig.thoughtsRepo) if (!fs.existsSync(expandedRepo)) { - console.log(chalk.red(`Error: Thoughts repository not found at ${config.thoughtsRepo}`)) + console.log( + chalk.red(`Error: Thoughts repository not found at ${tempProfileConfig.thoughtsRepo}`), + ) console.log(chalk.yellow('The thoughts repository may have been moved or deleted.')) const recreate = await prompt('Do you want to recreate it? (Y/n): ') if (recreate.toLowerCase() === 'n') { console.log('Please update your configuration or restore the thoughts repository.') process.exit(1) } - ensureThoughtsRepoExists(config.thoughtsRepo, config.reposDir, config.globalDir) + ensureThoughtsRepoExists( + tempProfileConfig.thoughtsRepo, + tempProfileConfig.reposDir, + tempProfileConfig.globalDir, + ) } // Map current repository - const reposDir = path.join(expandedRepo, config.reposDir) + const reposDir = path.join(expandedRepo, tempProfileConfig.reposDir) // Ensure repos directory exists if (!fs.existsSync(reposDir)) { @@ -475,7 +521,9 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { mappedName = sanitizedDir console.log( - chalk.green(`✓ Using existing: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`), + chalk.green( + `✓ Using existing: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}`, + ), ) } else { // Interactive mode @@ -484,7 +532,9 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { console.log(`Setting up thoughts for: ${chalk.cyan(currentRepo)}`) console.log('') console.log( - chalk.gray(`This will create a subdirectory in ${config.thoughtsRepo}/${config.reposDir}/`), + chalk.gray( + `This will create a subdirectory in ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/`, + ), ) console.log(chalk.gray('to store thoughts specific to this repository.')) console.log('') @@ -503,7 +553,7 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { console.log('') console.log( chalk.gray( - `This name will be used for the directory: ${config.thoughtsRepo}/${config.reposDir}/[name]`, + `This name will be used for the directory: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/[name]`, ), ) const nameInput = await prompt( @@ -514,13 +564,15 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { // Sanitize the name mappedName = sanitizeDirectoryName(mappedName) console.log( - chalk.green(`✓ Will create: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`), + chalk.green( + `✓ Will create: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}`, + ), ) } else { mappedName = existingRepos[selection] console.log( chalk.green( - `✓ Will use existing: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`, + `✓ Will use existing: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}`, ), ) } @@ -529,7 +581,7 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { const defaultName = getRepoNameFromPath(currentRepo) console.log( chalk.gray( - `This name will be used for the directory: ${config.thoughtsRepo}/${config.reposDir}/[name]`, + `This name will be used for the directory: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/[name]`, ), ) const nameInput = await prompt( @@ -540,26 +592,33 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { // Sanitize the name mappedName = sanitizeDirectoryName(mappedName) console.log( - chalk.green(`✓ Will create: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`), + chalk.green( + `✓ Will create: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}`, + ), ) } } console.log('') - // Update config - config.repoMappings[currentRepo] = mappedName + // Update config with profile-aware mapping + if (options.profile) { + config.repoMappings[currentRepo] = { + repo: mappedName, + profile: options.profile, + } + } else { + // Keep string format for backward compatibility + config.repoMappings[currentRepo] = mappedName + } saveThoughtsConfig(config, options) } - // Create directory structure - createThoughtsDirectoryStructure( - config.thoughtsRepo, - config.reposDir, - config.globalDir, - mappedName, - config.user, - ) + // Resolve profile config for directory creation + const profileConfig = resolveProfileForRepo(config, currentRepo) + + // Create directory structure using profile config + createThoughtsDirectoryStructure(profileConfig, mappedName, config.user) // Create thoughts directory in current repo const thoughtsDir = path.join(currentRepo, 'thoughts') @@ -583,8 +642,8 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { fs.mkdirSync(thoughtsDir) // Create symlinks - flipped structure for easier access - const repoTarget = getRepoThoughtsPath(config.thoughtsRepo, config.reposDir, mappedName) - const globalTarget = getGlobalThoughtsPath(config.thoughtsRepo, config.globalDir) + const repoTarget = getRepoThoughtsPath(profileConfig, mappedName) + const globalTarget = getGlobalThoughtsPath(profileConfig) // Direct symlinks to user and shared directories for repo-specific thoughts fs.symlinkSync(path.join(repoTarget, config.user), path.join(thoughtsDir, config.user), 'dir') @@ -594,13 +653,7 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { fs.symlinkSync(globalTarget, path.join(thoughtsDir, 'global'), 'dir') // Check for other users and create symlinks - const otherUsers = updateSymlinksForNewUsers( - currentRepo, - config.thoughtsRepo, - config.reposDir, - mappedName, - config.user, - ) + const otherUsers = updateSymlinksForNewUsers(currentRepo, profileConfig, mappedName, config.user) if (otherUsers.length > 0) { console.log(chalk.green(`✓ Added symlinks for other users: ${otherUsers.join(', ')}`)) @@ -624,7 +677,7 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { } // Generate CLAUDE.md - const claudeMd = generateClaudeMd(config.thoughtsRepo, config.reposDir, mappedName, config.user) + const claudeMd = generateClaudeMd(profileConfig.thoughtsRepo, profileConfig.reposDir, mappedName, config.user) fs.writeFileSync(path.join(thoughtsDir, 'CLAUDE.md'), claudeMd) // Setup git hooks @@ -641,13 +694,13 @@ export async function thoughtsInitCommand(options: InitOptions): Promise { console.log(` ${chalk.cyan(currentRepo)}/`) console.log(` └── thoughts/`) console.log( - ` ├── ${config.user}/ ${chalk.gray(`→ ${config.thoughtsRepo}/${config.reposDir}/${mappedName}/${config.user}/`)}`, + ` ├── ${config.user}/ ${chalk.gray(`→ ${profileConfig.thoughtsRepo}/${profileConfig.reposDir}/${mappedName}/${config.user}/`)}`, ) console.log( - ` ├── shared/ ${chalk.gray(`→ ${config.thoughtsRepo}/${config.reposDir}/${mappedName}/shared/`)}`, + ` ├── shared/ ${chalk.gray(`→ ${profileConfig.thoughtsRepo}/${profileConfig.reposDir}/${mappedName}/shared/`)}`, ) console.log( - ` └── global/ ${chalk.gray(`→ ${config.thoughtsRepo}/${config.globalDir}/`)}`, + ` └── global/ ${chalk.gray(`→ ${profileConfig.thoughtsRepo}/${profileConfig.globalDir}/`)}`, ) console.log(` ├── ${config.user}/ ${chalk.gray('(your cross-repo notes)')}`) console.log(` └── shared/ ${chalk.gray('(team cross-repo notes)')}`) diff --git a/hlyr/src/commands/thoughts/profile/create.ts b/hlyr/src/commands/thoughts/profile/create.ts new file mode 100644 index 000000000..c9efc60d1 --- /dev/null +++ b/hlyr/src/commands/thoughts/profile/create.ts @@ -0,0 +1,124 @@ +import chalk from 'chalk' +import readline from 'readline' +import { + loadThoughtsConfig, + saveThoughtsConfig, + getDefaultThoughtsRepo, + ensureThoughtsRepoExists, + sanitizeProfileName, + validateProfile, +} from '../../../thoughtsConfig.js' +import type { ProfileConfig } from '../../../config.js' + +interface CreateOptions { + repo?: string + reposDir?: string + globalDir?: string + configFile?: string +} + +function prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise(resolve => { + rl.question(question, answer => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +export async function profileCreateCommand(profileName: string, options: CreateOptions): Promise { + try { + // Load existing config + const config = loadThoughtsConfig(options) + + if (!config) { + console.error(chalk.red('Error: Thoughts not configured.')) + console.error('Run "humanlayer thoughts init" first to set up the base configuration.') + process.exit(1) + } + + // Sanitize profile name + const sanitizedName = sanitizeProfileName(profileName) + if (sanitizedName !== profileName) { + console.log(chalk.yellow(`Profile name sanitized: "${profileName}" → "${sanitizedName}"`)) + } + + // Check if profile already exists + if (validateProfile(config, sanitizedName)) { + console.error(chalk.red(`Error: Profile "${sanitizedName}" already exists.`)) + console.error('Use a different name or delete the existing profile first.') + process.exit(1) + } + + // Get profile configuration + let thoughtsRepo: string + let reposDir: string + let globalDir: string + + if (options.repo && options.reposDir && options.globalDir) { + // Non-interactive mode + thoughtsRepo = options.repo + reposDir = options.reposDir + globalDir = options.globalDir + } else { + // Interactive mode + console.log(chalk.blue(`\n=== Creating Profile: ${sanitizedName} ===\n`)) + + const defaultRepo = getDefaultThoughtsRepo() + `-${sanitizedName}` + console.log(chalk.gray('Specify the thoughts repository location for this profile.')) + const repoInput = await prompt(`Thoughts repository [${defaultRepo}]: `) + thoughtsRepo = repoInput || defaultRepo + + console.log('') + const reposDirInput = await prompt(`Repository-specific thoughts directory [repos]: `) + reposDir = reposDirInput || 'repos' + + const globalDirInput = await prompt(`Global thoughts directory [global]: `) + globalDir = globalDirInput || 'global' + } + + // Create profile config + const profileConfig: ProfileConfig = { + thoughtsRepo, + reposDir, + globalDir, + } + + // Initialize profiles object if it doesn't exist + if (!config.profiles) { + config.profiles = {} + } + + // Add profile + config.profiles[sanitizedName] = profileConfig + + // Save config + saveThoughtsConfig(config, options) + + // Create the profile's thoughts repository structure + console.log(chalk.gray('\nInitializing profile thoughts repository...')) + ensureThoughtsRepoExists(profileConfig) + + console.log(chalk.green(`\n✅ Profile "${sanitizedName}" created successfully!`)) + console.log('') + console.log(chalk.blue('=== Profile Configuration ===')) + console.log(` Name: ${chalk.cyan(sanitizedName)}`) + console.log(` Thoughts repository: ${chalk.cyan(thoughtsRepo)}`) + console.log(` Repos directory: ${chalk.cyan(reposDir)}`) + console.log(` Global directory: ${chalk.cyan(globalDir)}`) + console.log('') + console.log(chalk.gray('Next steps:')) + console.log( + chalk.gray(` 1. Run "humanlayer thoughts init --profile ${sanitizedName}" in a repository`), + ) + console.log(chalk.gray(` 2. Your thoughts will sync to the profile's repository`)) + } catch (error) { + console.error(chalk.red(`Error creating profile: ${error}`)) + process.exit(1) + } +} diff --git a/hlyr/src/commands/thoughts/profile/delete.ts b/hlyr/src/commands/thoughts/profile/delete.ts new file mode 100644 index 000000000..2a954a8a7 --- /dev/null +++ b/hlyr/src/commands/thoughts/profile/delete.ts @@ -0,0 +1,100 @@ +import chalk from 'chalk' +import readline from 'readline' +import { loadThoughtsConfig, saveThoughtsConfig, validateProfile } from '../../../thoughtsConfig.js' + +interface DeleteOptions { + force?: boolean + configFile?: string +} + +function prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise(resolve => { + rl.question(question, answer => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +export async function profileDeleteCommand(profileName: string, options: DeleteOptions): Promise { + try { + const config = loadThoughtsConfig(options) + + if (!config) { + console.error(chalk.red('Error: Thoughts not configured.')) + process.exit(1) + } + + if (!validateProfile(config, profileName)) { + console.error(chalk.red(`Error: Profile "${profileName}" not found.`)) + process.exit(1) + } + + // Check if any repositories are using this profile + const usingRepos: string[] = [] + Object.entries(config.repoMappings).forEach(([repoPath, mapping]) => { + if (typeof mapping === 'object' && mapping.profile === profileName) { + usingRepos.push(repoPath) + } + }) + + if (usingRepos.length > 0 && !options.force) { + console.error( + chalk.red(`Error: Profile "${profileName}" is in use by ${usingRepos.length} repository(ies):`), + ) + console.error('') + usingRepos.forEach(repo => { + console.error(chalk.gray(` - ${repo}`)) + }) + console.error('') + console.error(chalk.yellow('Options:')) + console.error(chalk.gray(' 1. Run "humanlayer thoughts uninit" in each repository')) + console.error( + chalk.gray(' 2. Use --force to delete anyway (repos will fall back to default config)'), + ) + process.exit(1) + } + + // Confirm deletion + if (!options.force) { + console.log(chalk.yellow(`\nYou are about to delete profile: ${chalk.cyan(profileName)}`)) + console.log(chalk.gray('This will remove the profile configuration.')) + console.log(chalk.gray('The thoughts repository files will NOT be deleted.')) + console.log('') + const confirm = await prompt('Are you sure? (y/N): ') + + if (confirm.toLowerCase() !== 'y') { + console.log('Deletion cancelled.') + return + } + } + + // Delete profile + delete config.profiles![profileName] + + // If profiles is now empty, remove it entirely + if (Object.keys(config.profiles!).length === 0) { + delete config.profiles + } + + // Save config + saveThoughtsConfig(config, options) + + console.log(chalk.green(`\n✅ Profile "${profileName}" deleted`)) + + if (usingRepos.length > 0) { + console.log('') + console.log( + chalk.yellow('⚠️ Warning: Repositories using this profile will fall back to default config'), + ) + } + } catch (error) { + console.error(chalk.red(`Error deleting profile: ${error}`)) + process.exit(1) + } +} diff --git a/hlyr/src/commands/thoughts/profile/list.ts b/hlyr/src/commands/thoughts/profile/list.ts new file mode 100644 index 000000000..cd4c44fb7 --- /dev/null +++ b/hlyr/src/commands/thoughts/profile/list.ts @@ -0,0 +1,55 @@ +import chalk from 'chalk' +import { loadThoughtsConfig } from '../../../thoughtsConfig.js' + +interface ListOptions { + json?: boolean + configFile?: string +} + +export async function profileListCommand(options: ListOptions): Promise { + try { + const config = loadThoughtsConfig(options) + + if (!config) { + console.error(chalk.red('Error: Thoughts not configured.')) + process.exit(1) + } + + if (options.json) { + console.log(JSON.stringify(config.profiles || {}, null, 2)) + return + } + + console.log(chalk.blue('Thoughts Profiles')) + console.log(chalk.gray('='.repeat(50))) + console.log('') + + // Show default config + console.log(chalk.yellow('Default Configuration:')) + console.log(` Thoughts repository: ${chalk.cyan(config.thoughtsRepo)}`) + console.log(` Repos directory: ${chalk.cyan(config.reposDir)}`) + console.log(` Global directory: ${chalk.cyan(config.globalDir)}`) + console.log('') + + // Show profiles + if (!config.profiles || Object.keys(config.profiles).length === 0) { + console.log(chalk.gray('No profiles configured.')) + console.log('') + console.log(chalk.gray('Create a profile with: humanlayer thoughts profile create ')) + } else { + console.log(chalk.yellow(`Profiles (${Object.keys(config.profiles).length}):`)) + console.log('') + + Object.entries(config.profiles).forEach(([name, profile]) => { + console.log(chalk.cyan(` ${name}:`)) + console.log(` Thoughts repository: ${profile.thoughtsRepo}`) + console.log(` Repos directory: ${profile.reposDir}`) + console.log(` Global directory: ${profile.globalDir}`) + console.log('') + }) + } + } catch (error) { + console.error(chalk.red(`Error listing profiles: ${error}`)) + process.exit(1) + } +} diff --git a/hlyr/src/commands/thoughts/profile/show.ts b/hlyr/src/commands/thoughts/profile/show.ts new file mode 100644 index 000000000..73d4f9aec --- /dev/null +++ b/hlyr/src/commands/thoughts/profile/show.ts @@ -0,0 +1,62 @@ +import chalk from 'chalk' +import { loadThoughtsConfig, validateProfile } from '../../../thoughtsConfig.js' + +interface ShowOptions { + json?: boolean + configFile?: string +} + +export async function profileShowCommand(profileName: string, options: ShowOptions): Promise { + try { + const config = loadThoughtsConfig(options) + + if (!config) { + console.error(chalk.red('Error: Thoughts not configured.')) + process.exit(1) + } + + if (!validateProfile(config, profileName)) { + console.error(chalk.red(`Error: Profile "${profileName}" not found.`)) + console.error('') + console.error(chalk.gray('Available profiles:')) + if (config.profiles) { + Object.keys(config.profiles).forEach(name => { + console.error(chalk.gray(` - ${name}`)) + }) + } else { + console.error(chalk.gray(' (none)')) + } + process.exit(1) + } + + const profile = config.profiles![profileName] + + if (options.json) { + console.log(JSON.stringify(profile, null, 2)) + return + } + + console.log(chalk.blue(`Profile: ${profileName}`)) + console.log(chalk.gray('='.repeat(50))) + console.log('') + console.log(chalk.yellow('Configuration:')) + console.log(` Thoughts repository: ${chalk.cyan(profile.thoughtsRepo)}`) + console.log(` Repos directory: ${chalk.cyan(profile.reposDir)}`) + console.log(` Global directory: ${chalk.cyan(profile.globalDir)}`) + console.log('') + + // Count repositories using this profile + let repoCount = 0 + Object.values(config.repoMappings).forEach(mapping => { + if (typeof mapping === 'object' && mapping.profile === profileName) { + repoCount++ + } + }) + + console.log(chalk.yellow('Usage:')) + console.log(` Repositories using this profile: ${chalk.cyan(repoCount)}`) + } catch (error) { + console.error(chalk.red(`Error showing profile: ${error}`)) + process.exit(1) + } +} diff --git a/hlyr/src/commands/thoughts/status.ts b/hlyr/src/commands/thoughts/status.ts index 5407888b1..337f2933c 100644 --- a/hlyr/src/commands/thoughts/status.ts +++ b/hlyr/src/commands/thoughts/status.ts @@ -2,7 +2,14 @@ import fs from 'fs' import path from 'path' import { execSync } from 'child_process' import chalk from 'chalk' -import { loadThoughtsConfig, getCurrentRepoPath, expandPath } from '../../thoughtsConfig.js' +import { + loadThoughtsConfig, + getCurrentRepoPath, + expandPath, + getRepoNameFromMapping, + getProfileNameFromMapping, + resolveProfileForRepo, +} from '../../thoughtsConfig.js' function getGitStatus(repoPath: string): string { try { @@ -144,11 +151,21 @@ export async function thoughtsStatusCommand(options: StatusOptions): Promise { process.exit(1) } - // Get current repo mapping - const mappedName = config.repoMappings[currentRepo] + // Get current repo mapping and resolve profile + const mapping = config.repoMappings[currentRepo] + const mappedName = getRepoNameFromMapping(mapping) + const profileConfig = resolveProfileForRepo(config, currentRepo) + if (mappedName) { - // Update symlinks for any new users - const newUsers = updateSymlinksForNewUsers( - currentRepo, - config.thoughtsRepo, - config.reposDir, - mappedName, - config.user, - ) + // Update symlinks for any new users using profile config + const newUsers = updateSymlinksForNewUsers(currentRepo, profileConfig, mappedName, config.user) if (newUsers.length > 0) { console.log(chalk.green(`✓ Added symlinks for new users: ${newUsers.join(', ')}`)) @@ -234,9 +233,9 @@ export async function thoughtsSyncCommand(options: SyncOptions): Promise { console.log(chalk.blue('Creating searchable index...')) createSearchDirectory(thoughtsDir) - // Sync the thoughts repository + // Sync the thoughts repository using profile's thoughtsRepo console.log(chalk.blue('Syncing thoughts...')) - syncThoughts(config.thoughtsRepo, options.message || '') + syncThoughts(profileConfig.thoughtsRepo, options.message || '') } catch (error) { console.error(chalk.red(`Error during thoughts sync: ${error}`)) process.exit(1) diff --git a/hlyr/src/commands/thoughts/uninit.ts b/hlyr/src/commands/thoughts/uninit.ts index 6415a6c08..158d835a8 100644 --- a/hlyr/src/commands/thoughts/uninit.ts +++ b/hlyr/src/commands/thoughts/uninit.ts @@ -2,7 +2,13 @@ import fs from 'fs' import path from 'path' import { execSync } from 'child_process' import chalk from 'chalk' -import { loadThoughtsConfig, saveThoughtsConfig, getCurrentRepoPath } from '../../thoughtsConfig.js' +import { + loadThoughtsConfig, + saveThoughtsConfig, + getCurrentRepoPath, + getRepoNameFromMapping, + getProfileNameFromMapping, +} from '../../thoughtsConfig.js' interface UninitOptions { force?: boolean @@ -27,7 +33,10 @@ export async function thoughtsUninitCommand(options: UninitOptions): Promise + repoMappings: Record + profiles?: Record } } diff --git a/hlyr/src/thoughtsConfig.test.ts b/hlyr/src/thoughtsConfig.test.ts new file mode 100644 index 000000000..baabfe7bf --- /dev/null +++ b/hlyr/src/thoughtsConfig.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect } from 'vitest' +import { + resolveProfileForRepo, + getRepoNameFromMapping, + getProfileNameFromMapping, + sanitizeProfileName, + validateProfile, + type ThoughtsConfig, +} from './thoughtsConfig.js' +import type { RepoMappingObject } from './config.js' + +describe('Profile Resolution', () => { + const mockConfig: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: { + '/path/to/legacy': 'legacy-repo', + '/path/to/default-obj': { repo: 'default-repo' }, + '/path/to/profiled': { repo: 'profiled-repo', profile: 'personal' }, + '/path/to/invalid-profile': { repo: 'test-repo', profile: 'nonexistent' }, + }, + profiles: { + personal: { + thoughtsRepo: '~/thoughts-personal', + reposDir: 'repos', + globalDir: 'global', + }, + work: { + thoughtsRepo: '~/thoughts-work', + reposDir: 'projects', + globalDir: 'shared', + }, + }, + } + + describe('resolveProfileForRepo()', () => { + it('should resolve default config for string mappings', () => { + const result = resolveProfileForRepo(mockConfig, '/path/to/legacy') + + expect(result.thoughtsRepo).toBe('~/thoughts') + expect(result.reposDir).toBe('repos') + expect(result.globalDir).toBe('global') + expect(result.profileName).toBeUndefined() + }) + + it('should resolve default config for object mapping without profile', () => { + const result = resolveProfileForRepo(mockConfig, '/path/to/default-obj') + + expect(result.thoughtsRepo).toBe('~/thoughts') + expect(result.reposDir).toBe('repos') + expect(result.globalDir).toBe('global') + expect(result.profileName).toBeUndefined() + }) + + it('should resolve profile config for object mapping with valid profile', () => { + const result = resolveProfileForRepo(mockConfig, '/path/to/profiled') + + expect(result.thoughtsRepo).toBe('~/thoughts-personal') + expect(result.reposDir).toBe('repos') + expect(result.globalDir).toBe('global') + expect(result.profileName).toBe('personal') + }) + + it('should fall back to default for object mapping with invalid profile', () => { + const result = resolveProfileForRepo(mockConfig, '/path/to/invalid-profile') + + expect(result.thoughtsRepo).toBe('~/thoughts') + expect(result.reposDir).toBe('repos') + expect(result.globalDir).toBe('global') + expect(result.profileName).toBeUndefined() + }) + + it('should return default config for unmapped repositories', () => { + const result = resolveProfileForRepo(mockConfig, '/path/to/unmapped') + + expect(result.thoughtsRepo).toBe('~/thoughts') + expect(result.reposDir).toBe('repos') + expect(result.globalDir).toBe('global') + expect(result.profileName).toBeUndefined() + }) + + it('should handle profile with different directory names', () => { + const result = resolveProfileForRepo(mockConfig, '/path/to/work-project') + + // Add work project mapping + mockConfig.repoMappings['/path/to/work-project'] = { repo: 'work-proj', profile: 'work' } + const workResult = resolveProfileForRepo(mockConfig, '/path/to/work-project') + + expect(workResult.thoughtsRepo).toBe('~/thoughts-work') + expect(workResult.reposDir).toBe('projects') + expect(workResult.globalDir).toBe('shared') + expect(workResult.profileName).toBe('work') + }) + + it('should handle config without profiles field', () => { + const configWithoutProfiles: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: { + '/path/to/repo': 'repo-name', + }, + } + + const result = resolveProfileForRepo(configWithoutProfiles, '/path/to/repo') + + expect(result.thoughtsRepo).toBe('~/thoughts') + expect(result.profileName).toBeUndefined() + }) + }) + + describe('getRepoNameFromMapping()', () => { + it('should extract repo name from string mapping', () => { + expect(getRepoNameFromMapping('test-repo')).toBe('test-repo') + }) + + it('should extract repo name from object mapping', () => { + const mapping: RepoMappingObject = { repo: 'test-repo', profile: 'personal' } + expect(getRepoNameFromMapping(mapping)).toBe('test-repo') + }) + + it('should extract repo name from object mapping without profile', () => { + const mapping: RepoMappingObject = { repo: 'test-repo' } + expect(getRepoNameFromMapping(mapping)).toBe('test-repo') + }) + + it('should return undefined for undefined mapping', () => { + expect(getRepoNameFromMapping(undefined)).toBeUndefined() + }) + + it('should return undefined for null mapping', () => { + expect(getRepoNameFromMapping(null as any)).toBeUndefined() + }) + }) + + describe('getProfileNameFromMapping()', () => { + it('should extract profile name from object mapping', () => { + const mapping: RepoMappingObject = { repo: 'test', profile: 'personal' } + expect(getProfileNameFromMapping(mapping)).toBe('personal') + }) + + it('should return undefined for string mapping', () => { + expect(getProfileNameFromMapping('test-repo')).toBeUndefined() + }) + + it('should return undefined for object mapping without profile', () => { + const mapping: RepoMappingObject = { repo: 'test' } + expect(getProfileNameFromMapping(mapping)).toBeUndefined() + }) + + it('should return undefined for undefined mapping', () => { + expect(getProfileNameFromMapping(undefined)).toBeUndefined() + }) + + it('should return undefined for null mapping', () => { + expect(getProfileNameFromMapping(null as any)).toBeUndefined() + }) + }) + + describe('validateProfile()', () => { + it('should return true for existing profile', () => { + expect(validateProfile(mockConfig, 'personal')).toBe(true) + expect(validateProfile(mockConfig, 'work')).toBe(true) + }) + + it('should return false for non-existent profile', () => { + expect(validateProfile(mockConfig, 'nonexistent')).toBe(false) + }) + + it('should return false when profiles field is undefined', () => { + const configWithoutProfiles: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: {}, + } + + expect(validateProfile(configWithoutProfiles, 'personal')).toBe(false) + }) + + it('should return false when profiles object is empty', () => { + const configWithEmptyProfiles: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: {}, + profiles: {}, + } + + expect(validateProfile(configWithEmptyProfiles, 'personal')).toBe(false) + }) + + it('should be case-sensitive', () => { + expect(validateProfile(mockConfig, 'Personal')).toBe(false) + expect(validateProfile(mockConfig, 'PERSONAL')).toBe(false) + }) + }) + + describe('sanitizeProfileName()', () => { + it('should keep valid profile names unchanged', () => { + expect(sanitizeProfileName('client-acme')).toBe('client-acme') + expect(sanitizeProfileName('personal')).toBe('personal') + expect(sanitizeProfileName('work_2024')).toBe('work_2024') + expect(sanitizeProfileName('Project-123')).toBe('Project-123') + }) + + it('should replace spaces with underscores', () => { + expect(sanitizeProfileName('client acme')).toBe('client_acme') + expect(sanitizeProfileName('my project')).toBe('my_project') + }) + + it('should replace special characters with underscores', () => { + expect(sanitizeProfileName('client@acme')).toBe('client_acme') + expect(sanitizeProfileName('client/acme')).toBe('client_acme') + expect(sanitizeProfileName('client.acme')).toBe('client_acme') + expect(sanitizeProfileName('client#acme')).toBe('client_acme') + }) + + it('should handle multiple consecutive special characters', () => { + expect(sanitizeProfileName('client@@acme')).toBe('client__acme') + expect(sanitizeProfileName('client///acme')).toBe('client___acme') + }) + + it('should preserve allowed characters (alphanumeric, underscore, hyphen)', () => { + expect(sanitizeProfileName('abc123-_XYZ')).toBe('abc123-_XYZ') + }) + + it('should handle empty string', () => { + expect(sanitizeProfileName('')).toBe('') + }) + + it('should handle strings with only special characters', () => { + expect(sanitizeProfileName('@@@')).toBe('___') + expect(sanitizeProfileName('...')).toBe('___') + }) + }) + + describe('Backward Compatibility', () => { + it('should handle configs with only string mappings', () => { + const legacyConfig: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: { + '/repo1': 'repo1', + '/repo2': 'repo2', + }, + } + + const result1 = resolveProfileForRepo(legacyConfig, '/repo1') + const result2 = resolveProfileForRepo(legacyConfig, '/repo2') + + expect(result1.thoughtsRepo).toBe('~/thoughts') + expect(result1.profileName).toBeUndefined() + expect(result2.thoughtsRepo).toBe('~/thoughts') + expect(result2.profileName).toBeUndefined() + }) + + it('should handle mixed string and object mappings', () => { + const mixedConfig: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: { + '/legacy-repo': 'legacy', + '/new-repo': { repo: 'new', profile: 'personal' }, + }, + profiles: { + personal: { + thoughtsRepo: '~/thoughts-personal', + reposDir: 'repos', + globalDir: 'global', + }, + }, + } + + const legacyResult = resolveProfileForRepo(mixedConfig, '/legacy-repo') + const newResult = resolveProfileForRepo(mixedConfig, '/new-repo') + + expect(legacyResult.thoughtsRepo).toBe('~/thoughts') + expect(legacyResult.profileName).toBeUndefined() + expect(newResult.thoughtsRepo).toBe('~/thoughts-personal') + expect(newResult.profileName).toBe('personal') + }) + }) + + describe('Edge Cases', () => { + it('should handle profile name with special characters needing sanitization', () => { + const sanitized = sanitizeProfileName('client@2024!') + expect(sanitized).toBe('client_2024_') + + // Verify it can be used as a valid profile name + const configWithSanitizedProfile: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: { + '/repo': { repo: 'test', profile: sanitized }, + }, + profiles: { + [sanitized]: { + thoughtsRepo: '~/thoughts-client', + reposDir: 'repos', + globalDir: 'global', + }, + }, + } + + const result = resolveProfileForRepo(configWithSanitizedProfile, '/repo') + expect(result.profileName).toBe(sanitized) + }) + + it('should handle repo path with trailing slashes', () => { + const result = resolveProfileForRepo(mockConfig, '/path/to/legacy/') + // Should not match because keys are exact + expect(result.profileName).toBeUndefined() + expect(result.thoughtsRepo).toBe('~/thoughts') + }) + + it('should handle object mapping with undefined profile field', () => { + const mapping: RepoMappingObject = { repo: 'test', profile: undefined } + const configWithUndefined: ThoughtsConfig = { + thoughtsRepo: '~/thoughts', + reposDir: 'repos', + globalDir: 'global', + user: 'test', + repoMappings: { + '/test': mapping, + }, + profiles: { + personal: { + thoughtsRepo: '~/thoughts-personal', + reposDir: 'repos', + globalDir: 'global', + }, + }, + } + + const result = resolveProfileForRepo(configWithUndefined, '/test') + expect(result.thoughtsRepo).toBe('~/thoughts') + expect(result.profileName).toBeUndefined() + }) + }) +}) diff --git a/hlyr/src/thoughtsConfig.ts b/hlyr/src/thoughtsConfig.ts index a056cc4c2..5e3988b11 100644 --- a/hlyr/src/thoughtsConfig.ts +++ b/hlyr/src/thoughtsConfig.ts @@ -3,13 +3,22 @@ import path from 'path' import os from 'os' import { execSync } from 'child_process' import { ConfigResolver, saveConfigFile } from './config.js' +import type { RepoMappingObject, ProfileConfig } from './config.js' export interface ThoughtsConfig { thoughtsRepo: string reposDir: string // Directory name within thoughtsRepo (e.g., "repos") globalDir: string // Directory name within thoughtsRepo (e.g., "global") user: string - repoMappings: Record + repoMappings: Record + profiles?: Record +} + +export interface ResolvedProfileConfig { + thoughtsRepo: string + reposDir: string + globalDir: string + profileName?: string // undefined for default config } export function loadThoughtsConfig(options: Record = {}): ThoughtsConfig | null { @@ -37,11 +46,34 @@ export function expandPath(filePath: string): string { return path.resolve(filePath) } +// Overloaded signatures for ensureThoughtsRepoExists +export function ensureThoughtsRepoExists(config: ResolvedProfileConfig): void export function ensureThoughtsRepoExists( thoughtsRepo: string, reposDir: string, globalDir: string, +): void +export function ensureThoughtsRepoExists( + configOrThoughtsRepo: ResolvedProfileConfig | string, + reposDir?: string, + globalDir?: string, ): void { + let thoughtsRepo: string + let effectiveReposDir: string + let effectiveGlobalDir: string + + if (typeof configOrThoughtsRepo === 'string') { + // Legacy signature: (thoughtsRepo, reposDir, globalDir) + thoughtsRepo = configOrThoughtsRepo + effectiveReposDir = reposDir! + effectiveGlobalDir = globalDir! + } else { + // New signature: (config) + thoughtsRepo = configOrThoughtsRepo.thoughtsRepo + effectiveReposDir = configOrThoughtsRepo.reposDir + effectiveGlobalDir = configOrThoughtsRepo.globalDir + } + const expandedRepo = expandPath(thoughtsRepo) // Create thoughts repo if it doesn't exist @@ -50,8 +82,8 @@ export function ensureThoughtsRepoExists( } // Create subdirectories - const expandedRepos = path.join(expandedRepo, reposDir) - const expandedGlobal = path.join(expandedRepo, globalDir) + const expandedRepos = path.join(expandedRepo, effectiveReposDir) + const expandedGlobal = path.join(expandedRepo, effectiveGlobalDir) if (!fs.existsSync(expandedRepos)) { fs.mkdirSync(expandedRepos, { recursive: true }) @@ -94,12 +126,39 @@ Thumbs.db } } -export function getRepoThoughtsPath(thoughtsRepo: string, reposDir: string, repoName: string): string { - return path.join(expandPath(thoughtsRepo), reposDir, repoName) +// Overloaded signatures for getRepoThoughtsPath +export function getRepoThoughtsPath(config: ResolvedProfileConfig, repoName: string): string +export function getRepoThoughtsPath(thoughtsRepo: string, reposDir: string, repoName: string): string +export function getRepoThoughtsPath( + thoughtsRepoOrConfig: string | ResolvedProfileConfig, + reposDirOrRepoName: string, + repoName?: string, +): string { + if (typeof thoughtsRepoOrConfig === 'string') { + // Legacy signature: (thoughtsRepo, reposDir, repoName) + return path.join(expandPath(thoughtsRepoOrConfig), reposDirOrRepoName, repoName!) + } + + // New signature: (config, repoName) + const config = thoughtsRepoOrConfig + return path.join(expandPath(config.thoughtsRepo), config.reposDir, reposDirOrRepoName) } -export function getGlobalThoughtsPath(thoughtsRepo: string, globalDir: string): string { - return path.join(expandPath(thoughtsRepo), globalDir) +// Overloaded signatures for getGlobalThoughtsPath +export function getGlobalThoughtsPath(config: ResolvedProfileConfig): string +export function getGlobalThoughtsPath(thoughtsRepo: string, globalDir: string): string +export function getGlobalThoughtsPath( + thoughtsRepoOrConfig: string | ResolvedProfileConfig, + globalDir?: string, +): string { + if (typeof thoughtsRepoOrConfig === 'string') { + // Legacy signature: (thoughtsRepo, globalDir) + return path.join(expandPath(thoughtsRepoOrConfig), globalDir!) + } + + // New signature: (config) + const config = thoughtsRepoOrConfig + return path.join(expandPath(config.thoughtsRepo), config.globalDir) } export function getCurrentRepoPath(): string { @@ -112,21 +171,144 @@ export function getRepoNameFromPath(repoPath: string): string { return parts[parts.length - 1] || 'unnamed_repo' } +/** + * Resolves the profile config for a given repository path + * Returns default config if no profile specified or profile not found + */ +export function resolveProfileForRepo(config: ThoughtsConfig, repoPath: string): ResolvedProfileConfig { + const mapping = config.repoMappings[repoPath] + + // Handle string format (legacy - no profile) + if (typeof mapping === 'string') { + return { + thoughtsRepo: config.thoughtsRepo, + reposDir: config.reposDir, + globalDir: config.globalDir, + profileName: undefined, + } + } + + // Handle object format + if (mapping && typeof mapping === 'object') { + const profileName = mapping.profile + + // If profile specified, look it up + if (profileName && config.profiles && config.profiles[profileName]) { + const profile = config.profiles[profileName] + return { + thoughtsRepo: profile.thoughtsRepo, + reposDir: profile.reposDir, + globalDir: profile.globalDir, + profileName, + } + } + + // Object format but no profile or profile not found - use default + return { + thoughtsRepo: config.thoughtsRepo, + reposDir: config.reposDir, + globalDir: config.globalDir, + profileName: undefined, + } + } + + // No mapping - use default + return { + thoughtsRepo: config.thoughtsRepo, + reposDir: config.reposDir, + globalDir: config.globalDir, + profileName: undefined, + } +} + +/** + * Gets the repo name from a mapping (handles both string and object formats) + */ +export function getRepoNameFromMapping( + mapping: string | RepoMappingObject | undefined, +): string | undefined { + if (!mapping) return undefined + if (typeof mapping === 'string') return mapping + return mapping.repo +} + +/** + * Gets the profile name from a mapping (returns undefined for string format) + */ +export function getProfileNameFromMapping( + mapping: string | RepoMappingObject | undefined, +): string | undefined { + if (!mapping) return undefined + if (typeof mapping === 'string') return undefined + return mapping.profile +} + +/** + * Validates that a profile exists in the configuration + */ +export function validateProfile(config: ThoughtsConfig, profileName: string): boolean { + return !!(config.profiles && config.profiles[profileName]) +} + +/** + * Sanitizes profile name (same rules as directory names) + */ +export function sanitizeProfileName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '_') +} + +// Overloaded signatures for createThoughtsDirectoryStructure +export function createThoughtsDirectoryStructure( + config: ResolvedProfileConfig, + repoName: string, + user: string, +): void export function createThoughtsDirectoryStructure( thoughtsRepo: string, reposDir: string, globalDir: string, repoName: string, user: string, +): void +export function createThoughtsDirectoryStructure( + configOrThoughtsRepo: ResolvedProfileConfig | string, + reposDirOrRepoName: string, + globalDirOrUser: string, + repoName?: string, + user?: string, ): void { + let resolvedConfig: { thoughtsRepo: string; reposDir: string; globalDir: string } + let effectiveRepoName: string + let effectiveUser: string + + if (typeof configOrThoughtsRepo === 'string') { + // Legacy signature: (thoughtsRepo, reposDir, globalDir, repoName, user) + resolvedConfig = { + thoughtsRepo: configOrThoughtsRepo, + reposDir: reposDirOrRepoName, + globalDir: globalDirOrUser, + } + effectiveRepoName = repoName! + effectiveUser = user! + } else { + // New signature: (config, repoName, user) + resolvedConfig = configOrThoughtsRepo + effectiveRepoName = reposDirOrRepoName + effectiveUser = globalDirOrUser + } + // Create repo-specific directories - const repoThoughtsPath = getRepoThoughtsPath(thoughtsRepo, reposDir, repoName) - const repoUserPath = path.join(repoThoughtsPath, user) + const repoThoughtsPath = getRepoThoughtsPath( + resolvedConfig.thoughtsRepo, + resolvedConfig.reposDir, + effectiveRepoName, + ) + const repoUserPath = path.join(repoThoughtsPath, effectiveUser) const repoSharedPath = path.join(repoThoughtsPath, 'shared') // Create global directories - const globalPath = getGlobalThoughtsPath(thoughtsRepo, globalDir) - const globalUserPath = path.join(globalPath, user) + const globalPath = getGlobalThoughtsPath(resolvedConfig.thoughtsRepo, resolvedConfig.globalDir) + const globalUserPath = path.join(globalPath, effectiveUser) const globalSharedPath = path.join(globalPath, 'shared') // Create all directories @@ -137,11 +319,11 @@ export function createThoughtsDirectoryStructure( } // Create initial README files - const repoReadme = `# ${repoName} Thoughts + const repoReadme = `# ${effectiveRepoName} Thoughts -This directory contains thoughts and notes specific to the ${repoName} repository. +This directory contains thoughts and notes specific to the ${effectiveRepoName} repository. -- \`${user}/\` - Your personal notes for this repository +- \`${effectiveUser}/\` - Your personal notes for this repository - \`shared/\` - Team-shared notes for this repository ` @@ -149,7 +331,7 @@ This directory contains thoughts and notes specific to the ${repoName} repositor This directory contains thoughts and notes that apply across all repositories. -- \`${user}/\` - Your personal cross-repository notes +- \`${effectiveUser}/\` - Your personal cross-repository notes - \`shared/\` - Team-shared cross-repository notes ` @@ -162,15 +344,52 @@ This directory contains thoughts and notes that apply across all repositories. } } +// Overloaded signatures for updateSymlinksForNewUsers +export function updateSymlinksForNewUsers( + currentRepoPath: string, + config: ResolvedProfileConfig, + repoName: string, + currentUser: string, +): string[] export function updateSymlinksForNewUsers( currentRepoPath: string, thoughtsRepo: string, reposDir: string, repoName: string, currentUser: string, +): string[] +export function updateSymlinksForNewUsers( + currentRepoPath: string, + configOrThoughtsRepo: ResolvedProfileConfig | string, + reposDirOrRepoName: string, + repoNameOrCurrentUser: string, + currentUser?: string, ): string[] { + let resolvedConfig: { thoughtsRepo: string; reposDir: string } + let effectiveRepoName: string + let effectiveUser: string + + if (typeof configOrThoughtsRepo === 'string') { + // Legacy signature: (currentRepoPath, thoughtsRepo, reposDir, repoName, currentUser) + resolvedConfig = { + thoughtsRepo: configOrThoughtsRepo, + reposDir: reposDirOrRepoName, + } + effectiveRepoName = repoNameOrCurrentUser + effectiveUser = currentUser! + } else { + // New signature: (currentRepoPath, config, repoName, currentUser) + resolvedConfig = configOrThoughtsRepo + effectiveRepoName = reposDirOrRepoName + effectiveUser = repoNameOrCurrentUser + } + const thoughtsDir = path.join(currentRepoPath, 'thoughts') - const repoThoughtsPath = getRepoThoughtsPath(thoughtsRepo, reposDir, repoName) + const repoThoughtsPath = getRepoThoughtsPath( + resolvedConfig.thoughtsRepo, + resolvedConfig.reposDir, + effectiveRepoName, + ) const addedSymlinks: string[] = [] if (!fs.existsSync(thoughtsDir) || !fs.existsSync(repoThoughtsPath)) { @@ -189,7 +408,7 @@ export function updateSymlinksForNewUsers( const targetPath = path.join(repoThoughtsPath, userName) // Skip if symlink already exists or if it's the current user (already handled) - if (!fs.existsSync(symlinkPath) && userName !== currentUser) { + if (!fs.existsSync(symlinkPath) && userName !== effectiveUser) { try { fs.symlinkSync(targetPath, symlinkPath, 'dir') addedSymlinks.push(userName)