mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
remove chmod restrictions from searchable thoughts directory (#264)
* Remove chmod restrictions from searchable thoughts directory The chmod 444 restrictions on hard links were fundamentally flawed - hard links share the same inode, so making them read-only also made the original files read-only. This prevented users from editing their own thoughts files. Changes: - Remove chmod commands from sync.ts that set files to 444 - Update CLAUDE.md generation to reflect files are editable - Change language from 'read-only copies' to 'hard links' - Emphasize using canonical paths for consistency, not prevention * Add thoughts uninit command and --directory option for init New features to improve thoughts workflow: - Add 'thoughts uninit' command to cleanly remove thoughts setup from a repository - Add --directory option to 'thoughts init' for non-interactive mode - Uninit removes symlinks, searchable directory, and config mapping - --directory option requires existing directory for safety - Both features support worktree automation workflows * Update worktree scripts for automatic thoughts management Improve developer experience with automatic thoughts setup/teardown: - create_worktree.sh: Auto-run 'thoughts init --directory humanlayer' and sync - cleanup_worktree.sh: Use 'thoughts uninit' with manual fallback - Both scripts handle cases where humanlayer command is not available - Ensures thoughts are ready immediately in new worktrees - Prevents permission issues during worktree cleanup * Refactor directory name sanitization and improve error messaging Address PR feedback for the --directory option: - Extract directory name sanitization into a helper function - Improve error message to clarify that only pre-existing directories are allowed - Change 'Existing directories' to 'Available directories' for better clarity
This commit is contained in:
@@ -45,21 +45,48 @@ cleanup_worktree() {
|
||||
if [ -d "$worktree_path/thoughts" ]; then
|
||||
echo "Found thoughts directory, cleaning up..."
|
||||
|
||||
# Reset permissions on searchable directory if it exists
|
||||
if [ -d "$worktree_path/thoughts/searchable" ]; then
|
||||
echo "Resetting permissions on thoughts/searchable..."
|
||||
chmod -R 755 "$worktree_path/thoughts/searchable" 2>/dev/null || {
|
||||
echo -e "${YELLOW}Warning: Could not reset all permissions, but continuing...${NC}"
|
||||
# Try to use humanlayer uninit command first
|
||||
if command -v humanlayer >/dev/null 2>&1; then
|
||||
echo "Running humanlayer thoughts uninit..."
|
||||
(cd "$worktree_path" && humanlayer thoughts uninit --force) || {
|
||||
echo -e "${YELLOW}Warning: humanlayer uninit failed, falling back to manual cleanup${NC}"
|
||||
|
||||
# Fallback: Reset permissions on searchable directory if it exists
|
||||
if [ -d "$worktree_path/thoughts/searchable" ]; then
|
||||
echo "Resetting permissions on thoughts/searchable..."
|
||||
chmod -R 755 "$worktree_path/thoughts/searchable" 2>/dev/null || {
|
||||
echo -e "${YELLOW}Warning: Could not reset all permissions, but continuing...${NC}"
|
||||
}
|
||||
fi
|
||||
|
||||
# Remove the entire thoughts directory
|
||||
echo "Removing thoughts directory..."
|
||||
rm -rf "$worktree_path/thoughts" || {
|
||||
echo -e "${RED}Error: Could not remove thoughts directory${NC}"
|
||||
echo "You may need to manually run: sudo rm -rf $worktree_path/thoughts"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
else
|
||||
# No humanlayer command available, do manual cleanup
|
||||
echo "humanlayer command not found, using manual cleanup..."
|
||||
|
||||
# Reset permissions on searchable directory if it exists
|
||||
if [ -d "$worktree_path/thoughts/searchable" ]; then
|
||||
echo "Resetting permissions on thoughts/searchable..."
|
||||
chmod -R 755 "$worktree_path/thoughts/searchable" 2>/dev/null || {
|
||||
echo -e "${YELLOW}Warning: Could not reset all permissions, but continuing...${NC}"
|
||||
}
|
||||
fi
|
||||
|
||||
# Remove the entire thoughts directory
|
||||
echo "Removing thoughts directory..."
|
||||
rm -rf "$worktree_path/thoughts" || {
|
||||
echo -e "${RED}Error: Could not remove thoughts directory${NC}"
|
||||
echo "You may need to manually run: sudo rm -rf $worktree_path/thoughts"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Remove the entire thoughts directory
|
||||
echo "Removing thoughts directory..."
|
||||
rm -rf "$worktree_path/thoughts" || {
|
||||
echo -e "${RED}Error: Could not remove thoughts directory${NC}"
|
||||
echo "You may need to manually run: sudo rm -rf $worktree_path/thoughts"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Step 2: Remove the worktree
|
||||
|
||||
@@ -94,6 +94,21 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Initialize thoughts (non-interactive mode with hardcoded directory)
|
||||
echo "🧠 Initializing thoughts..."
|
||||
cd "$WORKTREE_PATH"
|
||||
if humanlayer thoughts init --directory humanlayer > /dev/null 2>&1; then
|
||||
echo "✅ Thoughts initialized!"
|
||||
# Run sync to create searchable directory
|
||||
if humanlayer thoughts sync > /dev/null 2>&1; then
|
||||
echo "✅ Thoughts searchable index created!"
|
||||
else
|
||||
echo "⚠️ Could not create searchable index. Run 'humanlayer thoughts sync' manually."
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Could not initialize thoughts automatically. Run 'humanlayer thoughts init' manually."
|
||||
fi
|
||||
|
||||
# Return to original directory
|
||||
cd - > /dev/null
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from 'commander'
|
||||
import { thoughtsInitCommand } from './thoughts/init.js'
|
||||
import { thoughtsUninitCommand } from './thoughts/uninit.js'
|
||||
import { thoughtsSyncCommand } from './thoughts/sync.js'
|
||||
import { thoughtsStatusCommand } from './thoughts/status.js'
|
||||
import { thoughtsConfigCommand } from './thoughts/config.js'
|
||||
@@ -12,8 +13,16 @@ export function thoughtsCommand(program: Command): void {
|
||||
.description('Initialize thoughts for current repository')
|
||||
.option('--force', 'Force reconfiguration even if already set up')
|
||||
.option('--config-file <path>', 'Path to config file')
|
||||
.option('--directory <name>', 'Specify the repository directory name (skips interactive prompt)')
|
||||
.action(thoughtsInitCommand)
|
||||
|
||||
thoughts
|
||||
.command('uninit')
|
||||
.description('Remove thoughts setup from current repository')
|
||||
.option('--force', 'Force removal even if not in configuration')
|
||||
.option('--config-file <path>', 'Path to config file')
|
||||
.action(thoughtsUninitCommand)
|
||||
|
||||
thoughts
|
||||
.command('sync')
|
||||
.description('Manually sync thoughts to thoughts repository')
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
interface InitOptions {
|
||||
force?: boolean
|
||||
configFile?: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function sanitizeDirectoryName(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
@@ -137,21 +142,21 @@ It is managed by the HumanLayer thoughts system and should not be committed to t
|
||||
- \`global/\` → Cross-repository thoughts (symlink to ${globalPath})
|
||||
- \`${user}/\` - Your personal notes that apply across all repositories
|
||||
- \`shared/\` - Team-shared notes that apply across all repositories
|
||||
- \`searchable/\` → Read-only hard links for searching (auto-generated)
|
||||
- \`searchable/\` → Hard links for searching (auto-generated)
|
||||
|
||||
## Searching in Thoughts
|
||||
|
||||
The \`searchable/\` directory contains read-only hard links to all thoughts files accessible in this repository. This allows search tools to find content without following symlinks.
|
||||
The \`searchable/\` directory contains hard links to all thoughts files accessible in this repository. This allows search tools to find content without following symlinks.
|
||||
|
||||
**IMPORTANT**:
|
||||
- Files found in \`thoughts/searchable/\` are read-only copies
|
||||
- To edit any file, use the original path (e.g., edit \`thoughts/${user}/todo.md\`, not \`thoughts/searchable/${user}/todo.md\`)
|
||||
- Files in \`thoughts/searchable/\` are hard links to the original files (editing either updates both)
|
||||
- For clarity and consistency, always reference files by their canonical path (e.g., \`thoughts/${user}/todo.md\`, not \`thoughts/searchable/${user}/todo.md\`)
|
||||
- The \`searchable/\` directory is automatically updated when you run \`humanlayer thoughts sync\`
|
||||
|
||||
This design ensures that:
|
||||
1. Search tools can find all your thoughts content easily
|
||||
2. The symlink structure remains intact for git operations
|
||||
3. You can't accidentally edit the wrong copy of a file
|
||||
3. Files remain editable while maintaining consistent path references
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -450,28 +455,78 @@ export async function thoughtsInitCommand(options: InitOptions): Promise<void> {
|
||||
let mappedName = config.repoMappings[currentRepo]
|
||||
|
||||
if (!mappedName) {
|
||||
console.log(chalk.blue('=== Repository Setup ==='))
|
||||
console.log('')
|
||||
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}/`),
|
||||
)
|
||||
console.log(chalk.gray('to store thoughts specific to this repository.'))
|
||||
console.log('')
|
||||
if (options.directory) {
|
||||
// Non-interactive mode with --directory option
|
||||
const sanitizedDir = sanitizeDirectoryName(options.directory)
|
||||
|
||||
if (existingRepos.length > 0) {
|
||||
console.log('Select or create a thoughts directory for this repository:')
|
||||
const options = [
|
||||
...existingRepos.map(repo => `Use existing: ${repo}`),
|
||||
'→ Create new directory',
|
||||
]
|
||||
const selection = await selectFromList('', options)
|
||||
if (!existingRepos.includes(sanitizedDir)) {
|
||||
console.error(
|
||||
chalk.red(`Error: Directory "${sanitizedDir}" not found in thoughts repository.`),
|
||||
)
|
||||
console.error(
|
||||
chalk.red('In non-interactive mode (--directory), you must specify a directory'),
|
||||
)
|
||||
console.error(chalk.red('name that already exists in the thoughts repository.'))
|
||||
console.error('')
|
||||
console.error(chalk.yellow('Available directories:'))
|
||||
existingRepos.forEach(repo => console.error(chalk.gray(` - ${repo}`)))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (selection === options.length - 1) {
|
||||
// Create new
|
||||
mappedName = sanitizedDir
|
||||
console.log(
|
||||
chalk.green(`✓ Using existing: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`),
|
||||
)
|
||||
} else {
|
||||
// Interactive mode
|
||||
console.log(chalk.blue('=== Repository Setup ==='))
|
||||
console.log('')
|
||||
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}/`),
|
||||
)
|
||||
console.log(chalk.gray('to store thoughts specific to this repository.'))
|
||||
console.log('')
|
||||
|
||||
if (existingRepos.length > 0) {
|
||||
console.log('Select or create a thoughts directory for this repository:')
|
||||
const options = [
|
||||
...existingRepos.map(repo => `Use existing: ${repo}`),
|
||||
'→ Create new directory',
|
||||
]
|
||||
const selection = await selectFromList('', options)
|
||||
|
||||
if (selection === options.length - 1) {
|
||||
// Create new
|
||||
const defaultName = getRepoNameFromPath(currentRepo)
|
||||
console.log('')
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`This name will be used for the directory: ${config.thoughtsRepo}/${config.reposDir}/[name]`,
|
||||
),
|
||||
)
|
||||
const nameInput = await prompt(
|
||||
`Directory name for this project's thoughts [${defaultName}]: `,
|
||||
)
|
||||
mappedName = nameInput || defaultName
|
||||
|
||||
// Sanitize the name
|
||||
mappedName = sanitizeDirectoryName(mappedName)
|
||||
console.log(
|
||||
chalk.green(`✓ Will create: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`),
|
||||
)
|
||||
} else {
|
||||
mappedName = existingRepos[selection]
|
||||
console.log(
|
||||
chalk.green(
|
||||
`✓ Will use existing: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No existing repos, just create new
|
||||
const defaultName = getRepoNameFromPath(currentRepo)
|
||||
console.log('')
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`This name will be used for the directory: ${config.thoughtsRepo}/${config.reposDir}/[name]`,
|
||||
@@ -483,32 +538,11 @@ export async function thoughtsInitCommand(options: InitOptions): Promise<void> {
|
||||
mappedName = nameInput || defaultName
|
||||
|
||||
// Sanitize the name
|
||||
mappedName = mappedName.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
mappedName = sanitizeDirectoryName(mappedName)
|
||||
console.log(
|
||||
chalk.green(`✓ Will create: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`),
|
||||
)
|
||||
} else {
|
||||
mappedName = existingRepos[selection]
|
||||
console.log(
|
||||
chalk.green(`✓ Will use existing: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No existing repos, just create new
|
||||
const defaultName = getRepoNameFromPath(currentRepo)
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`This name will be used for the directory: ${config.thoughtsRepo}/${config.reposDir}/[name]`,
|
||||
),
|
||||
)
|
||||
const nameInput = await prompt(`Directory name for this project's thoughts [${defaultName}]: `)
|
||||
mappedName = nameInput || defaultName
|
||||
|
||||
// Sanitize the name
|
||||
mappedName = mappedName.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
console.log(
|
||||
chalk.green(`✓ Will create: ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`),
|
||||
)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
|
||||
@@ -164,18 +164,6 @@ function createSearchDirectory(thoughtsDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Make .search directory read-only
|
||||
try {
|
||||
// First set directories to be readable and traversable
|
||||
execSync(`find "${searchDir}" -type d -exec chmod 755 {} +`, { stdio: 'pipe' })
|
||||
// Then set files to be read-only
|
||||
execSync(`find "${searchDir}" -type f -exec chmod 444 {} +`, { stdio: 'pipe' })
|
||||
// Finally make directories read-only but still traversable
|
||||
execSync(`find "${searchDir}" -type d -exec chmod 555 {} +`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
// Ignore chmod errors on systems that don't support it
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Created ${linkedCount} hard links in searchable directory`))
|
||||
}
|
||||
|
||||
|
||||
84
hlyr/src/commands/thoughts/uninit.ts
Normal file
84
hlyr/src/commands/thoughts/uninit.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import chalk from 'chalk'
|
||||
import { loadThoughtsConfig, saveThoughtsConfig, getCurrentRepoPath } from '../../thoughtsConfig.js'
|
||||
|
||||
interface UninitOptions {
|
||||
force?: boolean
|
||||
configFile?: string
|
||||
}
|
||||
|
||||
export async function thoughtsUninitCommand(options: UninitOptions): Promise<void> {
|
||||
try {
|
||||
const currentRepo = getCurrentRepoPath()
|
||||
const thoughtsDir = path.join(currentRepo, 'thoughts')
|
||||
|
||||
// Check if thoughts directory exists
|
||||
if (!fs.existsSync(thoughtsDir)) {
|
||||
console.error(chalk.red('Error: Thoughts not initialized for this repository.'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Load config
|
||||
const config = loadThoughtsConfig(options)
|
||||
if (!config) {
|
||||
console.error(chalk.red('Error: Thoughts configuration not found.'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const mappedName = config.repoMappings[currentRepo]
|
||||
if (!mappedName && !options.force) {
|
||||
console.error(chalk.red('Error: This repository is not in the thoughts configuration.'))
|
||||
console.error(chalk.yellow('Use --force to remove the thoughts directory anyway.'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(chalk.blue('Removing thoughts setup from current repository...'))
|
||||
|
||||
// Step 1: Handle searchable directory if it exists
|
||||
const searchableDir = path.join(thoughtsDir, 'searchable')
|
||||
if (fs.existsSync(searchableDir)) {
|
||||
console.log(chalk.gray('Removing searchable directory...'))
|
||||
try {
|
||||
// Reset permissions in case they're restricted
|
||||
execSync(`chmod -R 755 "${searchableDir}"`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
// Ignore chmod errors
|
||||
}
|
||||
fs.rmSync(searchableDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// Step 2: Remove the entire thoughts directory
|
||||
// IMPORTANT: This only removes the local thoughts/ directory containing symlinks
|
||||
// The actual thoughts content in the thoughts repository remains untouched
|
||||
console.log(chalk.gray('Removing thoughts directory (symlinks only)...'))
|
||||
try {
|
||||
fs.rmSync(thoughtsDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error removing thoughts directory: ${error}`))
|
||||
console.error(chalk.yellow('You may need to manually remove: ' + thoughtsDir))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Step 3: Remove from config if mapped
|
||||
if (mappedName) {
|
||||
console.log(chalk.gray('Removing repository from thoughts configuration...'))
|
||||
delete config.repoMappings[currentRepo]
|
||||
saveThoughtsConfig(config, options)
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ Thoughts removed from repository'))
|
||||
|
||||
// Provide info about what was done
|
||||
if (mappedName) {
|
||||
console.log('')
|
||||
console.log(chalk.gray('Note: Your thoughts content remains safe in:'))
|
||||
console.log(chalk.gray(` ${config.thoughtsRepo}/${config.reposDir}/${mappedName}`))
|
||||
console.log(chalk.gray('Only the local symlinks and configuration were removed.'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error during thoughts uninit: ${error}`))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user