From 4c2f22bf3e6c286d5ef0f2b09f8d4eb5b8deacd7 Mon Sep 17 00:00:00 2001 From: jae Date: Mon, 23 Mar 2026 20:14:41 +0100 Subject: [PATCH] feat: add 16 default extensions for Agent JAE New extensions shipping with Agent JAE: - jae-rules: Per-project .jae/rules.md injection - cost-tracker: Token/cost tracking with budget limits - project-dna: Auto-analyze project fingerprint - teach-mode: Pedagogical explanation mode - bookmarks: File bookmark management - replay: Session recording and asciicast export - checkpoints: Git-based undo/time-travel - pr-review: Autonomous PR diff review - auto-docs: Auto-generate README and docs - screenshot-context: Vision analysis for screenshots - deploy: One-command deploy (Docker/Vercel/rsync/static) - pair-programming: Real-time file watching with feedback - dashboard: Terminal HUD with live stats - swarm: Multi-agent parallel task execution - skill-marketplace: Search/install/publish skills - widget-api-demo: TUI widget API demonstration --- default-extensions/auto-docs.ts | 149 ++++++++++++++++++ default-extensions/bookmarks.ts | 132 ++++++++++++++++ default-extensions/checkpoints.ts | 126 +++++++++++++++ default-extensions/cost-tracker.ts | 104 +++++++++++++ default-extensions/dashboard.ts | 109 +++++++++++++ default-extensions/deploy.ts | 134 ++++++++++++++++ default-extensions/jae-rules.ts | 48 ++++++ default-extensions/pair-programming.ts | 112 ++++++++++++++ default-extensions/pr-review.ts | 112 ++++++++++++++ default-extensions/project-dna.ts | 139 +++++++++++++++++ default-extensions/replay.ts | 127 +++++++++++++++ default-extensions/screenshot-context.ts | 47 ++++++ default-extensions/skill-marketplace.ts | 189 +++++++++++++++++++++++ default-extensions/swarm.ts | 147 ++++++++++++++++++ default-extensions/teach-mode.ts | 38 +++++ default-extensions/widget-api-demo.ts | 84 ++++++++++ 16 files changed, 1797 insertions(+) create mode 100644 default-extensions/auto-docs.ts create mode 100644 default-extensions/bookmarks.ts create mode 100644 default-extensions/checkpoints.ts create mode 100644 default-extensions/cost-tracker.ts create mode 100644 default-extensions/dashboard.ts create mode 100644 default-extensions/deploy.ts create mode 100644 default-extensions/jae-rules.ts create mode 100644 default-extensions/pair-programming.ts create mode 100644 default-extensions/pr-review.ts create mode 100644 default-extensions/project-dna.ts create mode 100644 default-extensions/replay.ts create mode 100644 default-extensions/screenshot-context.ts create mode 100644 default-extensions/skill-marketplace.ts create mode 100644 default-extensions/swarm.ts create mode 100644 default-extensions/teach-mode.ts create mode 100644 default-extensions/widget-api-demo.ts diff --git a/default-extensions/auto-docs.ts b/default-extensions/auto-docs.ts new file mode 100644 index 0000000..d859442 --- /dev/null +++ b/default-extensions/auto-docs.ts @@ -0,0 +1,149 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, basename } from "node:path"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + async function analyzeAndGenerate(cwd: string, ctx: any): Promise { + const sections: string[] = []; + + // Project name + let projectName = basename(cwd); + let description = ""; + const pkgPath = join(cwd, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + projectName = pkg.name || projectName; + description = pkg.description || ""; + } catch { /* ignore */ } + } + + sections.push(`# ${projectName}\n`); + if (description) sections.push(`${description}\n`); + + // Tech stack + const stackChecks = [ + { file: "package.json", label: "Node.js/JavaScript" }, + { file: "tsconfig.json", label: "TypeScript" }, + { file: "Cargo.toml", label: "Rust" }, + { file: "go.mod", label: "Go" }, + { file: "pyproject.toml", label: "Python" }, + { file: "requirements.txt", label: "Python" }, + { file: "Dockerfile", label: "Docker" }, + { file: "docker-compose.yml", label: "Docker Compose" }, + { file: "next.config.js", label: "Next.js" }, + { file: "next.config.mjs", label: "Next.js" }, + { file: "vite.config.ts", label: "Vite" }, + { file: "tailwind.config.js", label: "Tailwind CSS" }, + { file: "tailwind.config.ts", label: "Tailwind CSS" }, + ]; + const stack = [...new Set(stackChecks.filter((c) => existsSync(join(cwd, c.file))).map((c) => c.label))]; + if (stack.length > 0) { + sections.push(`## Tech Stack\n${stack.map((s) => `- ${s}`).join("\n")}\n`); + } + + // Prerequisites + sections.push(`## Prerequisites\n`); + if (existsSync(join(cwd, "package.json"))) sections.push(`- Node.js (v18+ recommended)\n- npm or yarn\n`); + if (existsSync(join(cwd, "requirements.txt"))) sections.push(`- Python 3.8+\n- pip\n`); + if (existsSync(join(cwd, "Cargo.toml"))) sections.push(`- Rust / cargo\n`); + if (existsSync(join(cwd, "Dockerfile"))) sections.push(`- Docker\n`); + + // Installation + sections.push(`## Installation\n\n\`\`\`bash\ngit clone \ncd ${basename(cwd)}\n`); + if (existsSync(join(cwd, "package.json"))) sections.push(`npm install\n`); + if (existsSync(join(cwd, "requirements.txt"))) sections.push(`pip install -r requirements.txt\n`); + sections.push(`\`\`\`\n`); + + // Scripts + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts && Object.keys(pkg.scripts).length > 0) { + sections.push(`## Available Scripts\n`); + for (const [name, cmd] of Object.entries(pkg.scripts).slice(0, 20) as [string, string][]) { + sections.push(`- \`npm run ${name}\` - ${cmd}`); + } + sections.push(""); + } + } catch { /* ignore */ } + } + + // Project structure + try { + const treeResult = await pi.exec( + "find", + [".", "-maxdepth", "2", "-type", "f", + "-not", "-path", "*/node_modules/*", + "-not", "-path", "*/.git/*", + "-not", "-path", "*/dist/*"], + { cwd }, + ); + if (treeResult.code === 0 && treeResult.stdout.trim()) { + const files = treeResult.stdout.trim().split("\n").sort().slice(0, 40); + sections.push(`## Project Structure\n\n\`\`\`\n${files.join("\n")}\n\`\`\`\n`); + } + } catch { /* ignore */ } + + // Env vars + const envExample = join(cwd, ".env.example"); + if (existsSync(envExample)) { + const envContent = readFileSync(envExample, "utf-8").trim(); + sections.push(`## Environment Variables\n\nCopy \`.env.example\` to \`.env\` and configure:\n\n\`\`\`\n${envContent}\n\`\`\`\n`); + } + + // License + const licensePath = join(cwd, "LICENSE"); + if (existsSync(licensePath)) { + const license = readFileSync(licensePath, "utf-8"); + const firstLine = license.split("\n")[0].trim(); + sections.push(`## License\n\n${firstLine || "See LICENSE file."}\n`); + } + + sections.push(`\n---\n*Auto-generated by Agent JAE on ${new Date().toISOString().split("T")[0]}*\n`); + + return sections.join("\n"); + } + + pi.registerTool({ + name: "generate_docs", + label: "Generate Documentation", + description: "Auto-generate README and documentation for the current project", + parameters: Type.Object({ + output: Type.Optional(Type.String({ description: "Output filename (default: README.md)" })), + }), + execute: async (args, ctx) => { + const filename = args.output || "README.md"; + const content = await analyzeAndGenerate(ctx.cwd, ctx); + const outPath = join(ctx.cwd, filename); + writeFileSync(outPath, content, "utf-8"); + return { output: `Documentation generated: ${outPath} (${content.length} chars)` }; + }, + }); + + pi.registerCommand("docs", { + description: "Auto-generate README & docs: /docs [filename]", + handler: async (args, ctx) => { + const filename = args.trim() || "README.md"; + ctx.ui.notify("\u{1F4DD} Analyzing project and generating documentation...", "info"); + + const content = await analyzeAndGenerate(ctx.cwd, ctx); + const outPath = join(ctx.cwd, filename); + + if (existsSync(outPath)) { + const confirmed = await ctx.ui.confirm( + "Overwrite?", + `${filename} already exists. Overwrite?`, + ); + if (!confirmed) { + ctx.ui.notify("Cancelled.", "info"); + return; + } + } + + writeFileSync(outPath, content, "utf-8"); + ctx.ui.notify(`\u{1F4DD} Documentation generated: ${outPath} (${content.length} chars)`, "info"); + }, + }); +} diff --git a/default-extensions/bookmarks.ts b/default-extensions/bookmarks.ts new file mode 100644 index 0000000..2c21d25 --- /dev/null +++ b/default-extensions/bookmarks.ts @@ -0,0 +1,132 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +interface Bookmark { + name: string; + path: string; + line?: number; + note?: string; + timestamp: string; +} + +export default function (pi: ExtensionAPI) { + let bookmarks: Bookmark[] = []; + + function getBookmarksPath(cwd: string): string { + return join(cwd, ".jae", "bookmarks.json"); + } + + function loadBookmarks(cwd: string): void { + const bPath = getBookmarksPath(cwd); + if (existsSync(bPath)) { + try { + bookmarks = JSON.parse(readFileSync(bPath, "utf-8")); + } catch { + bookmarks = []; + } + } + } + + function saveBookmarks(cwd: string): void { + const jaeDir = join(cwd, ".jae"); + if (!existsSync(jaeDir)) mkdirSync(jaeDir, { recursive: true }); + writeFileSync(getBookmarksPath(cwd), JSON.stringify(bookmarks, null, 2), "utf-8"); + } + + pi.on("session_start", async (_event, ctx) => { + loadBookmarks(ctx.cwd); + }); + + pi.registerCommand("bookmark", { + description: "Manage bookmarks: /bookmark [add [:line]] | /bookmark list | /bookmark remove ", + handler: async (args, ctx) => { + loadBookmarks(ctx.cwd); + const parts = args.trim().split(/\s+/); + const subcommand = parts[0] || "list"; + + if (subcommand === "list" || subcommand === "ls") { + if (bookmarks.length === 0) { + ctx.ui.notify("No bookmarks saved. Use /bookmark add to add one.", "info"); + return; + } + const list = bookmarks + .map((b, i) => ` ${i + 1}. ${b.name} -> ${b.path}${b.line ? `:${b.line}` : ""}${b.note ? ` (${b.note})` : ""}`) + .join("\n"); + ctx.ui.notify(`\u{1F516} Bookmarks:\n${list}`, "info"); + return; + } + + if (subcommand === "add") { + const name = parts[1]; + const pathArg = parts[2]; + const note = parts.slice(3).join(" ") || undefined; + if (!name || !pathArg) { + ctx.ui.notify("Usage: /bookmark add [:line] [note]", "warning"); + return; + } + let filePath = pathArg; + let line: number | undefined; + if (pathArg.includes(":")) { + const colonIdx = pathArg.lastIndexOf(":"); + const maybeNum = parseInt(pathArg.substring(colonIdx + 1), 10); + if (!isNaN(maybeNum)) { + filePath = pathArg.substring(0, colonIdx); + line = maybeNum; + } + } + // Remove existing bookmark with same name + bookmarks = bookmarks.filter((b) => b.name !== name); + bookmarks.push({ name, path: filePath, line, note, timestamp: new Date().toISOString() }); + saveBookmarks(ctx.cwd); + ctx.ui.notify(`\u{1F516} Bookmark '${name}' saved: ${filePath}${line ? `:${line}` : ""}`, "info"); + return; + } + + if (subcommand === "remove" || subcommand === "rm" || subcommand === "delete") { + const name = parts[1]; + if (!name) { + ctx.ui.notify("Usage: /bookmark remove ", "warning"); + return; + } + const before = bookmarks.length; + bookmarks = bookmarks.filter((b) => b.name !== name); + if (bookmarks.length < before) { + saveBookmarks(ctx.cwd); + ctx.ui.notify(`\u{1F516} Bookmark '${name}' removed.`, "info"); + } else { + ctx.ui.notify(`Bookmark '${name}' not found.`, "warning"); + } + return; + } + + ctx.ui.notify("Usage: /bookmark [add|list|remove] ...", "info"); + }, + }); + + pi.registerCommand("search", { + description: "Search bookmarks: /search ", + handler: async (args, ctx) => { + loadBookmarks(ctx.cwd); + const query = args.trim().toLowerCase(); + if (!query) { + ctx.ui.notify("Usage: /search ", "warning"); + return; + } + const results = bookmarks.filter( + (b) => + b.name.toLowerCase().includes(query) || + b.path.toLowerCase().includes(query) || + (b.note && b.note.toLowerCase().includes(query)), + ); + if (results.length === 0) { + ctx.ui.notify(`No bookmarks matching '${query}'.`, "info"); + } else { + const list = results + .map((b) => ` - ${b.name} -> ${b.path}${b.line ? `:${b.line}` : ""}${b.note ? ` (${b.note})` : ""}`) + .join("\n"); + ctx.ui.notify(`\u{1F50D} Search results for '${query}':\n${list}`, "info"); + } + }, + }); +} diff --git a/default-extensions/checkpoints.ts b/default-extensions/checkpoints.ts new file mode 100644 index 0000000..0f6863b --- /dev/null +++ b/default-extensions/checkpoints.ts @@ -0,0 +1,126 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; + +export default function (pi: ExtensionAPI) { + let checkpointCount = 0; + + async function createCheckpoint(ctx: any, label: string): Promise { + try { + // Check if in git repo + const statusResult = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd }); + if (statusResult.code !== 0) return false; + + // Only checkpoint if there are changes + if (!statusResult.stdout.trim()) return false; + + // Stage all and create stash + await pi.exec("git", ["add", "-A"], { cwd: ctx.cwd }); + checkpointCount++; + const tag = `jae-checkpoint-${checkpointCount}`; + const result = await pi.exec( + "git", + ["stash", "push", "-m", `${tag}: ${label}`, "--include-untracked"], + { cwd: ctx.cwd }, + ); + + if (result.code === 0 && !result.stdout.includes("No local changes")) { + // Immediately pop to restore working state - the stash entry remains in reflog + await pi.exec("git", ["stash", "pop"], { cwd: ctx.cwd }); + return true; + } + return false; + } catch { + return false; + } + } + + // Auto-checkpoint on file mutations + pi.on("tool_execution_start", async (event, ctx) => { + const mutatingTools = ["write", "edit"]; + if (mutatingTools.includes(event.toolName)) { + await createCheckpoint(ctx, `before ${event.toolName}`); + } + }); + + // Also checkpoint on bash commands that might modify files + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash") { + const cmd = (event.input as any)?.command || ""; + const dangerousPatterns = ["rm ", "mv ", "cp ", "sed ", "chmod ", "chown ", "truncate", "dd "]; + if (dangerousPatterns.some((p) => cmd.includes(p))) { + await createCheckpoint(ctx, `before bash: ${cmd.substring(0, 50)}`); + } + } + }); + + pi.registerCommand("undo", { + description: "Undo last file change by restoring from git stash checkpoint", + handler: async (_args, ctx) => { + try { + // List stashes to find jae checkpoints + const listResult = await pi.exec("git", ["stash", "list"], { cwd: ctx.cwd }); + if (listResult.code !== 0) { + ctx.ui.notify("Not in a git repository.", "error"); + return; + } + + const stashes = listResult.stdout.trim().split("\n").filter(Boolean); + const jaeStashes = stashes.filter((s) => s.includes("jae-checkpoint-")); + + if (jaeStashes.length === 0) { + ctx.ui.notify("No JAE checkpoints available to undo.", "warning"); + return; + } + + // Show what we're undoing + const latest = jaeStashes[0]; + const confirmed = await ctx.ui.confirm( + "Undo Checkpoint", + `Restore to: ${latest}\n\nThis will discard current changes and restore the checkpoint.`, + ); + + if (!confirmed) { + ctx.ui.notify("Undo cancelled.", "info"); + return; + } + + // Hard reset and apply stash + const stashRef = latest.split(":")[0]; // e.g., "stash@{0}" + await pi.exec("git", ["checkout", "."], { cwd: ctx.cwd }); + await pi.exec("git", ["clean", "-fd"], { cwd: ctx.cwd }); + const applyResult = await pi.exec("git", ["stash", "apply", stashRef], { cwd: ctx.cwd }); + + if (applyResult.code === 0) { + ctx.ui.notify(`\u23EA Restored checkpoint: ${latest}`, "info"); + } else { + ctx.ui.notify(`Failed to apply stash: ${applyResult.stderr}`, "error"); + } + } catch (err) { + ctx.ui.notify(`Undo error: ${err}`, "error"); + } + }, + }); + + pi.registerCommand("checkpoints", { + description: "List available JAE checkpoints", + handler: async (_args, ctx) => { + const listResult = await pi.exec("git", ["stash", "list"], { cwd: ctx.cwd }); + if (listResult.code !== 0) { + ctx.ui.notify("Not in a git repository.", "error"); + return; + } + + const stashes = listResult.stdout.trim().split("\n").filter(Boolean); + const jaeStashes = stashes.filter((s) => s.includes("jae-checkpoint-")); + + if (jaeStashes.length === 0) { + ctx.ui.notify("No JAE checkpoints found.", "info"); + return; + } + + ctx.ui.notify( + `\u{1F4CC} JAE Checkpoints (${jaeStashes.length}):\n${jaeStashes.map((s) => ` ${s}`).join("\n")}`, + "info", + ); + }, + }); +} diff --git a/default-extensions/cost-tracker.ts b/default-extensions/cost-tracker.ts new file mode 100644 index 0000000..8808f7d --- /dev/null +++ b/default-extensions/cost-tracker.ts @@ -0,0 +1,104 @@ +import type { ExtensionAPI, ExtensionContext } from "@jaeswift/jae-coding-agent"; + +interface CostState { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalCost: number; + budgetLimit: number | null; + turns: number; +} + +const COST_PER_1K_INPUT = 0.003; +const COST_PER_1K_OUTPUT = 0.015; +const COST_PER_1K_CACHE_READ = 0.0003; +const COST_PER_1K_CACHE_WRITE = 0.00375; + +export default function (pi: ExtensionAPI) { + const state: CostState = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalCost: 0, + budgetLimit: null, + turns: 0, + }; + + function recalcCost() { + state.totalCost = + (state.inputTokens / 1000) * COST_PER_1K_INPUT + + (state.outputTokens / 1000) * COST_PER_1K_OUTPUT + + (state.cacheReadTokens / 1000) * COST_PER_1K_CACHE_READ + + (state.cacheWriteTokens / 1000) * COST_PER_1K_CACHE_WRITE; + } + + function updateStatus(ctx: ExtensionContext) { + const cost = state.totalCost.toFixed(4); + const budget = state.budgetLimit ? ` / $${state.budgetLimit.toFixed(2)}` : ""; + const tokens = ((state.inputTokens + state.outputTokens) / 1000).toFixed(1); + ctx.ui.setStatus("cost-tracker", `\u{1F4B0} $${cost}${budget} | ${tokens}k tok | ${state.turns} turns`); + } + + pi.on("turn_start", async (_event, ctx) => { + state.turns++; + updateStatus(ctx); + }); + + pi.on("agent_end", async (event, ctx) => { + for (const message of event.messages) { + if (message && typeof message === "object" && "usage" in message) { + const u = (message as any).usage; + state.inputTokens += u.input || 0; + state.outputTokens += u.output || 0; + state.cacheReadTokens += u.cacheRead || 0; + state.cacheWriteTokens += u.cacheWrite || 0; + } + } + recalcCost(); + updateStatus(ctx); + + if (state.budgetLimit && state.totalCost >= state.budgetLimit) { + ctx.ui.notify( + `\u26A0\uFE0F Budget limit of $${state.budgetLimit} reached! Total: $${state.totalCost.toFixed(4)}`, + "warning", + ); + } + }); + + pi.registerCommand("cost", { + description: "Show session cost breakdown", + handler: async (_args, ctx) => { + ctx.ui.notify( + `\u{1F4CA} Cost Report:\n` + + ` Input: ${state.inputTokens.toLocaleString()} tokens ($${((state.inputTokens / 1000) * COST_PER_1K_INPUT).toFixed(4)})\n` + + ` Output: ${state.outputTokens.toLocaleString()} tokens ($${((state.outputTokens / 1000) * COST_PER_1K_OUTPUT).toFixed(4)})\n` + + ` Cache Read: ${state.cacheReadTokens.toLocaleString()} tokens\n` + + ` Cache Write: ${state.cacheWriteTokens.toLocaleString()} tokens\n` + + ` Total Cost: $${state.totalCost.toFixed(4)}\n` + + ` Turns: ${state.turns}`, + "info", + ); + }, + }); + + pi.registerCommand("budget", { + description: "Set session budget limit: /budget ", + handler: async (args, ctx) => { + const amount = parseFloat(args || ""); + if (isNaN(amount) || amount <= 0) { + ctx.ui.notify( + state.budgetLimit + ? `Current budget: $${state.budgetLimit.toFixed(2)} | Spent: $${state.totalCost.toFixed(4)}` + : "No budget set. Usage: /budget 5.00", + "info", + ); + } else { + state.budgetLimit = amount; + ctx.ui.notify(`\u{1F4B0} Budget set to $${amount.toFixed(2)}`, "info"); + updateStatus(ctx); + } + }, + }); +} diff --git a/default-extensions/dashboard.ts b/default-extensions/dashboard.ts new file mode 100644 index 0000000..f79cadb --- /dev/null +++ b/default-extensions/dashboard.ts @@ -0,0 +1,109 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; + +interface DashboardState { + gitBranch: string; + modelName: string; + sessionTokens: number; + filesModified: Set; + toolCalls: number; + startTime: number; +} + +export default function (pi: ExtensionAPI) { + const state: DashboardState = { + gitBranch: "", + modelName: "", + sessionTokens: 0, + filesModified: new Set(), + toolCalls: 0, + startTime: Date.now(), + }; + + function formatUptime(): string { + const elapsed = Math.floor((Date.now() - state.startTime) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + return mins > 0 ? `${mins}m${secs}s` : `${secs}s`; + } + + function updateDashboard(ctx: any): void { + const tokens = (state.sessionTokens / 1000).toFixed(1); + const branch = state.gitBranch || "n/a"; + const model = state.modelName || "unknown"; + const files = state.filesModified.size; + const tools = state.toolCalls; + const uptime = formatUptime(); + + ctx.ui.setFooter( + "dashboard", + `\u{1F4CA} ${model} | \u{1F333} ${branch} | ${tokens}k tok | ${files} files | ${tools} tools | ${uptime}`, + ); + } + + pi.on("session_start", async (_event, ctx) => { + state.startTime = Date.now(); + + // Detect git branch + try { + const result = await pi.exec("git", ["branch", "--show-current"], { cwd: ctx.cwd }); + if (result.code === 0 && result.stdout.trim()) { + state.gitBranch = result.stdout.trim(); + } + } catch { /* not a git repo */ } + + // Get model name + if (ctx.model) { + state.modelName = ctx.model.name || ctx.model.id || "unknown"; + } + + updateDashboard(ctx); + }); + + pi.on("message_end", async (event, ctx) => { + if (event.message?.usage) { + const u = event.message.usage; + state.sessionTokens += (u.inputTokens || 0) + (u.outputTokens || 0); + } + if (ctx.model) { + state.modelName = ctx.model.name || ctx.model.id || state.modelName; + } + updateDashboard(ctx); + }); + + pi.on("tool_execution_end", async (event, ctx) => { + state.toolCalls++; + + // Track file modifications + if (["write", "edit", "write_file", "edit_file"].includes(event.toolName)) { + const filePath = (event.input as any)?.path || (event.input as any)?.file_path || ""; + if (filePath) state.filesModified.add(filePath); + } + + // Refresh git branch (might have changed) + try { + const result = await pi.exec("git", ["branch", "--show-current"], { cwd: ctx.cwd }); + if (result.code === 0 && result.stdout.trim()) { + state.gitBranch = result.stdout.trim(); + } + } catch { /* ignore */ } + + updateDashboard(ctx); + }); + + pi.registerCommand("dashboard", { + description: "Show session dashboard stats", + handler: async (_args, ctx) => { + const uptime = formatUptime(); + ctx.ui.notify( + `\u{1F4CA} Session Dashboard: + Model: ${state.modelName || "unknown"} + Git Branch: ${state.gitBranch || "n/a"} + Session Tokens: ${state.sessionTokens.toLocaleString()} + Files Modified: ${state.filesModified.size} (${Array.from(state.filesModified).slice(0, 5).join(", ")}${state.filesModified.size > 5 ? "..." : ""}) + Tool Calls: ${state.toolCalls} + Uptime: ${uptime}`, + "info", + ); + }, + }); +} diff --git a/default-extensions/deploy.ts b/default-extensions/deploy.ts new file mode 100644 index 0000000..84059af --- /dev/null +++ b/default-extensions/deploy.ts @@ -0,0 +1,134 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync } from "node:fs"; +import { join, basename } from "node:path"; +import { Type } from "@sinclair/typebox"; + +type DeployTarget = "docker" | "vercel" | "rsync" | "static" | "unknown"; + +interface StackInfo { + type: DeployTarget; + runtime: string; + entrypoint?: string; +} + +export default function (pi: ExtensionAPI) { + function detectStack(cwd: string): StackInfo { + if (existsSync(join(cwd, "Dockerfile"))) { + return { type: "docker", runtime: "Docker", entrypoint: "Dockerfile" }; + } + if (existsSync(join(cwd, "docker-compose.yml"))) { + return { type: "docker", runtime: "Docker Compose", entrypoint: "docker-compose.yml" }; + } + if (existsSync(join(cwd, "vercel.json"))) { + return { type: "vercel", runtime: "Vercel" }; + } + if (existsSync(join(cwd, "package.json"))) { + try { + const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + if (deps["next"]) return { type: "vercel", runtime: "Next.js" }; + if (deps["vite"] || deps["react-scripts"]) return { type: "static", runtime: "SPA (Vite/CRA)" }; + if (pkg.scripts?.start) return { type: "docker", runtime: "Node.js" }; + } catch { /* ignore */ } + return { type: "docker", runtime: "Node.js" }; + } + if (existsSync(join(cwd, "requirements.txt")) || existsSync(join(cwd, "pyproject.toml"))) { + return { type: "docker", runtime: "Python" }; + } + if (existsSync(join(cwd, "index.html"))) { + return { type: "static", runtime: "Static HTML" }; + } + return { type: "unknown", runtime: "Unknown" }; + } + + pi.registerTool({ + name: "deploy", + label: "Deploy Project", + description: "Detect project stack and deploy. Supports Docker, Vercel, rsync, and static sites.", + parameters: Type.Object({ + target: Type.Optional(Type.String({ description: "Deploy target: docker, vercel, rsync, static (auto-detected if omitted)" })), + host: Type.Optional(Type.String({ description: "Remote host for rsync deploy (user@host:/path)" })), + tag: Type.Optional(Type.String({ description: "Docker image tag" })), + }), + execute: async (args, ctx) => { + const stack = detectStack(ctx.cwd); + const deployType = (args.target as DeployTarget) || stack.type; + + if (deployType === "unknown") { + return { error: "Could not detect project stack. Specify a target: docker, vercel, rsync, static" }; + } + + const results: string[] = [`Detected: ${stack.runtime}`, `Deploy target: ${deployType}`]; + + if (deployType === "docker") { + const tag = args.tag || basename(ctx.cwd).toLowerCase(); + const buildResult = await pi.exec("docker", ["build", "-t", tag, "."], { cwd: ctx.cwd }); + if (buildResult.code !== 0) { + return { error: `Docker build failed: ${buildResult.stderr}` }; + } + results.push(`Docker image built: ${tag}`); + results.push(`Run with: docker run -d -p 3000:3000 ${tag}`); + } else if (deployType === "vercel") { + const vercelResult = await pi.exec("npx", ["vercel", "--yes"], { cwd: ctx.cwd }); + results.push(vercelResult.code === 0 ? `Vercel deployed: ${vercelResult.stdout.trim()}` : `Vercel error: ${vercelResult.stderr}`); + } else if (deployType === "rsync") { + if (!args.host) { + return { error: "rsync deploy requires --host argument (user@host:/path)" }; + } + const rsyncResult = await pi.exec( + "rsync", + ["-avz", "--exclude", "node_modules", "--exclude", ".git", "./", args.host], + { cwd: ctx.cwd }, + ); + results.push(rsyncResult.code === 0 ? `Synced to ${args.host}` : `rsync error: ${rsyncResult.stderr}`); + } else if (deployType === "static") { + // Build if needed + const pkgPath = join(ctx.cwd, "package.json"); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.build) { + const buildResult = await pi.exec("npm", ["run", "build"], { cwd: ctx.cwd }); + results.push(buildResult.code === 0 ? "Build succeeded" : `Build error: ${buildResult.stderr}`); + } + } + const distDir = existsSync(join(ctx.cwd, "dist")) ? "dist" : existsSync(join(ctx.cwd, "build")) ? "build" : "."; + results.push(`Static files in: ${distDir}/`); + results.push(`Serve locally: npx serve ${distDir}`); + if (args.host) { + const rsyncResult = await pi.exec("rsync", ["-avz", `${distDir}/`, args.host], { cwd: ctx.cwd }); + results.push(rsyncResult.code === 0 ? `Deployed to ${args.host}` : `rsync error: ${rsyncResult.stderr}`); + } + } + + return { output: results.join("\n") }; + }, + }); + + pi.registerCommand("deploy", { + description: "Deploy project: /deploy [docker|vercel|rsync|static] [--host user@host:/path] [--tag image-tag]", + handler: async (args, ctx) => { + const stack = detectStack(ctx.cwd); + const parts = args.trim().split(/\s+/); + const target = parts[0] || undefined; + + let host: string | undefined; + let tag: string | undefined; + for (let i = 0; i < parts.length; i++) { + if (parts[i] === "--host" && parts[i + 1]) host = parts[i + 1]; + if (parts[i] === "--tag" && parts[i + 1]) tag = parts[i + 1]; + } + + if (!target) { + ctx.ui.notify( + `\u{1F680} Stack detected: ${stack.runtime} (${stack.type})\n\nUsage: /deploy [target] [options]\n Targets: docker, vercel, rsync, static\n Options: --host user@host:/path --tag image-name`, + "info", + ); + return; + } + + ctx.ui.notify(`\u{1F680} Deploying as ${target}...`, "info"); + // Delegate to the tool + ctx.ui.notify(`Use the deploy tool for full deployment. Target: ${target}, Host: ${host || "none"}, Tag: ${tag || "auto"}`, "info"); + }, + }); +} diff --git a/default-extensions/jae-rules.ts b/default-extensions/jae-rules.ts new file mode 100644 index 0000000..f9fc883 --- /dev/null +++ b/default-extensions/jae-rules.ts @@ -0,0 +1,48 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export default function (pi: ExtensionAPI) { + pi.on("before_agent_start", async (_event, ctx) => { + const rulesPath = join(ctx.cwd, ".jae", "rules.md"); + if (existsSync(rulesPath)) { + const rules = readFileSync(rulesPath, "utf-8").trim(); + if (rules.length > 0) { + return { systemPrompt: `\n\n# Project Rules (.jae/rules.md)\n\n${rules}` }; + } + } + }); + + pi.registerCommand("rules", { + description: "Show, create, or edit project rules (.jae/rules.md)", + handler: async (args, ctx) => { + const jaeDir = join(ctx.cwd, ".jae"); + const rulesPath = join(jaeDir, "rules.md"); + + if (args.trim() === "init") { + if (!existsSync(jaeDir)) mkdirSync(jaeDir, { recursive: true }); + if (!existsSync(rulesPath)) { + writeFileSync( + rulesPath, + "# Project Rules\n\nAdd your project-specific rules here.\nThese will be injected into JAE's system prompt.\n", + "utf-8", + ); + ctx.ui.notify("Created .jae/rules.md - edit it to set project rules.", "info"); + } else { + ctx.ui.notify(".jae/rules.md already exists.", "info"); + } + return; + } + + if (existsSync(rulesPath)) { + const rules = readFileSync(rulesPath, "utf-8"); + ctx.ui.notify( + `\u{1F4CB} Project Rules (${rules.length} chars):\n${rules.substring(0, 500)}${rules.length > 500 ? "..." : ""}`, + "info", + ); + } else { + ctx.ui.notify("No .jae/rules.md found. Run /rules init to create one.", "warning"); + } + }, + }); +} diff --git a/default-extensions/pair-programming.ts b/default-extensions/pair-programming.ts new file mode 100644 index 0000000..c1d739b --- /dev/null +++ b/default-extensions/pair-programming.ts @@ -0,0 +1,112 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { watch, type FSWatcher, readFileSync } from "node:fs"; +import { join, relative, extname } from "node:path"; + +export default function (pi: ExtensionAPI) { + let watcher: FSWatcher | null = null; + let pairMode = false; + let watchedFiles = new Set(); + let lastChangeTime = 0; + const DEBOUNCE_MS = 2000; + + const CODE_EXTENSIONS = new Set([ + ".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".go", + ".java", ".c", ".cpp", ".h", ".css", ".scss", + ".html", ".vue", ".svelte", ".rb", ".php", + ".json", ".yaml", ".yml", ".toml", ".md", + ]); + + const IGNORE_PATTERNS = [ + "node_modules", ".git", "dist", "build", ".jae", + ".next", ".cache", "__pycache__", "target", + ]; + + function shouldWatch(filepath: string): boolean { + if (IGNORE_PATTERNS.some((p) => filepath.includes(p))) return false; + return CODE_EXTENSIONS.has(extname(filepath)); + } + + function startWatching(cwd: string, ctx: any): void { + if (watcher) { + watcher.close(); + } + + try { + watcher = watch(cwd, { recursive: true }, (eventType, filename) => { + if (!filename || !pairMode) return; + const filepath = join(cwd, filename); + if (!shouldWatch(filepath)) return; + + const now = Date.now(); + if (now - lastChangeTime < DEBOUNCE_MS) return; + lastChangeTime = now; + + const relPath = relative(cwd, filepath); + watchedFiles.add(relPath); + + try { + const content = readFileSync(filepath, "utf-8"); + const lines = content.split("\n").length; + ctx.ui.notify( + `\u{1F4DD} File changed: ${relPath} (${lines} lines, ${eventType})\nJAE is watching in pair mode.`, + "info", + ); + } catch { + ctx.ui.notify(`\u{1F4DD} File ${eventType}: ${relPath}`, "info"); + } + }); + + ctx.ui.notify( + `\u{1F91D} Pair programming mode started!\nWatching: ${cwd}\nJAE will observe your changes and provide feedback.`, + "info", + ); + } catch (err) { + ctx.ui.notify(`Failed to start file watcher: ${err}`, "error"); + } + } + + function stopWatching(ctx: any): void { + if (watcher) { + watcher.close(); + watcher = null; + } + const filesCount = watchedFiles.size; + watchedFiles.clear(); + ctx.ui.notify( + `\u{1F91D} Pair programming mode stopped. ${filesCount} files were modified during session.`, + "info", + ); + } + + pi.on("session_end", async (_event, _ctx) => { + if (watcher) { + watcher.close(); + watcher = null; + pairMode = false; + } + }); + + pi.on("before_agent_start", async (_event, _ctx) => { + if (pairMode && watchedFiles.size > 0) { + const filesList = Array.from(watchedFiles).slice(0, 10).join(", "); + return { + systemPrompt: `\n\n# Pair Programming Mode Active\nRecently modified files: ${filesList}\nProvide constructive feedback on changes. Suggest improvements, catch bugs, and explain patterns.\n`, + }; + } + }); + + pi.registerCommand("pair", { + description: "Toggle pair programming mode - JAE watches and comments on your file changes", + handler: async (_args, ctx) => { + pairMode = !pairMode; + + if (pairMode) { + startWatching(ctx.cwd, ctx); + ctx.ui.setFooter("pair", "\u{1F91D} Pair Mode"); + } else { + stopWatching(ctx); + ctx.ui.setFooter("pair", undefined); + } + }, + }); +} diff --git a/default-extensions/pr-review.ts b/default-extensions/pr-review.ts new file mode 100644 index 0000000..6d28e1a --- /dev/null +++ b/default-extensions/pr-review.ts @@ -0,0 +1,112 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + async function reviewDiff(diff: string, ctx: any): Promise { + if (!diff.trim()) return "No diff content to review."; + + const lines = diff.split("\n"); + const stats = { + filesChanged: 0, + additions: 0, + deletions: 0, + files: [] as string[], + }; + + for (const line of lines) { + if (line.startsWith("+++ b/")) { + stats.filesChanged++; + stats.files.push(line.replace("+++ b/", "")); + } else if (line.startsWith("+") && !line.startsWith("+++")) { + stats.additions++; + } else if (line.startsWith("-") && !line.startsWith("---")) { + stats.deletions++; + } + } + + return [ + `## PR Review Summary`, + ``, + `**Files changed:** ${stats.filesChanged}`, + `**Additions:** +${stats.additions}`, + `**Deletions:** -${stats.deletions}`, + ``, + `### Files:`, + ...stats.files.map((f) => `- ${f}`), + ``, + `### Diff Preview (first 200 lines):`, + "```diff", + lines.slice(0, 200).join("\n"), + "```", + ``, + `> Full review requires LLM analysis. Send this diff as context to JAE for detailed review.`, + ].join("\n"); + } + + pi.registerTool({ + name: "pr_review", + label: "PR Review", + description: "Review a pull request or git diff. Provide a branch name, PR URL, or commit range.", + parameters: Type.Object({ + target: Type.String({ description: "Branch name, PR URL, or commit range (e.g., 'main..feature', 'origin/main')" }), + }), + execute: async (args, ctx) => { + const { target } = args; + let diff = ""; + + // Try as branch comparison + const diffResult = await pi.exec( + "git", + ["diff", target], + { cwd: ctx.cwd }, + ); + + if (diffResult.code === 0 && diffResult.stdout.trim()) { + diff = diffResult.stdout; + } else { + // Try as range + const rangeResult = await pi.exec( + "git", + ["diff", `${target}`], + { cwd: ctx.cwd }, + ); + if (rangeResult.code === 0) { + diff = rangeResult.stdout; + } else { + return { error: `Could not get diff for: ${target}. Error: ${diffResult.stderr || rangeResult.stderr}` }; + } + } + + const review = await reviewDiff(diff, ctx); + return { output: review }; + }, + }); + + pi.registerCommand("pr-review", { + description: "Review a PR or branch diff: /pr-review ", + handler: async (args, ctx) => { + const target = args.trim(); + if (!target) { + ctx.ui.notify("Usage: /pr-review \nExamples:\n /pr-review main..feature\n /pr-review origin/main", "info"); + return; + } + + ctx.ui.notify(`\u{1F50D} Fetching diff for: ${target}...`, "info"); + + const diffResult = await pi.exec("git", ["diff", target], { cwd: ctx.cwd }); + + if (diffResult.code !== 0) { + ctx.ui.notify(`Failed to get diff: ${diffResult.stderr}`, "error"); + return; + } + + if (!diffResult.stdout.trim()) { + ctx.ui.notify("No differences found.", "info"); + return; + } + + const review = await reviewDiff(diffResult.stdout, ctx); + ctx.ui.notify(review, "info"); + }, + }); +} diff --git a/default-extensions/project-dna.ts b/default-extensions/project-dna.ts new file mode 100644 index 0000000..bad177f --- /dev/null +++ b/default-extensions/project-dna.ts @@ -0,0 +1,139 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export default function (pi: ExtensionAPI) { + pi.on("before_agent_start", async (_event, ctx) => { + const dnaPath = join(ctx.cwd, ".jae", "project-dna.md"); + if (existsSync(dnaPath)) { + const dna = readFileSync(dnaPath, "utf-8").trim(); + if (dna.length > 0) { + return { systemPrompt: `\n\n# Project DNA (auto-generated fingerprint)\n\n${dna}` }; + } + } + }); + + pi.on("session_start", async (_event, ctx) => { + const dnaPath = join(ctx.cwd, ".jae", "project-dna.md"); + if (!existsSync(dnaPath)) { + ctx.ui.notify("\u{1F9EC} No project DNA found. Run /dna to auto-analyze your project.", "info"); + } + }); + + pi.registerCommand("dna", { + description: "Auto-analyze project and generate .jae/project-dna.md fingerprint", + handler: async (args, ctx) => { + const cwd = ctx.cwd; + const jaeDir = join(cwd, ".jae"); + const dnaPath = join(jaeDir, "project-dna.md"); + + if (args.trim() === "show" && existsSync(dnaPath)) { + const dna = readFileSync(dnaPath, "utf-8"); + ctx.ui.notify(`\u{1F9EC} Project DNA:\n${dna.substring(0, 1500)}`, "info"); + return; + } + + ctx.ui.notify("\u{1F50D} Analyzing project...", "info"); + + const sections: string[] = ["# Project DNA\n"]; + + // Tech stack detection + const checks = [ + { file: "package.json", label: "Node.js" }, + { file: "Cargo.toml", label: "Rust" }, + { file: "go.mod", label: "Go" }, + { file: "pyproject.toml", label: "Python (pyproject)" }, + { file: "requirements.txt", label: "Python (pip)" }, + { file: "Gemfile", label: "Ruby" }, + { file: "pom.xml", label: "Java (Maven)" }, + { file: "build.gradle", label: "Java/Kotlin (Gradle)" }, + { file: "docker-compose.yml", label: "Docker Compose" }, + { file: "Dockerfile", label: "Docker" }, + { file: ".github/workflows", label: "GitHub Actions" }, + { file: "tsconfig.json", label: "TypeScript" }, + { file: ".eslintrc.json", label: "ESLint" }, + { file: "vitest.config.ts", label: "Vitest" }, + { file: "jest.config.js", label: "Jest" }, + { file: "tailwind.config.js", label: "Tailwind CSS" }, + { file: "next.config.js", label: "Next.js" }, + { file: "vite.config.ts", label: "Vite" }, + ]; + + const detected: string[] = []; + for (const c of checks) { + if (existsSync(join(cwd, c.file))) detected.push(c.label); + } + sections.push(`## Tech Stack\n${detected.length > 0 ? detected.map((d) => `- ${d}`).join("\n") : "- Unknown"}\n`); + + // Package.json details + const pkgPath = join(cwd, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + sections.push( + `## Package Info\n- Name: ${pkg.name || "unknown"}\n- Version: ${pkg.version || "unknown"}\n- Description: ${pkg.description || "none"}\n`, + ); + if (pkg.dependencies) { + const deps = Object.keys(pkg.dependencies).slice(0, 20); + sections.push(`## Dependencies (top 20)\n${deps.map((d) => `- ${d}`).join("\n")}\n`); + } + if (pkg.scripts) { + const scripts = Object.entries(pkg.scripts).slice(0, 15) as [string, string][]; + sections.push(`## Scripts\n${scripts.map(([k, v]) => `- \`${k}\`: ${v}`).join("\n")}\n`); + } + } catch { + /* ignore parse errors */ + } + } + + // Git info + try { + const gitResult = await pi.exec("git", ["log", "--oneline", "-10"], { cwd }); + if (gitResult.code === 0 && gitResult.stdout.trim()) { + sections.push(`## Recent Git History\n\`\`\`\n${gitResult.stdout.trim()}\n\`\`\`\n`); + } + const branchResult = await pi.exec("git", ["branch", "--show-current"], { cwd }); + if (branchResult.code === 0 && branchResult.stdout.trim()) { + sections.push(`## Current Branch: ${branchResult.stdout.trim()}\n`); + } + } catch { + /* not a git repo */ + } + + // File stats + try { + const findResult = await pi.exec( + "find", + [".", "-maxdepth", "3", "-type", "f", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*", "-not", "-path", "*/dist/*", "-not", "-path", "*/.jae/*"], + { cwd }, + ); + if (findResult.code === 0) { + const files = findResult.stdout.trim().split("\n").filter(Boolean); + sections.push(`## File Count: ${files.length}\n`); + const extCounts: Record = {}; + for (const f of files) { + const ext = f.includes(".") ? (f.split(".").pop() || "none") : "(no ext)"; + extCounts[ext] = (extCounts[ext] || 0) + 1; + } + const sorted = Object.entries(extCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15); + sections.push(`## File Types\n${sorted.map(([ext, count]) => `- .${ext}: ${count}`).join("\n")}\n`); + } + } catch { + /* ignore */ + } + + sections.push(`\n---\nGenerated: ${new Date().toISOString()}\n`); + + const dnaContent = sections.join("\n"); + if (!existsSync(jaeDir)) mkdirSync(jaeDir, { recursive: true }); + writeFileSync(dnaPath, dnaContent, "utf-8"); + + ctx.ui.notify( + `\u{1F9EC} Project DNA generated (${dnaContent.length} chars). Saved to .jae/project-dna.md`, + "info", + ); + }, + }); +} diff --git a/default-extensions/replay.ts b/default-extensions/replay.ts new file mode 100644 index 0000000..c25a014 --- /dev/null +++ b/default-extensions/replay.ts @@ -0,0 +1,127 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +interface ReplayEvent { + time: number; + type: string; + text: string; +} + +export default function (pi: ExtensionAPI) { + let recording = false; + let events: ReplayEvent[] = []; + let startTime = 0; + + pi.on("message_end", async (event, ctx) => { + if (!recording) return; + const msg = event.message; + if (!msg) return; + + let text = ""; + if ("content" in msg && typeof msg.content === "string") { + text = msg.content; + } else if ("content" in msg && Array.isArray(msg.content)) { + text = (msg.content as any[]) + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join(""); + } + + if (text) { + events.push({ + time: (Date.now() - startTime) / 1000, + type: (msg as any).role || "unknown", + text, + }); + } + }); + + pi.on("tool_execution_end", async (event, _ctx) => { + if (!recording) return; + const resultText = + typeof event.result === "string" + ? event.result + : JSON.stringify(event.result).substring(0, 500); + events.push({ + time: (Date.now() - startTime) / 1000, + type: `tool:${event.toolName}`, + text: resultText, + }); + }); + + pi.registerCommand("replay", { + description: "Session replay: /replay start | /replay stop | /replay export [file.cast]", + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const sub = parts[0] || "status"; + + if (sub === "start") { + if (recording) { + ctx.ui.notify("Already recording.", "warning"); + return; + } + recording = true; + events = []; + startTime = Date.now(); + ctx.ui.setStatus("replay", "\u{1F534} REC"); + ctx.ui.notify("\u{1F3AC} Replay recording started.", "info"); + return; + } + + if (sub === "stop") { + if (!recording) { + ctx.ui.notify("Not recording.", "warning"); + return; + } + recording = false; + ctx.ui.setStatus("replay", undefined); + ctx.ui.notify(`\u{1F3AC} Recording stopped. ${events.length} events captured. Use /replay export to save.`, "info"); + return; + } + + if (sub === "export") { + if (events.length === 0) { + ctx.ui.notify("No events recorded. Start a recording first.", "warning"); + return; + } + const filename = parts[1] || `jae-replay-${Date.now()}.cast`; + const outPath = join(ctx.cwd, filename); + + // asciicast v2 format + const header = JSON.stringify({ + version: 2, + width: 120, + height: 40, + timestamp: Math.floor(startTime / 1000), + title: "JAE Session Replay", + env: { TERM: "xterm-256color" }, + }); + + const lines = [header]; + for (const ev of events) { + const prefix = ev.type === "assistant" ? "\x1b[36m" : ev.type.startsWith("tool:") ? "\x1b[33m" : "\x1b[37m"; + const label = ev.type === "assistant" ? "JAE" : ev.type === "user" ? "YOU" : ev.type; + const displayText = `${prefix}[${label}]\x1b[0m ${ev.text}\r\n`; + lines.push(JSON.stringify([ev.time, "o", displayText])); + } + + writeFileSync(outPath, lines.join("\n") + "\n", "utf-8"); + ctx.ui.notify(`\u{1F3AC} Exported ${events.length} events to ${outPath}\nPlay with: asciinema play ${filename}`, "info"); + return; + } + + if (sub === "status") { + ctx.ui.notify( + recording + ? `\u{1F534} Recording in progress: ${events.length} events (${((Date.now() - startTime) / 1000).toFixed(0)}s)` + : `\u{1F3AC} Not recording. ${events.length} events in buffer.`, + "info", + ); + return; + } + + ctx.ui.notify("Usage: /replay start | stop | export [file.cast] | status", "info"); + }, + }); +} diff --git a/default-extensions/screenshot-context.ts b/default-extensions/screenshot-context.ts new file mode 100644 index 0000000..41d7d05 --- /dev/null +++ b/default-extensions/screenshot-context.ts @@ -0,0 +1,47 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("before_agent_start", async (_event, _ctx) => { + return { + systemPrompt: ` +# Screenshot & Image Analysis + +When the user pastes or attaches an image (screenshot, diagram, mockup, error screenshot): +1. Analyze the visual content thoroughly +2. If it's a UI screenshot: describe layout, identify components, suggest improvements +3. If it's an error screenshot: read the error message, diagnose the issue, suggest fixes +4. If it's a design mockup: break down into implementable components +5. If it's a diagram: interpret the architecture/flow and relate to the codebase +6. If it's code in an image: transcribe it accurately and analyze +Always acknowledge what you see in the image before providing analysis. +`, + }; + }); + + pi.on("input", async (event, ctx) => { + // Check if input contains image attachments + const input = event as any; + if (input.images && input.images.length > 0) { + ctx.ui.notify( + `\u{1F4F7} ${input.images.length} image(s) detected. JAE will analyze with vision capabilities.`, + "info", + ); + } + }); + + pi.registerCommand("screenshot", { + description: "Tips for using screenshot context with JAE", + handler: async (_args, ctx) => { + ctx.ui.notify( + `\u{1F4F7} Screenshot Context Tips: + +- Paste screenshots directly into the chat +- JAE will automatically analyze images with vision +- Supported: UI screenshots, error messages, diagrams, mockups +- For best results, crop to the relevant area +- You can paste multiple images in one message`, + "info", + ); + }, + }); +} diff --git a/default-extensions/skill-marketplace.ts b/default-extensions/skill-marketplace.ts new file mode 100644 index 0000000..961c0bc --- /dev/null +++ b/default-extensions/skill-marketplace.ts @@ -0,0 +1,189 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, writeFileSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { Type } from "@sinclair/typebox"; + +const SKILLS_API = "https://skills.jaeswift.xyz/api/skills"; + +interface SkillInfo { + name: string; + description: string; + version: string; + author: string; +} + +export default function (pi: ExtensionAPI) { + async function fetchJSON(url: string): Promise { + const result = await pi.exec("curl", ["-s", "-f", url]); + if (result.code !== 0) { + throw new Error(`HTTP request failed: ${result.stderr}`); + } + return JSON.parse(result.stdout); + } + + function getSkillsDir(ctx: any): string { + const dir = join(ctx.agentDir, "skills"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return dir; + } + + function listLocalSkills(ctx: any): string[] { + const dir = getSkillsDir(ctx); + try { + return readdirSync(dir).filter((f) => f.endsWith(".md") || existsSync(join(dir, f, "SKILL.md"))); + } catch { + return []; + } + } + + // /search-skills command + pi.registerCommand("search-skills", { + description: "Search the JAE skill marketplace: /search-skills ", + handler: async (args, ctx) => { + const query = args.trim(); + if (!query) { + ctx.ui.notify("Usage: /search-skills ", "warning"); + return; + } + + ctx.ui.notify(`\u{1F50D} Searching skills for: ${query}...`, "info"); + + try { + const skills = await fetchJSON(`${SKILLS_API}/search?q=${encodeURIComponent(query)}`); + if (!skills || skills.length === 0) { + ctx.ui.notify(`No skills found matching '${query}'.`, "info"); + return; + } + const list = skills + .map((s: SkillInfo) => ` - ${s.name} (v${s.version}) by ${s.author}\n ${s.description}`) + .join("\n"); + ctx.ui.notify(`\u{1F3EA} Skills matching '${query}':\n${list}`, "info"); + } catch (err: any) { + ctx.ui.notify(`Failed to search skills: ${err.message}\nAPI: ${SKILLS_API}`, "error"); + } + }, + }); + + // /install-skill command + pi.registerCommand("install-skill", { + description: "Install a skill from the marketplace: /install-skill ", + handler: async (args, ctx) => { + const name = args.trim(); + if (!name) { + ctx.ui.notify("Usage: /install-skill ", "warning"); + return; + } + + ctx.ui.notify(`\u{1F4E6} Installing skill: ${name}...`, "info"); + + try { + const skill = await fetchJSON(`${SKILLS_API}/${encodeURIComponent(name)}`); + if (!skill || !skill.content) { + ctx.ui.notify(`Skill '${name}' not found or has no content.`, "error"); + return; + } + + const skillsDir = getSkillsDir(ctx); + const skillDir = join(skillsDir, name); + if (!existsSync(skillDir)) mkdirSync(skillDir, { recursive: true }); + + writeFileSync(join(skillDir, "SKILL.md"), skill.content, "utf-8"); + + // Write any additional files + if (skill.files && typeof skill.files === "object") { + for (const [filename, content] of Object.entries(skill.files)) { + writeFileSync(join(skillDir, filename), content as string, "utf-8"); + } + } + + ctx.ui.notify( + `\u2705 Skill '${name}' installed to ${skillDir}\nRestart JAE to activate.`, + "info", + ); + } catch (err: any) { + ctx.ui.notify(`Failed to install skill: ${err.message}`, "error"); + } + }, + }); + + // /publish-skill command + pi.registerCommand("publish-skill", { + description: "Publish a local skill to the marketplace: /publish-skill ", + handler: async (args, ctx) => { + const name = args.trim(); + if (!name) { + ctx.ui.notify("Usage: /publish-skill ", "warning"); + return; + } + + const skillsDir = getSkillsDir(ctx); + const skillDir = join(skillsDir, name); + const skillMd = join(skillDir, "SKILL.md"); + + if (!existsSync(skillMd)) { + ctx.ui.notify(`Skill '${name}' not found locally at ${skillDir}`, "error"); + return; + } + + const content = readFileSync(skillMd, "utf-8"); + + // Collect extra files + const files: Record = {}; + try { + for (const f of readdirSync(skillDir)) { + if (f !== "SKILL.md") { + files[f] = readFileSync(join(skillDir, f), "utf-8"); + } + } + } catch { /* ignore */ } + + ctx.ui.notify(`\u{1F4E4} Publishing skill: ${name}...`, "info"); + + try { + const payload = JSON.stringify({ name, content, files }); + const result = await pi.exec( + "curl", + ["-s", "-f", "-X", "POST", "-H", "Content-Type: application/json", "-d", payload, SKILLS_API], + ); + if (result.code === 0) { + ctx.ui.notify(`\u2705 Skill '${name}' published to marketplace!`, "info"); + } else { + ctx.ui.notify(`Publish failed: ${result.stderr}`, "error"); + } + } catch (err: any) { + ctx.ui.notify(`Publish error: ${err.message}`, "error"); + } + }, + }); + + // /skills command - list local skills + pi.registerCommand("skills", { + description: "List locally installed skills", + handler: async (_args, ctx) => { + const local = listLocalSkills(ctx); + if (local.length === 0) { + ctx.ui.notify("No skills installed. Use /search-skills and /install-skill to get started.", "info"); + return; + } + ctx.ui.notify(`\u{1F9E0} Installed Skills (${local.length}):\n${local.map((s) => ` - ${s}`).join("\n")}`, "info"); + }, + }); + + // LLM tool for skill search + pi.registerTool({ + name: "search_skills", + label: "Search Skills", + description: "Search the JAE skill marketplace for installable skills", + parameters: Type.Object({ + query: Type.String({ description: "Search query for skills" }), + }), + execute: async (args, _ctx) => { + try { + const skills = await fetchJSON(`${SKILLS_API}/search?q=${encodeURIComponent(args.query)}`); + return { output: JSON.stringify(skills, null, 2) }; + } catch (err: any) { + return { error: `Skill search failed: ${err.message}` }; + } + }, + }); +} diff --git a/default-extensions/swarm.ts b/default-extensions/swarm.ts new file mode 100644 index 0000000..df12eea --- /dev/null +++ b/default-extensions/swarm.ts @@ -0,0 +1,147 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { execSync, spawn } from "node:child_process"; +import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { Type } from "@sinclair/typebox"; + +interface SwarmTask { + id: number; + description: string; + status: "pending" | "running" | "done" | "error"; + result: string; +} + +export default function (pi: ExtensionAPI) { + let taskCounter = 0; + + async function runSubtask( + description: string, + cwd: string, + ): Promise<{ success: boolean; output: string }> { + return new Promise((resolve) => { + const timeout = 120_000; // 2 minutes max per subtask + let output = ""; + let resolved = false; + + try { + const child = spawn( + "jae", + ["-p", description], + { + cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout, + env: { ...process.env }, + }, + ); + + child.stdout?.on("data", (data: Buffer) => { + output += data.toString(); + }); + + child.stderr?.on("data", (data: Buffer) => { + output += data.toString(); + }); + + child.on("close", (code) => { + if (!resolved) { + resolved = true; + resolve({ success: code === 0, output: output.trim() || "(no output)" }); + } + }); + + child.on("error", (err) => { + if (!resolved) { + resolved = true; + resolve({ success: false, output: `Process error: ${err.message}` }); + } + }); + + setTimeout(() => { + if (!resolved) { + resolved = true; + try { child.kill("SIGTERM"); } catch {} + resolve({ success: false, output: output.trim() + "\n(timed out)" }); + } + }, timeout); + } catch (err: any) { + resolve({ success: false, output: `Spawn error: ${err.message}` }); + } + }); + } + + pi.registerTool({ + name: "swarm_task", + label: "Swarm Task", + description: + "Split a complex task into subtasks and run them in parallel using multiple JAE instances. Provide the main task and a list of subtask descriptions.", + parameters: Type.Object({ + task: Type.String({ description: "Main task description" }), + subtasks: Type.Array(Type.String(), { + description: "List of subtask descriptions to run in parallel", + }), + }), + execute: async (args, ctx) => { + const { task, subtasks } = args; + if (!subtasks || subtasks.length === 0) { + return { error: "No subtasks provided" }; + } + + const tasks: SwarmTask[] = subtasks.map((desc: string) => ({ + id: ++taskCounter, + description: desc, + status: "pending" as const, + result: "", + })); + + ctx.ui.notify( + `\u{1F41D} Swarm: Launching ${tasks.length} subtasks for: ${task}`, + "info", + ); + + // Run all in parallel + const promises = tasks.map(async (t) => { + t.status = "running"; + const res = await runSubtask(t.description, ctx.cwd); + t.status = res.success ? "done" : "error"; + t.result = res.output; + return t; + }); + + const results = await Promise.all(promises); + + const summary = results + .map( + (t) => + `### Subtask #${t.id}: ${t.status === "done" ? "\u2705" : "\u274C"} ${t.description}\n${t.result.substring(0, 500)}`, + ) + .join("\n\n"); + + const successCount = results.filter((t) => t.status === "done").length; + + return { + output: `# Swarm Results\n\nTask: ${task}\nCompleted: ${successCount}/${tasks.length}\n\n${summary}`, + }; + }, + }); + + pi.registerCommand("swarm", { + description: + "Run parallel subtasks: /swarm (JAE will decompose into subtasks)", + handler: async (args, ctx) => { + const task = args.trim(); + if (!task) { + ctx.ui.notify( + "Usage: /swarm \n\nJAE will decompose the task into parallel subtasks and run them simultaneously.", + "info", + ); + return; + } + + ctx.ui.notify( + `\u{1F41D} Swarm mode: Submit this task to JAE and it will use the swarm_task tool to parallelize it.\n\nTask: ${task}`, + "info", + ); + }, + }); +} diff --git a/default-extensions/teach-mode.ts b/default-extensions/teach-mode.ts new file mode 100644 index 0000000..fa8213f --- /dev/null +++ b/default-extensions/teach-mode.ts @@ -0,0 +1,38 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; + +const TEACH_PROMPT = ` +# Teach Mode Active + +You are in TEACH MODE. For every action you take: +1. Explain WHY you are making this decision (not just what) +2. Describe alternative approaches you considered and why you rejected them +3. Point out patterns, best practices, and potential pitfalls +4. When writing code, add educational comments explaining non-obvious logic +5. After completing a task, provide a brief summary of what was learned +6. Use analogies and examples to clarify complex concepts +7. Highlight any trade-offs being made (performance vs readability, etc.) +`; + +export default function (pi: ExtensionAPI) { + let teachModeOn = false; + + pi.on("before_agent_start", async (_event, _ctx) => { + if (teachModeOn) { + return { systemPrompt: TEACH_PROMPT }; + } + }); + + pi.registerCommand("teach", { + description: "Toggle teach mode - JAE explains every decision in detail", + handler: async (_args, ctx) => { + teachModeOn = !teachModeOn; + ctx.ui.setStatus("teach-mode", teachModeOn ? "\u{1F393} Teach Mode" : undefined); + ctx.ui.notify( + teachModeOn + ? "\u{1F393} Teach Mode ON - JAE will explain every decision in detail." + : "\u{1F393} Teach Mode OFF - Back to normal operation.", + "info", + ); + }, + }); +} diff --git a/default-extensions/widget-api-demo.ts b/default-extensions/widget-api-demo.ts new file mode 100644 index 0000000..ccff3e9 --- /dev/null +++ b/default-extensions/widget-api-demo.ts @@ -0,0 +1,84 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; + +export default function (pi: ExtensionAPI) { + interface WidgetState { + activeTool: string; + lastToolDuration: number; + errorsCount: number; + warningsCount: number; + lastActivity: string; + } + + const state: WidgetState = { + activeTool: "none", + lastToolDuration: 0, + errorsCount: 0, + warningsCount: 0, + lastActivity: "idle", + }; + + let toolStartTime = 0; + + function renderWidget(): string { + const lines = [ + `\u{250C}\u{2500} JAE Status \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}`, + `\u{2502} Activity: ${state.lastActivity.padEnd(12)} \u{2502}`, + `\u{2502} Tool: ${state.activeTool.padEnd(12)} \u{2502}`, + `\u{2502} Last: ${(state.lastToolDuration + "ms").padEnd(12)} \u{2502}`, + `\u{2502} Errors: ${String(state.errorsCount).padEnd(12)} \u{2502}`, + `\u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}`, + ]; + return lines.join("\n"); + } + + function updateWidget(ctx: any): void { + ctx.ui.setFooter("widget-demo", `\u{1F4DF} ${state.lastActivity} | Tool: ${state.activeTool} | Errs: ${state.errorsCount}`); + } + + pi.on("tool_execution_start", async (event, ctx) => { + toolStartTime = Date.now(); + state.activeTool = event.toolName || "unknown"; + state.lastActivity = "running"; + updateWidget(ctx); + }); + + pi.on("tool_execution_end", async (event, ctx) => { + state.lastToolDuration = Date.now() - toolStartTime; + state.activeTool = "none"; + state.lastActivity = "idle"; + + // Check for errors in result + const result = typeof event.result === "string" ? event.result : JSON.stringify(event.result); + if (result.toLowerCase().includes("error")) { + state.errorsCount++; + } + + updateWidget(ctx); + }); + + pi.on("message_end", async (event, ctx) => { + state.lastActivity = "thinking"; + updateWidget(ctx); + }); + + pi.on("turn_start", async (_event, ctx) => { + state.lastActivity = "processing"; + updateWidget(ctx); + }); + + pi.on("turn_end", async (_event, ctx) => { + state.lastActivity = "idle"; + updateWidget(ctx); + }); + + pi.registerCommand("widget", { + description: "Show the JAE status widget demo", + handler: async (_args, ctx) => { + const widget = renderWidget(); + ctx.ui.notify( + `\u{1F4DF} Widget API Demo:\n\n${widget}\n\nThis demonstrates how extensions can render live TUI widgets.\nThe footer bar shows real-time status updates.\n\nWidget State:\n Active Tool: ${state.activeTool}\n Last Duration: ${state.lastToolDuration}ms\n Errors: ${state.errorsCount}\n Activity: ${state.lastActivity}`, + "info", + ); + }, + }); +}