From 86800d0e09294f87a126b511b1a9bf2ecb1f2a5c Mon Sep 17 00:00:00 2001 From: jae Date: Tue, 24 Mar 2026 02:53:36 +0100 Subject: [PATCH] Add 6 new extensions: dependency-auditor, docker-manager, env-manager, theme-system, agent-to-agent, gamification --- default-extensions/agent-to-agent.ts | 311 ++++++++++++++ default-extensions/dependency-auditor.ts | 257 ++++++++++++ default-extensions/docker-manager.ts | 318 +++++++++++++++ default-extensions/env-manager.ts | 295 ++++++++++++++ default-extensions/gamification.ts | 495 +++++++++++++++++++++++ default-extensions/theme-system.ts | 299 ++++++++++++++ 6 files changed, 1975 insertions(+) create mode 100644 default-extensions/agent-to-agent.ts create mode 100644 default-extensions/dependency-auditor.ts create mode 100644 default-extensions/docker-manager.ts create mode 100644 default-extensions/env-manager.ts create mode 100644 default-extensions/gamification.ts create mode 100644 default-extensions/theme-system.ts diff --git a/default-extensions/agent-to-agent.ts b/default-extensions/agent-to-agent.ts new file mode 100644 index 0000000..3108476 --- /dev/null +++ b/default-extensions/agent-to-agent.ts @@ -0,0 +1,311 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { spawn, type ChildProcess } from "node:child_process"; +import { Type } from "@sinclair/typebox"; + +interface AgentInstance { + id: string; + role: string; + systemPrompt: string; + status: "running" | "idle" | "stopped" | "error"; + process: ChildProcess | null; + output: string; + createdAt: number; + lastMessage: string; +} + +const ROLE_PROMPTS: Record = { + frontend: "You are a frontend development specialist. Focus on UI/UX, React, CSS, accessibility, responsive design, and browser APIs. Write clean component code with proper state management.", + backend: "You are a backend development specialist. Focus on API design, databases, server architecture, authentication, and performance. Write robust server-side code with proper error handling.", + tester: "You are a QA and testing specialist. Focus on writing comprehensive tests: unit tests, integration tests, E2E tests. Identify edge cases, write test plans, and ensure code reliability.", + reviewer: "You are a senior code reviewer. Analyze code for bugs, security issues, performance problems, and style violations. Provide constructive feedback with specific suggestions.", + devops: "You are a DevOps and infrastructure specialist. Focus on CI/CD pipelines, Docker, Kubernetes, monitoring, and deployment automation. Write infrastructure as code.", + security: "You are a security specialist. Focus on vulnerability assessment, secure coding practices, authentication/authorization, encryption, and threat modeling.", + architect: "You are a software architect. Focus on system design, scalability patterns, technology selection, API contracts, and architectural decision records.", + docs: "You are a technical documentation specialist. Focus on writing clear README files, API docs, tutorials, inline comments, and architectural documentation.", +}; + +export default function (pi: ExtensionAPI) { + const agents = new Map(); + let idCounter = 0; + + function generateId(role: string): string { + idCounter++; + return `${role}-${idCounter}`; + } + + function getSystemPrompt(role: string): string { + return ROLE_PROMPTS[role] || `You are a ${role} specialist. Focus on tasks related to ${role} and provide expert-level assistance.`; + } + + async function spawnAgent(role: string, cwd: string): Promise { + const id = generateId(role); + const systemPrompt = getSystemPrompt(role); + + const agent: AgentInstance = { + id, + role, + systemPrompt, + status: "idle", + process: null, + output: "", + createdAt: Date.now(), + lastMessage: "", + }; + + agents.set(id, agent); + return agent; + } + + async function sendMessage( + agent: AgentInstance, + message: string, + cwd: string, + ): Promise { + return new Promise((resolve) => { + const timeout = 180_000; // 3 minutes + let output = ""; + let resolved = false; + + agent.status = "running"; + agent.lastMessage = message; + + const prompt = `[System: ${agent.systemPrompt}]\n\n${message}`; + + try { + const child = spawn( + "jae", + ["-p", prompt], + { + cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout, + env: { ...process.env }, + }, + ); + + agent.process = child; + + child.stdout?.on("data", (data: Buffer) => { + output += data.toString(); + }); + + child.stderr?.on("data", (data: Buffer) => { + output += data.toString(); + }); + + child.on("close", (code) => { + agent.process = null; + agent.status = code === 0 ? "idle" : "error"; + agent.output = output.trim(); + if (!resolved) { + resolved = true; + resolve(output.trim() || "(no output)"); + } + }); + + child.on("error", (err) => { + agent.process = null; + agent.status = "error"; + if (!resolved) { + resolved = true; + resolve(`Agent error: ${err.message}`); + } + }); + + setTimeout(() => { + if (!resolved) { + resolved = true; + try { child.kill("SIGTERM"); } catch {} + agent.status = "error"; + resolve(output.trim() + "\n(timed out after 3 minutes)"); + } + }, timeout); + } catch (err: any) { + agent.status = "error"; + resolve(`Spawn error: ${err.message}`); + } + }); + } + + function killAgent(id: string): boolean { + const agent = agents.get(id); + if (!agent) return false; + if (agent.process) { + try { agent.process.kill("SIGTERM"); } catch {} + } + agent.status = "stopped"; + agent.process = null; + agents.delete(id); + return true; + } + + function formatAgentList(): string { + if (agents.size === 0) return "No active agents. Use /a2a spawn to create one."; + const lines: string[] = []; + for (const [id, agent] of agents) { + const uptime = Math.floor((Date.now() - agent.createdAt) / 1000); + const uptimeStr = uptime > 60 ? `${Math.floor(uptime / 60)}m${uptime % 60}s` : `${uptime}s`; + const statusIcon = agent.status === "running" ? "\u{1F7E2}" : agent.status === "idle" ? "\u{1F535}" : agent.status === "error" ? "\u{1F534}" : "\u26AB"; + lines.push(` ${statusIcon} ${id} [${agent.role}] — ${agent.status} — uptime: ${uptimeStr}`); + } + return lines.join("\n"); + } + + pi.registerTool({ + name: "a2a_communicate", + label: "Agent-to-Agent Communication", + description: "Spawn specialized AI agents and communicate with them. Agents can be given roles like 'frontend', 'backend', 'tester', 'reviewer', 'devops', 'security', 'architect', or 'docs'.", + parameters: Type.Object({ + action: Type.String({ description: "Action: 'spawn', 'send', 'broadcast', 'list', 'kill'" }), + role: Type.Optional(Type.String({ description: "Agent role for spawn (e.g., 'frontend', 'backend', 'tester')" })), + agent_id: Type.Optional(Type.String({ description: "Target agent ID for send/kill" })), + message: Type.Optional(Type.String({ description: "Message to send to agent(s)" })), + }), + execute: async (args, ctx) => { + const action = args.action.toLowerCase(); + + switch (action) { + case "spawn": { + const role = args.role || "general"; + const agent = await spawnAgent(role, ctx.cwd); + return { output: `\u2705 Agent spawned: ${agent.id} [${role}]\nSystem prompt: ${agent.systemPrompt.substring(0, 100)}...` }; + } + case "send": { + if (!args.agent_id) return { error: "Specify agent_id to send message to." }; + if (!args.message) return { error: "Specify message to send." }; + const agent = agents.get(args.agent_id); + if (!agent) return { error: `Agent '${args.agent_id}' not found. Use action 'list' to see agents.` }; + if (agent.status === "running") return { error: `Agent '${args.agent_id}' is busy processing. Wait for completion.` }; + const response = await sendMessage(agent, args.message, ctx.cwd); + return { output: `\u{1F4AC} Response from ${args.agent_id}:\n\n${response}` }; + } + case "broadcast": { + if (!args.message) return { error: "Specify message to broadcast." }; + if (agents.size === 0) return { error: "No active agents. Spawn some first." }; + const idle = Array.from(agents.values()).filter((a) => a.status === "idle"); + if (idle.length === 0) return { error: "All agents are busy. Wait for them to finish." }; + const promises = idle.map(async (agent) => { + const response = await sendMessage(agent, args.message!, ctx.cwd); + return `### ${agent.id} [${agent.role}]:\n${response.substring(0, 500)}`; + }); + const results = await Promise.all(promises); + return { output: `\u{1F4E2} Broadcast to ${idle.length} agents:\n\n${results.join("\n\n")}` }; + } + case "list": { + return { output: `\u{1F916} Active Agents:\n${formatAgentList()}` }; + } + case "kill": { + if (!args.agent_id) return { error: "Specify agent_id to kill." }; + const success = killAgent(args.agent_id); + return { output: success ? `\u2705 Agent '${args.agent_id}' terminated.` : `\u274C Agent '${args.agent_id}' not found.` }; + } + default: + return { error: `Unknown action '${action}'. Use: spawn, send, broadcast, list, kill` }; + } + }, + }); + + pi.registerCommand("a2a", { + description: "Agent-to-Agent: /a2a spawn | list | send | broadcast | kill ", + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const subcommand = (parts[0] || "list").toLowerCase(); + + switch (subcommand) { + case "spawn": { + const role = parts[1] || "general"; + const validRoles = [...Object.keys(ROLE_PROMPTS), "general"]; + const agent = await spawnAgent(role, ctx.cwd); + ctx.ui.notify( + `\u{1F916} Agent Spawned!\n${'\u2500'.repeat(30)}\n\n` + + ` ID: ${agent.id}\n` + + ` Role: ${role}\n` + + ` Prompt: ${agent.systemPrompt.substring(0, 80)}...\n\n` + + `Available roles: ${validRoles.join(", ")}\n` + + `Send messages: /a2a send ${agent.id} `, + "info", + ); + break; + } + case "list": + case "ls": { + ctx.ui.notify(`\u{1F916} Agent Registry\n${'\u2500'.repeat(30)}\n\n${formatAgentList()}`, "info"); + break; + } + case "send": { + const agentId = parts[1]; + const message = parts.slice(2).join(" "); + if (!agentId || !message) { + ctx.ui.notify("Usage: /a2a send ", "warning"); + return; + } + const agent = agents.get(agentId); + if (!agent) { + ctx.ui.notify(`\u274C Agent '${agentId}' not found. Use /a2a list.`, "warning"); + return; + } + if (agent.status === "running") { + ctx.ui.notify(`\u23F3 Agent '${agentId}' is busy. Wait for completion.`, "warning"); + return; + } + ctx.ui.notify(`\u{1F4E4} Sending to ${agentId}...`, "info"); + const response = await sendMessage(agent, message, ctx.cwd); + ctx.ui.notify(`\u{1F4AC} ${agentId} responded:\n\n${response.substring(0, 2000)}`, "info"); + break; + } + case "broadcast": { + const message = parts.slice(1).join(" "); + if (!message) { + ctx.ui.notify("Usage: /a2a broadcast ", "warning"); + return; + } + const idle = Array.from(agents.values()).filter((a) => a.status === "idle"); + if (idle.length === 0) { + ctx.ui.notify("\u274C No idle agents. Spawn some first with /a2a spawn .", "warning"); + return; + } + ctx.ui.notify(`\u{1F4E2} Broadcasting to ${idle.length} agents...`, "info"); + const promises = idle.map(async (agent) => { + const response = await sendMessage(agent, message, ctx.cwd); + return `\u{1F916} ${agent.id}:\n${response.substring(0, 500)}`; + }); + const results = await Promise.all(promises); + ctx.ui.notify(`\u{1F4E2} Broadcast Results:\n${'\u2500'.repeat(30)}\n\n${results.join("\n\n")}`, "info"); + break; + } + case "kill": + case "stop": { + const agentId = parts[1]; + if (!agentId) { + ctx.ui.notify("Usage: /a2a kill ", "warning"); + return; + } + if (agentId === "all") { + const count = agents.size; + for (const id of Array.from(agents.keys())) killAgent(id); + ctx.ui.notify(`\u2705 Terminated ${count} agents.`, "info"); + return; + } + const success = killAgent(agentId); + ctx.ui.notify( + success ? `\u2705 Agent '${agentId}' terminated.` : `\u274C Agent '${agentId}' not found.`, + success ? "info" : "warning", + ); + break; + } + default: + ctx.ui.notify( + `\u{1F916} Agent-to-Agent Communication\n${'\u2500'.repeat(30)}\n\n` + + "Commands:\n" + + " /a2a spawn - Spawn specialized agent\n" + + " /a2a list - List active agents\n" + + " /a2a send - Send message to agent\n" + + " /a2a broadcast - Send to all idle agents\n" + + " /a2a kill - Terminate agent(s)\n\n" + + `Roles: ${Object.keys(ROLE_PROMPTS).join(", ")}`, + "info", + ); + } + }, + }); +} diff --git a/default-extensions/dependency-auditor.ts b/default-extensions/dependency-auditor.ts new file mode 100644 index 0000000..d829b1c --- /dev/null +++ b/default-extensions/dependency-auditor.ts @@ -0,0 +1,257 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { Type } from "@sinclair/typebox"; + +interface AuditResult { + outdated: string[]; + vulnerabilities: string[]; + unused: string[]; + missing: string[]; + summary: string; +} + +export default function (pi: ExtensionAPI) { + async function parsePackageJson(cwd: string): Promise<{ deps: Record; devDeps: Record; scripts: Record } | null> { + const pkgPath = join(cwd, "package.json"); + if (!existsSync(pkgPath)) return null; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + return { + deps: pkg.dependencies || {}, + devDeps: pkg.devDependencies || {}, + scripts: pkg.scripts || {}, + }; + } catch { + return null; + } + } + + async function runNpmAudit(cwd: string): Promise { + try { + const result = await pi.exec("npm", ["audit", "--json"], { cwd }); + if (result.stdout.trim()) { + try { + const audit = JSON.parse(result.stdout); + const meta = audit.metadata || {}; + const vulns = meta.vulnerabilities || {}; + const lines: string[] = []; + let totalVulns = 0; + for (const [severity, count] of Object.entries(vulns)) { + if (typeof count === "number" && count > 0) { + lines.push(` ${severity}: ${count}`); + totalVulns += count; + } + } + if (totalVulns === 0) return "\u2705 No known vulnerabilities found."; + return `\u26A0\uFE0F Found ${totalVulns} vulnerabilities:\n${lines.join("\n")}`; + } catch { + return result.stdout.substring(0, 1000); + } + } + return result.stderr.trim() || "No audit data available."; + } catch (err: any) { + return `Audit error: ${err.message}`; + } + } + + async function runNpmOutdated(cwd: string): Promise { + try { + const result = await pi.exec("npm", ["outdated", "--json"], { cwd }); + if (result.stdout.trim()) { + try { + const outdated = JSON.parse(result.stdout); + const lines: string[] = []; + for (const [pkg, info] of Object.entries(outdated)) { + const i = info as any; + lines.push(` ${pkg}: ${i.current || "?"} \u2192 ${i.latest || "?"}`); + } + return lines; + } catch { + return [result.stdout.substring(0, 500)]; + } + } + return []; + } catch { + return []; + } + } + + async function findUnusedDeps(cwd: string, deps: Record): Promise { + const unused: string[] = []; + const depNames = Object.keys(deps); + if (depNames.length === 0) return unused; + + for (const dep of depNames) { + try { + // Search for import/require of this dep in source files + const result = await pi.exec( + "grep", + ["-r", "-l", "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx", "--include=*.mjs", "--include=*.cjs", dep, "."], + { cwd }, + ); + if (result.code !== 0 || !result.stdout.trim()) { + // Also check if referenced in config files + const configResult = await pi.exec( + "grep", + ["-r", "-l", "--include=*.json", "--include=*.yaml", "--include=*.yml", "--include=*.toml", "--include=*.config.*", dep, "."], + { cwd }, + ); + // Exclude package.json and lock files + const configFiles = (configResult.stdout || "").split("\n").filter( + (f) => f.trim() && !f.includes("package.json") && !f.includes("package-lock") && !f.includes("node_modules"), + ); + if (configFiles.length === 0) { + unused.push(dep); + } + } + } catch { + // grep failed, skip + } + } + return unused; + } + + async function fullAudit(cwd: string): Promise { + const pkg = await parsePackageJson(cwd); + if (!pkg) { + return { + outdated: [], + vulnerabilities: ["No package.json found in current directory."], + unused: [], + missing: [], + summary: "\u274C No package.json found. Navigate to a Node.js project directory.", + }; + } + + const allDeps = { ...pkg.deps, ...pkg.devDeps }; + const totalDeps = Object.keys(allDeps).length; + + // Run checks in parallel + const [outdated, securityReport, unused] = await Promise.all([ + runNpmOutdated(cwd), + runNpmAudit(cwd), + findUnusedDeps(cwd, pkg.deps), + ]); + + // Check for missing deps (referenced but not in package.json) + const missing: string[] = []; + try { + const result = await pi.exec( + "grep", + ["-r", "-h", "-o", "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx", + `from ['"][^./][^'"]*['"]`, "."], + { cwd }, + ); + if (result.stdout) { + const imports = new Set( + result.stdout.split("\n") + .map((line) => { + const match = line.match(/from\s+['"]([^./][^'"]*)['"]/); + return match ? match[1].split("/")[0] : null; + }) + .filter((name): name is string => name !== null && !name.startsWith("node:")), + ); + for (const imp of imports) { + if (!allDeps[imp] && !imp.startsWith("@")) { + missing.push(imp); + } + } + } + } catch { /* ignore grep errors */ } + + const summaryLines = [ + `\u{1F4E6} Dependencies: ${totalDeps} total (${Object.keys(pkg.deps).length} prod, ${Object.keys(pkg.devDeps).length} dev)`, + `\u{1F504} Outdated: ${outdated.length > 0 ? outdated.length + " packages" : "all up to date \u2705"}`, + `\u{1F6E1}\uFE0F Security: ${securityReport}`, + `\u{1F5D1}\uFE0F Potentially unused: ${unused.length > 0 ? unused.join(", ") : "none detected \u2705"}`, + ]; + if (missing.length > 0) { + summaryLines.push(`\u26A0\uFE0F Possibly missing: ${missing.join(", ")}`); + } + + return { + outdated, + vulnerabilities: [securityReport], + unused, + missing, + summary: summaryLines.join("\n"), + }; + } + + pi.registerTool({ + name: "audit_dependencies", + label: "Audit Dependencies", + description: "Scan project dependencies for outdated packages, security vulnerabilities, and unused dependencies. Optionally auto-fix issues.", + parameters: Type.Object({ + action: Type.Optional(Type.String({ description: "Action: 'scan' (default), 'fix', 'security'" })), + }), + execute: async (args, ctx) => { + const action = (args.action || "scan").toLowerCase(); + + if (action === "fix") { + const updateResult = await pi.exec("npm", ["update"], { cwd: ctx.cwd }); + const auditFixResult = await pi.exec("npm", ["audit", "fix"], { cwd: ctx.cwd }); + return { + output: `\u{1F527} Auto-fix results:\n\nnpm update:\n${updateResult.stdout || updateResult.stderr}\n\nnpm audit fix:\n${auditFixResult.stdout || auditFixResult.stderr}`, + }; + } + + if (action === "security") { + const report = await runNpmAudit(ctx.cwd); + return { output: `\u{1F6E1}\uFE0F Security Report:\n\n${report}` }; + } + + const result = await fullAudit(ctx.cwd); + return { output: result.summary }; + }, + }); + + pi.registerCommand("audit", { + description: "Audit dependencies: /audit [fix|security]", + handler: async (args, ctx) => { + const subcommand = args.trim().toLowerCase(); + + if (subcommand === "fix") { + ctx.ui.notify("\u{1F527} Running npm update and audit fix...", "info"); + const updateResult = await pi.exec("npm", ["update"], { cwd: ctx.cwd }); + const auditFixResult = await pi.exec("npm", ["audit", "fix"], { cwd: ctx.cwd }); + ctx.ui.notify( + `\u{1F527} Fix Results:\n\nnpm update:\n${(updateResult.stdout || updateResult.stderr || "done").substring(0, 500)}\n\nnpm audit fix:\n${(auditFixResult.stdout || auditFixResult.stderr || "done").substring(0, 500)}`, + "info", + ); + return; + } + + if (subcommand === "security") { + ctx.ui.notify("\u{1F6E1}\uFE0F Running security audit...", "info"); + const report = await runNpmAudit(ctx.cwd); + ctx.ui.notify(`\u{1F6E1}\uFE0F Security Report:\n\n${report}`, "info"); + return; + } + + ctx.ui.notify("\u{1F50D} Scanning dependencies...", "info"); + const result = await fullAudit(ctx.cwd); + + let output = `\u{1F4CB} Dependency Audit Report\n${'\u2500'.repeat(40)}\n\n${result.summary}`; + + if (result.outdated.length > 0) { + output += `\n\n\u{1F504} Outdated Packages:\n${result.outdated.join("\n")}`; + } + + if (result.unused.length > 0) { + output += `\n\n\u{1F5D1}\uFE0F Potentially Unused:\n ${result.unused.join(", ")}`; + output += `\n Run: npm uninstall ${result.unused.join(" ")}`; + } + + if (result.missing.length > 0) { + output += `\n\n\u26A0\uFE0F Possibly Missing:\n ${result.missing.join(", ")}`; + output += `\n Run: npm install ${result.missing.join(" ")}`; + } + + output += `\n\n\u{1F4A1} Tip: /audit fix to auto-update, /audit security for vuln details`; + + ctx.ui.notify(output, "info"); + }, + }); +} diff --git a/default-extensions/docker-manager.ts b/default-extensions/docker-manager.ts new file mode 100644 index 0000000..77711f2 --- /dev/null +++ b/default-extensions/docker-manager.ts @@ -0,0 +1,318 @@ +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"; + +export default function (pi: ExtensionAPI) { + async function dockerExec(args: string[], cwd: string): Promise<{ ok: boolean; output: string }> { + try { + const result = await pi.exec("docker", args, { cwd }); + return { + ok: result.code === 0, + output: (result.stdout || "").trim() || (result.stderr || "").trim() || "(no output)", + }; + } catch (err: any) { + return { ok: false, output: `Docker error: ${err.message}` }; + } + } + + async function getContainerStatus(cwd: string): Promise { + const result = await dockerExec( + ["ps", "--format", "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], + cwd, + ); + if (!result.ok) return `\u274C Docker not available: ${result.output}`; + if (!result.output.trim() || result.output.split("\n").length <= 1) { + return "\u{1F4AD} No running containers."; + } + return `\u{1F433} Running Containers:\n\n${result.output}`; + } + + async function getContainerLogs(container: string, cwd: string, tail: number = 50): Promise { + const result = await dockerExec(["logs", "--tail", String(tail), container], cwd); + if (!result.ok) return `\u274C Failed to get logs for '${container}': ${result.output}`; + return `\u{1F4DC} Logs for ${container} (last ${tail} lines):\n\n${result.output}`; + } + + async function buildImage(cwd: string, tag?: string, dockerfile?: string): Promise { + const imageTag = tag || basename(cwd).toLowerCase().replace(/[^a-z0-9_.-]/g, "-"); + const args = ["build", "-t", imageTag]; + if (dockerfile) args.push("-f", dockerfile); + args.push("."); + + const result = await dockerExec(args, cwd); + if (!result.ok) return `\u274C Build failed:\n${result.output.substring(0, 1000)}`; + + // Get image info + const info = await dockerExec(["images", imageTag, "--format", "{{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedAt}}"], cwd); + return `\u2705 Image built successfully: ${imageTag}\n${info.output}\n\nRun: docker run -d -p 3000:3000 ${imageTag}`; + } + + async function analyzeProjectForDockerfile(cwd: string): Promise { + const analysis: string[] = []; + + // Detect project type + 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"]) { + analysis.push("Detected: Next.js application"); + analysis.push("Recommended: Multi-stage build with Node.js Alpine"); + analysis.push(generateNodeDockerfile("next", pkg)); + } else if (deps["vite"] || deps["react-scripts"]) { + analysis.push("Detected: SPA (React/Vite)"); + analysis.push("Recommended: Multi-stage build with nginx"); + analysis.push(generateSPADockerfile(pkg)); + } else { + analysis.push("Detected: Node.js application"); + analysis.push(generateNodeDockerfile("node", pkg)); + } + } catch { + analysis.push("Could not parse package.json"); + } + } else if (existsSync(join(cwd, "requirements.txt")) || existsSync(join(cwd, "pyproject.toml"))) { + analysis.push("Detected: Python application"); + analysis.push(generatePythonDockerfile(cwd)); + } else if (existsSync(join(cwd, "go.mod"))) { + analysis.push("Detected: Go application"); + analysis.push(generateGoDockerfile()); + } else if (existsSync(join(cwd, "Cargo.toml"))) { + analysis.push("Detected: Rust application"); + analysis.push("Recommended: Multi-stage build with rust:alpine"); + } else { + analysis.push("Could not detect project type. Provide a Dockerfile manually."); + } + + return analysis.join("\n\n"); + } + + function generateNodeDockerfile(type: string, pkg: any): string { + const startCmd = pkg.scripts?.start ? "npm start" : "node index.js"; + if (type === "next") { + return `# Suggested Dockerfile for Next.js +FROM node:20-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"]`; + } + return `# Suggested Dockerfile for Node.js +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +EXPOSE 3000 +CMD ["${startCmd.split(" ")[0]}", ${startCmd.split(" ").slice(1).map((s: string) => `"${s}"`).join(", ")}]`; + } + + function generateSPADockerfile(pkg: any): string { + const buildDir = pkg.scripts?.build?.includes("vite") ? "dist" : "build"; + return `# Suggested Dockerfile for SPA +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/${buildDir} /usr/share/nginx/html +COPY < { + const action = args.action.toLowerCase(); + + switch (action) { + case "status": + case "ps": + case "list": { + const status = await getContainerStatus(ctx.cwd); + return { output: status }; + } + case "build": { + if (!existsSync(join(ctx.cwd, "Dockerfile"))) { + return { error: "No Dockerfile found. Use action 'generate' to create one." }; + } + const result = await buildImage(ctx.cwd, args.tag); + return { output: result }; + } + case "logs": { + if (!args.container) return { error: "Specify a container name or ID." }; + const logs = await getContainerLogs(args.container, ctx.cwd); + return { output: logs }; + } + case "generate": { + const analysis = await analyzeProjectForDockerfile(ctx.cwd); + return { output: `\u{1F433} Dockerfile Generation:\n\n${analysis}` }; + } + case "run": { + const tag = args.tag || basename(ctx.cwd).toLowerCase().replace(/[^a-z0-9_.-]/g, "-"); + const runArgs = ["run", "-d"]; + if (args.port) runArgs.push("-p", args.port); + runArgs.push(tag); + const result = await dockerExec(runArgs, ctx.cwd); + return { output: result.ok ? `\u2705 Container started: ${result.output.substring(0, 12)}` : `\u274C ${result.output}` }; + } + case "stop": { + if (!args.container) return { error: "Specify a container name or ID." }; + const result = await dockerExec(["stop", args.container], ctx.cwd); + return { output: result.ok ? `\u2705 Stopped: ${args.container}` : `\u274C ${result.output}` }; + } + default: + return { error: `Unknown action '${action}'. Use: status, build, logs, generate, run, stop` }; + } + }, + }); + + pi.registerCommand("docker", { + description: "Docker manager: /docker [build|logs |generate|status]", + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const subcommand = (parts[0] || "status").toLowerCase(); + + switch (subcommand) { + case "status": + case "ps": + case "": { + const status = await getContainerStatus(ctx.cwd); + ctx.ui.notify(status, "info"); + break; + } + case "build": { + if (!existsSync(join(ctx.cwd, "Dockerfile"))) { + ctx.ui.notify("\u274C No Dockerfile found. Use /docker generate first.", "warning"); + return; + } + ctx.ui.notify("\u{1F528} Building Docker image...", "info"); + const tag = parts[1] || undefined; + const result = await buildImage(ctx.cwd, tag); + ctx.ui.notify(result, "info"); + break; + } + case "logs": { + const container = parts[1]; + if (!container) { + ctx.ui.notify("Usage: /docker logs ", "warning"); + return; + } + const tail = parseInt(parts[2] || "50", 10); + const logs = await getContainerLogs(container, ctx.cwd, tail); + ctx.ui.notify(logs, "info"); + break; + } + case "generate": { + ctx.ui.notify("\u{1F50D} Analyzing project for Dockerfile generation...", "info"); + const analysis = await analyzeProjectForDockerfile(ctx.cwd); + ctx.ui.notify(`\u{1F433} Dockerfile Suggestion:\n\n${analysis}\n\n\u{1F4A1} Tip: Ask JAE to create the Dockerfile based on this analysis.`, "info"); + break; + } + case "stop": { + const container = parts[1]; + if (!container) { + ctx.ui.notify("Usage: /docker stop ", "warning"); + return; + } + const result = await dockerExec(["stop", container], ctx.cwd); + ctx.ui.notify(result.ok ? `\u2705 Stopped: ${container}` : `\u274C ${result.output}`, "info"); + break; + } + case "run": { + const tag = parts[1]; + if (!tag) { + ctx.ui.notify("Usage: /docker run [port-mapping]", "warning"); + return; + } + const port = parts[2] || undefined; + const runArgs = ["run", "-d"]; + if (port) runArgs.push("-p", port); + runArgs.push(tag); + const result = await dockerExec(runArgs, ctx.cwd); + ctx.ui.notify(result.ok ? `\u2705 Container started: ${result.output.substring(0, 12)}` : `\u274C ${result.output}`, "info"); + break; + } + default: + ctx.ui.notify( + `\u{1F433} Docker Manager\n${'\u2500'.repeat(30)}\n\n` + + "Commands:\n" + + " /docker - Show running containers\n" + + " /docker build - Build image from Dockerfile\n" + + " /docker logs - Show container logs\n" + + " /docker generate - Generate Dockerfile for project\n" + + " /docker run - Run a container\n" + + " /docker stop - Stop a container", + "info", + ); + } + }, + }); +} diff --git a/default-extensions/env-manager.ts b/default-extensions/env-manager.ts new file mode 100644 index 0000000..370e471 --- /dev/null +++ b/default-extensions/env-manager.ts @@ -0,0 +1,295 @@ +import type { ExtensionAPI } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, basename } from "node:path"; +import { Type } from "@sinclair/typebox"; + +interface EnvFile { + name: string; + path: string; + vars: Map; +} + +export default function (pi: ExtensionAPI) { + function parseEnvFile(filePath: string): Map { + const vars = new Map(); + if (!existsSync(filePath)) return vars; + try { + const content = readFileSync(filePath, "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) continue; + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + // Strip quotes + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + vars.set(key, value); + } + } catch { /* ignore read errors */ } + return vars; + } + + function findEnvFiles(cwd: string): EnvFile[] { + const envFiles: EnvFile[] = []; + + function scanDir(dir: string, depth: number = 0): void { + if (depth > 3) return; // Don't go too deep + try { + const entries = readdirSync(dir); + for (const entry of entries) { + if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === "build") continue; + const fullPath = join(dir, entry); + try { + const stat = statSync(fullPath); + if (stat.isFile() && (entry.startsWith(".env") || entry.endsWith(".env"))) { + envFiles.push({ + name: relative(cwd, fullPath) || entry, + path: fullPath, + vars: parseEnvFile(fullPath), + }); + } else if (stat.isDirectory() && depth < 3) { + scanDir(fullPath, depth + 1); + } + } catch { /* skip inaccessible */ } + } + } catch { /* skip unreadable dirs */ } + } + + scanDir(cwd); + return envFiles; + } + + function maskValue(value: string): string { + if (value.length <= 4) return "****"; + return value.substring(0, 2) + "*".repeat(Math.min(value.length - 4, 20)) + value.substring(value.length - 2); + } + + function diffEnvFiles(fileA: EnvFile, fileB: EnvFile): string { + const allKeys = new Set([...fileA.vars.keys(), ...fileB.vars.keys()]); + const lines: string[] = [ + `\u{1F504} Diff: ${fileA.name} vs ${fileB.name}`, + `${'\u2500'.repeat(50)}`, + ]; + + let onlyA: string[] = []; + let onlyB: string[] = []; + let different: string[] = []; + let same = 0; + + for (const key of Array.from(allKeys).sort()) { + const hasA = fileA.vars.has(key); + const hasB = fileB.vars.has(key); + + if (hasA && !hasB) { + onlyA.push(key); + } else if (!hasA && hasB) { + onlyB.push(key); + } else if (hasA && hasB) { + const valA = fileA.vars.get(key)!; + const valB = fileB.vars.get(key)!; + if (valA !== valB) { + different.push(` ${key}: ${maskValue(valA)} \u2192 ${maskValue(valB)}`); + } else { + same++; + } + } + } + + if (onlyA.length > 0) lines.push(`\n\u{1F7E2} Only in ${fileA.name}:\n ${onlyA.join(", ")}`); + if (onlyB.length > 0) lines.push(`\n\u{1F535} Only in ${fileB.name}:\n ${onlyB.join(", ")}`); + if (different.length > 0) lines.push(`\n\u{1F7E1} Different values:\n${different.join("\n")}`); + lines.push(`\n\u2705 ${same} variables identical`); + + return lines.join("\n"); + } + + function checkMissingVars(envFiles: EnvFile[]): string { + if (envFiles.length < 2) return "Need at least 2 .env files to check for missing variables."; + + // Collect all keys across all files + const allKeys = new Set(); + for (const f of envFiles) { + for (const key of f.vars.keys()) { + allKeys.add(key); + } + } + + const lines: string[] = [ + `\u{1F50D} Missing Variables Check`, + `${'\u2500'.repeat(50)}`, + `Files: ${envFiles.map((f) => f.name).join(", ")}`, + `Total unique keys: ${allKeys.size}`, + "", + ]; + + let missingCount = 0; + for (const key of Array.from(allKeys).sort()) { + const presentIn: string[] = []; + const missingFrom: string[] = []; + for (const f of envFiles) { + if (f.vars.has(key)) presentIn.push(f.name); + else missingFrom.push(f.name); + } + if (missingFrom.length > 0) { + lines.push(` \u26A0\uFE0F ${key}: missing from ${missingFrom.join(", ")}`); + missingCount++; + } + } + + if (missingCount === 0) { + lines.push("\u2705 All variables present in all env files!"); + } else { + lines.push(`\n\u{1F4CA} ${missingCount} variables missing across environments`); + } + + return lines.join("\n"); + } + + function syncEnvFiles(source: EnvFile, target: EnvFile): { added: string[]; content: string } { + const added: string[] = []; + const targetContent = existsSync(target.path) ? readFileSync(target.path, "utf-8") : ""; + let newContent = targetContent; + + for (const [key, value] of source.vars) { + if (!target.vars.has(key)) { + added.push(key); + const appendLine = `\n# Synced from ${source.name}\n${key}=${value}`; + newContent += appendLine; + } + } + + return { added, content: newContent }; + } + + pi.registerTool({ + name: "env_manage", + label: "Environment Manager", + description: "Manage .env files: list, diff, check for missing vars, or sync between environments.", + parameters: Type.Object({ + action: Type.String({ description: "Action: 'list', 'diff', 'check', 'sync'" }), + source: Type.Optional(Type.String({ description: "Source env file name for diff/sync" })), + target: Type.Optional(Type.String({ description: "Target env file name for diff/sync" })), + }), + execute: async (args, ctx) => { + const envFiles = findEnvFiles(ctx.cwd); + const action = args.action.toLowerCase(); + + switch (action) { + case "list": { + if (envFiles.length === 0) return { output: "No .env files found in project." }; + const list = envFiles + .map((f) => ` ${f.name}: ${f.vars.size} variables`) + .join("\n"); + return { output: `\u{1F4C1} Environment Files:\n${list}` }; + } + case "diff": { + if (envFiles.length < 2) return { error: "Need at least 2 .env files to diff." }; + const source = args.source ? envFiles.find((f) => f.name.includes(args.source!)) : envFiles[0]; + const target = args.target ? envFiles.find((f) => f.name.includes(args.target!)) : envFiles[1]; + if (!source || !target) return { error: "Could not find specified env files." }; + return { output: diffEnvFiles(source, target) }; + } + case "check": { + return { output: checkMissingVars(envFiles) }; + } + case "sync": { + if (!args.source || !args.target) return { error: "Specify source and target env files." }; + const source = envFiles.find((f) => f.name.includes(args.source!)); + const target = envFiles.find((f) => f.name.includes(args.target!)); + if (!source || !target) return { error: "Could not find specified env files." }; + const { added, content } = syncEnvFiles(source, target); + if (added.length === 0) return { output: "\u2705 Target already has all source variables." }; + writeFileSync(target.path, content, "utf-8"); + return { output: `\u2705 Synced ${added.length} variables to ${target.name}:\n ${added.join(", ")}` }; + } + default: + return { error: `Unknown action '${action}'. Use: list, diff, check, sync` }; + } + }, + }); + + pi.registerCommand("env", { + description: "Manage env files: /env [diff|check|sync ]", + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const subcommand = (parts[0] || "list").toLowerCase(); + const envFiles = findEnvFiles(ctx.cwd); + + switch (subcommand) { + case "list": + case "": { + if (envFiles.length === 0) { + ctx.ui.notify("\u{1F4C1} No .env files found in this project.", "info"); + return; + } + const list = envFiles + .map((f) => { + const keys = Array.from(f.vars.keys()).slice(0, 5).join(", "); + const more = f.vars.size > 5 ? ` +${f.vars.size - 5} more` : ""; + return ` \u{1F4C4} ${f.name} (${f.vars.size} vars): ${keys}${more}`; + }) + .join("\n"); + ctx.ui.notify(`\u{1F4C1} Environment Files:\n\n${list}`, "info"); + break; + } + case "diff": { + if (envFiles.length < 2) { + ctx.ui.notify("Need at least 2 .env files to diff.", "warning"); + return; + } + const source = parts[1] ? envFiles.find((f) => f.name.includes(parts[1])) : envFiles[0]; + const target = parts[2] ? envFiles.find((f) => f.name.includes(parts[2])) : envFiles[1]; + if (!source || !target) { + ctx.ui.notify("Could not find specified env files. Use /env to list them.", "warning"); + return; + } + ctx.ui.notify(diffEnvFiles(source, target), "info"); + break; + } + case "check": { + ctx.ui.notify(checkMissingVars(envFiles), "info"); + break; + } + case "sync": { + const sourceName = parts[1]; + const targetName = parts[2]; + if (!sourceName || !targetName) { + ctx.ui.notify("Usage: /env sync \n\nExample: /env sync .env.development .env.production", "warning"); + return; + } + const source = envFiles.find((f) => f.name.includes(sourceName)); + const target = envFiles.find((f) => f.name.includes(targetName)); + if (!source) { + ctx.ui.notify(`Could not find env file matching '${sourceName}'.`, "warning"); + return; + } + if (!target) { + ctx.ui.notify(`Could not find env file matching '${targetName}'.`, "warning"); + return; + } + const { added, content } = syncEnvFiles(source, target); + if (added.length === 0) { + ctx.ui.notify("\u2705 Target already has all source variables. Nothing to sync.", "info"); + return; + } + writeFileSync(target.path, content, "utf-8"); + ctx.ui.notify(`\u2705 Synced ${added.length} missing variables to ${target.name}:\n ${added.join(", ")}`, "info"); + break; + } + default: + ctx.ui.notify( + `\u{1F4C1} Environment Manager\n${'\u2500'.repeat(30)}\n\n` + + "Commands:\n" + + " /env - List all .env files\n" + + " /env diff [a] [b] - Compare two env files\n" + + " /env check - Find missing vars across files\n" + + " /env sync - Sync missing vars from source to target", + "info", + ); + } + }, + }); +} diff --git a/default-extensions/gamification.ts b/default-extensions/gamification.ts new file mode 100644 index 0000000..58b5ead --- /dev/null +++ b/default-extensions/gamification.ts @@ -0,0 +1,495 @@ +import type { ExtensionAPI, ExtensionContext } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +interface Stats { + sessionsCount: number; + totalPrompts: number; + filesCreated: number; + filesEdited: number; + linesWritten: number; + toolsUsed: number; + commandsRun: number; + bugsFixed: number; + timeSpentMinutes: number; + fileTypesEdited: Set; + usedSwarm: boolean; + usedAllVeniceSkills: boolean; + longestSessionMinutes: number; + shortestSessionMinutes: number; + currentSessionStart: number; + nightOwlSessions: number; +} + +interface Achievement { + id: string; + name: string; + description: string; + icon: string; + check: (stats: Stats) => boolean; +} + +interface StatsData { + sessionsCount: number; + totalPrompts: number; + filesCreated: number; + filesEdited: number; + linesWritten: number; + toolsUsed: number; + commandsRun: number; + bugsFixed: number; + timeSpentMinutes: number; + fileTypesEdited: string[]; + usedSwarm: boolean; + usedAllVeniceSkills: boolean; + longestSessionMinutes: number; + shortestSessionMinutes: number; + nightOwlSessions: number; + unlockedAchievements: string[]; + personalBests: Record; +} + +export default function (pi: ExtensionAPI) { + const configDir = join(process.env.HOME || "/root", ".jae"); + const statsPath = join(configDir, "stats.json"); + + const VENICE_SKILLS = [ + "venice-chat", "venice-image-gen", "venice-tts", + "venice-video-generate", "venice-list-text-models", + "venice-list-image-models", "venice-list-video-models", + "venice-chat-benchmark", "venice-video-queue", + "venice-video-quote", "venice-video-retrieve", + ]; + const veniceSkillsUsed = new Set(); + + const stats: Stats = { + sessionsCount: 0, + totalPrompts: 0, + filesCreated: 0, + filesEdited: 0, + linesWritten: 0, + toolsUsed: 0, + commandsRun: 0, + bugsFixed: 0, + timeSpentMinutes: 0, + fileTypesEdited: new Set(), + usedSwarm: false, + usedAllVeniceSkills: false, + longestSessionMinutes: 0, + shortestSessionMinutes: Infinity, + currentSessionStart: Date.now(), + nightOwlSessions: 0, + }; + + let unlockedAchievements = new Set(); + let personalBests: Record = {}; + + const ACHIEVEMENTS: Achievement[] = [ + { + id: "first_prompt", + name: "First Prompt", + description: "Sent your first prompt to JAE", + icon: "\u{1F476}", + check: (s) => s.totalPrompts >= 1, + }, + { + id: "centurion", + name: "100 Prompts", + description: "Sent 100 prompts total", + icon: "\u{1F4AF}", + check: (s) => s.totalPrompts >= 100, + }, + { + id: "file_creator", + name: "File Creator", + description: "Created 10 files", + icon: "\u{1F4C4}", + check: (s) => s.filesCreated >= 10, + }, + { + id: "bug_squasher", + name: "Bug Squasher", + description: "Fixed 10 bugs", + icon: "\u{1F41B}", + check: (s) => s.bugsFixed >= 10, + }, + { + id: "night_owl", + name: "Night Owl", + description: "Coded after midnight", + icon: "\u{1F989}", + check: (s) => s.nightOwlSessions >= 1, + }, + { + id: "speed_demon", + name: "Speed Demon", + description: "Completed a session in under 5 minutes", + icon: "\u26A1", + check: (s) => s.shortestSessionMinutes > 0 && s.shortestSessionMinutes < 5, + }, + { + id: "marathon", + name: "Marathon", + description: "Session lasting over 2 hours", + icon: "\u{1F3C3}", + check: (s) => s.longestSessionMinutes >= 120, + }, + { + id: "swarm_master", + name: "Swarm Master", + description: "Used swarm mode", + icon: "\u{1F41D}", + check: (s) => s.usedSwarm, + }, + { + id: "dragon_rider", + name: "Dragon Rider", + description: "Used all Venice AI skills", + icon: "\u{1F409}", + check: (s) => s.usedAllVeniceSkills, + }, + { + id: "full_stack", + name: "Full Stack", + description: "Edited 5+ different file types", + icon: "\u{1F4DA}", + check: (s) => s.fileTypesEdited.size >= 5, + }, + { + id: "tool_master", + name: "Tool Master", + description: "Used 50 tools in a session", + icon: "\u{1F6E0}\uFE0F", + check: (s) => s.toolsUsed >= 50, + }, + { + id: "prolific_writer", + name: "Prolific Writer", + description: "Written 1000+ lines of code", + icon: "\u270D\uFE0F", + check: (s) => s.linesWritten >= 1000, + }, + ]; + + function loadStats(): void { + try { + if (existsSync(statsPath)) { + const data: StatsData = JSON.parse(readFileSync(statsPath, "utf-8")); + stats.sessionsCount = data.sessionsCount || 0; + stats.totalPrompts = data.totalPrompts || 0; + stats.filesCreated = data.filesCreated || 0; + stats.filesEdited = data.filesEdited || 0; + stats.linesWritten = data.linesWritten || 0; + stats.toolsUsed = data.toolsUsed || 0; + stats.commandsRun = data.commandsRun || 0; + stats.bugsFixed = data.bugsFixed || 0; + stats.timeSpentMinutes = data.timeSpentMinutes || 0; + stats.fileTypesEdited = new Set(data.fileTypesEdited || []); + stats.usedSwarm = data.usedSwarm || false; + stats.usedAllVeniceSkills = data.usedAllVeniceSkills || false; + stats.longestSessionMinutes = data.longestSessionMinutes || 0; + stats.shortestSessionMinutes = data.shortestSessionMinutes || Infinity; + stats.nightOwlSessions = data.nightOwlSessions || 0; + unlockedAchievements = new Set(data.unlockedAchievements || []); + personalBests = data.personalBests || {}; + } + } catch { /* ignore */ } + } + + function saveStats(): void { + try { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + const data: StatsData = { + sessionsCount: stats.sessionsCount, + totalPrompts: stats.totalPrompts, + filesCreated: stats.filesCreated, + filesEdited: stats.filesEdited, + linesWritten: stats.linesWritten, + toolsUsed: stats.toolsUsed, + commandsRun: stats.commandsRun, + bugsFixed: stats.bugsFixed, + timeSpentMinutes: stats.timeSpentMinutes, + fileTypesEdited: Array.from(stats.fileTypesEdited), + usedSwarm: stats.usedSwarm, + usedAllVeniceSkills: stats.usedAllVeniceSkills, + longestSessionMinutes: stats.longestSessionMinutes, + shortestSessionMinutes: stats.shortestSessionMinutes === Infinity ? 0 : stats.shortestSessionMinutes, + nightOwlSessions: stats.nightOwlSessions, + unlockedAchievements: Array.from(unlockedAchievements), + personalBests, + }; + writeFileSync(statsPath, JSON.stringify(data, null, 2), "utf-8"); + } catch { /* ignore write errors */ } + } + + function checkAchievements(ctx: ExtensionContext): void { + for (const achievement of ACHIEVEMENTS) { + if (!unlockedAchievements.has(achievement.id) && achievement.check(stats)) { + unlockedAchievements.add(achievement.id); + ctx.ui.notify( + `${achievement.icon} Achievement Unlocked!\n\n` + + ` ${achievement.name}\n` + + ` ${achievement.description}\n\n` + + `Total: ${unlockedAchievements.size}/${ACHIEVEMENTS.length} achievements`, + "info", + ); + saveStats(); + } + } + } + + function updatePersonalBests(): void { + const sessionMinutes = Math.floor((Date.now() - stats.currentSessionStart) / 60000); + if (sessionMinutes > (personalBests.longestSession || 0)) { + personalBests.longestSession = sessionMinutes; + } + if (stats.totalPrompts > (personalBests.mostPrompts || 0)) { + personalBests.mostPrompts = stats.totalPrompts; + } + if (stats.filesCreated > (personalBests.mostFilesCreated || 0)) { + personalBests.mostFilesCreated = stats.filesCreated; + } + if (stats.toolsUsed > (personalBests.mostToolsUsed || 0)) { + personalBests.mostToolsUsed = stats.toolsUsed; + } + if (stats.linesWritten > (personalBests.mostLinesWritten || 0)) { + personalBests.mostLinesWritten = stats.linesWritten; + } + } + + function getFileExtension(filepath: string): string { + const parts = filepath.split("."); + return parts.length > 1 ? "." + parts[parts.length - 1] : ""; + } + + function renderStatsDashboard(): string { + const sessionMinutes = Math.floor((Date.now() - stats.currentSessionStart) / 60000); + const totalHours = (stats.timeSpentMinutes / 60).toFixed(1); + const bar = '\u2588'; + const dim = '\u2591'; + + // Progress bars + const promptProgress = Math.min(stats.totalPrompts / 100, 1); + const promptBar = bar.repeat(Math.floor(promptProgress * 20)) + dim.repeat(20 - Math.floor(promptProgress * 20)); + + const fileProgress = Math.min(stats.filesCreated / 10, 1); + const fileBar = bar.repeat(Math.floor(fileProgress * 20)) + dim.repeat(20 - Math.floor(fileProgress * 20)); + + const bugProgress = Math.min(stats.bugsFixed / 10, 1); + const bugBar = bar.repeat(Math.floor(bugProgress * 20)) + dim.repeat(20 - Math.floor(bugProgress * 20)); + + const achievementProgress = unlockedAchievements.size / ACHIEVEMENTS.length; + const achieveBar = bar.repeat(Math.floor(achievementProgress * 20)) + dim.repeat(20 - Math.floor(achievementProgress * 20)); + + const lines = [ + `\u{250C}${'\u2500'.repeat(50)}\u{2510}`, + `\u{2502} \u{1F4CA} JAE Coding Statistics Dashboard${' '.repeat(16)}\u{2502}`, + `\u{251C}${'\u2500'.repeat(50)}\u{2524}`, + `\u{2502} \u{2502}`, + `\u{2502} \u{1F3AE} Sessions: ${String(stats.sessionsCount).padEnd(8)} \u23F1\uFE0F This: ${sessionMinutes}m${' '.repeat(Math.max(0, 12 - String(sessionMinutes).length))}\u{2502}`, + `\u{2502} \u{1F4AC} Prompts: ${String(stats.totalPrompts).padEnd(8)} \u{1F4DD} Lines: ${String(stats.linesWritten).padEnd(10)}\u{2502}`, + `\u{2502} \u{1F4C4} Created: ${String(stats.filesCreated).padEnd(8)} \u270F\uFE0F Edited: ${String(stats.filesEdited).padEnd(9)}\u{2502}`, + `\u{2502} \u{1F6E0}\uFE0F Tools: ${String(stats.toolsUsed).padEnd(8)} \u{1F41B} Fixes: ${String(stats.bugsFixed).padEnd(10)}\u{2502}`, + `\u{2502} \u23F0 Total time: ${totalHours}h${' '.repeat(Math.max(0, 35 - totalHours.length))}\u{2502}`, + `\u{2502} \u{2502}`, + `\u{251C}${'\u2500'.repeat(50)}\u{2524}`, + `\u{2502} Progress to Achievements:${' '.repeat(24)}\u{2502}`, + `\u{2502} Prompts [${promptBar}] ${stats.totalPrompts}/100${' '.repeat(Math.max(0, 6 - String(stats.totalPrompts).length))}\u{2502}`, + `\u{2502} Files [${fileBar}] ${stats.filesCreated}/10${' '.repeat(Math.max(0, 7 - String(stats.filesCreated).length))}\u{2502}`, + `\u{2502} Bugs [${bugBar}] ${stats.bugsFixed}/10${' '.repeat(Math.max(0, 7 - String(stats.bugsFixed).length))}\u{2502}`, + `\u{2502} Achieve [${achieveBar}] ${unlockedAchievements.size}/${ACHIEVEMENTS.length}${' '.repeat(Math.max(0, 7 - String(unlockedAchievements.size).length))}\u{2502}`, + `\u{2502} \u{2502}`, + `\u{2502} \u{1F4C2} File types: ${Array.from(stats.fileTypesEdited).slice(0, 8).join(", ") || "none"}${' '.repeat(Math.max(0, 5))}\u{2502}`, + `\u{2514}${'\u2500'.repeat(50)}\u{2518}`, + ]; + + return lines.join("\n"); + } + + function renderAchievements(): string { + const lines: string[] = [ + `\u{1F3C6} Achievements (${unlockedAchievements.size}/${ACHIEVEMENTS.length})`, + `${'\u2500'.repeat(50)}`, + "", + ]; + + for (const a of ACHIEVEMENTS) { + const unlocked = unlockedAchievements.has(a.id); + const status = unlocked ? "\u2705" : "\u{1F512}"; + const icon = unlocked ? a.icon : "\u2753"; + lines.push(` ${status} ${icon} ${a.name}`); + lines.push(` ${unlocked ? a.description : "????"}`); + lines.push(""); + } + + return lines.join("\n"); + } + + function renderLeaderboard(): string { + const lines: string[] = [ + `\u{1F3C5} Personal Bests`, + `${'\u2500'.repeat(40)}`, + "", + ]; + + const bests: [string, string, number][] = [ + ["\u23F1\uFE0F", "Longest Session", personalBests.longestSession || 0], + ["\u{1F4AC}", "Most Prompts", personalBests.mostPrompts || 0], + ["\u{1F4C4}", "Most Files Created", personalBests.mostFilesCreated || 0], + ["\u{1F6E0}\uFE0F", "Most Tools Used", personalBests.mostToolsUsed || 0], + ["\u{1F4DD}", "Most Lines Written", personalBests.mostLinesWritten || 0], + ]; + + for (const [icon, label, value] of bests) { + const unit = label.includes("Session") ? " min" : ""; + lines.push(` ${icon} ${label}: ${value}${unit}`); + } + + return lines.join("\n"); + } + + // Load stats on init + loadStats(); + + // Track session start + pi.on("session_start", async (_event, ctx) => { + loadStats(); + stats.sessionsCount++; + stats.currentSessionStart = Date.now(); + + // Check for Night Owl + const hour = new Date().getHours(); + if (hour >= 0 && hour < 5) { + stats.nightOwlSessions++; + } + + saveStats(); + checkAchievements(ctx); + + ctx.ui.setFooter( + "gamification", + `\u{1F3AE} S:${stats.sessionsCount} P:${stats.totalPrompts} F:${stats.filesCreated} \u{1F41B}:${stats.bugsFixed} \u{1F3C6}:${unlockedAchievements.size}/${ACHIEVEMENTS.length}`, + ); + }); + + // Track prompts + pi.on("turn_start", async (_event, ctx) => { + stats.totalPrompts++; + saveStats(); + checkAchievements(ctx); + + ctx.ui.setFooter( + "gamification", + `\u{1F3AE} S:${stats.sessionsCount} P:${stats.totalPrompts} F:${stats.filesCreated} \u{1F41B}:${stats.bugsFixed} \u{1F3C6}:${unlockedAchievements.size}/${ACHIEVEMENTS.length}`, + ); + }); + + // Track tool calls and file operations + pi.on("tool_execution_end", async (event, ctx) => { + stats.toolsUsed++; + const toolName = event.toolName || ""; + + // Track file creates/edits + if (["write", "write_file"].includes(toolName)) { + stats.filesCreated++; + const filePath = (event.input as any)?.path || (event.input as any)?.file_path || ""; + if (filePath) { + const ext = getFileExtension(filePath); + if (ext) stats.fileTypesEdited.add(ext); + } + // Estimate lines written + const content = (event.input as any)?.content || ""; + if (typeof content === "string") { + stats.linesWritten += content.split("\n").length; + } + } + + if (["edit", "edit_file"].includes(toolName)) { + stats.filesEdited++; + const filePath = (event.input as any)?.path || (event.input as any)?.file_path || ""; + if (filePath) { + const ext = getFileExtension(filePath); + if (ext) stats.fileTypesEdited.add(ext); + } + } + + if (toolName === "bash" || toolName === "terminal") { + stats.commandsRun++; + } + + // Track swarm usage + if (toolName === "swarm_task") { + stats.usedSwarm = true; + } + + // Track Venice skill usage + if (VENICE_SKILLS.includes(toolName)) { + veniceSkillsUsed.add(toolName); + if (veniceSkillsUsed.size >= VENICE_SKILLS.length) { + stats.usedAllVeniceSkills = true; + } + } + + saveStats(); + checkAchievements(ctx); + }); + + // Track bug fixes via agent messages + pi.on("agent_end", async (event, ctx) => { + // Check messages for bug fix indicators + for (const message of event.messages) { + if (message && typeof message === "object" && "content" in message) { + const content = typeof (message as any).content === "string" ? (message as any).content : ""; + const fixPatterns = ["fixed", "bug fix", "resolved", "patched", "corrected the error", "fixed the issue", "fixed the bug"]; + const lowerContent = content.toLowerCase(); + for (const pattern of fixPatterns) { + if (lowerContent.includes(pattern)) { + stats.bugsFixed++; + break; + } + } + } + } + + // Update session time + const sessionMinutes = Math.floor((Date.now() - stats.currentSessionStart) / 60000); + stats.timeSpentMinutes += 1; // Increment per agent_end call + if (sessionMinutes > stats.longestSessionMinutes) { + stats.longestSessionMinutes = sessionMinutes; + } + if (sessionMinutes > 0 && sessionMinutes < stats.shortestSessionMinutes) { + stats.shortestSessionMinutes = sessionMinutes; + } + + updatePersonalBests(); + saveStats(); + checkAchievements(ctx); + }); + + pi.registerCommand("stats", { + description: "Show coding statistics dashboard", + handler: async (_args, ctx) => { + loadStats(); + const dashboard = renderStatsDashboard(); + ctx.ui.notify(dashboard, "info"); + }, + }); + + pi.registerCommand("achievements", { + description: "Show unlocked achievements", + handler: async (_args, ctx) => { + loadStats(); + const achievements = renderAchievements(); + ctx.ui.notify(achievements, "info"); + }, + }); + + pi.registerCommand("leaderboard", { + description: "Show personal bests", + handler: async (_args, ctx) => { + loadStats(); + const leaderboard = renderLeaderboard(); + ctx.ui.notify(leaderboard, "info"); + }, + }); +} diff --git a/default-extensions/theme-system.ts b/default-extensions/theme-system.ts new file mode 100644 index 0000000..227d94a --- /dev/null +++ b/default-extensions/theme-system.ts @@ -0,0 +1,299 @@ +import type { ExtensionAPI, ExtensionContext } from "@jaeswift/jae-coding-agent"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +interface ThemeColors { + primary: string; + secondary: string; + accent: string; + bg: string; + surface: string; + border: string; + text: string; + muted: string; +} + +interface Theme { + name: string; + description: string; + colors: ThemeColors; +} + +interface ThemeConfig { + active: string; + custom: Theme[]; +} + +export default function (pi: ExtensionAPI) { + const THEMES: Record = { + dragon: { + name: "dragon", + description: "Default JAE theme — black with orange accents", + colors: { + primary: "#FF6B00", + secondary: "#FF8C38", + accent: "#FFA500", + bg: "#0a0a0a", + surface: "#111111", + border: "#1e1e1e", + text: "#f8f8f8", + muted: "#888888", + }, + }, + midnight: { + name: "midnight", + description: "Deep dark blue — calm and focused", + colors: { + primary: "#4A9EFF", + secondary: "#6BB5FF", + accent: "#80CAFF", + bg: "#0a0e1a", + surface: "#111827", + border: "#1e293b", + text: "#e2e8f0", + muted: "#64748b", + }, + }, + matrix: { + name: "matrix", + description: "Green on black — classic hacker aesthetic", + colors: { + primary: "#00FF41", + secondary: "#00CC33", + accent: "#33FF77", + bg: "#000000", + surface: "#0a0a0a", + border: "#0f3d0f", + text: "#00FF41", + muted: "#00802a", + }, + }, + frost: { + name: "frost", + description: "Light cool tones — easy on the eyes", + colors: { + primary: "#5B8DEF", + secondary: "#7AA8F2", + accent: "#98C1FF", + bg: "#0c1220", + surface: "#141e30", + border: "#1f2d42", + text: "#dce6f5", + muted: "#7b8da6", + }, + }, + sunset: { + name: "sunset", + description: "Warm oranges and reds — vibrant energy", + colors: { + primary: "#FF4500", + secondary: "#FF6347", + accent: "#FF8C00", + bg: "#0d0806", + surface: "#1a0f0a", + border: "#2d1810", + text: "#fce4d6", + muted: "#a0735c", + }, + }, + }; + + const configDir = join(process.env.HOME || "/root", ".jae"); + const configPath = join(configDir, "theme.json"); + + function loadConfig(): ThemeConfig { + try { + if (existsSync(configPath)) { + return JSON.parse(readFileSync(configPath, "utf-8")); + } + } catch { /* ignore */ } + return { active: "dragon", custom: [] }; + } + + function saveConfig(config: ThemeConfig): void { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + } + + function getTheme(name: string, config: ThemeConfig): Theme | undefined { + if (THEMES[name]) return THEMES[name]; + return config.custom.find((t) => t.name === name); + } + + function getAllThemes(config: ThemeConfig): Theme[] { + return [...Object.values(THEMES), ...config.custom]; + } + + function applyTheme(theme: Theme, ctx: ExtensionContext): void { + const c = theme.colors; + ctx.ui.setStatus( + "theme", + `\u{1F3A8} ${theme.name}`, + ); + // Apply theme colors via footer display + ctx.ui.setFooter( + "theme", + `\u{1F3A8} Theme: ${theme.name} | ${c.primary} ${c.secondary}`, + ); + } + + function formatThemePreview(theme: Theme): string { + const c = theme.colors; + return ( + ` \u{1F3A8} ${theme.name} — ${theme.description}\n` + + ` Primary: ${c.primary} Secondary: ${c.secondary}\n` + + ` Accent: ${c.accent} Background: ${c.bg}\n` + + ` Surface: ${c.surface} Border: ${c.border}\n` + + ` Text: ${c.text} Muted: ${c.muted}` + ); + } + + // Apply theme on session start + pi.on("session_start", async (_event, ctx) => { + const config = loadConfig(); + const theme = getTheme(config.active, config) || THEMES.dragon; + applyTheme(theme, ctx); + }); + + pi.registerCommand("theme", { + description: "Manage themes: /theme [list|set |create |show]", + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const subcommand = (parts[0] || "show").toLowerCase(); + const config = loadConfig(); + + switch (subcommand) { + case "show": + case "": { + const theme = getTheme(config.active, config) || THEMES.dragon; + ctx.ui.notify( + `\u{1F3A8} Current Theme\n${'\u2500'.repeat(40)}\n\n${formatThemePreview(theme)}`, + "info", + ); + break; + } + case "list": + case "ls": { + const all = getAllThemes(config); + const list = all + .map((t) => { + const active = t.name === config.active ? " \u2705 (active)" : ""; + const isCustom = !THEMES[t.name] ? " [custom]" : ""; + return ` \u{25CF} ${t.name}${active}${isCustom} — ${t.description}`; + }) + .join("\n"); + ctx.ui.notify( + `\u{1F3A8} Available Themes\n${'\u2500'.repeat(40)}\n\n${list}\n\nUse /theme set to apply a theme.`, + "info", + ); + break; + } + case "set": + case "apply": { + const name = parts[1]; + if (!name) { + ctx.ui.notify("Usage: /theme set \n\nUse /theme list to see available themes.", "warning"); + return; + } + const theme = getTheme(name.toLowerCase(), config); + if (!theme) { + ctx.ui.notify( + `\u274C Theme '${name}' not found.\n\nAvailable: ${getAllThemes(config).map((t) => t.name).join(", ")}`, + "warning", + ); + return; + } + config.active = theme.name; + saveConfig(config); + applyTheme(theme, ctx); + ctx.ui.notify( + `\u2705 Theme set to '${theme.name}'\n\n${formatThemePreview(theme)}`, + "info", + ); + break; + } + case "create": { + const name = parts[1]; + if (!name) { + ctx.ui.notify( + "Usage: /theme create \n\n" + + "Creates a custom theme based on the current theme.\n" + + "Edit ~/.jae/theme.json to customize colors.", + "warning", + ); + return; + } + const themeName = name.toLowerCase().replace(/[^a-z0-9_-]/g, "-"); + if (THEMES[themeName]) { + ctx.ui.notify(`\u274C Cannot override built-in theme '${themeName}'.`, "warning"); + return; + } + const existing = config.custom.find((t) => t.name === themeName); + if (existing) { + ctx.ui.notify(`\u274C Custom theme '${themeName}' already exists. Edit ~/.jae/theme.json to modify.`, "warning"); + return; + } + + // Clone current theme as base + const base = getTheme(config.active, config) || THEMES.dragon; + const newTheme: Theme = { + name: themeName, + description: `Custom theme based on ${base.name}`, + colors: { ...base.colors }, + }; + config.custom.push(newTheme); + config.active = themeName; + saveConfig(config); + applyTheme(newTheme, ctx); + ctx.ui.notify( + `\u2705 Custom theme '${themeName}' created and activated!\n\n${formatThemePreview(newTheme)}\n\n\u{1F4DD} Edit colors in ~/.jae/theme.json`, + "info", + ); + break; + } + case "reset": { + config.active = "dragon"; + saveConfig(config); + applyTheme(THEMES.dragon, ctx); + ctx.ui.notify("\u2705 Theme reset to dragon (default).", "info"); + break; + } + case "delete": + case "remove": { + const name = parts[1]; + if (!name) { + ctx.ui.notify("Usage: /theme delete ", "warning"); + return; + } + const themeName = name.toLowerCase(); + if (THEMES[themeName]) { + ctx.ui.notify(`\u274C Cannot delete built-in theme '${themeName}'.`, "warning"); + return; + } + const idx = config.custom.findIndex((t) => t.name === themeName); + if (idx === -1) { + ctx.ui.notify(`\u274C Custom theme '${themeName}' not found.`, "warning"); + return; + } + config.custom.splice(idx, 1); + if (config.active === themeName) config.active = "dragon"; + saveConfig(config); + ctx.ui.notify(`\u2705 Custom theme '${themeName}' deleted.`, "info"); + break; + } + default: + ctx.ui.notify( + `\u{1F3A8} Theme System\n${'\u2500'.repeat(30)}\n\n` + + "Commands:\n" + + " /theme - Show current theme\n" + + " /theme list - List all themes\n" + + " /theme set - Apply a theme\n" + + " /theme create - Create custom theme\n" + + " /theme delete - Delete custom theme\n" + + " /theme reset - Reset to dragon (default)", + "info", + ); + } + }, + }); +}