Major changes: - Replace monolithic SessionEvent with reason discriminator with individual event types: session_start, session_before_switch, session_switch, session_before_new, session_new, session_before_branch, session_branch, session_before_compact, session_compact, session_shutdown - Each event has dedicated result type (SessionBeforeSwitchResult, etc.) - HookHandler type now allows bare return statements (void in return type) - HookAPI.on() has proper overloads for each event with correct typing Additional fixes: - AgentSession now always subscribes to agent in constructor (was only subscribing when external subscribe() called, breaking internal handlers) - Standardize on undefined over null throughout codebase - HookUIContext methods return undefined instead of null - SessionManager methods return undefined instead of null - Simplify hook exports to 'export type * from types.js' - Add detailed JSDoc for skipConversationRestore vs cancel - Fix createBranchedSession to rebuild index in persist mode - newSession() now returns the session file path Updated all example hooks, tests, and emission sites to use new event types.
214 lines
6.4 KiB
TypeScript
214 lines
6.4 KiB
TypeScript
import chalk from "chalk";
|
|
import { spawnSync } from "child_process";
|
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "fs";
|
|
import { arch, platform } from "os";
|
|
import { join } from "path";
|
|
import { Readable } from "stream";
|
|
import { finished } from "stream/promises";
|
|
import { APP_NAME, getToolsDir } from "../config.js";
|
|
|
|
const TOOLS_DIR = getToolsDir();
|
|
|
|
interface ToolConfig {
|
|
name: string;
|
|
repo: string; // GitHub repo (e.g., "sharkdp/fd")
|
|
binaryName: string; // Name of the binary inside the archive
|
|
tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0)
|
|
getAssetName: (version: string, plat: string, architecture: string) => string | null;
|
|
}
|
|
|
|
const TOOLS: Record<string, ToolConfig> = {
|
|
fd: {
|
|
name: "fd",
|
|
repo: "sharkdp/fd",
|
|
binaryName: "fd",
|
|
tagPrefix: "v",
|
|
getAssetName: (version, plat, architecture) => {
|
|
if (plat === "darwin") {
|
|
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
|
return `fd-v${version}-${archStr}-apple-darwin.tar.gz`;
|
|
} else if (plat === "linux") {
|
|
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
|
return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;
|
|
} else if (plat === "win32") {
|
|
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
|
return `fd-v${version}-${archStr}-pc-windows-msvc.zip`;
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
rg: {
|
|
name: "ripgrep",
|
|
repo: "BurntSushi/ripgrep",
|
|
binaryName: "rg",
|
|
tagPrefix: "",
|
|
getAssetName: (version, plat, architecture) => {
|
|
if (plat === "darwin") {
|
|
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
|
return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;
|
|
} else if (plat === "linux") {
|
|
if (architecture === "arm64") {
|
|
return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;
|
|
}
|
|
return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;
|
|
} else if (plat === "win32") {
|
|
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
|
return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;
|
|
}
|
|
return null;
|
|
},
|
|
},
|
|
};
|
|
|
|
// Check if a command exists in PATH by trying to run it
|
|
function commandExists(cmd: string): boolean {
|
|
try {
|
|
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
|
// Check for ENOENT error (command not found)
|
|
return result.error === undefined || result.error === null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get the path to a tool (system-wide or in our tools dir)
|
|
export function getToolPath(tool: "fd" | "rg"): string | null {
|
|
const config = TOOLS[tool];
|
|
if (!config) return null;
|
|
|
|
// Check our tools directory first
|
|
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
|
|
if (existsSync(localPath)) {
|
|
return localPath;
|
|
}
|
|
|
|
// Check system PATH - if found, just return the command name (it's in PATH)
|
|
if (commandExists(config.binaryName)) {
|
|
return config.binaryName;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Fetch latest release version from GitHub
|
|
async function getLatestVersion(repo: string): Promise<string> {
|
|
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
|
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`GitHub API error: ${response.status}`);
|
|
}
|
|
|
|
const data = (await response.json()) as { tag_name: string };
|
|
return data.tag_name.replace(/^v/, "");
|
|
}
|
|
|
|
// Download a file from URL
|
|
async function downloadFile(url: string, dest: string): Promise<void> {
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download: ${response.status}`);
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error("No response body");
|
|
}
|
|
|
|
const fileStream = createWriteStream(dest);
|
|
await finished(Readable.fromWeb(response.body as any).pipe(fileStream));
|
|
}
|
|
|
|
// Download and install a tool
|
|
async function downloadTool(tool: "fd" | "rg"): Promise<string> {
|
|
const config = TOOLS[tool];
|
|
if (!config) throw new Error(`Unknown tool: ${tool}`);
|
|
|
|
const plat = platform();
|
|
const architecture = arch();
|
|
|
|
// Get latest version
|
|
const version = await getLatestVersion(config.repo);
|
|
|
|
// Get asset name for this platform
|
|
const assetName = config.getAssetName(version, plat, architecture);
|
|
if (!assetName) {
|
|
throw new Error(`Unsupported platform: ${plat}/${architecture}`);
|
|
}
|
|
|
|
// Create tools directory
|
|
mkdirSync(TOOLS_DIR, { recursive: true });
|
|
|
|
const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;
|
|
const archivePath = join(TOOLS_DIR, assetName);
|
|
const binaryExt = plat === "win32" ? ".exe" : "";
|
|
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
|
|
|
|
// Download
|
|
await downloadFile(downloadUrl, archivePath);
|
|
|
|
// Extract
|
|
const extractDir = join(TOOLS_DIR, "extract_tmp");
|
|
mkdirSync(extractDir, { recursive: true });
|
|
|
|
try {
|
|
if (assetName.endsWith(".tar.gz")) {
|
|
spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" });
|
|
} else if (assetName.endsWith(".zip")) {
|
|
spawnSync("unzip", ["-o", archivePath, "-d", extractDir], { stdio: "pipe" });
|
|
}
|
|
|
|
// Find the binary in extracted files
|
|
const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, ""));
|
|
const extractedBinary = join(extractedDir, config.binaryName + binaryExt);
|
|
|
|
if (existsSync(extractedBinary)) {
|
|
renameSync(extractedBinary, binaryPath);
|
|
} else {
|
|
throw new Error(`Binary not found in archive: ${extractedBinary}`);
|
|
}
|
|
|
|
// Make executable (Unix only)
|
|
if (plat !== "win32") {
|
|
chmodSync(binaryPath, 0o755);
|
|
}
|
|
} finally {
|
|
// Cleanup
|
|
rmSync(archivePath, { force: true });
|
|
rmSync(extractDir, { recursive: true, force: true });
|
|
}
|
|
|
|
return binaryPath;
|
|
}
|
|
|
|
// Ensure a tool is available, downloading if necessary
|
|
// Returns the path to the tool, or null if unavailable
|
|
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | undefined> {
|
|
const existingPath = getToolPath(tool);
|
|
if (existingPath) {
|
|
return existingPath;
|
|
}
|
|
|
|
const config = TOOLS[tool];
|
|
if (!config) return undefined;
|
|
|
|
// Tool not found - download it
|
|
if (!silent) {
|
|
console.log(chalk.dim(`${config.name} not found. Downloading...`));
|
|
}
|
|
|
|
try {
|
|
const path = await downloadTool(tool);
|
|
if (!silent) {
|
|
console.log(chalk.dim(`${config.name} installed to ${path}`));
|
|
}
|
|
return path;
|
|
} catch (e) {
|
|
if (!silent) {
|
|
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|