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:
Allison Durham
2025-07-02 10:59:06 -07:00
committed by GitHub
parent 1ae3486bfb
commit 9d388f1fbe
6 changed files with 228 additions and 71 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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('')

View File

@@ -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`))
}

View 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)
}
}