452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
/**
|
|
* Utility functions for encoding/decoding Claude Code project directory names.
|
|
*
|
|
* Directory naming pattern:
|
|
* - Path: /Users/username/projectname
|
|
* - Encoded: -Users-username-projectname
|
|
*
|
|
* IMPORTANT: This encoding is LOSSY for paths containing dashes.
|
|
* For accurate path resolution, use extractCwd() from jsonl.ts to read
|
|
* the actual cwd from session files.
|
|
*/
|
|
|
|
// =============================================================================
|
|
// Core Encoding/Decoding
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Encodes an absolute path into Claude Code's directory naming format.
|
|
* Replaces all path separators (/ and \) with dashes.
|
|
*
|
|
* @param absolutePath - The absolute path to encode (e.g., "/Users/username/projectname")
|
|
* @returns The encoded directory name (e.g., "-Users-username-projectname")
|
|
*/
|
|
export function encodePath(absolutePath: string): string {
|
|
if (!absolutePath) {
|
|
return '';
|
|
}
|
|
|
|
const encoded = absolutePath.replace(/[/\\]/g, '-');
|
|
|
|
// Ensure leading dash for absolute paths
|
|
return encoded.startsWith('-') ? encoded : `-${encoded}`;
|
|
}
|
|
|
|
/**
|
|
* Decodes a project directory name to its original path.
|
|
* Note: This is a best-effort decode. Paths with dashes cannot be decoded accurately.
|
|
*
|
|
* @param encodedName - The encoded directory name (e.g., "-Users-username-projectname")
|
|
* @returns The decoded path (e.g., "/Users/username/projectname")
|
|
*/
|
|
export function decodePath(encodedName: string): string {
|
|
if (!encodedName) {
|
|
return '';
|
|
}
|
|
|
|
// Legacy Windows format observed in some Claude installs: "C--Users-name-project"
|
|
// (no leading dash, drive separator encoded as "--").
|
|
const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/;
|
|
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
|
|
if (legacyWindowsMatch) {
|
|
const drive = legacyWindowsMatch[1].toUpperCase();
|
|
const rest = legacyWindowsMatch[2].replace(/-/g, '/');
|
|
return `${drive}:/${rest}`;
|
|
}
|
|
|
|
// Remove leading dash if present (indicates absolute path)
|
|
const withoutLeadingDash = encodedName.startsWith('-') ? encodedName.slice(1) : encodedName;
|
|
|
|
// Replace dashes with slashes
|
|
const decodedPath = withoutLeadingDash.replace(/-/g, '/');
|
|
|
|
// Windows paths may decode to "C:/..."
|
|
if (/^[a-zA-Z]:\//.test(decodedPath)) {
|
|
return decodedPath;
|
|
}
|
|
|
|
// Ensure leading slash for POSIX-style absolute paths
|
|
const absolutePath = decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`;
|
|
|
|
// Translate WSL mount paths to Windows drive-letter paths on Windows
|
|
return translateWslMountPath(absolutePath);
|
|
}
|
|
|
|
/**
|
|
* Extract the project name (last path segment) from an encoded directory name.
|
|
*
|
|
* @param encodedName - The encoded directory name
|
|
* @returns The project name
|
|
*/
|
|
export function extractProjectName(encodedName: string, cwdHint?: string): string {
|
|
// Prefer cwdHint (actual filesystem path) since decodePath is lossy for
|
|
// paths containing dashes (e.g., "claude-devtools" → "claude/code/context").
|
|
if (cwdHint) {
|
|
const segments = cwdHint.split(/[/\\]/).filter(Boolean);
|
|
const last = segments[segments.length - 1];
|
|
if (last) return last;
|
|
}
|
|
const decoded = decodePath(encodedName);
|
|
const segments = decoded.split('/').filter(Boolean);
|
|
return segments[segments.length - 1] || encodedName;
|
|
}
|
|
|
|
/**
|
|
* Translate WSL mount paths (/mnt/X/...) to Windows drive-letter paths (X:/...)
|
|
* when running on Windows. No-op on other platforms.
|
|
*/
|
|
export function translateWslMountPath(posixPath: string): string {
|
|
if (process.platform !== 'win32') {
|
|
return posixPath;
|
|
}
|
|
const match = /^\/mnt\/([a-zA-Z])(\/.*)?$/.exec(posixPath);
|
|
if (match) {
|
|
const drive = match[1].toUpperCase();
|
|
const rest = match[2] ?? '';
|
|
return `${drive}:${rest}`;
|
|
}
|
|
return posixPath;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Validation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Validates if a directory name follows the Claude Code encoding pattern.
|
|
*
|
|
* @param encodedName - The directory name to validate
|
|
* @returns true if valid, false otherwise
|
|
*/
|
|
export function isValidEncodedPath(encodedName: string): boolean {
|
|
if (!encodedName) {
|
|
return false;
|
|
}
|
|
|
|
// Support legacy Windows format: "C--Users-name-project"
|
|
// (no leading dash, drive separator encoded as "--").
|
|
if (/^[a-zA-Z]--[a-zA-Z0-9_.\s-]+$/.test(encodedName)) {
|
|
return true;
|
|
}
|
|
|
|
// Must start with a dash (indicates absolute path)
|
|
if (!encodedName.startsWith('-')) {
|
|
return false;
|
|
}
|
|
|
|
// Allow only expected encoded characters:
|
|
// - alphanumeric, underscores, dots, spaces, dashes
|
|
// - optional ":" for Windows drive notation (e.g., -C:-Users-name-project)
|
|
const validPattern = /^-[a-zA-Z0-9_.\s:-]+$/;
|
|
if (!validPattern.test(encodedName)) {
|
|
return false;
|
|
}
|
|
|
|
// Windows-style drive syntax is allowed only at the beginning after "-"
|
|
// e.g. "-C:-Users-name-project". Reject stray ":" elsewhere.
|
|
const firstColon = encodedName.indexOf(':');
|
|
if (firstColon === -1) {
|
|
return true;
|
|
}
|
|
|
|
if (!/^-[a-zA-Z]:/.test(encodedName)) {
|
|
return false;
|
|
}
|
|
|
|
return !encodedName.includes(':', firstColon + 1);
|
|
}
|
|
|
|
/**
|
|
* Validates a project ID that may be either a plain encoded path or
|
|
* a composite subproject ID (`{encodedPath}::{8-char-hex}`).
|
|
*
|
|
* @param projectId - The project ID to validate
|
|
* @returns true if valid
|
|
*/
|
|
export function isValidProjectId(projectId: string): boolean {
|
|
if (!projectId) {
|
|
return false;
|
|
}
|
|
|
|
const sep = projectId.indexOf('::');
|
|
if (sep === -1) {
|
|
// Plain encoded path
|
|
return isValidEncodedPath(projectId);
|
|
}
|
|
|
|
// Composite ID: validate base part and hash suffix
|
|
const basePart = projectId.slice(0, sep);
|
|
const hashPart = projectId.slice(sep + 2);
|
|
|
|
return isValidEncodedPath(basePart) && /^[a-f0-9]{8}$/.test(hashPart);
|
|
}
|
|
|
|
/**
|
|
* Extract the base directory (encoded path) from a project ID.
|
|
* For composite IDs (`{encoded}::{hash}`), returns the encoded part.
|
|
* For plain IDs, returns the ID as-is.
|
|
*/
|
|
export function extractBaseDir(projectId: string): string {
|
|
const sep = projectId.indexOf('::');
|
|
if (sep !== -1) {
|
|
return projectId.slice(0, sep);
|
|
}
|
|
return projectId;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Session ID Extraction
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Extract session ID from a JSONL filename.
|
|
*
|
|
* @param filename - The filename (e.g., "abc123.jsonl")
|
|
* @returns The session ID (e.g., "abc123")
|
|
*/
|
|
export function extractSessionId(filename: string): string {
|
|
return filename.replace(/\.jsonl$/, '');
|
|
}
|
|
|
|
// =============================================================================
|
|
// Path Construction
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Construct the path to a session JSONL file.
|
|
* Handles composite project IDs by extracting the base directory.
|
|
*/
|
|
export function buildSessionPath(basePath: string, projectId: string, sessionId: string): string {
|
|
return path.join(basePath, extractBaseDir(projectId), `${sessionId}.jsonl`);
|
|
}
|
|
|
|
/**
|
|
* Construct the path to a session's subagents directory.
|
|
* Handles composite project IDs by extracting the base directory.
|
|
*/
|
|
export function buildSubagentsPath(basePath: string, projectId: string, sessionId: string): string {
|
|
return path.join(basePath, extractBaseDir(projectId), sessionId, 'subagents');
|
|
}
|
|
|
|
/**
|
|
* Construct the path to a task list file (stored in todos directory).
|
|
*/
|
|
export function buildTodoPath(claudeBasePath: string, sessionId: string): string {
|
|
return path.join(claudeBasePath, 'todos', `${sessionId}.json`);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Home Directory
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Try Electron's app.getPath('home') which correctly handles Unicode paths
|
|
* on Windows (Cyrillic, CJK, etc.) unlike Node's os.homedir() / env vars
|
|
* that can suffer from UTF-8 vs system codepage mismatches.
|
|
*
|
|
* Returns null when Electron app is unavailable (e.g. in tests).
|
|
*/
|
|
function getElectronHome(): string | null {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Lazy require to avoid hard dependency on electron in test environments
|
|
const electron = require('electron') as {
|
|
app?: { getPath: (name: string) => string };
|
|
};
|
|
const app = electron.app;
|
|
if (app && typeof app.getPath === 'function') {
|
|
const home = app.getPath('home');
|
|
if (home) return home;
|
|
}
|
|
} catch {
|
|
// Not in Electron context (tests, standalone builds, etc.)
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the user's home directory.
|
|
*
|
|
* Priority:
|
|
* 1. Electron app.getPath('home') — correct Unicode handling on all platforms
|
|
* 2. HOME env var (Unix) / USERPROFILE (Windows)
|
|
* 3. HOMEDRIVE + HOMEPATH (Windows fallback)
|
|
* 4. os.homedir() (Node.js built-in)
|
|
*/
|
|
export function getHomeDir(): string {
|
|
const electronHome = getElectronHome();
|
|
if (electronHome) return electronHome;
|
|
|
|
const windowsHome =
|
|
process.env.HOMEDRIVE && process.env.HOMEPATH
|
|
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
|
: null;
|
|
return process.env.HOME || process.env.USERPROFILE || windowsHome || os.homedir() || '/';
|
|
}
|
|
|
|
let claudeBasePathOverride: string | null = null;
|
|
|
|
function getDefaultClaudeBasePath(): string {
|
|
return path.join(getHomeDir(), '.claude');
|
|
}
|
|
|
|
/**
|
|
* Get the auto-detected Claude config base path (~/.claude) without considering overrides.
|
|
*/
|
|
export function getAutoDetectedClaudeBasePath(): string {
|
|
return getDefaultClaudeBasePath();
|
|
}
|
|
|
|
function normalizeOverridePath(claudeBasePath: string): string | null {
|
|
const trimmed = claudeBasePath.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = path.normalize(trimmed);
|
|
if (!path.isAbsolute(normalized)) {
|
|
return null;
|
|
}
|
|
|
|
const resolved = path.resolve(normalized);
|
|
const root = path.parse(resolved).root;
|
|
if (resolved === root) {
|
|
return resolved;
|
|
}
|
|
let end = resolved.length;
|
|
while (end > root.length) {
|
|
const char = resolved[end - 1];
|
|
if (char !== '/' && char !== '\\') {
|
|
break;
|
|
}
|
|
end--;
|
|
}
|
|
|
|
return resolved.slice(0, end);
|
|
}
|
|
|
|
/**
|
|
* Override the Claude config base path (~/.claude).
|
|
* Pass null to return to auto-detection.
|
|
*/
|
|
export function setClaudeBasePathOverride(claudeBasePath: string | null | undefined): void {
|
|
if (claudeBasePath == null) {
|
|
claudeBasePathOverride = null;
|
|
return;
|
|
}
|
|
|
|
claudeBasePathOverride = normalizeOverridePath(claudeBasePath);
|
|
}
|
|
|
|
/**
|
|
* Get the Claude config base path (~/.claude).
|
|
*/
|
|
export function getClaudeBasePath(): string {
|
|
return claudeBasePathOverride ?? getDefaultClaudeBasePath();
|
|
}
|
|
|
|
/**
|
|
* Get the projects directory path (~/.claude/projects).
|
|
*/
|
|
export function getProjectsBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'projects');
|
|
}
|
|
|
|
/**
|
|
* Get the todos directory path (~/.claude/todos).
|
|
*/
|
|
export function getTodosBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'todos');
|
|
}
|
|
|
|
/**
|
|
* Get the teams directory path (~/.claude/teams).
|
|
*/
|
|
export function getTeamsBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'teams');
|
|
}
|
|
|
|
/**
|
|
* Get the tasks directory path (~/.claude/tasks).
|
|
*/
|
|
export function getTasksBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'tasks');
|
|
}
|
|
|
|
/**
|
|
* Get the tools directory path (~/.claude/tools).
|
|
*/
|
|
export function getToolsBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'tools');
|
|
}
|
|
|
|
/**
|
|
* Get the schedules directory path (~/.claude/claude-devtools-schedules).
|
|
*/
|
|
export function getSchedulesBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'claude-devtools-schedules');
|
|
}
|
|
|
|
export function getTaskChangeSummariesBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'task-change-summaries');
|
|
}
|
|
|
|
export function getTaskChangePresenceBasePath(): string {
|
|
return path.join(getClaudeBasePath(), 'task-change-presence');
|
|
}
|
|
|
|
/**
|
|
* Get the backups directory path for the app's own storage.
|
|
*/
|
|
export function getBackupsBasePath(): string {
|
|
return path.join(getAppDataBasePath(), 'backups');
|
|
}
|
|
|
|
/**
|
|
* Get the app's own data directory (attachments, task-attachments).
|
|
* Separate from ~/.claude/ so CLI cannot delete our data.
|
|
*/
|
|
export function getAppDataPath(): string {
|
|
return path.join(getAppDataBasePath(), 'data');
|
|
}
|
|
|
|
// ── App data root (Electron userData) ──
|
|
|
|
let appDataBasePathOverride: string | null = null;
|
|
|
|
export function setAppDataBasePath(p: string | null | undefined): void {
|
|
appDataBasePathOverride = p ?? null;
|
|
}
|
|
|
|
function getAppDataBasePath(): string {
|
|
if (appDataBasePathOverride) return appDataBasePathOverride;
|
|
// Fallback: resolve lazily from Electron app (safe after app.whenReady)
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { app } = require('electron') as typeof import('electron');
|
|
return app.getPath('userData');
|
|
} catch {
|
|
// Outside Electron (tests, CLI) — fall back to home dir
|
|
return path.join(getHomeDir(), '.claude-agent-teams-ui');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Directory for per-team MCP config JSON files.
|
|
* Stored in app's userData so they persist across sessions and are
|
|
* accessible by Claude CLI subprocess on all platforms (including AppImage).
|
|
*/
|
|
export function getMcpConfigsBasePath(): string {
|
|
return path.join(getAppDataBasePath(), 'mcp-configs');
|
|
}
|
|
|
|
/**
|
|
* Directory for the stable MCP server bundle copy (packaged builds).
|
|
* Versioned subdirectories contain the copied index.js + package.json
|
|
* so the server runs from a writable, non-FUSE location.
|
|
*/
|
|
export function getMcpServerBasePath(): string {
|
|
return path.join(getAppDataBasePath(), 'mcp-server');
|
|
}
|