Add 6 new extensions: dependency-auditor, docker-manager, env-manager, theme-system, agent-to-agent, gamification
Some checks are pending
CI / build-check-test (push) Waiting to run
Some checks are pending
CI / build-check-test (push) Waiting to run
This commit is contained in:
parent
ca58123469
commit
86800d0e09
6 changed files with 1975 additions and 0 deletions
311
default-extensions/agent-to-agent.ts
Normal file
311
default-extensions/agent-to-agent.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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<string, AgentInstance>();
|
||||
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<AgentInstance> {
|
||||
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<string> {
|
||||
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 <role> 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 <role> | list | send <id> <msg> | broadcast <msg> | kill <id>",
|
||||
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} <message>`,
|
||||
"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 <agent-id> <message>", "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 <message>", "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 <role>.", "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 <agent-id>", "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 <role> - Spawn specialized agent\n" +
|
||||
" /a2a list - List active agents\n" +
|
||||
" /a2a send <id> <message> - Send message to agent\n" +
|
||||
" /a2a broadcast <message> - Send to all idle agents\n" +
|
||||
" /a2a kill <id|all> - Terminate agent(s)\n\n" +
|
||||
`Roles: ${Object.keys(ROLE_PROMPTS).join(", ")}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
257
default-extensions/dependency-auditor.ts
Normal file
257
default-extensions/dependency-auditor.ts
Normal file
|
|
@ -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<string, string>; devDeps: Record<string, string>; scripts: Record<string, string> } | 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<string> {
|
||||
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<string[]> {
|
||||
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<string, string>): Promise<string[]> {
|
||||
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<AuditResult> {
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
318
default-extensions/docker-manager.ts
Normal file
318
default-extensions/docker-manager.ts
Normal file
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 <<EOF /etc/nginx/conf.d/default.conf
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]`;
|
||||
}
|
||||
|
||||
function generatePythonDockerfile(cwd: string): string {
|
||||
const hasRequirements = existsSync(join(cwd, "requirements.txt"));
|
||||
const hasPyproject = existsSync(join(cwd, "pyproject.toml"));
|
||||
const installCmd = hasRequirements
|
||||
? "COPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt"
|
||||
: hasPyproject
|
||||
? "COPY pyproject.toml .\nRUN pip install --no-cache-dir ."
|
||||
: "# Add your dependency installation here";
|
||||
return `# Suggested Dockerfile for Python
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
${installCmd}
|
||||
COPY . .
|
||||
EXPOSE 8000
|
||||
CMD ["python", "main.py"]`;
|
||||
}
|
||||
|
||||
function generateGoDockerfile(): string {
|
||||
return `# Suggested Dockerfile for Go
|
||||
FROM golang:1.22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /app/server .
|
||||
|
||||
FROM alpine:latest
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]`;
|
||||
}
|
||||
|
||||
pi.registerTool({
|
||||
name: "docker_manage",
|
||||
label: "Docker Manager",
|
||||
description: "Manage Docker containers: list running containers, build images, view logs, or generate Dockerfiles from project analysis.",
|
||||
parameters: Type.Object({
|
||||
action: Type.String({ description: "Action: 'status', 'build', 'logs', 'generate', 'run', 'stop'" }),
|
||||
container: Type.Optional(Type.String({ description: "Container name or ID (for logs/stop)" })),
|
||||
tag: Type.Optional(Type.String({ description: "Image tag for build" })),
|
||||
port: Type.Optional(Type.String({ description: "Port mapping for run (e.g., '3000:3000')" })),
|
||||
}),
|
||||
execute: async (args, ctx) => {
|
||||
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 <container>|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 <container-name-or-id>", "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 <container-name-or-id>", "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 <image> [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 <id> - Show container logs\n" +
|
||||
" /docker generate - Generate Dockerfile for project\n" +
|
||||
" /docker run <img> - Run a container\n" +
|
||||
" /docker stop <id> - Stop a container",
|
||||
"info",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
295
default-extensions/env-manager.ts
Normal file
295
default-extensions/env-manager.ts
Normal file
|
|
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
function parseEnvFile(filePath: string): Map<string, string> {
|
||||
const vars = new Map<string, string>();
|
||||
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<string>();
|
||||
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 <source> <target>]",
|
||||
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 <source> <target>\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 <s> <t> - Sync missing vars from source to target",
|
||||
"info",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
495
default-extensions/gamification.ts
Normal file
495
default-extensions/gamification.ts
Normal file
|
|
@ -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<string>;
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
|
||||
const stats: Stats = {
|
||||
sessionsCount: 0,
|
||||
totalPrompts: 0,
|
||||
filesCreated: 0,
|
||||
filesEdited: 0,
|
||||
linesWritten: 0,
|
||||
toolsUsed: 0,
|
||||
commandsRun: 0,
|
||||
bugsFixed: 0,
|
||||
timeSpentMinutes: 0,
|
||||
fileTypesEdited: new Set<string>(),
|
||||
usedSwarm: false,
|
||||
usedAllVeniceSkills: false,
|
||||
longestSessionMinutes: 0,
|
||||
shortestSessionMinutes: Infinity,
|
||||
currentSessionStart: Date.now(),
|
||||
nightOwlSessions: 0,
|
||||
};
|
||||
|
||||
let unlockedAchievements = new Set<string>();
|
||||
let personalBests: Record<string, number> = {};
|
||||
|
||||
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");
|
||||
},
|
||||
});
|
||||
}
|
||||
299
default-extensions/theme-system.ts
Normal file
299
default-extensions/theme-system.ts
Normal file
|
|
@ -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<string, Theme> = {
|
||||
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 <name>|create <name>|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 <name> to apply a theme.`,
|
||||
"info",
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "set":
|
||||
case "apply": {
|
||||
const name = parts[1];
|
||||
if (!name) {
|
||||
ctx.ui.notify("Usage: /theme set <name>\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 <name>\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 <name>", "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 <name> - Apply a theme\n" +
|
||||
" /theme create <name>- Create custom theme\n" +
|
||||
" /theme delete <name>- Delete custom theme\n" +
|
||||
" /theme reset - Reset to dragon (default)",
|
||||
"info",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue