UNPKG

humanlayer

Version:

HumanLayer, but on your command-line.

1,374 lines (1,337 loc) 104 kB
#!/usr/bin/env node // src/index.ts import { Command } from "commander"; import { spawn as spawn2 } from "child_process"; // src/commands/configShow.ts import chalk2 from "chalk"; // src/config.ts import dotenv from "dotenv"; import fs2 from "fs"; import path2 from "path"; import chalk from "chalk"; // src/utils/invocation.ts import { execSync } from "child_process"; import * as fs from "fs"; import * as path from "path"; function getInvocationName() { const invocationPath = process.argv[1] || process.argv[0]; return path.basename(invocationPath); } function getDefaultSocketPath(invocationName2) { if (invocationName2 === "codelayer-nightly" || invocationName2 === "humanlayer-nightly") { return "~/.humanlayer/daemon-nightly.sock"; } return "~/.humanlayer/daemon.sock"; } function shouldLaunchApp(invocationName2, hasArgs2) { return (invocationName2 === "codelayer" || invocationName2 === "codelayer-nightly") && !hasArgs2; } function getAppPath(invocationName2) { const isNightly = invocationName2 === "codelayer-nightly"; const appName = isNightly ? "CodeLayer-Nightly" : "CodeLayer"; const appPath = `/Applications/${appName}.app`; if (fs.existsSync(appPath)) { return appPath; } const caskPath = `/opt/homebrew/Caskroom/${isNightly ? "codelayer-nightly" : "codelayer"}`; if (fs.existsSync(caskPath)) { const versions = fs.readdirSync(caskPath).filter((v) => !v.startsWith(".")); if (versions.length > 0) { const latestVersion = versions.sort().pop(); const caskAppPath = `${caskPath}/${latestVersion}/${appName}.app`; if (fs.existsSync(caskAppPath)) { return caskAppPath; } } } return null; } function launchApp(appPath) { console.log(`Opening ${appPath}`); console.log(`For more options run:`); console.log(` ${getInvocationName()} --help`); try { execSync(`open "${appPath}"`, { stdio: "inherit" }); } catch (error) { console.error(`Failed to open application: ${error}`); process.exit(1); } } // src/config.ts dotenv.config(); var CONFIG_SCHEMA = { www_base_url: { envVar: "HUMANLAYER_WWW_BASE_URL", configKey: "www_base_url", flagKey: "wwwBase", defaultValue: "https://www.humanlayer.dev", required: true }, daemon_socket: { envVar: "HUMANLAYER_DAEMON_SOCKET", configKey: "daemon_socket", flagKey: "daemonSocket", defaultValue: getDefaultSocketPath(getInvocationName()), required: true }, run_id: { envVar: "HUMANLAYER_RUN_ID", configKey: "run_id", flagKey: "runId", required: false } }; var ConfigResolver = class { constructor(options = {}) { this.configFile = this.loadConfigFile(options.configFile); this.configFilePath = this.getConfigFilePath(options.configFile); } loadConfigFile(configFile) { if (configFile) { const configContent = fs2.readFileSync(configFile, "utf8"); return JSON.parse(configContent); } const configPaths = ["humanlayer.json", getDefaultConfigPath()]; for (const configPath of configPaths) { try { if (fs2.existsSync(configPath)) { const configContent = fs2.readFileSync(configPath, "utf8"); return JSON.parse(configContent); } } catch (error) { console.error(chalk.yellow(`Warning: Could not parse config file ${configPath}: ${error}`)); } } return {}; } getConfigFilePath(configFile) { if (configFile) return configFile; const configPaths = ["humanlayer.json", getDefaultConfigPath()]; for (const configPath of configPaths) { try { if (fs2.existsSync(configPath)) { return configPath; } } catch { } } return getDefaultConfigPath(); } resolveValue(key, options = {}) { const schema = CONFIG_SCHEMA[key]; if (options[schema.flagKey]) { return { value: options[schema.flagKey], source: "flag", sourceName: "flag" }; } const envValue = process.env[schema.envVar]; if (envValue) { return { value: envValue, source: "env", sourceName: schema.envVar }; } const configValue = this.configFile[schema.configKey]; if (configValue) { return { value: configValue, source: "config", sourceName: this.configFilePath }; } if (schema.defaultValue) { return { value: schema.defaultValue, source: "default", sourceName: "default" }; } return { value: void 0, source: "none", sourceName: "none" }; } resolveAll(options = {}) { const www_base_url = this.resolveValue("www_base_url", options); const daemon_socket = this.resolveValue("daemon_socket", options); const run_id = this.resolveValue("run_id", options); return { www_base_url, daemon_socket, run_id }; } // Legacy compatibility resolveFullConfig(options = {}) { const resolved = this.resolveAll(options); return { www_base_url: resolved.www_base_url.value, daemon_socket: resolved.daemon_socket.value, run_id: resolved.run_id.value }; } }; function saveConfigFile(config, configFile) { const configPath = configFile || getDefaultConfigPath(); console.log(chalk.yellow(`Writing config to ${configPath}`)); const configDir = path2.dirname(configPath); fs2.mkdirSync(configDir, { recursive: true }); fs2.writeFileSync(configPath, JSON.stringify(config, null, 2)); console.log(chalk.green("Config saved successfully")); } function getDefaultConfigPath() { const xdgConfigHome = process.env.XDG_CONFIG_HOME || path2.join(process.env.HOME || "", ".config"); return path2.join(xdgConfigHome, "humanlayer", "humanlayer.json"); } function resolveConfigWithSources(options = {}) { const resolver = new ConfigResolver(options); const resolved = resolver.resolveAll(options); return { www_base_url: resolved.www_base_url, daemon_socket: resolved.daemon_socket, run_id: resolved.run_id }; } function resolveFullConfig(options = {}) { const resolver = new ConfigResolver(options); return resolver.resolveFullConfig(options); } // src/commands/configShow.ts function configShowCommand(options) { try { const resolvedConfig = resolveFullConfig(options); if (options.json) { const jsonOutput = { www_base_url: resolvedConfig.www_base_url, daemon_socket: resolvedConfig.daemon_socket, run_id: resolvedConfig.run_id }; console.log(JSON.stringify(jsonOutput, null, 2)); return; } console.log(chalk2.blue("HumanLayer Configuration")); console.log(chalk2.gray("=".repeat(50))); console.log(""); console.log(chalk2.yellow("Config File Sources:")); const configPath = options.configFile || getDefaultConfigPath(); console.log(` Primary: ${configPath}`); console.log(` Local: humanlayer.json`); console.log(""); console.log(chalk2.yellow("Configuration:")); console.log(` WWW Base URL: ${chalk2.cyan(resolvedConfig.www_base_url)}`); console.log(` Daemon Socket: ${chalk2.cyan(resolvedConfig.daemon_socket)}`); if (resolvedConfig.run_id) { console.log(` Run ID: ${chalk2.cyan(resolvedConfig.run_id)}`); } console.log(""); console.log(chalk2.yellow("Configuration Sources:")); const configWithSources = resolveConfigWithSources(options); if (configWithSources.www_base_url.source !== "default") { console.log( ` WWW Base URL: ${chalk2.cyan(configWithSources.www_base_url.value)} ${chalk2.gray( `(${configWithSources.www_base_url.sourceName})` )}` ); } if (configWithSources.daemon_socket.source !== "default") { console.log( ` Daemon Socket: ${chalk2.cyan(configWithSources.daemon_socket.value)} ${chalk2.gray( `(${configWithSources.daemon_socket.sourceName})` )}` ); } if (configWithSources.run_id?.value) { console.log( ` Run ID: ${chalk2.cyan(configWithSources.run_id.value)} ${chalk2.gray( `(${configWithSources.run_id.sourceName})` )}` ); } const hasAdditionalConfig = configWithSources.www_base_url.source !== "default" || configWithSources.daemon_socket.source !== "default" || configWithSources.run_id?.value; if (!hasAdditionalConfig) { console.log(chalk2.gray(" Using default configuration")); } } catch (error) { console.error(chalk2.red(`Error showing config: ${error}`)); process.exit(1); } } // src/daemonClient.ts import { connect } from "net"; import { EventEmitter } from "events"; import { homedir } from "os"; import { join } from "path"; var DaemonClient = class extends EventEmitter { constructor(socketPath) { super(); this.requestId = 0; this.socketPath = socketPath || join(homedir(), ".humanlayer", "daemon.sock"); } async connect() { return new Promise((resolve, reject) => { this.conn = connect(this.socketPath, () => { resolve(); }); const connectErrorHandler = (err) => { reject(new Error(`Failed to connect to daemon: ${err.message}`)); }; this.conn.once("error", connectErrorHandler); this.conn.once("connect", () => { this.conn.removeListener("error", connectErrorHandler); }); }); } // Subscribe creates a separate connection for subscriptions (like the Go client) async subscribe(request = {}) { const subConn = await this.createSubscriptionConnection(); const req = { jsonrpc: "2.0", method: "Subscribe", params: request, id: ++this.requestId }; await new Promise((resolve, reject) => { subConn.write(JSON.stringify(req) + "\n", (err) => { if (err) reject(err); else resolve(); }); }); const subscriptionEmitter = new EventEmitter(); let buffer = ""; let subscriptionConfirmed = false; subConn.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.trim()) { try { const response = JSON.parse(line); if (response.error) { continue; } if (!subscriptionConfirmed && response.result && typeof response.result === "object" && "subscription_id" in response.result) { subscriptionConfirmed = true; subscriptionEmitter.emit("subscribed", response.result); continue; } if (response.result && typeof response.result === "object" && "type" in response.result && response.result.type === "heartbeat") { continue; } if (response.result && typeof response.result === "object" && "event" in response.result) { const notification = response.result; if (notification.event && notification.event.type) { subscriptionEmitter.emit("event", notification.event); } } } catch { } } } }); subConn.on("close", () => { subscriptionEmitter.emit("close"); }); subConn.on("error", (err) => { subscriptionEmitter.emit("error", err); }); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { subConn.destroy(); reject(new Error("Timeout waiting for subscription confirmation")); }, 5e3); subscriptionEmitter.once("subscribed", (_response) => { clearTimeout(timeout); resolve(subscriptionEmitter); }); subscriptionEmitter.once("error", (err) => { clearTimeout(timeout); reject(err); }); }); } async createSubscriptionConnection() { return new Promise((resolve, reject) => { const conn = connect(this.socketPath, () => { resolve(conn); }); conn.once("error", (err) => { reject(new Error(`Failed to create subscription connection: ${err.message}`)); }); }); } // Call sends an RPC request and waits for the response (like the Go client) async call(method, params) { if (!this.conn) { throw new Error("Not connected to daemon"); } const id = ++this.requestId; const req = { jsonrpc: "2.0", method, params, id }; await new Promise((resolve, reject) => { this.conn.write(JSON.stringify(req) + "\n", (err) => { if (err) reject(err); else resolve(); }); }); return new Promise((resolve, reject) => { let buffer = ""; let timeout; const dataHandler = (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.trim()) { try { const response = JSON.parse(line); if (response.id === id) { if (timeout) { clearTimeout(timeout); timeout = void 0; } if (this.conn) { this.conn.removeListener("data", dataHandler); } if (response.error) { reject(new Error(`RPC error ${response.error.code}: ${response.error.message}`)); } else { resolve(response.result); } return; } } catch { } } } }; this.conn.on("data", dataHandler); timeout = setTimeout(() => { timeout = void 0; if (this.conn) { this.conn.removeListener("data", dataHandler); } reject(new Error("RPC call timeout")); }, 3e4); }); } // Public methods matching the Go client interface async health() { const resp = await this.call("health"); if (resp.status !== "ok") { throw new Error(`Daemon unhealthy: ${resp.status}`); } return resp; } async launchSession(req) { return this.call("launchSession", req); } async listSessions() { return this.call("listSessions"); } async createApproval(runId, toolName, toolInput, toolUseId) { return this.call("createApproval", { run_id: runId, tool_name: toolName, tool_input: toolInput, ...toolUseId && { tool_use_id: toolUseId } }); } async fetchApprovals(sessionId) { const resp = await this.call("fetchApprovals", { session_id: sessionId }); return resp.approvals; } async getApproval(approvalId) { const resp = await this.call("getApproval", { approval_id: approvalId }); return resp.approval; } async sendDecision(approvalId, decision, comment) { const resp = await this.call("sendDecision", { approval_id: approvalId, decision, comment }); if (!resp.success) { throw new Error(`Decision failed: ${resp.error}`); } } close() { if (this.conn) { this.conn.destroy(); this.conn = void 0; } } async reconnect() { this.close(); await this.connect(); } }; async function connectWithRetry(socketPath, maxRetries = 3, retryDelay = 1e3) { let lastError; for (let i = 0; i <= maxRetries; i++) { try { const client = new DaemonClient(socketPath); await client.connect(); await client.health(); return client; } catch (err) { lastError = err; if (i < maxRetries) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); } } } throw new Error(`Failed to connect to daemon after ${maxRetries + 1} attempts: ${lastError?.message}`); } // src/commands/launch.ts import { homedir as homedir2 } from "os"; import { join as join2 } from "path"; var launchCommand = async (query, options = {}) => { try { const config = resolveFullConfig(options); let socketPath = config.daemon_socket; if (socketPath.startsWith("~")) { socketPath = join2(homedir2(), socketPath.slice(1)); } const additionalDirs = options.additionalDirectories || options.addDir || []; console.log("Launching Claude Code session..."); console.log("Query:", query); if (options.title) console.log("Title:", options.title); if (options.model) console.log("Model:", options.model); console.log("Working directory:", options.workingDir || process.cwd()); if (additionalDirs.length > 0) { console.log("Additional directories:", additionalDirs); } console.log("Daemon socket:", socketPath); console.log("Approvals enabled:", options.approvals !== false); if (options.dangerouslySkipPermissions) { console.log("\u26A0\uFE0F Dangerously skip permissions enabled - ALL tools will be auto-approved"); if (options.dangerouslySkipPermissionsTimeout) { console.log(` Auto-disabling after ${options.dangerouslySkipPermissionsTimeout} minutes`); } } const client = await connectWithRetry(socketPath, 3, 1e3); try { const result = await client.launchSession({ query, title: options.title, model: options.model, working_dir: options.workingDir || process.cwd(), additional_directories: additionalDirs, max_turns: options.maxTurns, // MCP config is now injected by daemon permission_prompt_tool: options.approvals !== false ? "mcp__codelayer__request_permission" : void 0, dangerously_skip_permissions: options.dangerouslySkipPermissions, dangerously_skip_permissions_timeout: options.dangerouslySkipPermissionsTimeout ? parseInt(options.dangerouslySkipPermissionsTimeout) * 60 * 1e3 : void 0 }); console.log("\nSession launched successfully!"); console.log("Session ID:", result.session_id); console.log("Run ID:", result.run_id); console.log("\nYou can now use CodeLayer manage this session."); } finally { client.close(); } } catch (error) { console.error("Failed to launch session:", error); console.error("\nMake sure the daemon is running. You can start it with:"); console.error(" npx humanlayer tui"); process.exit(1); } }; // src/commands/thoughts/init.ts import fs4 from "fs"; import path4 from "path"; import os2 from "os"; import { execSync as execSync3 } from "child_process"; import chalk3 from "chalk"; import readline from "readline"; // src/thoughtsConfig.ts import fs3 from "fs"; import path3 from "path"; import os from "os"; import { execSync as execSync2 } from "child_process"; function loadThoughtsConfig(options = {}) { const resolver = new ConfigResolver(options); return resolver.configFile.thoughts || null; } function saveThoughtsConfig(thoughtsConfig, options = {}) { const resolver = new ConfigResolver(options); resolver.configFile.thoughts = thoughtsConfig; saveConfigFile(resolver.configFile, options.configFile); } function getDefaultThoughtsRepo() { return path3.join(os.homedir(), "thoughts"); } function expandPath(filePath) { if (filePath.startsWith("~/")) { return path3.join(os.homedir(), filePath.slice(2)); } return path3.resolve(filePath); } function ensureThoughtsRepoExists(configOrThoughtsRepo, reposDir, globalDir) { let thoughtsRepo; let effectiveReposDir; let effectiveGlobalDir; if (typeof configOrThoughtsRepo === "string") { thoughtsRepo = configOrThoughtsRepo; effectiveReposDir = reposDir; effectiveGlobalDir = globalDir; } else { thoughtsRepo = configOrThoughtsRepo.thoughtsRepo; effectiveReposDir = configOrThoughtsRepo.reposDir; effectiveGlobalDir = configOrThoughtsRepo.globalDir; } const expandedRepo = expandPath(thoughtsRepo); if (!fs3.existsSync(expandedRepo)) { fs3.mkdirSync(expandedRepo, { recursive: true }); } const expandedRepos = path3.join(expandedRepo, effectiveReposDir); const expandedGlobal = path3.join(expandedRepo, effectiveGlobalDir); if (!fs3.existsSync(expandedRepos)) { fs3.mkdirSync(expandedRepos, { recursive: true }); } if (!fs3.existsSync(expandedGlobal)) { fs3.mkdirSync(expandedGlobal, { recursive: true }); } const gitPath = path3.join(expandedRepo, ".git"); const isGitRepo = fs3.existsSync(gitPath) && (fs3.statSync(gitPath).isDirectory() || fs3.statSync(gitPath).isFile()); if (!isGitRepo) { execSync2("git init", { cwd: expandedRepo }); const gitignore = `# OS files .DS_Store Thumbs.db # Editor files .vscode/ .idea/ *.swp *.swo *~ # Temporary files *.tmp *.bak `; fs3.writeFileSync(path3.join(expandedRepo, ".gitignore"), gitignore); execSync2("git add .gitignore", { cwd: expandedRepo }); execSync2('git commit -m "Initial thoughts repository setup"', { cwd: expandedRepo }); } } function getRepoThoughtsPath(thoughtsRepoOrConfig, reposDirOrRepoName, repoName) { if (typeof thoughtsRepoOrConfig === "string") { return path3.join(expandPath(thoughtsRepoOrConfig), reposDirOrRepoName, repoName); } const config = thoughtsRepoOrConfig; return path3.join(expandPath(config.thoughtsRepo), config.reposDir, reposDirOrRepoName); } function getGlobalThoughtsPath(thoughtsRepoOrConfig, globalDir) { if (typeof thoughtsRepoOrConfig === "string") { return path3.join(expandPath(thoughtsRepoOrConfig), globalDir); } const config = thoughtsRepoOrConfig; return path3.join(expandPath(config.thoughtsRepo), config.globalDir); } function getCurrentRepoPath() { return process.cwd(); } function getRepoNameFromPath(repoPath) { const parts = repoPath.split(path3.sep); return parts[parts.length - 1] || "unnamed_repo"; } function resolveProfileForRepo(config, repoPath) { const mapping = config.repoMappings[repoPath]; if (typeof mapping === "string") { return { thoughtsRepo: config.thoughtsRepo, reposDir: config.reposDir, globalDir: config.globalDir, profileName: void 0 }; } if (mapping && typeof mapping === "object") { const profileName = mapping.profile; if (profileName && config.profiles && config.profiles[profileName]) { const profile = config.profiles[profileName]; return { thoughtsRepo: profile.thoughtsRepo, reposDir: profile.reposDir, globalDir: profile.globalDir, profileName }; } return { thoughtsRepo: config.thoughtsRepo, reposDir: config.reposDir, globalDir: config.globalDir, profileName: void 0 }; } return { thoughtsRepo: config.thoughtsRepo, reposDir: config.reposDir, globalDir: config.globalDir, profileName: void 0 }; } function getRepoNameFromMapping(mapping) { if (!mapping) return void 0; if (typeof mapping === "string") return mapping; return mapping.repo; } function getProfileNameFromMapping(mapping) { if (!mapping) return void 0; if (typeof mapping === "string") return void 0; return mapping.profile; } function validateProfile(config, profileName) { return !!(config.profiles && config.profiles[profileName]); } function sanitizeProfileName(name) { return name.replace(/[^a-zA-Z0-9_-]/g, "_"); } function createThoughtsDirectoryStructure(configOrThoughtsRepo, reposDirOrRepoName, globalDirOrUser, repoName, user) { let resolvedConfig; let effectiveRepoName; let effectiveUser; if (typeof configOrThoughtsRepo === "string") { resolvedConfig = { thoughtsRepo: configOrThoughtsRepo, reposDir: reposDirOrRepoName, globalDir: globalDirOrUser }; effectiveRepoName = repoName; effectiveUser = user; } else { resolvedConfig = configOrThoughtsRepo; effectiveRepoName = reposDirOrRepoName; effectiveUser = globalDirOrUser; } const repoThoughtsPath = getRepoThoughtsPath( resolvedConfig.thoughtsRepo, resolvedConfig.reposDir, effectiveRepoName ); const repoUserPath = path3.join(repoThoughtsPath, effectiveUser); const repoSharedPath = path3.join(repoThoughtsPath, "shared"); const globalPath = getGlobalThoughtsPath(resolvedConfig.thoughtsRepo, resolvedConfig.globalDir); const globalUserPath = path3.join(globalPath, effectiveUser); const globalSharedPath = path3.join(globalPath, "shared"); for (const dir of [repoUserPath, repoSharedPath, globalUserPath, globalSharedPath]) { if (!fs3.existsSync(dir)) { fs3.mkdirSync(dir, { recursive: true }); } } const repoReadme = `# ${effectiveRepoName} Thoughts This directory contains thoughts and notes specific to the ${effectiveRepoName} repository. - \`${effectiveUser}/\` - Your personal notes for this repository - \`shared/\` - Team-shared notes for this repository `; const globalReadme = `# Global Thoughts This directory contains thoughts and notes that apply across all repositories. - \`${effectiveUser}/\` - Your personal cross-repository notes - \`shared/\` - Team-shared cross-repository notes `; if (!fs3.existsSync(path3.join(repoThoughtsPath, "README.md"))) { fs3.writeFileSync(path3.join(repoThoughtsPath, "README.md"), repoReadme); } if (!fs3.existsSync(path3.join(globalPath, "README.md"))) { fs3.writeFileSync(path3.join(globalPath, "README.md"), globalReadme); } } function updateSymlinksForNewUsers(currentRepoPath, configOrThoughtsRepo, reposDirOrRepoName, repoNameOrCurrentUser, currentUser) { let resolvedConfig; let effectiveRepoName; let effectiveUser; if (typeof configOrThoughtsRepo === "string") { resolvedConfig = { thoughtsRepo: configOrThoughtsRepo, reposDir: reposDirOrRepoName }; effectiveRepoName = repoNameOrCurrentUser; effectiveUser = currentUser; } else { resolvedConfig = configOrThoughtsRepo; effectiveRepoName = reposDirOrRepoName; effectiveUser = repoNameOrCurrentUser; } const thoughtsDir = path3.join(currentRepoPath, "thoughts"); const repoThoughtsPath = getRepoThoughtsPath( resolvedConfig.thoughtsRepo, resolvedConfig.reposDir, effectiveRepoName ); const addedSymlinks = []; if (!fs3.existsSync(thoughtsDir) || !fs3.existsSync(repoThoughtsPath)) { return addedSymlinks; } const entries = fs3.readdirSync(repoThoughtsPath, { withFileTypes: true }); const userDirs = entries.filter((entry) => entry.isDirectory() && entry.name !== "shared" && !entry.name.startsWith(".")).map((entry) => entry.name); for (const userName of userDirs) { const symlinkPath = path3.join(thoughtsDir, userName); const targetPath = path3.join(repoThoughtsPath, userName); if (!fs3.existsSync(symlinkPath) && userName !== effectiveUser) { try { fs3.symlinkSync(targetPath, symlinkPath, "dir"); addedSymlinks.push(userName); } catch { } } } return addedSymlinks; } // src/commands/thoughts/init.ts function sanitizeDirectoryName(name) { return name.replace(/[^a-zA-Z0-9_-]/g, "_"); } function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); }); } function checkExistingSetup(config) { const thoughtsDir = path4.join(process.cwd(), "thoughts"); if (!fs4.existsSync(thoughtsDir)) { return { exists: false, isValid: false }; } if (!fs4.lstatSync(thoughtsDir).isDirectory()) { return { exists: true, isValid: false, message: "thoughts exists but is not a directory" }; } const localPath = path4.join(thoughtsDir, "local"); const hasOldLocal = fs4.existsSync(localPath) && fs4.lstatSync(localPath).isSymbolicLink(); if (hasOldLocal) { return { exists: true, isValid: false, isOldStructure: true, message: "thoughts directory uses old structure (needs upgrade)" }; } if (!config) { return { exists: true, isValid: false, message: "thoughts directory exists but configuration is missing" }; } const userPath = path4.join(thoughtsDir, config.user); const sharedPath = path4.join(thoughtsDir, "shared"); const globalPath = path4.join(thoughtsDir, "global"); const hasUser = fs4.existsSync(userPath) && fs4.lstatSync(userPath).isSymbolicLink(); const hasShared = fs4.existsSync(sharedPath) && fs4.lstatSync(sharedPath).isSymbolicLink(); const hasGlobal = fs4.existsSync(globalPath) && fs4.lstatSync(globalPath).isSymbolicLink(); if (!hasUser || !hasShared || !hasGlobal) { return { exists: true, isValid: false, message: "thoughts directory exists but symlinks are missing or broken" }; } return { exists: true, isValid: true }; } async function selectFromList(message, options) { if (message) { console.log(chalk3.cyan(message)); } options.forEach((opt, idx) => { console.log(` [${idx + 1}] ${opt}`); }); while (true) { const answer = await prompt("Select option: "); const num = parseInt(answer); if (num >= 1 && num <= options.length) { return num - 1; } console.log(chalk3.red("Invalid selection. Please try again.")); } } function generateClaudeMd(thoughtsRepo, reposDir, repoName, user) { const reposPath = path4.join(thoughtsRepo, reposDir, repoName).replace(os2.homedir(), "~"); const globalPath = path4.join(thoughtsRepo, "global").replace(os2.homedir(), "~"); return `# Thoughts Directory Structure This directory contains developer thoughts and notes for the ${repoName} repository. It is managed by the HumanLayer thoughts system and should not be committed to the code repository. ## Structure - \`${user}/\` \u2192 Your personal notes for this repository (symlink to ${reposPath}/${user}) - \`shared/\` \u2192 Team-shared notes for this repository (symlink to ${reposPath}/shared) - \`global/\` \u2192 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/\` \u2192 Hard links for searching (auto-generated) ## Searching in Thoughts 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 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. Files remain editable while maintaining consistent path references ## Usage Create markdown files in these directories to document: - Architecture decisions - Design notes - TODO items - Investigation results - Any other development thoughts Quick access: - \`thoughts/${user}/\` for your repo-specific notes (most common) - \`thoughts/global/${user}/\` for your cross-repo notes These files will be automatically synchronized with your thoughts repository when you commit code changes. ## Important - Never commit the thoughts/ directory to your code repository - The git pre-commit hook will prevent accidental commits - Use \`humanlayer thoughts sync\` to manually sync changes - Use \`humanlayer thoughts status\` to see sync status `; } function setupGitHooks(repoPath) { const updated = []; let gitCommonDir; try { gitCommonDir = execSync3("git rev-parse --git-common-dir", { cwd: repoPath, encoding: "utf8", stdio: "pipe" }).trim(); if (!path4.isAbsolute(gitCommonDir)) { gitCommonDir = path4.join(repoPath, gitCommonDir); } } catch (error) { throw new Error(`Failed to find git common directory: ${error}`); } const hooksDir = path4.join(gitCommonDir, "hooks"); if (!fs4.existsSync(hooksDir)) { fs4.mkdirSync(hooksDir, { recursive: true }); } const HOOK_VERSION = "3"; const preCommitPath = path4.join(hooksDir, "pre-commit"); const preCommitContent = `#!/bin/bash # HumanLayer thoughts protection - prevent committing thoughts directory # Version: ${HOOK_VERSION} if git diff --cached --name-only | grep -q "^thoughts/"; then echo "\u274C Cannot commit thoughts/ to code repository" echo "The thoughts directory should only exist in your separate thoughts repository." git reset HEAD -- thoughts/ exit 1 fi # Call any existing pre-commit hook if [ -f "${preCommitPath}.old" ]; then "${preCommitPath}.old" "$@" fi `; const postCommitPath = path4.join(hooksDir, "post-commit"); const postCommitContent = `#!/bin/bash # HumanLayer thoughts auto-sync # Version: ${HOOK_VERSION} # Check if we're in a worktree if [ -f .git ]; then # Skip auto-sync in worktrees to avoid repository boundary confusion # See: https://linear.app/humanlayer/issue/ENG-1455 exit 0 fi # Get the commit message COMMIT_MSG=$(git log -1 --pretty=%B) # Auto-sync thoughts after each commit (only in non-worktree repos) humanlayer thoughts sync --message "Auto-sync with commit: $COMMIT_MSG" >/dev/null 2>&1 & # Call any existing post-commit hook if [ -f "${postCommitPath}.old" ]; then "${postCommitPath}.old" "$@" fi `; const hookNeedsUpdate = (hookPath) => { if (!fs4.existsSync(hookPath)) return true; const content = fs4.readFileSync(hookPath, "utf8"); if (!content.includes("HumanLayer thoughts")) return false; const versionMatch = content.match(/# Version: (\d+)/); if (!versionMatch) return true; const currentVersion = parseInt(versionMatch[1]); return currentVersion < parseInt(HOOK_VERSION); }; if (fs4.existsSync(preCommitPath)) { const content = fs4.readFileSync(preCommitPath, "utf8"); if (!content.includes("HumanLayer thoughts") || hookNeedsUpdate(preCommitPath)) { if (!content.includes("HumanLayer thoughts")) { fs4.renameSync(preCommitPath, `${preCommitPath}.old`); } else { fs4.unlinkSync(preCommitPath); } } } if (fs4.existsSync(postCommitPath)) { const content = fs4.readFileSync(postCommitPath, "utf8"); if (!content.includes("HumanLayer thoughts") || hookNeedsUpdate(postCommitPath)) { if (!content.includes("HumanLayer thoughts")) { fs4.renameSync(postCommitPath, `${postCommitPath}.old`); } else { fs4.unlinkSync(postCommitPath); } } } if (!fs4.existsSync(preCommitPath) || hookNeedsUpdate(preCommitPath)) { fs4.writeFileSync(preCommitPath, preCommitContent); fs4.chmodSync(preCommitPath, "755"); updated.push("pre-commit"); } if (!fs4.existsSync(postCommitPath) || hookNeedsUpdate(postCommitPath)) { fs4.writeFileSync(postCommitPath, postCommitContent); fs4.chmodSync(postCommitPath, "755"); updated.push("post-commit"); } return { updated }; } async function thoughtsInitCommand(options) { try { const currentRepo = getCurrentRepoPath(); try { execSync3("git rev-parse --git-dir", { stdio: "pipe" }); } catch { console.error(chalk3.red("Error: Not in a git repository")); process.exit(1); } let config = loadThoughtsConfig(options); if (!config) { console.log(chalk3.blue("=== Initial Thoughts Setup ===")); console.log(""); console.log("First, let's configure your global thoughts system."); console.log(""); const defaultRepo = getDefaultThoughtsRepo(); console.log(chalk3.gray("This is where all your thoughts across all projects will be stored.")); const thoughtsRepoInput = await prompt(`Thoughts repository location [${defaultRepo}]: `); const thoughtsRepo = thoughtsRepoInput || defaultRepo; console.log(""); console.log(chalk3.gray("Your thoughts will be organized into two main directories:")); console.log(chalk3.gray("- Repository-specific thoughts (one subdirectory per project)")); console.log(chalk3.gray("- Global thoughts (shared across all projects)")); console.log(""); const reposDirInput = await prompt(`Directory name for repository-specific thoughts [repos]: `); const reposDir2 = reposDirInput || "repos"; const globalDirInput = await prompt(`Directory name for global thoughts [global]: `); const globalDir = globalDirInput || "global"; console.log(""); const defaultUser = process.env.USER || "user"; let user = ""; while (!user || user.toLowerCase() === "global") { const userInput = await prompt(`Your username [${defaultUser}]: `); user = userInput || defaultUser; if (user.toLowerCase() === "global") { console.log( chalk3.red(`Username cannot be "global" as it's reserved for cross-project thoughts.`) ); user = ""; } } config = { thoughtsRepo, reposDir: reposDir2, globalDir, user, repoMappings: {} }; console.log(""); console.log(chalk3.yellow("Creating thoughts structure:")); console.log(` ${chalk3.cyan(thoughtsRepo)}/`); console.log(` \u251C\u2500\u2500 ${chalk3.cyan(reposDir2)}/ ${chalk3.gray("(project-specific thoughts)")}`); console.log(` \u2514\u2500\u2500 ${chalk3.cyan(globalDir)}/ ${chalk3.gray("(cross-project thoughts)")}`); console.log(""); ensureThoughtsRepoExists(thoughtsRepo, reposDir2, globalDir); saveThoughtsConfig(config, options); console.log(chalk3.green("\u2705 Global thoughts configuration created")); console.log(""); } if (options.profile) { if (!validateProfile(config, options.profile)) { console.error(chalk3.red(`Error: Profile "${options.profile}" does not exist.`)); console.error(""); console.error(chalk3.gray("Available profiles:")); if (config.profiles) { Object.keys(config.profiles).forEach((name) => { console.error(chalk3.gray(` - ${name}`)); }); } else { console.error(chalk3.gray(" (none)")); } console.error(""); console.error(chalk3.yellow("Create a profile first:")); console.error(chalk3.gray(` humanlayer thoughts profile create ${options.profile}`)); process.exit(1); } } 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: void 0 }; const setupStatus = checkExistingSetup(config); if (setupStatus.exists && !options.force) { if (setupStatus.isValid) { console.log(chalk3.yellow("Thoughts directory already configured for this repository.")); const reconfigure = await prompt("Do you want to reconfigure? (y/N): "); if (reconfigure.toLowerCase() !== "y") { console.log("Setup cancelled."); return; } } else { console.log(chalk3.yellow(`\u26A0\uFE0F ${setupStatus.message || "Thoughts setup is incomplete"}`)); if (setupStatus.isOldStructure) { console.log(""); console.log(chalk3.blue("The thoughts system has been upgraded to use a simpler structure:")); console.log(` OLD: thoughts/local/${config.user}/`); console.log(` NEW: thoughts/${config.user}/`); console.log(""); } const fix = await prompt("Do you want to fix the setup? (Y/n): "); if (fix.toLowerCase() === "n") { console.log("Setup cancelled."); return; } } } const expandedRepo = expandPath(tempProfileConfig.thoughtsRepo); if (!fs4.existsSync(expandedRepo)) { console.log( chalk3.red(`Error: Thoughts repository not found at ${tempProfileConfig.thoughtsRepo}`) ); console.log(chalk3.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( tempProfileConfig.thoughtsRepo, tempProfileConfig.reposDir, tempProfileConfig.globalDir ); } const reposDir = path4.join(expandedRepo, tempProfileConfig.reposDir); if (!fs4.existsSync(reposDir)) { fs4.mkdirSync(reposDir, { recursive: true }); } const existingRepos = fs4.readdirSync(reposDir).filter((name) => { const fullPath = path4.join(reposDir, name); return fs4.statSync(fullPath).isDirectory() && !name.startsWith("."); }); let mappedName = config.repoMappings[currentRepo]; if (!mappedName) { if (options.directory) { const sanitizedDir = sanitizeDirectoryName(options.directory); if (!existingRepos.includes(sanitizedDir)) { console.error( chalk3.red(`Error: Directory "${sanitizedDir}" not found in thoughts repository.`) ); console.error( chalk3.red("In non-interactive mode (--directory), you must specify a directory") ); console.error(chalk3.red("name that already exists in the thoughts repository.")); console.error(""); console.error(chalk3.yellow("Available directories:")); existingRepos.forEach((repo) => console.error(chalk3.gray(` - ${repo}`))); process.exit(1); } mappedName = sanitizedDir; console.log( chalk3.green( `\u2713 Using existing: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}` ) ); } else { console.log(chalk3.blue("=== Repository Setup ===")); console.log(""); console.log(`Setting up thoughts for: ${chalk3.cyan(currentRepo)}`); console.log(""); console.log( chalk3.gray( `This will create a subdirectory in ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/` ) ); console.log(chalk3.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 options2 = [ ...existingRepos.map((repo) => `Use existing: ${repo}`), "\u2192 Create new directory" ]; const selection = await selectFromList("", options2); if (selection === options2.length - 1) { const defaultName = getRepoNameFromPath(currentRepo); console.log(""); console.log( chalk3.gray( `This name will be used for the directory: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/[name]` ) ); const nameInput = await prompt( `Directory name for this project's thoughts [${defaultName}]: ` ); mappedName = nameInput || defaultName; mappedName = sanitizeDirectoryName(mappedName); console.log( chalk3.green( `\u2713 Will create: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}` ) ); } else { mappedName = existingRepos[selection]; console.log( chalk3.green( `\u2713 Will use existing: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}` ) ); } } else { const defaultName = getRepoNameFromPath(currentRepo); console.log( chalk3.gray( `This name will be used for the directory: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/[name]` ) ); const nameInput = await prompt( `Directory name for this project's thoughts [${defaultName}]: ` ); mappedName = nameInput || defaultName; mappedName = sanitizeDirectoryName(mappedName); console.log( chalk3.green( `\u2713 Will create: ${tempProfileConfig.thoughtsRepo}/${tempProfileConfig.reposDir}/${mappedName}` ) ); } } console.log(""); if (options.profile) { config.repoMappings[currentRepo] = { repo: mappedName, profile: options.profile }; } else { config.repoMappings[currentRepo] = mappedName; } saveThoughtsConfig(config, options); } const profileConfig = resolveProfileForRepo(config, currentRepo); createThoughtsDirectoryStructure(profileConfig, mappedName, config.user); const thoughtsDir = path4.join(currentRepo, "thoughts"); if (fs4.existsSync(thoughtsDir)) { const searchableDir = path4.join(thoughtsDir, "searchable"); const oldSearchDir = path4.join(thoughtsDir, ".search"); for (const dir of [searchableDir, oldSearchDir]) { if (fs4.existsSync(dir)) { try { execSync3(`chmod -R 755 "${dir}"`, { stdio: "pipe" }); } catch { } } } fs4.rmSync(thoughtsDir, { recursive: true, force: true }); } fs4.mkdirSync(thoughtsDir); const repoTarget = getRepoThoughtsPath(profileConfig, mappedName); const globalTarget = getGlobalThoughtsPath(profileConfig); fs4.symlinkSync(path4.join(repoTarget, config.user), path4.join(thoughtsDir, config.user), "dir"); fs4.symlinkSync(path4.join(repoTarget, "shared"), path4.join(thoughtsDir, "shared"), "dir"); fs4.symlinkSync(globalTarget, path4.join(thoughtsDir, "global"), "dir"); const otherUsers = updateSymlinksForNewUsers(currentRepo, profileConfig, mappedName, config.user); if (otherUsers.length > 0) { console.log(chalk3.green(`\u2713 Added symlinks for other users: ${otherUsers.join(", ")}`)); } try { execSync3("git remote get-url origin", { cwd: expandedRepo, stdio: "pipe" }); try { execSync3("git pull --rebase", { stdio: "pipe", cwd: expandedRepo }); console.log(chalk3.green("\u2713 Pulled latest thoughts from remote")); } catch (error) { console.warn(chalk3.yellow("Warning: Could not pull latest thoughts:"), error.message); } } catch { } const claudeMd = generateClaudeMd( profileConfig.thoughtsRepo, profileConfig.reposDir, mappedName, config.user ); fs4.writeFileSync(path4.join(thoughtsDir, "CLAUDE.md"), claudeMd); const hookResult = setupGitHooks(currentRepo); if (hookResult.updated.length > 0) { console.log(chalk3.yellow(`\u2713 Updated git hooks: ${hookResult.updated.join(", ")}`)); } console.log(chalk3.green("\u2705 Thoughts setup complete!")); console.log(""); console.log(chalk3.blue("=== Summary ===")); console.log(""); console.log("Repository structure created:"); console.log(` ${chalk3.cyan(currentRepo)}/`); console.log(` \u2514\u2500\u2500 thoughts/`); console.log( ` \u251C\u2500\u2500 ${config.user}/ ${chalk3.gray(`\u2192 ${profileConfig.thoughtsRepo}/${profileConfig.reposDir}/${mappedName}/${config.user}/`)}` ); console.log( ` \u251C\u2500\u2500 shared/ ${chalk3.gray(`\u2192 ${profileConfig.thoughtsRepo}/${profileConfig.reposDir}/${mappedName}/shared/`)}` ); console.log( ` \u2514\u2500\u2500 global/ ${chalk3.gray(`\u2192 ${profileConfig.thoughtsRepo}/${profileConfig.globalDir}/`)}` ); console.log(` \u251C\u2500\u2500 ${config.user}/ ${chalk3.gray("(your cross-repo notes)")}`); console.log(` \u2514\u2500\u2500 shared/ ${chalk3.gray("(team cross-repo notes)")}`); console.log(""); console.log("Protection enabled:"); console.log(` ${chalk3.green("\u2713")} Pre-commit hook: Prevents committing thoughts/`); console.log(` ${chalk3.green("\u2713")} Post-commit hook: Auto-syncs thoughts after commits`); console.log(""); console.log("Next steps:"); console.log(` 1. Run ${chalk3.cyan("humanlayer thoughts sync")} to create the searchable index`); console.log( ` 2. Create markdown files in ${chalk3.cyan(`thoughts/${config.user}/`)} for your notes` ); console.log(` 3. Your thoughts will sync automatically when you commit code`); console.log(` 4. Run ${chalk3.cyan("humanlayer thoughts status")} to check sync status`); } catch (error) { console.error(chalk3.red(`Error during thoughts init: ${error}`)); process.exit(1); } } // src/commands/thoughts/uninit.ts import fs5 from "fs"; import path5 from "path"; import { execSync as execSync4 } from "child_process"; import chalk4 from "chalk"; async function thoughtsUninitCommand(options) { try { const currentRepo = getCurrentRepoPath(); const thoughtsDir = path5.join(currentRepo, "thoughts"); if (!fs5.existsSync(thoughtsDir)) { console.error(chalk4.red("Error: Thoughts not initialized for this repository.")); process.exit(1); } const config = loadThoughtsConfig(options); if (!config) { console.error(chalk4.red("Error: Thoughts configuration not found.")); process.exit(1); } const mapping = config.repoMappings[currentRepo]; const mappedName = getRepoNameFromMapping(mapping); const profile