From 39ce3a155cbd2a3b27d9cabdcd8c13aae19e2892 Mon Sep 17 00:00:00 2001 From: Eka Prasetia Date: Sun, 5 Apr 2026 16:33:11 +0700 Subject: [PATCH] feat: add hitory and resume --- package.json | 18 ++- pnpm-lock.yaml | 71 ++++++++++ src/cli.ts | 342 +++++++++++++++++++++++++++++++++++++++++-------- src/history.ts | 86 +++++++++++++ src/resume.ts | 67 ++++++++++ src/runner.ts | 19 ++- 6 files changed, 534 insertions(+), 69 deletions(-) create mode 100644 src/history.ts create mode 100644 src/resume.ts diff --git a/package.json b/package.json index 3bd080f..249a3a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ekaone/json-cli", - "version": "0.1.6", + "version": "0.2.0", "description": "AI-powered CLI task runner with JSON command plans", "keywords": ["ai", "agent", "cli", "task-runner", "llm"], "author": { @@ -26,14 +26,19 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { - "json-cli": "./dist/cli.cjs" + "json-cli": "./dist/cli.cjs", + "jc": "./dist/cli.cjs" }, "scripts": { "dev": "tsx src/cli.ts", - "build": "tsup", - "test": "vitest", - "prepublishOnly": "npm run build", - "typecheck": "tsc --noEmit" + "build": "pnpm typecheck && pnpm clean && pnpm tsup", + "clean": "rimraf dist", + "prepublishOnly": "pnpm run clean && pnpm run test && pnpm run build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@anthropic-ai/sdk": "^0.80.0", @@ -45,6 +50,7 @@ "devDependencies": { "@types/node": "^25.5.0", "tsup": "^8.5.1", + "rimraf": "^6.1.3", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df8833f..6dffb55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.5.0 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3) @@ -547,6 +550,14 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -643,6 +654,10 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -755,9 +770,21 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -795,6 +822,9 @@ packages: zod: optional: true + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -807,6 +837,10 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -861,6 +895,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + rolldown@1.0.0-rc.11: resolution: {integrity: sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1415,6 +1454,12 @@ snapshots: assertion-error@2.0.1: {} + balanced-match@4.0.4: {} + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + bundle-require@5.1.0(esbuild@0.27.4): dependencies: esbuild: 0.27.4 @@ -1526,6 +1571,12 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + human-signals@8.0.1: {} is-plain-obj@4.1.0: {} @@ -1598,10 +1649,18 @@ snapshots: load-tsconfig@0.2.5: {} + lru-cache@11.2.7: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minipass@7.1.3: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -1632,12 +1691,19 @@ snapshots: optionalDependencies: zod: 4.3.6 + package-json-from-dist@1.0.1: {} + parse-ms@4.0.0: {} path-key@3.1.1: {} path-key@4.0.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.7 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1675,6 +1741,11 @@ snapshots: resolve-pkg-maps@1.0.0: {} + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + rolldown@1.0.0-rc.11: dependencies: '@oxc-project/types': 0.122.0 diff --git a/src/cli.ts b/src/cli.ts index 4b64da7..7ef4b07 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,13 @@ import { resolveProvider, type ProviderName } from "./providers/index.js"; import { generatePlan } from "./planner.js"; import { runPlan } from "./runner.js"; import { calculateCost, formatCost } from "./providers/pricing.js"; +import { saveResume, loadResume, clearResume, hasResume } from "./resume.js"; +import { + appendHistory, + getRecentHistory, + clearHistory, + hasHistory, +} from "./history.js"; import type { Step } from "./catalog.js"; // --------------------------------------------------------------------------- @@ -12,12 +19,16 @@ import type { Step } from "./catalog.js"; function showHelp(): void { p.intro("json-cli — AI-powered CLI task runner"); p.log.message(`Usage\n json-cli "" [options]\n`); + p.log.message(`Alias\n jc "" [options]\n`); p.log.message( `Options --provider AI provider: claude | openai | ollama (default: claude) --yes Skip confirmation prompt --dry-run Show plan without executing --debug Show system prompt and raw AI response + --resume Resume from last failed step + --history Browse and re-run past commands + --history --clear Clear command history --help Show this help message --version, -v Show version`, ); @@ -31,14 +42,15 @@ function showHelp(): void { json-cli "run tests and publish" --provider openai json-cli "run tests" --dry-run json-cli "run tests" --debug - json-cli "run tests" --debug --dry-run`, + json-cli "run tests" --debug --dry-run + json-cli --resume + json-cli --history`, ); p.outro("Docs: https://github.com/ekaone/json-cli"); } // --------------------------------------------------------------------------- // Parse CLI args -// e.g. json-cli "run tests" --provider claude --yes --dry-run // --------------------------------------------------------------------------- function parseArgs(): { prompt: string; @@ -46,18 +58,21 @@ function parseArgs(): { yes: boolean; dryRun: boolean; debug: boolean; + resume: boolean; + history: boolean; + historyClear: boolean; } { const args = process.argv.slice(2); const require = createRequire(import.meta.url); const { version, name } = require("../package.json"); - // show version + // version if (args.includes("--version") || args.includes("-v")) { console.log(`${name}@${version}`); process.exit(0); } - // show help if no args or --help flag + // help if (args.length === 0 || args.includes("--help")) { showHelp(); process.exit(0); @@ -70,20 +85,35 @@ function parseArgs(): { const yes = args.includes("--yes"); const dryRun = args.includes("--dry-run"); const debug = args.includes("--debug"); + const resume = args.includes("--resume"); + const history = args.includes("--history"); + const historyClear = history && args.includes("--clear"); const prompt = args .filter( (a, i) => - !a.startsWith("--") && (providerFlag === -1 || i !== providerFlag + 1), + !a.startsWith("--") && + !a.startsWith("-v") && + (providerFlag === -1 || i !== providerFlag + 1), ) .join(" "); - if (!prompt) { + // resume and history don't need a prompt + if (!prompt && !resume && !history) { showHelp(); process.exit(0); } - return { prompt, provider, yes, dryRun, debug }; + return { + prompt, + provider, + yes, + dryRun, + debug, + resume, + history, + historyClear, + }; } // --------------------------------------------------------------------------- @@ -98,12 +128,255 @@ function formatStep(step: Step): string { return `${cmd.padEnd(35)} → ${step.description}`; } +// --------------------------------------------------------------------------- +// Format usage string +// --------------------------------------------------------------------------- +function formatUsage( + input: number, + output: number, + provider: ProviderName, +): string { + if (input === 0 && output === 0) return ""; + const cost = calculateCost(provider, input, output); + const costStr = formatCost(cost); + return ` | tokens: ${input} in / ${output} out${costStr ? ` | ${costStr}` : ""}`; +} + +// --------------------------------------------------------------------------- +// Handle --history +// --------------------------------------------------------------------------- +async function handleHistory(): Promise { + p.intro("json-cli — history"); + + if (!hasHistory()) { + p.log.warn("No history found."); + await new Promise((r) => setTimeout(r, 100)); + process.exit(0); + } + + const entries = getRecentHistory(); + const options = entries.map((e) => ({ + value: e.prompt, + label: `${e.prompt.slice(0, 60)}${e.prompt.length > 60 ? "..." : ""}`, + hint: `${e.steps} steps · ${e.provider} · ${new Date(e.timestamp).toLocaleString()}`, + })); + + options.unshift({ + value: "__exit__", + label: "Exit", + hint: "Close without running", + }); + + options.push({ + value: "__clear__", + label: "Clear history", + hint: "Remove all entries", + }); + + const selected = await p.select({ + message: "Pick a command to re-run:", + options, + }); + + if (selected === "__exit__") { + p.cancel("Cancelled."); + await new Promise((r) => setTimeout(r, 100)); + process.exit(0); + } + + if (p.isCancel(selected)) { + p.cancel("Cancelled."); + await new Promise((r) => setTimeout(r, 100)); + process.exit(0); + } + + if (selected === "__clear__") { + clearHistory(); + p.outro("History cleared."); + await new Promise((r) => setTimeout(r, 100)); + process.exit(0); + } + + return selected as string; +} + +// --------------------------------------------------------------------------- +// Handle --resume +// --------------------------------------------------------------------------- +async function handleResume(): Promise<{ + prompt: string; + provider: ProviderName; + startFrom: number; +} | null> { + if (!hasResume()) { + p.log.warn("No resume state found — nothing to resume."); + await new Promise((r) => setTimeout(r, 100)); + process.exit(0); + } + + const data = loadResume()!; + const resumeFrom = data.failedAt; + + p.intro(`json-cli — resuming from step ${resumeFrom + 1}`); + p.log.info(`Original goal: ${data.plan.goal}`); + p.log.message(`Skipping steps 1-${resumeFrom}, resuming from:`); + + data.plan.steps.slice(resumeFrom).forEach((step, i) => { + p.log.message(` ${resumeFrom + i + 1}. ${formatStep(step)}`); + }); + + return { + prompt: data.prompt, + provider: data.provider, + startFrom: resumeFrom, + }; +} + +// --------------------------------------------------------------------------- +// Execute plan +// --------------------------------------------------------------------------- +async function executePlan( + planResult: Awaited>, + providerName: ProviderName, + prompt: string, + yes: boolean, + startFrom = 0, +): Promise { + // Confirm + if (!yes) { + const confirmed = await p.confirm({ + message: startFrom > 0 ? "Resume execution?" : "Proceed?", + }); + if (p.isCancel(confirmed) || !confirmed) { + p.cancel("Aborted."); + await new Promise((r) => setTimeout(r, 100)); + process.exit(0); + } + } else { + p.log.info("Skipping confirmation (--yes)"); + } + + // Execute + console.log(""); + const result = await runPlan( + planResult.plan, + (step, i, total) => { + p.log.step(`Step ${i + 1}/${total}: ${formatStep(step)}`); + }, + startFrom, + ); + + const usageStr = formatUsage( + planResult.usage.input, + planResult.usage.output, + providerName, + ); + + if (result.success) { + // clear resume on success + clearResume(); + + // append to history + appendHistory({ + prompt, + provider: providerName, + steps: planResult.plan.steps.length, + success: true, + timestamp: new Date().toISOString(), + }); + + p.outro(`✅ All steps completed successfully.${usageStr}`); + } else { + // save resume state on failure + saveResume({ + plan: planResult.plan, + failedAt: result.failedIndex ?? 0, + provider: providerName, + prompt, + timestamp: new Date().toISOString(), + }); + + p.log.error( + `❌ Failed at step ${result.failedStep?.id}: ${result.failedStep?.description}\n${result.error ?? ""}`, + ); + p.log.warn('Run "json-cli --resume" to continue from this step.'); + await new Promise((r) => setTimeout(r, 100)); + process.exit(1); + } +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { - const { prompt, provider: providerName, yes, dryRun, debug } = parseArgs(); + const { + prompt, + provider: providerName, + yes, + dryRun, + debug, + resume, + history, + historyClear, + } = parseArgs(); + + // --------------------------------------------------------------------------- + // Handle --history --clear + // --------------------------------------------------------------------------- + if (historyClear) { + clearHistory(); + p.outro("History cleared."); + await new Promise((r) => setTimeout(r, 100)); + process.exit(0); + } + + // --------------------------------------------------------------------------- + // Handle --history + // --------------------------------------------------------------------------- + if (history) { + const selected = await handleHistory(); + if (!selected) process.exit(0); + + // re-run selected prompt + p.intro(`json-cli — powered by ${providerName}`); + const spinner = p.spinner(); + spinner.start("Thinking..."); + const provider = resolveProvider(providerName); + const planResult = await generatePlan(selected, provider, false); + spinner.stop("Plan ready"); + + p.log.info(`Goal: ${planResult.plan.goal}\n`); + planResult.plan.steps.forEach((step, i) => { + p.log.message(` ${i + 1}. ${formatStep(step)}`); + }); + + await executePlan(planResult, providerName, selected, yes); + return; + } + + // --------------------------------------------------------------------------- + // Handle --resume + // --------------------------------------------------------------------------- + if (resume) { + const resumeState = await handleResume(); + if (!resumeState) process.exit(0); + const data = loadResume()!; + const planResult = { plan: data.plan, usage: { input: 0, output: 0 } }; + + await executePlan( + planResult, + resumeState.provider, + resumeState.prompt, + yes, + resumeState.startFrom, + ); + return; + } + + // --------------------------------------------------------------------------- + // Normal flow + // --------------------------------------------------------------------------- p.intro( `json-cli — powered by ${providerName}${dryRun ? " (dry run)" : ""}${debug ? " (debug)" : ""}`, ); @@ -116,7 +389,7 @@ async function main() { try { const provider = resolveProvider(providerName); planResult = await generatePlan(prompt, provider, debug); - spinner.stop(debug ? "" : "Plan ready"); + spinner.stop(debug ? undefined : "Plan ready"); } catch (err) { spinner.stop("Failed to generate plan"); p.log.error(err instanceof Error ? err.message : String(err)); @@ -142,55 +415,18 @@ async function main() { // Step 4: Dry run — show plan and exit if (dryRun) { - const { input, output } = planResult.usage; - const cost = calculateCost(providerName, input, output); - const costStr = formatCost(cost); - const usageStr = - input > 0 || output > 0 - ? ` | tokens: ${input} in / ${output} out${costStr ? ` | ${costStr}` : ""}` - : ""; - + const usageStr = formatUsage( + planResult.usage.input, + planResult.usage.output, + providerName, + ); p.outro(`Dry run complete — no commands were executed.${usageStr}`); await new Promise((r) => setTimeout(r, 100)); process.exit(0); } - // Step 5: Confirm — skip if --yes - if (!yes) { - const confirmed = await p.confirm({ message: "Proceed?" }); - if (p.isCancel(confirmed) || !confirmed) { - p.cancel("Aborted."); - await new Promise((r) => setTimeout(r, 100)); - process.exit(0); - } - } else { - p.log.info("Skipping confirmation (--yes)"); - } - - // Step 6: Execute - console.log(""); - const result = await runPlan(planResult.plan, (step, i, total) => { - p.log.step(`Step ${i + 1}/${total}: ${formatStep(step)}`); - }); - - // Step 7: Result - if (result.success) { - const { input, output } = planResult.usage; - const cost = calculateCost(providerName, input, output); - const costStr = formatCost(cost); - const usageStr = - input > 0 || output > 0 - ? ` | tokens: ${input} in / ${output} out${costStr ? ` | ${costStr}` : ""}` - : ""; - - p.outro(`✅ All steps completed successfully.${usageStr}`); - } else { - p.log.error( - `❌ Failed at step ${result.failedStep?.id}: ${result.failedStep?.description}\n${result.error ?? ""}`, - ); - await new Promise((r) => setTimeout(r, 100)); - process.exit(1); - } + // Step 5 - 7: Confirm + Execute + await executePlan(planResult, providerName, prompt, yes); } main(); diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..9ff58b3 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,86 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import type { ProviderName } from "./providers/index.js"; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- +const JSON_CLI_DIR = join(homedir(), ".json-cli"); +const HISTORY_FILE = join(JSON_CLI_DIR, "history.json"); +const MAX_ENTRIES = 50; +const DISPLAY_LIMIT = 10; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +export interface HistoryEntry { + id: number; + prompt: string; + provider: ProviderName; + steps: number; + success: boolean; + timestamp: string; +} + +interface HistoryFile { + entries: HistoryEntry[]; +} + +// --------------------------------------------------------------------------- +// Ensure ~/.json-cli/ exists +// --------------------------------------------------------------------------- +function ensureDir(): void { + if (!existsSync(JSON_CLI_DIR)) { + mkdirSync(JSON_CLI_DIR, { recursive: true }); + } +} + +// --------------------------------------------------------------------------- +// Load history file +// --------------------------------------------------------------------------- +export function loadHistory(): HistoryEntry[] { + if (!existsSync(HISTORY_FILE)) return []; + try { + const data = JSON.parse(readFileSync(HISTORY_FILE, "utf-8")) as HistoryFile; + return data.entries ?? []; + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Append entry — keeps max 50, drops oldest +// --------------------------------------------------------------------------- +export function appendHistory(entry: Omit): void { + ensureDir(); + const entries = loadHistory(); + const id = (entries[entries.length - 1]?.id ?? 0) + 1; + entries.push({ id, ...entry }); + + // keep only last MAX_ENTRIES + const trimmed = entries.slice(-MAX_ENTRIES); + writeFileSync(HISTORY_FILE, JSON.stringify({ entries: trimmed }, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Get last N entries for display (most recent first) +// --------------------------------------------------------------------------- +export function getRecentHistory(limit = DISPLAY_LIMIT): HistoryEntry[] { + return loadHistory().slice(-limit).reverse(); +} + +// --------------------------------------------------------------------------- +// Clear all history +// --------------------------------------------------------------------------- +export function clearHistory(): void { + ensureDir(); + writeFileSync(HISTORY_FILE, JSON.stringify({ entries: [] }, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Check if history exists +// --------------------------------------------------------------------------- +export function hasHistory(): boolean { + return loadHistory().length > 0; +} diff --git a/src/resume.ts b/src/resume.ts new file mode 100644 index 0000000..c2dd7ef --- /dev/null +++ b/src/resume.ts @@ -0,0 +1,67 @@ +import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import type { Plan } from "./catalog.js"; +import type { ProviderName } from "./providers/index.js"; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- +const JSON_CLI_DIR = join(homedir(), ".json-cli"); +const RESUME_FILE = join(JSON_CLI_DIR, "last-plan.json"); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +export interface ResumeData { + plan: Plan; + failedAt: number; // step index (0-based) to resume from + provider: ProviderName; + prompt: string; + timestamp: string; +} + +// --------------------------------------------------------------------------- +// Ensure ~/.json-cli/ exists +// --------------------------------------------------------------------------- +function ensureDir(): void { + if (!existsSync(JSON_CLI_DIR)) { + mkdirSync(JSON_CLI_DIR, { recursive: true }); + } +} + +// --------------------------------------------------------------------------- +// Save resume state after failure +// --------------------------------------------------------------------------- +export function saveResume(data: ResumeData): void { + ensureDir(); + writeFileSync(RESUME_FILE, JSON.stringify(data, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Load resume state +// --------------------------------------------------------------------------- +export function loadResume(): ResumeData | null { + if (!existsSync(RESUME_FILE)) return null; + try { + return JSON.parse(readFileSync(RESUME_FILE, "utf-8")) as ResumeData; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Clear resume state after success or manual clear +// --------------------------------------------------------------------------- +export function clearResume(): void { + if (existsSync(RESUME_FILE)) { + unlinkSync(RESUME_FILE); + } +} + +// --------------------------------------------------------------------------- +// Check if resume is available +// --------------------------------------------------------------------------- +export function hasResume(): boolean { + return existsSync(RESUME_FILE); +} diff --git a/src/runner.ts b/src/runner.ts index 56a8a09..fb14e7f 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -6,11 +6,8 @@ import type { Plan, Step } from "./catalog.js"; // --------------------------------------------------------------------------- function resolveCommand(step: Step): { bin: string; args: string[] } { if (step.type === "shell") { - // shell: command is the binary, args are the args return { bin: step.command, args: step.args }; } - - // For npm/pnpm/yarn/bun/git: binary is the type, command + args follow return { bin: step.type, args: [step.command, ...step.args] }; } @@ -24,8 +21,8 @@ export async function runStep( try { await execa(bin, args, { - cwd: step.cwd ?? process.cwd(), - stdout: "inherit", // stream directly to terminal + cwd: step.cwd ?? process.cwd(), + stdout: "inherit", stderr: "inherit", }); return { success: true }; @@ -33,8 +30,8 @@ export async function runStep( const parts = [ `Command: ${bin} ${args.join(" ")}`, err?.exitCode ? `Exit code: ${err.exitCode}` : null, - err?.stderr ? `Reason: ${err.stderr.trim()}` : null, - !err?.stderr ? (err?.message ?? String(err)) : null, + err?.stderr ? `Reason: ${err.stderr.trim()}` : null, + !err?.stderr ? (err?.message ?? String(err)) : null, ] .filter(Boolean) .join("\n "); @@ -45,21 +42,23 @@ export async function runStep( // --------------------------------------------------------------------------- // Run the full plan, stopping on first failure +// startFrom: step index (0-based) to resume from — skips earlier steps // --------------------------------------------------------------------------- export async function runPlan( plan: Plan, onStep: (step: Step, index: number, total: number) => void, -): Promise<{ success: boolean; failedStep?: Step; error?: string }> { + startFrom = 0, // ← new param for resume +): Promise<{ success: boolean; failedStep?: Step; failedIndex?: number; error?: string }> { const total = plan.steps.length; - for (let i = 0; i < total; i++) { + for (let i = startFrom; i < total; i++) { const step = plan.steps[i]; onStep(step, i, total); const result = await runStep(step); if (!result.success) { - return { success: false, failedStep: step, error: result.error }; + return { success: false, failedStep: step, failedIndex: i, error: result.error }; } }