mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 22:04:46 +00:00
Reorganize file structure: core/, utils/, modes/interactive/components/, modes/interactive/theme/
This commit is contained in:
parent
00982705f2
commit
83a6c26969
56 changed files with 133 additions and 128 deletions
99
packages/coding-agent/src/utils/changelog.ts
Normal file
99
packages/coding-agent/src/utils/changelog.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { existsSync, readFileSync } from "fs";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse changelog entries from CHANGELOG.md
|
||||
* Scans for ## lines and collects content until next ## or EOF
|
||||
*/
|
||||
export function parseChangelog(changelogPath: string): ChangelogEntry[] {
|
||||
if (!existsSync(changelogPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(changelogPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const entries: ChangelogEntry[] = [];
|
||||
|
||||
let currentLines: string[] = [];
|
||||
let currentVersion: { major: number; minor: number; patch: number } | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Check if this is a version header (## [x.y.z] ...)
|
||||
if (line.startsWith("## ")) {
|
||||
// Save previous entry if exists
|
||||
if (currentVersion && currentLines.length > 0) {
|
||||
entries.push({
|
||||
...currentVersion,
|
||||
content: currentLines.join("\n").trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to parse version from this line
|
||||
const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
|
||||
if (versionMatch) {
|
||||
currentVersion = {
|
||||
major: Number.parseInt(versionMatch[1], 10),
|
||||
minor: Number.parseInt(versionMatch[2], 10),
|
||||
patch: Number.parseInt(versionMatch[3], 10),
|
||||
};
|
||||
currentLines = [line];
|
||||
} else {
|
||||
// Reset if we can't parse version
|
||||
currentVersion = null;
|
||||
currentLines = [];
|
||||
}
|
||||
} else if (currentVersion) {
|
||||
// Collect lines for current version
|
||||
currentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last entry
|
||||
if (currentVersion && currentLines.length > 0) {
|
||||
entries.push({
|
||||
...currentVersion,
|
||||
content: currentLines.join("\n").trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
} catch (error) {
|
||||
console.error(`Warning: Could not parse changelog: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
|
||||
*/
|
||||
export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number {
|
||||
if (v1.major !== v2.major) return v1.major - v2.major;
|
||||
if (v1.minor !== v2.minor) return v1.minor - v2.minor;
|
||||
return v1.patch - v2.patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries newer than lastVersion
|
||||
*/
|
||||
export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] {
|
||||
// Parse lastVersion
|
||||
const parts = lastVersion.split(".").map(Number);
|
||||
const last: ChangelogEntry = {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
patch: parts[2] || 0,
|
||||
content: "",
|
||||
};
|
||||
|
||||
return entries.filter((entry) => compareVersions(entry, last) > 0);
|
||||
}
|
||||
|
||||
// Re-export getChangelogPath from paths.ts for convenience
|
||||
export { getChangelogPath } from "./config.js";
|
||||
28
packages/coding-agent/src/utils/clipboard.ts
Normal file
28
packages/coding-agent/src/utils/clipboard.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { execSync } from "child_process";
|
||||
import { platform } from "os";
|
||||
|
||||
export function copyToClipboard(text: string): void {
|
||||
const p = platform();
|
||||
const options = { input: text, timeout: 5000 };
|
||||
|
||||
try {
|
||||
if (p === "darwin") {
|
||||
execSync("pbcopy", options);
|
||||
} else if (p === "win32") {
|
||||
execSync("clip", options);
|
||||
} else {
|
||||
// Linux - try xclip first, fall back to xsel
|
||||
try {
|
||||
execSync("xclip -selection clipboard", options);
|
||||
} catch {
|
||||
execSync("xsel --clipboard --input", options);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (p === "linux") {
|
||||
throw new Error(`Failed to copy to clipboard. Install xclip or xsel: ${msg}`);
|
||||
}
|
||||
throw new Error(`Failed to copy to clipboard: ${msg}`);
|
||||
}
|
||||
}
|
||||
132
packages/coding-agent/src/utils/config.ts
Normal file
132
packages/coding-agent/src/utils/config.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { existsSync, readFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// =============================================================================
|
||||
// Package Detection
|
||||
// =============================================================================
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Detect if we're running as a Bun compiled binary.
|
||||
* Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path)
|
||||
*/
|
||||
export const isBunBinary =
|
||||
import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN");
|
||||
|
||||
// =============================================================================
|
||||
// Package Asset Paths (shipped with executable)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md).
|
||||
* - For Bun binary: returns the directory containing the executable
|
||||
* - For Node.js (dist/): returns __dirname (the dist/ directory)
|
||||
* - For tsx (src/): returns parent directory (the package root)
|
||||
*/
|
||||
export function getPackageDir(): string {
|
||||
if (isBunBinary) {
|
||||
// Bun binary: process.execPath points to the compiled executable
|
||||
return dirname(process.execPath);
|
||||
}
|
||||
// Node.js: check if package.json exists in __dirname (dist/) or parent (src/ case)
|
||||
if (existsSync(join(__dirname, "package.json"))) {
|
||||
return __dirname;
|
||||
}
|
||||
// Running from src/ via tsx - go up one level to package root
|
||||
return dirname(__dirname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to built-in themes directory (shipped with package)
|
||||
* - For Bun binary: theme/ next to executable
|
||||
* - For Node.js (dist/): dist/theme/
|
||||
* - For tsx (src/): src/theme/
|
||||
*/
|
||||
export function getThemesDir(): string {
|
||||
if (isBunBinary) {
|
||||
return join(dirname(process.execPath), "theme");
|
||||
}
|
||||
// __dirname is either dist/ or src/ - theme is always a subdirectory
|
||||
return join(__dirname, "theme");
|
||||
}
|
||||
|
||||
/** Get path to package.json */
|
||||
export function getPackageJsonPath(): string {
|
||||
return join(getPackageDir(), "package.json");
|
||||
}
|
||||
|
||||
/** Get path to README.md */
|
||||
export function getReadmePath(): string {
|
||||
return resolve(join(getPackageDir(), "README.md"));
|
||||
}
|
||||
|
||||
/** Get path to CHANGELOG.md */
|
||||
export function getChangelogPath(): string {
|
||||
return resolve(join(getPackageDir(), "CHANGELOG.md"));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// App Config (from package.json piConfig)
|
||||
// =============================================================================
|
||||
|
||||
const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8"));
|
||||
|
||||
export const APP_NAME: string = pkg.piConfig?.name || "pi";
|
||||
export const CONFIG_DIR_NAME: string = pkg.piConfig?.configDir || ".pi";
|
||||
export const VERSION: string = pkg.version;
|
||||
|
||||
// e.g., PI_CODING_AGENT_DIR or TAU_CODING_AGENT_DIR
|
||||
export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
|
||||
|
||||
// =============================================================================
|
||||
// User Config Paths (~/.pi/agent/*)
|
||||
// =============================================================================
|
||||
|
||||
/** Get the agent config directory (e.g., ~/.pi/agent/) */
|
||||
export function getAgentDir(): string {
|
||||
return process.env[ENV_AGENT_DIR] || join(homedir(), CONFIG_DIR_NAME, "agent");
|
||||
}
|
||||
|
||||
/** Get path to user's custom themes directory */
|
||||
export function getCustomThemesDir(): string {
|
||||
return join(getAgentDir(), "themes");
|
||||
}
|
||||
|
||||
/** Get path to models.json */
|
||||
export function getModelsPath(): string {
|
||||
return join(getAgentDir(), "models.json");
|
||||
}
|
||||
|
||||
/** Get path to oauth.json */
|
||||
export function getOAuthPath(): string {
|
||||
return join(getAgentDir(), "oauth.json");
|
||||
}
|
||||
|
||||
/** Get path to settings.json */
|
||||
export function getSettingsPath(): string {
|
||||
return join(getAgentDir(), "settings.json");
|
||||
}
|
||||
|
||||
/** Get path to tools directory */
|
||||
export function getToolsDir(): string {
|
||||
return join(getAgentDir(), "tools");
|
||||
}
|
||||
|
||||
/** Get path to slash commands directory */
|
||||
export function getCommandsDir(): string {
|
||||
return join(getAgentDir(), "commands");
|
||||
}
|
||||
|
||||
/** Get path to sessions directory */
|
||||
export function getSessionsDir(): string {
|
||||
return join(getAgentDir(), "sessions");
|
||||
}
|
||||
|
||||
/** Get path to debug log file */
|
||||
export function getDebugLogPath(): string {
|
||||
return join(getAgentDir(), `${APP_NAME}-debug.log`);
|
||||
}
|
||||
83
packages/coding-agent/src/utils/fuzzy.ts
Normal file
83
packages/coding-agent/src/utils/fuzzy.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
|
||||
// Lower score = better match.
|
||||
|
||||
export interface FuzzyMatch {
|
||||
matches: boolean;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
||||
const queryLower = query.toLowerCase();
|
||||
const textLower = text.toLowerCase();
|
||||
|
||||
if (queryLower.length === 0) {
|
||||
return { matches: true, score: 0 };
|
||||
}
|
||||
|
||||
if (queryLower.length > textLower.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
let queryIndex = 0;
|
||||
let score = 0;
|
||||
let lastMatchIndex = -1;
|
||||
let consecutiveMatches = 0;
|
||||
|
||||
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||
if (textLower[i] === queryLower[queryIndex]) {
|
||||
const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!);
|
||||
|
||||
// Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
// Penalize gaps between matched characters
|
||||
if (lastMatchIndex >= 0) {
|
||||
score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Reward matches at word boundaries (start of words are more likely intentional targets)
|
||||
if (isWordBoundary) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Slight penalty for matches later in the string (prefer earlier matches)
|
||||
score += i * 0.1;
|
||||
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Not all query characters were found in order
|
||||
if (queryIndex < queryLower.length) {
|
||||
return { matches: false, score: 0 };
|
||||
}
|
||||
|
||||
return { matches: true, score };
|
||||
}
|
||||
|
||||
// Filter and sort items by fuzzy match quality (best matches first)
|
||||
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
||||
if (!query.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const results: { item: T; score: number }[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const text = getText(item);
|
||||
const match = fuzzyMatch(query, text);
|
||||
if (match.matches) {
|
||||
results.push({ item, score: match.score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort ascending by score (lower = better match)
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
|
||||
return results.map((r) => r.item);
|
||||
}
|
||||
132
packages/coding-agent/src/utils/shell.ts
Normal file
132
packages/coding-agent/src/utils/shell.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { SettingsManager } from "../core/settings-manager.js";
|
||||
|
||||
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
||||
|
||||
/**
|
||||
* Find bash executable on PATH (Windows)
|
||||
*/
|
||||
function findBashOnPath(): string | null {
|
||||
try {
|
||||
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
||||
if (firstMatch && existsSync(firstMatch)) {
|
||||
return firstMatch;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shell configuration based on platform.
|
||||
* Resolution order:
|
||||
* 1. User-specified shellPath in settings.json
|
||||
* 2. On Windows: Git Bash in known locations
|
||||
* 3. Fallback: bash on PATH (Windows) or sh (Unix)
|
||||
*/
|
||||
export function getShellConfig(): { shell: string; args: string[] } {
|
||||
if (cachedShellConfig) {
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
const settings = new SettingsManager();
|
||||
const customShellPath = settings.getShellPath();
|
||||
|
||||
// 1. Check user-specified shell path
|
||||
if (customShellPath) {
|
||||
if (existsSync(customShellPath)) {
|
||||
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
throw new Error(
|
||||
`Custom shell path not found: ${customShellPath}\n` + `Please update shellPath in ~/.pi/agent/settings.json`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// 2. Try Git Bash in known locations
|
||||
const paths: string[] = [];
|
||||
const programFiles = process.env.ProgramFiles;
|
||||
if (programFiles) {
|
||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
cachedShellConfig = { shell: path, args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
||||
const bashOnPath = findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No bash shell found. Options:\n` +
|
||||
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
||||
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
||||
` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` +
|
||||
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
cachedShellConfig = { shell: "sh", args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize binary output for display/storage.
|
||||
* Removes characters that crash string-width or cause display issues:
|
||||
* - Control characters (except tab, newline, carriage return)
|
||||
* - Lone surrogates
|
||||
* - Unicode Format characters (crash string-width due to a bug)
|
||||
*/
|
||||
export function sanitizeBinaryOutput(str: string): string {
|
||||
// Fast path: use regex to remove problematic characters
|
||||
// - \p{Format}: Unicode format chars like \u0601 that crash string-width
|
||||
// - \p{Surrogate}: Lone surrogates from invalid UTF-8
|
||||
// - Control chars except \t \n \r
|
||||
return str.replace(/[\p{Format}\p{Surrogate}]/gu, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and all its children (cross-platform)
|
||||
*/
|
||||
export function killProcessTree(pid: number): void {
|
||||
if (process.platform === "win32") {
|
||||
// Use taskkill on Windows to kill process tree
|
||||
try {
|
||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors if taskkill fails
|
||||
}
|
||||
} else {
|
||||
// Use SIGKILL on Unix/Linux/Mac
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// Fallback to killing just the child if process group kill fails
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
213
packages/coding-agent/src/utils/tools-manager.ts
Normal file
213
packages/coding-agent/src/utils/tools-manager.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
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 | null> {
|
||||
const existingPath = getToolPath(tool);
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
const config = TOOLS[tool];
|
||||
if (!config) return null;
|
||||
|
||||
// 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 null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue