humanlayer
Version:
HumanLayer, but on your command-line.
1,374 lines (1,337 loc) • 104 kB
JavaScript
#!/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