From 3535fcec878d7610ce91fa0c255bd156a8d53f32 Mon Sep 17 00:00:00 2001 From: Ryan Rozich Date: Thu, 13 Nov 2025 18:24:32 -0600 Subject: [PATCH 1/5] feat: Add type system and core resolution logic for multi-profile thoughts support - Add RepoMappingObject and ProfileConfig types to support profile-based configuration - Extend ConfigFile type to include optional profiles field - Update ThoughtsConfig interface to support both string and object mapping formats - Add ResolvedProfileConfig interface for profile resolution results - Implement resolveProfileForRepo() function for automatic profile detection - Add helper functions for mapping format conversion and profile validation - Update all path resolution functions with function overloads for backward compatibility Refs: #843 --- hlyr/src/config.ts | 14 +- hlyr/src/thoughtsConfig.ts | 253 ++++++++++++++++++++++++++++++++++--- 2 files changed, 249 insertions(+), 18 deletions(-) diff --git a/hlyr/src/config.ts b/hlyr/src/config.ts index 2516343a5..16016d89e 100644 --- a/hlyr/src/config.ts +++ b/hlyr/src/config.ts @@ -7,6 +7,17 @@ import { getInvocationName, getDefaultSocketPath } from './utils/invocation.js' // Load environment variables dotenv.config() +export type RepoMappingObject = { + repo: string + profile?: string +} + +export type ProfileConfig = { + thoughtsRepo: string + reposDir: string + globalDir: string +} + export type ConfigFile = { www_base_url?: string daemon_socket?: string @@ -16,7 +27,8 @@ export type ConfigFile = { reposDir: string globalDir: string user: string - repoMappings: Record + repoMappings: Record + profiles?: Record } } 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) From 189bafb43802e3dc5606eff88dbfd2f03390def0 Mon Sep 17 00:00:00 2001 From: Ryan Rozich Date: Thu, 13 Nov 2025 18:24:43 -0600 Subject: [PATCH 2/5] feat: Add profile management commands - Add 'humanlayer thoughts profile create' command with interactive and non-interactive modes - Add 'humanlayer thoughts profile list' command with JSON output support - Add 'humanlayer thoughts profile show' command for viewing profile details - Add 'humanlayer thoughts profile delete' command with safety checks - Register all profile subcommands in thoughts command group - Support profile validation, sanitization, and usage tracking Users can now create and manage multiple named thoughts profiles for different organizational contexts (personal, client A, client B, etc.). Refs: #843 --- hlyr/src/commands/thoughts.ts | 38 ++++++ hlyr/src/commands/thoughts/profile/create.ts | 124 +++++++++++++++++++ hlyr/src/commands/thoughts/profile/delete.ts | 100 +++++++++++++++ hlyr/src/commands/thoughts/profile/list.ts | 55 ++++++++ hlyr/src/commands/thoughts/profile/show.ts | 62 ++++++++++ 5 files changed, 379 insertions(+) create mode 100644 hlyr/src/commands/thoughts/profile/create.ts create mode 100644 hlyr/src/commands/thoughts/profile/delete.ts create mode 100644 hlyr/src/commands/thoughts/profile/list.ts create mode 100644 hlyr/src/commands/thoughts/profile/show.ts 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/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) + } +} From 5113ee93aeef694647773590fa1d065394c19664 Mon Sep 17 00:00:00 2001 From: Ryan Rozich Date: Thu, 13 Nov 2025 18:25:02 -0600 Subject: [PATCH 3/5] feat: Update existing commands to be profile-aware - init: Add --profile option and early profile resolution (tempProfileConfig) - init: Use profile config for directory listings and path messages - init: Write object-format mappings when profile specified - sync: Resolve profile and use correct thoughtsRepo for git operations - status: Display profile information and use profile's repo for git status - config: Show profiles and handle new mapping format in display - uninit: Handle object-format mappings and show correct profile repo path All commands now automatically detect and use the correct profile based on repository mappings. Includes fix for bug where init command was using default thoughtsRepo for messages when --profile was specified. Refs: #843 --- hlyr/src/commands/thoughts/config.ts | 31 ++++++- hlyr/src/commands/thoughts/init.ts | 121 +++++++++++++++++++-------- hlyr/src/commands/thoughts/status.ts | 30 +++++-- hlyr/src/commands/thoughts/sync.ts | 23 +++-- hlyr/src/commands/thoughts/uninit.ts | 23 ++++- 5 files changed, 171 insertions(+), 57 deletions(-) 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/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 Date: Thu, 13 Nov 2025 18:25:13 -0600 Subject: [PATCH 4/5] docs: Add multi-profile thoughts repository documentation - Add comprehensive profile management section to README - Include examples of profile creation, usage, and management - Document --profile flag for init command - Explain profile features and use cases - Provide configuration examples Refs: #843 --- hlyr/README.md | 49 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) 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` From 7164607d183ffedb6fae9d7c1485f944f443d4f9 Mon Sep 17 00:00:00 2001 From: Ryan Rozich Date: Fri, 14 Nov 2025 06:33:41 -0600 Subject: [PATCH 5/5] test: Add comprehensive unit tests for profile resolution logic - Add 34 unit tests covering all profile-related functionality - Test resolveProfileForRepo() with all edge cases (string, object, invalid profiles) - Test helper functions (getRepoNameFromMapping, getProfileNameFromMapping) - Test profile validation and name sanitization - Test backward compatibility with legacy configs - All 61 tests passing (27 existing + 34 new) Coverage includes: - Profile resolution for string/object mappings - Fallback behavior for invalid profiles - Mixed legacy and new format mappings - Edge cases (undefined profiles, empty configs, special characters) - Profile name sanitization rules Refs: #843 --- hlyr/src/thoughtsConfig.test.ts | 352 ++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 hlyr/src/thoughtsConfig.test.ts 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() + }) + }) +})