diff --git a/src/commands/repeat.ts b/src/commands/repeat.ts new file mode 100644 index 0000000..d57d57c --- /dev/null +++ b/src/commands/repeat.ts @@ -0,0 +1,694 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * /repeat — schedule a recurring prompt at a fixed interval. + * + * Usage: + * /repeat [interval] + * /repeat cancel + * /repeat list + */ +import chalk from 'chalk'; +import type { SlashCommand } from '../core/slashCommandTypes.js'; +import type { RepeatManager } from '../core/RepeatManager.js'; +import type { LLMProvider } from '../providers/LLMProvider.js'; + +export const metadata: SlashCommand = { + command: '/repeat', + description: 'Schedule a recurring prompt at a fixed interval', + implemented: true, +}; + +export interface RepeatCommandContext { + repeatManager?: RepeatManager; + llm?: LLMProvider; +} + +const DEFAULT_INTERVAL = '5m'; + +// ─── Parsing ──────────────────────────────────────────────────────────────── + +interface ParsedInput { + interval: string; + prompt: string; + /** True when the interval was explicitly matched by a regex rule (not the default fallback) */ + explicit?: boolean; + /** Maximum number of executions before auto-cancel. */ + maxRuns?: number; + /** Custom expiry duration in shorthand (e.g. "7d", "2h"). */ + expiresIn?: string; +} + +const LEADING_INTERVAL_RE = /^\d+[smhd]$/; +const LEADING_EVERY_RE = /^every\s+(\d+)?\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?|d(?:ays?)?)\s+/i; +const TRAILING_EVERY_RE = /\s+every\s+(\d+)?\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?|d(?:ays?)?)$/i; +const MIDDLE_EVERY_RE = /\s+every\s+(\d+)?\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:(?:ou)?rs?)?|d(?:ays?)?)\s+/i; +const UNIT_MAP: Record = { + s: 's', sec: 's', secs: 's', second: 's', seconds: 's', + m: 'm', min: 'm', mins: 'm', minute: 'm', minutes: 'm', + h: 'h', hr: 'h', hrs: 'h', hour: 'h', hours: 'h', + d: 'd', day: 'd', days: 'd', +}; + +/** + * Parse raw user input into { interval, prompt } following the priority rules: + * 1. Leading token matching ^\d+[smhd]$ + * 2. Leading "every " clause (e.g. "every 2 minutes run tests") + * 3. Trailing "every " clause (e.g. "run tests every 5m") + * 4. Middle "every " clause (e.g. "tell a joke every 10s about life") + * 5. Default interval with entire input as prompt + */ +export function parseInput(raw: string): ParsedInput { + const trimmed = raw.trim(); + const parts = trimmed.split(/\s+/); + + // Rule 1: leading interval token (e.g. "5m run tests") + if (parts.length >= 1 && LEADING_INTERVAL_RE.test(parts[0])) { + return { + interval: parts[0], + prompt: parts.slice(1).join(' '), + explicit: true, + }; + } + + // Rule 2: leading "every [N] " (e.g. "every 2 minutes run tests", "every minute check logs") + const leadingMatch = trimmed.match(LEADING_EVERY_RE); + if (leadingMatch) { + const n = leadingMatch[1] || '1'; + const rawUnit = leadingMatch[2].toLowerCase(); + const unit = UNIT_MAP[rawUnit]; + if (unit) { + return { + interval: `${n}${unit}`, + prompt: trimmed.slice(leadingMatch[0].length).trim(), + explicit: true, + }; + } + } + + // Rule 3: trailing "every [N] " (e.g. "run tests every 5m", "say hello every minute") + const trailingMatch = trimmed.match(TRAILING_EVERY_RE); + if (trailingMatch) { + const n = trailingMatch[1] || '1'; + const rawUnit = trailingMatch[2].toLowerCase(); + const unit = UNIT_MAP[rawUnit]; + if (unit) { + return { + interval: `${n}${unit}`, + prompt: trimmed.slice(0, trailingMatch.index!).trim(), + explicit: true, + }; + } + } + + // Rule 4: middle "every [N] " (e.g. "tell a joke every 10s about life", "say hello every minute to me") + const middleMatch = trimmed.match(MIDDLE_EVERY_RE); + if (middleMatch) { + const n = middleMatch[1] || '1'; + const rawUnit = middleMatch[2].toLowerCase(); + const unit = UNIT_MAP[rawUnit]; + if (unit) { + const before = trimmed.slice(0, middleMatch.index!).trim(); + const after = trimmed.slice(middleMatch.index! + middleMatch[0].length).trim(); + const prompt = [before, after].filter(Boolean).join(' '); + return { + interval: `${n}${unit}`, + prompt, + explicit: true, + }; + } + } + + // Rule 5: default — no interval detected + return { + interval: DEFAULT_INTERVAL, + prompt: trimmed, + explicit: false, + }; +} + +// ─── Interval → cron ──────────────────────────────────────────────────────── + +interface CronResult { + cronExpression: string; + intervalMs: number; + humanReadable: string; + roundedNote?: string; +} + +/** + * Convert a shorthand interval (e.g. "5m", "2h", "1d", "30s") to a cron expression. + * Returns the cron expression, the interval in ms, a human-readable cadence, + * and an optional note if the interval was rounded. + */ +export function intervalToCron(interval: string): CronResult { + const match = interval.match(/^(\d+)([smhd])$/); + if (!match) { + throw new Error(`Invalid interval format: ${interval}`); + } + + let n = parseInt(match[1], 10); + const unit = match[2]; + + // Seconds → round up to nearest minute (min 1m) + if (unit === 's') { + const minutes = Math.max(1, Math.ceil(n / 60)); + const roundedNote = `Rounded ${n}s up to ${minutes}m (cron minimum granularity is 1 minute).`; + return intervalToCronMinutes(minutes, roundedNote); + } + + if (unit === 'm') { + return intervalToCronMinutes(n); + } + + if (unit === 'h') { + if (n <= 0) throw new Error('Hour interval must be >= 1'); + if (n > 23) { + // Convert to days + const days = Math.round(n / 24); + const roundedNote = `Rounded ${n}h to ${days}d (${days * 24}h).`; + return { + cronExpression: `0 0 */${days} * *`, + intervalMs: days * 24 * 60 * 60 * 1000, + humanReadable: `every ${days} day${days > 1 ? 's' : ''} at midnight`, + roundedNote, + }; + } + // Check if it divides 24 cleanly + let roundedNote: string | undefined; + if (24 % n !== 0) { + const clean = nearestDivisor(n, 24); + roundedNote = `${n}h doesn't divide 24 evenly; rounded to every ${clean}h.`; + n = clean; + } + return { + cronExpression: `0 */${n} * * *`, + intervalMs: n * 60 * 60 * 1000, + humanReadable: `every ${n} hour${n > 1 ? 's' : ''}`, + roundedNote, + }; + } + + // Days + if (n <= 0) throw new Error('Day interval must be >= 1'); + return { + cronExpression: `0 0 */${n} * *`, + intervalMs: n * 24 * 60 * 60 * 1000, + humanReadable: `every ${n} day${n > 1 ? 's' : ''} at midnight`, + }; +} + +function intervalToCronMinutes(n: number, roundedNote?: string): CronResult { + if (n <= 0) throw new Error('Minute interval must be >= 1'); + + if (n < 60) { + // Check if it divides 60 cleanly + let note = roundedNote; + if (60 % n !== 0) { + const clean = nearestDivisor(n, 60); + note = (note ? note + ' ' : '') + `${n}m doesn't divide 60 evenly; rounded to every ${clean}m.`; + n = clean; + } + return { + cronExpression: `*/${n} * * * *`, + intervalMs: n * 60 * 1000, + humanReadable: `every ${n} minute${n > 1 ? 's' : ''}`, + roundedNote: note, + }; + } + + // 60+ minutes → convert to hours + let hours = Math.round(n / 60); + if (hours <= 0) hours = 1; + let note = roundedNote; + if (24 % hours !== 0) { + const clean = nearestDivisor(hours, 24); + note = (note ? note + ' ' : '') + `${n}m rounds to ${hours}h which doesn't divide 24; using every ${clean}h.`; + hours = clean; + } else if (n !== hours * 60) { + note = (note ? note + ' ' : '') + `Rounded ${n}m to ${hours}h.`; + } + return { + cronExpression: `0 */${hours} * * *`, + intervalMs: hours * 60 * 60 * 1000, + humanReadable: `every ${hours} hour${hours > 1 ? 's' : ''}`, + roundedNote: note, + }; +} + +/** + * Find the divisor of `max` closest to `n`. + */ +function nearestDivisor(n: number, max: number): number { + let best = 1; + for (let d = 1; d <= max; d++) { + if (max % d === 0 && Math.abs(d - n) < Math.abs(best - n)) { + best = d; + } + } + return best; +} + +// ─── Duration helpers ──────────────────────────────────────────────────────── + +/** + * Convert shorthand duration (e.g. "7d", "2h") to milliseconds. + */ +function shorthandToMs(shorthand: string): number { + const match = shorthand.match(/^(\d+)([smhd])$/); + if (!match) return 3 * 24 * 60 * 60 * 1000; // fallback 3 days + const n = parseInt(match[1], 10); + const unit = match[2]; + switch (unit) { + case 's': return n * 1000; + case 'm': return n * 60 * 1000; + case 'h': return n * 60 * 60 * 1000; + case 'd': return n * 24 * 60 * 60 * 1000; + default: return 3 * 24 * 60 * 60 * 1000; + } +} + +/** + * Convert shorthand duration to human-readable string. + */ +function shorthandToHuman(shorthand: string): string { + const match = shorthand.match(/^(\d+)([smhd])$/); + if (!match) return shorthand; + const n = parseInt(match[1], 10); + const unit = match[2]; + const names: Record = { s: 'second', m: 'minute', h: 'hour', d: 'day' }; + const name = names[unit] ?? unit; + return `${n} ${name}${n !== 1 ? 's' : ''}`; +} + +// ─── LLM intent extraction ────────────────────────────────────────────────── + +const REPEAT_SYSTEM_PROMPT = `# Goal +Extract a recurring schedule from natural language. Return the interval and the actionable task as JSON. + +# Persona +You are a scheduling intent parser for a CLI coding agent. You receive raw user input and decompose it into exactly two parts: how often (interval) and what to do (prompt). You are precise, never hallucinate fields, and always return valid JSON. + +# Action +Return ONLY a JSON object with these fields: +- "interval": (required) shorthand format where unit is s, m, h, or d (e.g. "5m", "2h", "1d") +- "prompt": (required) the actionable task, cleaned of all scheduling language and conversational filler +- "maxRuns": (optional, integer) if the user specifies a finite execution count, include it. Omit if unlimited. +- "expiresIn": (optional, shorthand like interval) if the user specifies a custom duration/deadline, include it. Omit to use the default 3-day expiry. + +## Interval extraction rules (in priority order) + +1. Explicit frequency → convert directly: + "every 5 minutes" → "5m" | "every 2 hours" → "2h" | "every 30 seconds" → "30s" | "every 3 days" → "3d" + +2. Named frequencies → map to interval: + "every minute" → "1m" | "every hour" → "1h" | "every day" → "1d" | "every second" → "1s" + "hourly" → "1h" | "daily" → "1d" | "every half hour" → "30m" | "every quarter hour" → "15m" + +3. Multiplicative expressions → compute interval: + "twice an hour" → "30m" | "three times an hour" → "20m" | "once an hour" → "1h" + "twice a day" → "12h" | "three times a day" → "8h" | "once a day" → "1d" + +4. Colloquial / vague frequencies → best-effort: + "every couple of minutes" → "2m" | "every few minutes" → "5m" | "every other hour" → "2h" + "continuously" / "nonstop" / "constantly" → "1m" | "periodically" / "frequently" / "regularly" → "5m" + +5. Implied repetition words are scheduling intent — strip them from the prompt: + "keep running tests" → interval "1m", prompt "run tests" + "keep checking the build" → interval "1m", prompt "check the build" + "repeatedly lint the codebase" → interval "5m", prompt "lint the codebase" + +6. Unsupported time scales → clamp to "1d": + "weekly" / "monthly" / "every week" / "every month" → "1d" + +7. No frequency detected at all → use "5m" as default. + +## Disambiguation: conflicting interval signals + +When the input contains multiple time references that could be intervals, apply these rules: +1. The most specific/granular interval wins as the "interval" field. +2. The broader time reference becomes "expiresIn" context (if it specifies a duration). + +Granularity order (most specific first): seconds → minutes → hours → days. + +Examples: +- "every day run this command with intervals of 10 minutes per run" → interval: "10m", expiresIn: "1d" + ("intervals of 10 minutes" is more specific than "every day") +- "check deploy every day for a week at 10 minute intervals" → interval: "10m", expiresIn: "7d" +- "run tests daily with 5 minute checks" → interval: "5m", expiresIn: "1d" +- "every hour check build every 2 minutes" → interval: "2m", expiresIn: "1h" + +## Execution count extraction (maxRuns) + +When the user specifies a finite number of executions, extract it as "maxRuns": +- "only 10 times" → maxRuns: 10 +- "do this 5 times" → maxRuns: 5 +- "repeat 3 times" → maxRuns: 3 +- "just once" / "one time" → maxRuns: 1 +- "twice" / "two times" → maxRuns: 2 +- No count mentioned → omit maxRuns entirely + +Strip the count language from the prompt (e.g. "only 10 times", "5 times", "twice"). + +## Custom expiry extraction (expiresIn) + +When the user specifies a duration/deadline for how long the job should stay active, extract it as "expiresIn": +- "for the next week" / "for a week" → expiresIn: "7d" +- "for 2 days" → expiresIn: "2d" +- "for the next hour" → expiresIn: "1h" +- "until tomorrow" → expiresIn: "1d" +- "for 30 minutes" → expiresIn: "30m" +- No duration mentioned → omit expiresIn entirely (default: 3 days) + +Strip the expiry language from the prompt. + +## Prompt cleaning rules + +1. Strip conversational filler: remove leading "can you", "could you", "please", "I want you to", "I need you to", "go ahead and", "make sure to", "try to". +2. Strip scheduling language: remove "every X minutes", "hourly", "daily", "continuously", "keep", "repeatedly", "nonstop", "on repeat", "periodically", etc. +3. Preserve slash commands and their arguments verbatim: "/deploy staging", "/babysit-prs", "/lint --fix". +4. Preserve technical content exactly: file paths, command flags, branch names, URLs. +5. Do NOT rephrase or summarize the task — keep the user's own words minus filler and scheduling. + +## Disambiguation: "every" as quantifier vs frequency + +"every" followed by a time expression (number + unit, or time noun) = frequency → extract it. +"every" followed by a non-time noun = quantifier (part of the task) → keep it in the prompt. + +Frequency: "check deploy every 5 minutes", "run hourly", "every 2h run tests" +Quantifier: "check every PR", "review every file", "test every endpoint", "lint every module" + +## Disambiguation: time words inside task names + +If a time word (hourly, daily, etc.) is part of a compound noun or script name, it describes the TASK, not the frequency. +"run the hourly backup script" → interval "5m", prompt "run the hourly backup script" +"check the daily report" → interval "5m", prompt "check the daily report" +"execute the 5-minute health check" → interval "5m", prompt "execute the 5-minute health check" + +# Examples + +Input: "can you run tests every 5 minutes" +Output: {"interval":"5m","prompt":"run tests"} + +Input: "please check the deploy hourly" +Output: {"interval":"1h","prompt":"check the deploy"} + +Input: "navigate to my gh pr list and pick one item to work on every 2 minutes" +Output: {"interval":"2m","prompt":"navigate to my gh pr list and pick one item to work on"} + +Input: "make a git commit with empty message to trigger ci cd hourly" +Output: {"interval":"1h","prompt":"make a git commit with empty message to trigger ci cd"} + +Input: "check for new issues on github twice an hour" +Output: {"interval":"30m","prompt":"check for new issues on github"} + +Input: "run the test suite" +Output: {"interval":"5m","prompt":"run the test suite"} + +Input: "keep monitoring the logs" +Output: {"interval":"1m","prompt":"monitor the logs"} + +Input: "check every PR for review comments" +Output: {"interval":"5m","prompt":"check every PR for review comments"} + +Input: "I want you to continuously watch the build pipeline" +Output: {"interval":"1m","prompt":"watch the build pipeline"} + +Input: "run the hourly backup script every 30m" +Output: {"interval":"30m","prompt":"run the hourly backup script"} + +Input: "/deploy staging every 10m" +Output: {"interval":"10m","prompt":"/deploy staging"} + +Input: "could you maybe check if the tests pass" +Output: {"interval":"5m","prompt":"check if the tests pass"} + +Input: "check for git commits from origin every minute" +Output: {"interval":"1m","prompt":"check for git commits from origin"} + +Input: "tell me a joke every 2 minutes only 10 times" +Output: {"interval":"2m","prompt":"tell me a joke","maxRuns":10} + +Input: "check PRs every day for the next week" +Output: {"interval":"1d","prompt":"check PRs","expiresIn":"7d"} + +Input: "run tests every 5m do this 3 times" +Output: {"interval":"5m","prompt":"run tests","maxRuns":3} + +Input: "check build every hour only 5 times for 2 days" +Output: {"interval":"1h","prompt":"check build","maxRuns":5,"expiresIn":"2d"} + +Input: "every day run echo hello world with intervals of 10 minutes per run" +Output: {"interval":"10m","prompt":"run echo hello world","expiresIn":"1d"} + +Input: "every hour check build every 2 minutes" +Output: {"interval":"2m","prompt":"check build","expiresIn":"1h"}`; + +/** + * Use the LLM to extract interval + prompt from ambiguous natural language. + * Falls back to the regex result on any error. + */ +async function llmParseInput(raw: string, llm: LLMProvider): Promise { + try { + const response = await llm.complete({ + messages: [ + { role: 'system', content: REPEAT_SYSTEM_PROMPT }, + { role: 'user', content: raw }, + ], + maxTokens: 200, + temperature: 0, + }); + + const text = response.content.trim(); + // Extract JSON from response (may have markdown fences) + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) return parseInput(raw); + + const parsed = JSON.parse(jsonMatch[0]) as Record; + const interval = typeof parsed.interval === 'string' ? parsed.interval : DEFAULT_INTERVAL; + const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : ''; + + // Validate the interval format + if (!/^\d+[smhd]$/.test(interval)) return parseInput(raw); + + // Extract optional maxRuns + const maxRuns = typeof parsed.maxRuns === 'number' && Number.isInteger(parsed.maxRuns) && parsed.maxRuns > 0 + ? parsed.maxRuns + : undefined; + + // Extract optional expiresIn + const expiresIn = typeof parsed.expiresIn === 'string' && /^\d+[smhd]$/.test(parsed.expiresIn) + ? parsed.expiresIn + : undefined; + + return { interval, prompt, maxRuns, expiresIn }; + } catch { + // On any LLM error, fall back to regex parsing + return parseInput(raw); + } +} + +// ─── Command handler ───────────────────────────────────────────────────────── + +export async function repeat( + ctx: RepeatCommandContext, + args: string[] = [], +): Promise { + if (!ctx.repeatManager) { + return chalk.yellow('Repeat manager not available.'); + } + + const raw = args.join(' ').trim(); + + // Subcommand: help + if (args[0]?.toLowerCase() === 'help') { + return showUsage(); + } + + // Subcommand: cancel + if (args[0]?.toLowerCase() === 'cancel') { + const id = args[1]?.trim(); + if (!id) { + return chalk.yellow('Usage: /repeat cancel '); + } + const ok = ctx.repeatManager.cancel(id); + return ok + ? chalk.green(`Cancelled recurring job ${chalk.bold(id)}.`) + : chalk.yellow(`No active job with ID ${chalk.bold(id)}.`); + } + + // Subcommand: list + if (args[0]?.toLowerCase() === 'list') { + const jobs = ctx.repeatManager.list(); + if (jobs.length === 0) { + return chalk.gray('No recurring jobs scheduled.'); + } + const lines = [chalk.bold('Active recurring jobs:'), '']; + for (const job of jobs) { + const remaining = Math.max(0, job.expiresAt - Date.now()); + const hoursLeft = Math.round(remaining / (60 * 60 * 1000)); + lines.push( + ` ${chalk.cyan(job.id)} ${chalk.white(job.humanInterval)} ${chalk.gray(job.cronExpression)}`, + ` ${chalk.gray('prompt:')} ${job.prompt}`, + ); + if (job.maxRuns !== undefined) { + lines.push(` ${chalk.gray(`runs: ${job.runCount}/${job.maxRuns}`)}`); + } + lines.push( + ` ${chalk.gray(`expires in ~${hoursLeft}h`)}`, + '', + ); + } + return lines.join('\n'); + } + + // Subcommand: help or no args + if (!raw) { + return showUsage(); + } + + // Typo detection: if the user typed a single word that looks like a + // misspelled subcommand, suggest the correct one instead of scheduling. + if (args.length === 1) { + const SUBCOMMANDS = ['list', 'cancel', 'help']; + const firstArg = args[0].toLowerCase(); + if (!SUBCOMMANDS.includes(firstArg)) { + const closest = findClosestSubcommand(firstArg, SUBCOMMANDS); + if (closest) { + return chalk.yellow(`Unknown subcommand "${args[0]}". Did you mean ${chalk.bold(`/repeat ${closest}`)}?`) + '\n\n' + showUsage(); + } + } + } + + // LLM-first parsing: try LLM for best natural language understanding, + // fall back to regex if LLM is unavailable or fails + let interval: string; + let prompt: string; + let maxRuns: number | undefined; + let expiresIn: string | undefined; + + if (ctx.llm) { + console.log(chalk.gray('Parsing schedule...')); + const llmResult = await llmParseInput(raw, ctx.llm); + interval = llmResult.interval; + prompt = llmResult.prompt; + maxRuns = llmResult.maxRuns; + expiresIn = llmResult.expiresIn; + } else { + const regexResult = parseInput(raw); + interval = regexResult.interval; + prompt = regexResult.prompt; + } + + if (!prompt) { + return showUsage(); + } + + // Convert interval to cron + let cron: CronResult; + try { + cron = intervalToCron(interval); + } catch (err) { + return chalk.red(`Invalid interval "${interval}": ${(err as Error).message}`); + } + + // Compute custom expiry duration + const expiresInMs = expiresIn ? shorthandToMs(expiresIn) : undefined; + const expiresInHuman = expiresIn ? shorthandToHuman(expiresIn) : undefined; + + // Schedule the job + const job = ctx.repeatManager.schedule(prompt, cron.intervalMs, cron.cronExpression, cron.humanReadable, { + maxRuns, + expiresInMs, + }); + + // Confirmation message + const lines = [ + chalk.green('Recurring job scheduled!'), + '', + ` ${chalk.gray('Job ID:')} ${chalk.cyan(job.id)}`, + ` ${chalk.gray('Prompt:')} ${prompt}`, + ` ${chalk.gray('Cadence:')} ${cron.humanReadable}`, + ` ${chalk.gray('Cron:')} ${cron.cronExpression}`, + ]; + + if (maxRuns !== undefined) { + lines.push(` ${chalk.gray('Limit:')} ${maxRuns} runs`); + } + + if (cron.roundedNote) { + lines.push(` ${chalk.yellow('Note:')} ${cron.roundedNote}`); + } + + const expiryLabel = expiresInHuman ?? '3 days'; + lines.push( + '', + chalk.gray(`Recurring jobs auto-expire after ${expiryLabel}.`), + chalk.gray(`Cancel sooner with: /repeat cancel ${job.id}`), + '', + ); + + return lines.join('\n'); +} + +/** + * Compute Levenshtein edit distance between two strings. + */ +function levenshtein(a: string, b: string): number { + const m = a.length, n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +/** + * Find the closest subcommand within edit distance ≤ 2. + * Only triggers for single-word inputs that look like typos (short, no spaces). + */ +function findClosestSubcommand(input: string, subcommands: string[]): string | null { + let best: string | null = null; + let bestDist = 3; // threshold: must be ≤ 2 + for (const cmd of subcommands) { + const dist = levenshtein(input, cmd); + if (dist < bestDist) { + bestDist = dist; + best = cmd; + } + } + return best; +} + +function showUsage(): string { + return ` +${chalk.cyan('/repeat — Schedule a recurring prompt')} + +${chalk.yellow('Usage:')} + /repeat [interval] Schedule a recurring prompt + /repeat list Show active jobs + /repeat cancel Cancel a job + +${chalk.yellow('Interval formats:')} + 5m every 5 minutes + 2h every 2 hours + 1d every day + 30s every 30 seconds (rounded to 1m) + +${chalk.yellow('Examples:')} + /repeat 5m run tests + /repeat check the deploy every 20m + /repeat run tests every 5 minutes + /repeat 2h check build status + /repeat list + /repeat cancel abc123 +`; +} diff --git a/src/commands/repeatCli.ts b/src/commands/repeatCli.ts new file mode 100644 index 0000000..17d2c86 --- /dev/null +++ b/src/commands/repeatCli.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * repeatCli — parsing and config for the --repeat CLI flag (non-interactive mode). + * + * Usage: + * autohand --repeat "" "" + * autohand --repeat "every 5 minutes run tests" + * autohand --repeat "5m" "run tests" --max-runs 10 --expires "7d" + */ + +import { parseInput, intervalToCron } from './repeat.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface RepeatFlagOptions { + maxRuns?: number; + expires?: string; +} + +export interface RepeatFlagResult { + interval: string; + prompt: string; + maxRuns?: number; + expiresIn?: string; + error?: string; +} + +export interface RepeatRunConfig { + intervalMs: number; + prompt: string; + maxRuns?: number; + expiresInMs: number; + cronExpression: string; + humanReadable: string; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const VALID_INTERVAL_RE = /^\d+[smhd]$/; +const INTERVAL_ATTEMPT_RE = /^\d+[a-zA-Z]+$/; +const VALID_SHORTHAND_RE = /^\d+[smhd]$/; +const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + +// ─── parseRepeatFlag ───────────────────────────────────────────────────────── + +/** + * Parse --repeat CLI flag arguments into a structured result. + * + * Supports: + * - Two-arg: parseRepeatFlag('5m', 'run tests') + * - Single natural language: parseRepeatFlag('every 5 minutes run tests') + * - Options: { maxRuns, expires } + */ +export function parseRepeatFlag( + firstArg: string, + prompt?: string, + options?: RepeatFlagOptions, +): RepeatFlagResult { + const trimmedFirst = firstArg.trim(); + + // Empty / whitespace input + if (!trimmedFirst) { + return { interval: '', prompt: '', error: 'No input provided. Usage: --repeat "" ""' }; + } + + let interval: string; + let taskPrompt: string; + + if (VALID_INTERVAL_RE.test(trimmedFirst)) { + // First arg is a valid interval like '5m', '2h' + const trimmedPrompt = prompt?.trim() ?? ''; + if (!trimmedPrompt) { + return { interval: trimmedFirst, prompt: '', error: 'No prompt provided. Usage: --repeat "" ""' }; + } + interval = trimmedFirst; + taskPrompt = trimmedPrompt; + } else if (INTERVAL_ATTEMPT_RE.test(trimmedFirst)) { + // Looks like an interval attempt but has invalid unit (e.g. '5x') + return { interval: '', prompt: '', error: `Invalid interval format: "${trimmedFirst}". Use where unit is s, m, h, or d.` }; + } else { + // Not an interval — combine with prompt and use natural language parsing + const combined = prompt ? `${trimmedFirst} ${prompt}`.trim() : trimmedFirst; + const parsed = parseInput(combined); + interval = parsed.interval; + taskPrompt = parsed.prompt; + + if (!taskPrompt.trim()) { + return { interval, prompt: '', error: 'Could not extract a prompt from the input.' }; + } + } + + // Apply CLI options + const maxRuns = options?.maxRuns !== undefined && options.maxRuns > 0 + ? options.maxRuns + : undefined; + + const expiresIn = options?.expires && VALID_SHORTHAND_RE.test(options.expires) + ? options.expires + : undefined; + + return { interval, prompt: taskPrompt, maxRuns, expiresIn }; +} + +// ─── buildRepeatRunConfig ──────────────────────────────────────────────────── + +/** + * Convert a parsed RepeatFlagResult into a runtime RepeatRunConfig. + */ +export function buildRepeatRunConfig( + parsed: Pick, +): RepeatRunConfig { + const cron = intervalToCron(parsed.interval); + const expiresInMs = parsed.expiresIn ? shorthandToMs(parsed.expiresIn) : THREE_DAYS_MS; + + return { + intervalMs: cron.intervalMs, + prompt: parsed.prompt, + maxRuns: parsed.maxRuns, + expiresInMs, + cronExpression: cron.cronExpression, + humanReadable: cron.humanReadable, + }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function shorthandToMs(shorthand: string): number { + const match = shorthand.match(/^(\d+)([smhd])$/); + if (!match) return THREE_DAYS_MS; + const n = parseInt(match[1], 10); + const unit = match[2]; + switch (unit) { + case 's': return n * 1000; + case 'm': return n * 60 * 1000; + case 'h': return n * 60 * 60 * 1000; + case 'd': return n * 24 * 60 * 60 * 1000; + default: return THREE_DAYS_MS; + } +} diff --git a/src/core/RepeatManager.ts b/src/core/RepeatManager.ts new file mode 100644 index 0000000..37d4a4e --- /dev/null +++ b/src/core/RepeatManager.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * RepeatManager — in-process scheduler for recurring prompts. + * Jobs run at a fixed interval using setInterval and auto-expire after 3 days. + */ + +import { randomUUID } from 'node:crypto'; + +export interface RepeatJob { + id: string; + prompt: string; + intervalMs: number; + cronExpression: string; + humanInterval: string; + createdAt: number; + expiresAt: number; + /** Maximum number of executions before auto-cancel. Undefined = unlimited. */ + maxRuns?: number; + /** Number of times the job has triggered so far. */ + runCount: number; +} + +export interface ScheduleOptions { + /** Auto-cancel after this many executions. */ + maxRuns?: number; + /** Custom expiry duration in ms (overrides the default 3 days). */ + expiresInMs?: number; +} + +export type RepeatJobCallback = (job: RepeatJob) => void; + +const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + +export class RepeatManager { + private jobs = new Map(); + private timers = new Map>(); + private expiryTimers = new Map>(); + private callback: RepeatJobCallback | null = null; + + /** + * Register the callback that fires each time a job triggers. + */ + onTrigger(cb: RepeatJobCallback): void { + this.callback = cb; + } + + /** + * Schedule a new recurring job. + * Returns the created RepeatJob. + */ + schedule(prompt: string, intervalMs: number, cronExpression: string, humanInterval: string, options?: ScheduleOptions): RepeatJob { + const id = randomUUID().slice(0, 8); + const now = Date.now(); + const expiresInMs = options?.expiresInMs ?? THREE_DAYS_MS; + const job: RepeatJob = { + id, + prompt, + intervalMs, + cronExpression, + humanInterval, + createdAt: now, + expiresAt: now + expiresInMs, + maxRuns: options?.maxRuns, + runCount: 0, + }; + + this.jobs.set(id, job); + + // Set up the recurring interval + const timer = setInterval(() => { + job.runCount++; + if (this.callback) { + this.callback(job); + } + // Auto-cancel when maxRuns limit reached + if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) { + this.cancel(id); + } + }, intervalMs); + + // Prevent the interval from keeping the process alive + if (timer.unref) timer.unref(); + + this.timers.set(id, timer); + + // Set up auto-expiry + const expiryTimer = setTimeout(() => { + this.cancel(id); + }, expiresInMs); + + if (expiryTimer.unref) expiryTimer.unref(); + + this.expiryTimers.set(id, expiryTimer); + + return job; + } + + /** + * Cancel a scheduled job by ID. + * Returns true if the job was found and cancelled. + */ + cancel(id: string): boolean { + const timer = this.timers.get(id); + if (timer) { + clearInterval(timer); + this.timers.delete(id); + } + + const expiryTimer = this.expiryTimers.get(id); + if (expiryTimer) { + clearTimeout(expiryTimer); + this.expiryTimers.delete(id); + } + + return this.jobs.delete(id); + } + + /** + * List all active jobs. + */ + list(): RepeatJob[] { + return [...this.jobs.values()]; + } + + /** + * Cancel all jobs and clean up. + */ + shutdown(): void { + for (const id of this.jobs.keys()) { + this.cancel(id); + } + } +} diff --git a/src/core/agent.ts b/src/core/agent.ts index 729891b..f614abe 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -21,6 +21,7 @@ import { ProviderNotConfiguredError } from '../providers/ProviderFactory.js'; import { ApiError, classifyApiError } from '../providers/errors.js'; import { getPromptBlockWidth, + promptInterrupt, promptNotify, readInstruction, safeEmitKeypressEvents @@ -85,6 +86,7 @@ type InkRenderer = any; import { PermissionManager } from '../permissions/PermissionManager.js'; import { HookManager } from './HookManager.js'; import { TeamManager } from './teams/TeamManager.js'; +import { RepeatManager } from './RepeatManager.js'; import { confirm as unifiedConfirm, isExternalCallbackEnabled } from '../ui/promptCallback.js'; import { ActivityIndicator } from '../ui/activityIndicator.js'; import { NotificationService } from '../utils/notification.js'; @@ -157,6 +159,7 @@ export class AutohandAgent { private notificationService: NotificationService; private versionCheckResult?: VersionCheckResult; private teamManager: TeamManager; + private repeatManager: RepeatManager; private suggestionEngine: SuggestionEngine | null = null; private pendingSuggestion: Promise | null = null; private isStartupSuggestion = false; @@ -289,6 +292,21 @@ export class AutohandAgent { } }); + // Initialize repeat manager for /repeat recurring prompts + this.repeatManager = new RepeatManager(); + this.repeatManager.onTrigger((job) => { + // If the agent is busy processing an instruction, queue for later. + // The main loop will pick it up when the current turn finishes. + if (this.isInstructionActive) { + this.pendingInkInstructions.push(job.prompt); + return; + } + + // Agent is idle — interrupt the blocking prompt so the main loop + // can process the instruction through the normal flow. + promptInterrupt(job.prompt); + }); + // Initialize team manager for /team, /tasks, /message commands this.teamManager = new TeamManager({ leadSessionId: randomUUID(), @@ -836,6 +854,8 @@ export class AutohandAgent { }, // Team manager for /team, /tasks, /message commands teamManager: this.teamManager, + // Repeat manager for /repeat recurring prompt scheduling + repeatManager: this.repeatManager, }; this.slashHandler = new SlashCommandHandler(slashContext, SLASH_COMMANDS); } @@ -1577,6 +1597,9 @@ If lint or tests fail, report the issues but do NOT commit.`; return command; } + // Echo the user's slash command to the chat log so it's visible + console.log(chalk.white(`\n› ${normalized}`)); + const handled = await this.runSlashCommandWithInput(command, args); if (handled !== null) { // Slash command returned display output - print it, don't send to LLM diff --git a/src/core/slashCommandHandler.ts b/src/core/slashCommandHandler.ts index 68b95c3..9b290e7 100644 --- a/src/core/slashCommandHandler.ts +++ b/src/core/slashCommandHandler.ts @@ -382,6 +382,10 @@ export class SlashCommandHandler { const { execute } = await import('../commands/import.js'); return execute(args); } + case '/repeat': { + const { repeat } = await import('../commands/repeat.js'); + return repeat({ repeatManager: this.ctx.repeatManager, llm: this.ctx.llm }, args); + } default: this.printUnsupported(command); return null; diff --git a/src/core/slashCommandTypes.ts b/src/core/slashCommandTypes.ts index bb98b7f..7ea2f63 100644 --- a/src/core/slashCommandTypes.ts +++ b/src/core/slashCommandTypes.ts @@ -14,6 +14,7 @@ import type { AutomodeManager } from './AutomodeManager.js'; import type { FileActionManager } from '../actions/filesystem.js'; import type { McpClientManager } from '../mcp/McpClientManager.js'; import type { TeamManager } from './teams/TeamManager.js'; +import type { RepeatManager } from './RepeatManager.js'; import type { LoadedConfig, ProviderName } from '../types.js'; export interface SlashCommandContext { @@ -68,6 +69,8 @@ export interface SlashCommandContext { onTopRecommendation?: (slug: string) => void; /** Team manager for /team and /tasks commands */ teamManager?: TeamManager; + /** Repeat manager for /repeat recurring prompt scheduling */ + repeatManager?: RepeatManager; } export interface SlashCommandSubcommand { diff --git a/src/core/slashCommands.ts b/src/core/slashCommands.ts index d57c64f..6bdca83 100644 --- a/src/core/slashCommands.ts +++ b/src/core/slashCommands.ts @@ -47,6 +47,7 @@ import * as teamCmd from '../commands/team.js'; import * as tasksCmd from '../commands/tasks.js'; import * as messageCmd from '../commands/message.js'; import * as importCmd from '../commands/import.js'; +import * as repeatCmd from '../commands/repeat.js'; import type { SlashCommand } from './slashCommandTypes.js'; export type { SlashCommand } from './slashCommandTypes.js'; @@ -103,4 +104,5 @@ export const SLASH_COMMANDS: SlashCommand[] = ([ tasksCmd.metadata, messageCmd.metadata, importCmd.metadata, + repeatCmd.metadata, ] as (SlashCommand | undefined)[]).filter((cmd): cmd is SlashCommand => cmd != null && typeof cmd.command === 'string'); diff --git a/src/ui/inputPrompt.ts b/src/ui/inputPrompt.ts index 8441ec0..536a503 100644 --- a/src/ui/inputPrompt.ts +++ b/src/ui/inputPrompt.ts @@ -52,6 +52,14 @@ export function promptNotify(message: string): void { promptEvents.emit('notify', message); } +/** + * Interrupt the active prompt, causing readInstruction to resolve with the given value. + * Used by repeat jobs to inject instructions while the prompt is blocking the loop. + */ +export function promptInterrupt(value: string): void { + promptEvents.emit('interrupt', value); +} + export const PROMPT_PREFIX = `${chalk.gray('›')} `; // Visible length of the prompt prefix (ANSI codes not counted) export const PROMPT_VISIBLE_LENGTH = 2; @@ -63,7 +71,7 @@ export const PROMPT_LINES_BELOW_INPUT = 1; export const PROMPT_BLOCK_LINE_COUNT = PROMPT_LINES_ABOVE_INPUT + 1 + PROMPT_LINES_BELOW_INPUT; export const PROMPT_PLACEHOLDER = 'Plan, search, build anything'; export const PROMPT_INPUT_PREFIX = '❯ '; -export const SHIFT_ENTER_RESIDUAL_PATTERN = /^13;?[234]?\d*[u~]$/; +export const SHIFT_ENTER_RESIDUAL_PATTERN = /^(?:13;?[234]?\d*[u~]|27;[234];13~)$/; export type SlashCommandHint = SlashCommand; @@ -536,6 +544,10 @@ export function isShiftEnterSequence(str: string, key: readline.Key | undefined) if (/^\x1b\[13;[234]\d*[u~]$/.test(seq)) { return true; } + // xterm modifyOtherKeys level 2: ESC[27;modifier;13~ + if (/^\x1b\[27;[234];13~$/.test(seq)) { + return true; + } // Alt+Enter: ESC followed by carriage return if (seq === '\x1b\r' || seq === '\x1b\n') { return true; @@ -552,7 +564,7 @@ export function countResidualModifiedEnterSequences(chunk: string): number { return 0; } - const matches = chunk.match(/13;?[234]?\d*[u~]/g); + const matches = chunk.match(/(?:13;?[234]?\d*[u~]|27;[234];13~)/g); if (!matches || matches.length === 0) { return 0; } @@ -565,7 +577,7 @@ export function countRawModifiedEnterSequences(chunk: string): number { return 0; } - const matches = chunk.match(/\x1b(?:\[13;[234]\d*[u~]|\r|\n)/g); + const matches = chunk.match(/\x1b(?:\[13;[234]\d*[u~]|\[27;[234];13~|\r|\n)/g); return matches?.length ?? 0; } @@ -886,7 +898,7 @@ function splitMultilineSegments(value: string): MultilineSegments { return { segments, separatorLengths }; } -function formatPromptStatusRow( +export function formatPromptStatusRow( statusLine: string | { left: string; right: string } | undefined, width: number ): string { @@ -900,7 +912,14 @@ function formatPromptStatusRow( } const plainLeft = stripAnsiCodes(left); - const plainRight = right ? stripAnsiCodes(right) : ''; + let plainRight = right ? stripAnsiCodes(right) : ''; + + // Truncate right part if it alone exceeds width — prevents terminal wrapping + // which breaks cursor positioning (moveUp assumes exactly 1 status row). + if (plainRight.length > width) { + plainRight = truncatePlainText(plainRight, width); + } + const minGap = plainRight ? 2 : 0; const availableForLeft = Math.max(0, width - plainRight.length - minGap); const clippedLeft = truncatePlainText(plainLeft, availableForLeft); @@ -1710,6 +1729,7 @@ async function promptOnce(options: PromptOnceOptions): Promise { mentionPreview.dispose(); resizeWatcher.dispose(); promptEvents.off('notify', onPromptNotify); + promptEvents.off('interrupt', onPromptInterrupt); input.off('keypress', handleKeypress); input.off('data', handleInputData); if (originalRefreshLine) { @@ -1740,6 +1760,20 @@ async function promptOnce(options: PromptOnceOptions): Promise { const onPromptNotify = (msg: string) => showPromptMessage(msg); promptEvents.on('notify', onPromptNotify); + // Subscribe to external interrupts (e.g. repeat job triggers). + // Mirrors the normal rl 'line' submit path so terminal state is clean. + const onPromptInterrupt = (value: string) => { + if (closed) return; + mentionPreview.reset(); + if (contextualHelpVisible) { + setContextualHelpVisible(false); + } + leavePromptSurface(stdOutput, STATUS_LINE_COUNT); + cleanup(); + resolve({ kind: 'submit', value }); + }; + promptEvents.on('interrupt', onPromptInterrupt); + const refreshLine = () => { renderActivePrompt(); }; @@ -1780,10 +1814,9 @@ async function promptOnce(options: PromptOnceOptions): Promise { if (pasteState.isInPaste) { return; } - // Catch residual CSI u fragments that readline passes as literal text. - // These never fire keypress events, so TextBuffer can't handle them. - // Insert a real newline into the TextBuffer. - if (/^13;?[234]?\d*[u~]$/.test(s)) { + // Catch residual CSI u / modifyOtherKeys fragments that readline + // passes as literal text. Insert a real newline into the TextBuffer. + if (/^(?:13;?[234]?\d*[u~]|27;[234];13~)$/.test(s)) { textBuffer.insert('\n'); syncReadlineFromBuffer(); renderActivePrompt(); @@ -1946,7 +1979,7 @@ async function promptOnce(options: PromptOnceOptions): Promise { if ( isShiftEnterSequence(_str, key) || isShiftEnterResidualSequence(rawSeq) || - (_str.length > 0 && /^[13;~u]+$/.test(_str)) + (_str.length > 0 && /^[\d;~u]+$/.test(_str)) ) { return; } diff --git a/tests/commands/repeat.test.ts b/tests/commands/repeat.test.ts new file mode 100644 index 0000000..4ceea97 --- /dev/null +++ b/tests/commands/repeat.test.ts @@ -0,0 +1,670 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseInput, intervalToCron, repeat } from '../../src/commands/repeat.js'; +import { RepeatManager } from '../../src/core/RepeatManager.js'; + +// ─── parseInput tests ─────────────────────────────────────────────────────── + +describe('parseInput', () => { + it('rule 1: leading interval token', () => { + expect(parseInput('5m /babysit-prs')).toMatchObject({ interval: '5m', prompt: '/babysit-prs', explicit: true }); + expect(parseInput('2h check build')).toMatchObject({ interval: '2h', prompt: 'check build', explicit: true }); + expect(parseInput('1d deploy')).toMatchObject({ interval: '1d', prompt: 'deploy', explicit: true }); + expect(parseInput('30s run quick check')).toMatchObject({ interval: '30s', prompt: 'run quick check', explicit: true }); + }); + + it('rule 1: leading token alone (empty prompt)', () => { + expect(parseInput('5m')).toMatchObject({ interval: '5m', prompt: '', explicit: true }); + }); + + it('rule 2: leading "every " clause', () => { + expect(parseInput('every 2 minutes bun run test')).toMatchObject({ interval: '2m', prompt: 'bun run test', explicit: true }); + expect(parseInput('every 5m check deploy')).toMatchObject({ interval: '5m', prompt: 'check deploy', explicit: true }); + expect(parseInput('every 1 hour run backup')).toMatchObject({ interval: '1h', prompt: 'run backup', explicit: true }); + expect(parseInput('every 30 seconds ping')).toMatchObject({ interval: '30s', prompt: 'ping', explicit: true }); + }); + + it('rule 3: trailing "every " (end of input)', () => { + expect(parseInput('check the deploy every 20m')).toMatchObject({ interval: '20m', prompt: 'check the deploy', explicit: true }); + }); + + it('rule 3: trailing "every "', () => { + expect(parseInput('run tests every 5 minutes')).toMatchObject({ interval: '5m', prompt: 'run tests', explicit: true }); + expect(parseInput('check status every 2 hours')).toMatchObject({ interval: '2h', prompt: 'check status', explicit: true }); + expect(parseInput('backup every 1 day')).toMatchObject({ interval: '1d', prompt: 'backup', explicit: true }); + expect(parseInput('ping every 30 seconds')).toMatchObject({ interval: '30s', prompt: 'ping', explicit: true }); + }); + + it('rule 4: middle "every " joins surrounding text', () => { + expect(parseInput('tell me a joke every 10s about life')).toMatchObject({ + interval: '10s', prompt: 'tell me a joke about life', explicit: true, + }); + expect(parseInput('run tests every 2m and report results')).toMatchObject({ + interval: '2m', prompt: 'run tests and report results', explicit: true, + }); + expect(parseInput('check deploy every 1h then notify me')).toMatchObject({ + interval: '1h', prompt: 'check deploy then notify me', explicit: true, + }); + }); + + it('rule 4: middle "every " joins surrounding text', () => { + expect(parseInput('pull changes every 5 minutes and run lint')).toMatchObject({ + interval: '5m', prompt: 'pull changes and run lint', explicit: true, + }); + }); + + it('rule: singular "every " without number implies 1', () => { + expect(parseInput('say hello to me every minute')).toMatchObject({ interval: '1m', prompt: 'say hello to me', explicit: true }); + expect(parseInput('check git commits every hour')).toMatchObject({ interval: '1h', prompt: 'check git commits', explicit: true }); + expect(parseInput('run backup every day')).toMatchObject({ interval: '1d', prompt: 'run backup', explicit: true }); + expect(parseInput('ping every second')).toMatchObject({ interval: '1s', prompt: 'ping', explicit: true }); + }); + + it('rule: leading singular "every "', () => { + expect(parseInput('every minute check the logs')).toMatchObject({ interval: '1m', prompt: 'check the logs', explicit: true }); + expect(parseInput('every hour run tests')).toMatchObject({ interval: '1h', prompt: 'run tests', explicit: true }); + }); + + it('rule: middle singular "every " joins surrounding text', () => { + expect(parseInput('say hello every minute to me')).toMatchObject({ interval: '1m', prompt: 'say hello to me', explicit: true }); + }); + + it('rule 5: "every" not followed by time expression → default', () => { + expect(parseInput('check every PR')).toMatchObject({ interval: '5m', prompt: 'check every PR', explicit: false }); + }); + + it('rule 5: no interval → default 5m', () => { + expect(parseInput('check the deploy')).toMatchObject({ interval: '5m', prompt: 'check the deploy', explicit: false }); + expect(parseInput('run tests')).toMatchObject({ interval: '5m', prompt: 'run tests', explicit: false }); + }); +}); + +// ─── intervalToCron tests ─────────────────────────────────────────────────── + +describe('intervalToCron', () => { + it('minutes that divide 60 cleanly', () => { + expect(intervalToCron('5m')).toMatchObject({ cronExpression: '*/5 * * * *', humanReadable: 'every 5 minutes' }); + expect(intervalToCron('10m')).toMatchObject({ cronExpression: '*/10 * * * *' }); + expect(intervalToCron('15m')).toMatchObject({ cronExpression: '*/15 * * * *' }); + expect(intervalToCron('30m')).toMatchObject({ cronExpression: '*/30 * * * *' }); + expect(intervalToCron('1m')).toMatchObject({ cronExpression: '*/1 * * * *', humanReadable: 'every 1 minute' }); + }); + + it('minutes that do not divide 60 → rounds to nearest clean divisor', () => { + const result = intervalToCron('7m'); + // 7 doesn't divide 60; nearest divisors of 60 are 6 and 10; 6 is closer + expect(result.cronExpression).toBe('*/6 * * * *'); + expect(result.roundedNote).toBeDefined(); + }); + + it('minutes >= 60 → converts to hours', () => { + const result = intervalToCron('60m'); + expect(result.cronExpression).toBe('0 */1 * * *'); + expect(result.humanReadable).toBe('every 1 hour'); + }); + + it('120m → 2 hours', () => { + const result = intervalToCron('120m'); + expect(result.cronExpression).toBe('0 */2 * * *'); + }); + + it('hours that divide 24', () => { + expect(intervalToCron('2h')).toMatchObject({ cronExpression: '0 */2 * * *', humanReadable: 'every 2 hours' }); + expect(intervalToCron('6h')).toMatchObject({ cronExpression: '0 */6 * * *' }); + expect(intervalToCron('12h')).toMatchObject({ cronExpression: '0 */12 * * *' }); + }); + + it('hours that do not divide 24 → rounds', () => { + const result = intervalToCron('7h'); + // 7 doesn't divide 24; nearest divisors are 6 and 8; 6 wins (found first at equal distance) + expect(result.cronExpression).toBe('0 */6 * * *'); + expect(result.roundedNote).toBeDefined(); + }); + + it('days', () => { + expect(intervalToCron('1d')).toMatchObject({ cronExpression: '0 0 */1 * *', humanReadable: 'every 1 day at midnight' }); + expect(intervalToCron('3d')).toMatchObject({ cronExpression: '0 0 */3 * *' }); + }); + + it('seconds → rounds up to minutes', () => { + const result = intervalToCron('30s'); + expect(result.cronExpression).toBe('*/1 * * * *'); + expect(result.roundedNote).toContain('Rounded'); + }); + + it('90s → 2m', () => { + const result = intervalToCron('90s'); + expect(result.cronExpression).toBe('*/2 * * * *'); + expect(result.roundedNote).toContain('Rounded'); + }); + + it('throws on invalid format', () => { + expect(() => intervalToCron('abc')).toThrow('Invalid interval'); + expect(() => intervalToCron('5x')).toThrow('Invalid interval'); + }); +}); + +// ─── RepeatManager tests ─────────────────────────────────────────────────── + +describe('RepeatManager', () => { + let manager: RepeatManager; + + beforeEach(() => { + vi.useFakeTimers(); + manager = new RepeatManager(); + }); + + afterEach(() => { + manager.shutdown(); + vi.useRealTimers(); + }); + + it('schedules a job and triggers callback', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + const job = manager.schedule('run tests', 60_000, '*/1 * * * *', 'every 1 minute'); + expect(job.id).toBeDefined(); + expect(job.prompt).toBe('run tests'); + + // Should not have fired yet + expect(cb).not.toHaveBeenCalled(); + + // Advance time by 1 minute + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(job); + + // Advance another minute + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('cancels a job', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + const job = manager.schedule('run tests', 60_000, '*/1 * * * *', 'every 1 minute'); + const ok = manager.cancel(job.id); + expect(ok).toBe(true); + + vi.advanceTimersByTime(120_000); + expect(cb).not.toHaveBeenCalled(); + }); + + it('cancel returns false for unknown id', () => { + expect(manager.cancel('nonexistent')).toBe(false); + }); + + it('lists active jobs', () => { + manager.schedule('job1', 60_000, '*/1 * * * *', 'every 1 minute'); + manager.schedule('job2', 120_000, '*/2 * * * *', 'every 2 minutes'); + expect(manager.list()).toHaveLength(2); + }); + + it('shutdown clears all jobs', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('job1', 60_000, '*/1 * * * *', 'every 1 minute'); + manager.schedule('job2', 60_000, '*/1 * * * *', 'every 1 minute'); + manager.shutdown(); + + expect(manager.list()).toHaveLength(0); + vi.advanceTimersByTime(120_000); + expect(cb).not.toHaveBeenCalled(); + }); + + // ─── maxRuns: auto-cancel after N executions ─────────────────────────── + + it('auto-cancels job after maxRuns triggers', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { maxRuns: 3 }); + + // Fire 3 times + vi.advanceTimersByTime(60_000); + vi.advanceTimersByTime(60_000); + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(3); + + // 4th tick should NOT fire — job was auto-cancelled + vi.advanceTimersByTime(60_000); + expect(cb).toHaveBeenCalledTimes(3); + expect(manager.list()).toHaveLength(0); + }); + + it('job without maxRuns keeps running indefinitely', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute'); + + vi.advanceTimersByTime(60_000 * 10); + expect(cb).toHaveBeenCalledTimes(10); + expect(manager.list()).toHaveLength(1); + }); + + it('stores maxRuns and runCount on the job', () => { + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { maxRuns: 5 }); + const job = manager.list()[0]; + expect(job.maxRuns).toBe(5); + expect(job.runCount).toBe(0); + }); + + it('increments runCount on each trigger', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { maxRuns: 10 }); + + vi.advanceTimersByTime(60_000 * 3); + const job = manager.list()[0]; + expect(job.runCount).toBe(3); + }); + + // ─── custom expiry duration ──────────────────────────────────────────── + + it('uses custom expiresInMs instead of default 3 days', () => { + const cb = vi.fn(); + manager.onTrigger(cb); + + const ONE_HOUR_MS = 60 * 60 * 1000; + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute', { expiresInMs: ONE_HOUR_MS }); + + const job = manager.list()[0]; + expect(job.expiresAt).toBe(job.createdAt + ONE_HOUR_MS); + + // Still alive before expiry + vi.advanceTimersByTime(ONE_HOUR_MS - 1); + expect(manager.list()).toHaveLength(1); + + // Expires at exactly ONE_HOUR_MS + vi.advanceTimersByTime(1); + expect(manager.list()).toHaveLength(0); + }); + + it('default expiry is still 3 days when no custom expiresInMs', () => { + manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute'); + const job = manager.list()[0]; + const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + expect(job.expiresAt).toBe(job.createdAt + THREE_DAYS_MS); + }); +}); + +// ─── /repeat command handler tests ────────────────────────────────────────── + +describe('/repeat command', () => { + let manager: RepeatManager; + + beforeEach(() => { + vi.useFakeTimers(); + manager = new RepeatManager(); + }); + + afterEach(() => { + manager.shutdown(); + vi.useRealTimers(); + }); + + it('shows usage when called with no args', async () => { + const result = await repeat({ repeatManager: manager }, []); + expect(result).toContain('/repeat'); + expect(result).toContain('Usage'); + }); + + it('shows usage when prompt is empty after parsing', async () => { + const result = await repeat({ repeatManager: manager }, ['5m']); + expect(result).toContain('Usage'); + }); + + it('returns warning when no repeat manager', async () => { + const result = await repeat({ repeatManager: undefined }, ['5m', 'test']); + expect(result).toContain('not available'); + }); + + it('schedules a job with leading interval', async () => { + const result = await repeat({ repeatManager: manager }, ['5m', 'run', 'tests']); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('run tests'); + expect(result).toContain('every 5 minutes'); + expect(manager.list()).toHaveLength(1); + }); + + it('schedules a job with trailing every clause', async () => { + const result = await repeat({ repeatManager: manager }, ['check', 'deploy', 'every', '20m']); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('check deploy'); + expect(result).toContain('every 20 minutes'); + }); + + it('schedules with default interval when none specified', async () => { + const result = await repeat({ repeatManager: manager }, ['check', 'the', 'deploy']); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); + }); + + it('cancel subcommand cancels a job', async () => { + const job = manager.schedule('test', 60_000, '*/1 * * * *', 'every 1 minute'); + const result = await repeat({ repeatManager: manager }, ['cancel', job.id]); + expect(result).toContain('Cancelled'); + expect(manager.list()).toHaveLength(0); + }); + + it('cancel subcommand with invalid id', async () => { + const result = await repeat({ repeatManager: manager }, ['cancel', 'bad-id']); + expect(result).toContain('No active job'); + }); + + it('cancel subcommand with no id shows usage', async () => { + const result = await repeat({ repeatManager: manager }, ['cancel']); + expect(result).toContain('Usage'); + }); + + it('list subcommand shows active jobs', async () => { + manager.schedule('job1', 60_000, '*/1 * * * *', 'every 1 minute'); + const result = await repeat({ repeatManager: manager }, ['list']); + expect(result).toContain('Active recurring jobs'); + expect(result).toContain('job1'); + }); + + it('list subcommand with no jobs', async () => { + const result = await repeat({ repeatManager: manager }, ['list']); + expect(result).toContain('No recurring jobs'); + }); + + it('schedules a job with middle every clause', async () => { + const result = await repeat( + { repeatManager: manager }, + 'tell me a joke every 10s about life'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('tell me a joke about life'); + // 10s rounds to 1m + expect(result).toContain('every 1 minute'); + expect(manager.list()).toHaveLength(1); + }); + + it('shows rounding note for non-clean intervals', async () => { + const result = await repeat({ repeatManager: manager }, ['7m', 'run', 'lint']); + expect(result).toContain('Note:'); + }); + + // ─── LLM intent extraction tests ──────────────────────────────────────── + + it('uses LLM to extract interval from natural language when regex falls through', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"2m","prompt":"navigate to my gh pr list and pick one item to work on"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'navigate to my gh pr list and pick one item to work on twice a minute'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 2 minutes'); + expect(result).toContain('navigate to my gh pr list and pick one item to work on'); + expect(mockLlm.complete).toHaveBeenCalledTimes(1); + }); + + it('uses LLM for ambiguous "hourly" phrasing', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1h","prompt":"make a git commit with empty message to trigger ci cd"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'make a git commit with empty message to trigger ci cd hourly'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 1 hour'); + expect(mockLlm.complete).toHaveBeenCalledTimes(1); + }); + + it('always calls LLM first when available, even for explicit intervals', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"5m","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['5m', 'run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(mockLlm.complete).toHaveBeenCalledTimes(1); + }); + + it('falls back to regex when LLM returns invalid JSON', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: 'I cannot parse this input', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); // default interval + expect(result).toContain('run tests'); + }); + + it('falls back to regex when LLM throws', async () => { + const mockLlm = { + complete: vi.fn().mockRejectedValue(new Error('API error')), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); + }); + + it('falls back to regex when LLM returns invalid interval format', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"every 5 minutes","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); // default + }); + + it('works without LLM (no llm in context)', async () => { + const result = await repeat( + { repeatManager: manager }, + ['check', 'deploy'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 5 minutes'); + }); + + // ─── BUG 2: subcommand typo detection ────────────────────────────────── + + it('suggests "list" when user types "lsit"', async () => { + const result = await repeat({ repeatManager: manager }, ['lsit']); + expect(result).not.toContain('Recurring job scheduled'); + expect(result).toContain('list'); + expect(manager.list()).toHaveLength(0); + }); + + it('suggests "cancel" when user types "cancle" alone', async () => { + const result = await repeat({ repeatManager: manager }, ['cancle']); + expect(result).not.toContain('Recurring job scheduled'); + expect(result).toContain('cancel'); + expect(manager.list()).toHaveLength(0); + }); + + it('shows usage when user types "help"', async () => { + const result = await repeat({ repeatManager: manager }, ['help']); + expect(result).toContain('Usage'); + expect(manager.list()).toHaveLength(0); + }); + + it('suggests "help" when user types "hepl"', async () => { + const result = await repeat({ repeatManager: manager }, ['hepl']); + expect(result).not.toContain('Recurring job scheduled'); + expect(result).toContain('help'); + expect(manager.list()).toHaveLength(0); + }); + + it('does not flag multi-word prompts as typos', async () => { + const result = await repeat({ repeatManager: manager }, ['lint', 'the', 'codebase']); + expect(result).toContain('Recurring job scheduled'); + }); + + it('does not flag words far from subcommands', async () => { + const result = await repeat({ repeatManager: manager }, ['deploy']); + expect(result).toContain('Recurring job scheduled'); + }); + + // ─── maxRuns: LLM extracts execution count ───────────────────────────── + + it('schedules with maxRuns when LLM extracts count', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"2m","prompt":"tell me a joke","maxRuns":10}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'tell me a joke every 2 minutes only 10 times'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('10 runs'); + const job = manager.list()[0]; + expect(job.maxRuns).toBe(10); + }); + + it('schedules without maxRuns when LLM omits it', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"5m","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['run', 'tests', 'every', '5m'], + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).not.toContain('runs'); + const job = manager.list()[0]; + expect(job.maxRuns).toBeUndefined(); + }); + + // ─── custom expiry: LLM extracts duration ────────────────────────────── + + it('schedules with custom expiry when LLM extracts expiresIn', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1d","prompt":"check PRs","expiresIn":"7d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'check PRs every day for the next week'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('7 day'); + const job = manager.list()[0]; + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + expect(job.expiresAt).toBe(job.createdAt + SEVEN_DAYS_MS); + }); + + it('defaults to 3 days expiry when LLM omits expiresIn', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"5m","prompt":"run tests"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + ['5m', 'run', 'tests'], + ); + expect(result).toContain('auto-expire after 3 days'); + }); + + // ─── Disambiguation: conflicting interval signals ─────────────────── + + it('resolves conflicting intervals: "every day" + "intervals of 10 minutes per run" → 10m wins', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"10m","prompt":"run this command echo hello world","expiresIn":"1d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'do this every day run this command echo hello world until tomorrow with intervals of 10 minutes per run'.split(' '), + ); + expect(result).toContain('Recurring job scheduled'); + expect(result).toContain('every 10 minutes'); + expect(result).toContain('1 day'); + const job = manager.list()[0]; + expect(job.intervalMs).toBe(10 * 60 * 1000); + }); + + it('resolves "every day at 10am for the next week" → interval 1d, expires 7d', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1d","prompt":"run the build","expiresIn":"7d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'every day at 10 am for the next week run the build'.split(' '), + ); + expect(result).toContain('every 1 day'); + expect(result).toContain('7 day'); + }); + + it('resolves "hourly for 2 days only 5 times" → all three fields', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1h","prompt":"check deployment status","maxRuns":5,"expiresIn":"2d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'check deployment status hourly for 2 days only 5 times'.split(' '), + ); + expect(result).toContain('every 1 hour'); + expect(result).toContain('5 runs'); + expect(result).toContain('2 day'); + }); + + it('shows both maxRuns and custom expiry in confirmation', async () => { + const mockLlm = { + complete: vi.fn().mockResolvedValue({ + content: '{"interval":"1h","prompt":"check build","maxRuns":5,"expiresIn":"2d"}', + }), + }; + + const result = await repeat( + { repeatManager: manager, llm: mockLlm as any }, + 'check build every hour only 5 times for 2 days'.split(' '), + ); + expect(result).toContain('5 runs'); + expect(result).toContain('2 day'); + }); +}); diff --git a/tests/commands/repeatCli.test.ts b/tests/commands/repeatCli.test.ts new file mode 100644 index 0000000..42cadf9 --- /dev/null +++ b/tests/commands/repeatCli.test.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Tests for the --repeat CLI flag (non-interactive mode). + * Validates parsing, scheduling, execution, and edge cases for + * `autohand --repeat "" ""`. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseRepeatFlag, type RepeatFlagOptions } from '../../src/commands/repeatCli.js'; + +// ─── parseRepeatFlag tests ────────────────────────────────────────────────── + +describe('parseRepeatFlag', () => { + // ─── Basic parsing ──────────────────────────────────────────────────── + + it('parses explicit interval and prompt: --repeat "5m" "run tests"', () => { + const result = parseRepeatFlag('5m', 'run tests'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + it('parses single natural language string: --repeat "every 5 minutes run tests"', () => { + const result = parseRepeatFlag('every 5 minutes run tests'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + it('parses shorthand intervals: s, m, h, d', () => { + expect(parseRepeatFlag('30s', 'ping').interval).toBe('30s'); + expect(parseRepeatFlag('10m', 'check').interval).toBe('10m'); + expect(parseRepeatFlag('2h', 'backup').interval).toBe('2h'); + expect(parseRepeatFlag('1d', 'report').interval).toBe('1d'); + }); + + it('defaults to 5m when no interval is specified', () => { + const result = parseRepeatFlag('run tests'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + // ─── maxRuns from CLI ───────────────────────────────────────────────── + + it('parses --max-runs option', () => { + const result = parseRepeatFlag('5m', 'run tests', { maxRuns: 10 }); + expect(result.maxRuns).toBe(10); + }); + + it('omits maxRuns when not specified', () => { + const result = parseRepeatFlag('5m', 'run tests'); + expect(result.maxRuns).toBeUndefined(); + }); + + it('rejects maxRuns of 0', () => { + const result = parseRepeatFlag('5m', 'run tests', { maxRuns: 0 }); + expect(result.maxRuns).toBeUndefined(); + }); + + it('rejects negative maxRuns', () => { + const result = parseRepeatFlag('5m', 'run tests', { maxRuns: -5 }); + expect(result.maxRuns).toBeUndefined(); + }); + + // ─── expiresIn from CLI ─────────────────────────────────────────────── + + it('parses --expires option as shorthand', () => { + const result = parseRepeatFlag('5m', 'run tests', { expires: '7d' }); + expect(result.expiresIn).toBe('7d'); + }); + + it('omits expiresIn when not specified', () => { + const result = parseRepeatFlag('5m', 'run tests'); + expect(result.expiresIn).toBeUndefined(); + }); + + it('rejects invalid expires format', () => { + const result = parseRepeatFlag('5m', 'run tests', { expires: 'next week' }); + expect(result.expiresIn).toBeUndefined(); + }); + + // ─── Edge cases: empty and whitespace ───────────────────────────────── + + it('returns error for empty input', () => { + const result = parseRepeatFlag(''); + expect(result.error).toBeDefined(); + }); + + it('returns error for whitespace-only input', () => { + const result = parseRepeatFlag(' '); + expect(result.error).toBeDefined(); + }); + + it('returns error when prompt is empty after interval extraction', () => { + const result = parseRepeatFlag('5m', ''); + expect(result.error).toBeDefined(); + }); + + it('returns error when prompt is only whitespace', () => { + const result = parseRepeatFlag('5m', ' '); + expect(result.error).toBeDefined(); + }); + + // ─── Edge cases: invalid intervals ──────────────────────────────────── + + it('returns error for invalid interval format', () => { + const result = parseRepeatFlag('5x', 'run tests'); + expect(result.error).toBeDefined(); + }); + + it('returns error for interval with no unit', () => { + const result = parseRepeatFlag('123', 'run tests'); + // '123' doesn't match \d+[smhd], should be treated as prompt text + expect(result.interval).toBe('5m'); // default + expect(result.prompt).toContain('123'); + }); + + // ─── Natural language: "every" variants ─────────────────────────────── + + it('parses "every 2 hours check build status"', () => { + const result = parseRepeatFlag('every 2 hours check build status'); + expect(result.interval).toBe('2h'); + expect(result.prompt).toBe('check build status'); + }); + + it('parses "run tests every 5 minutes"', () => { + const result = parseRepeatFlag('run tests every 5 minutes'); + expect(result.interval).toBe('5m'); + expect(result.prompt).toBe('run tests'); + }); + + it('parses "check deploy every day"', () => { + const result = parseRepeatFlag('check deploy every day'); + expect(result.interval).toBe('1d'); + expect(result.prompt).toBe('check deploy'); + }); + + // ─── Preserves technical content ────────────────────────────────────── + + it('preserves slash commands in prompt', () => { + const result = parseRepeatFlag('5m', '/deploy staging'); + expect(result.prompt).toBe('/deploy staging'); + }); + + it('preserves file paths in prompt', () => { + const result = parseRepeatFlag('10m', 'lint src/core/*.ts'); + expect(result.prompt).toBe('lint src/core/*.ts'); + }); + + it('preserves shell commands with special chars', () => { + const result = parseRepeatFlag('5m', 'echo "hello world"'); + expect(result.prompt).toBe('echo "hello world"'); + }); + + it('preserves backtick commands in prompt', () => { + const result = parseRepeatFlag('5m', 'run `git status` and report'); + expect(result.prompt).toBe('run `git status` and report'); + }); + + // ─── Combined options ───────────────────────────────────────────────── + + it('combines interval, maxRuns, and expires', () => { + const result = parseRepeatFlag('10m', 'run tests', { maxRuns: 5, expires: '2d' }); + expect(result.interval).toBe('10m'); + expect(result.prompt).toBe('run tests'); + expect(result.maxRuns).toBe(5); + expect(result.expiresIn).toBe('2d'); + }); + + it('two-arg form: interval and prompt are separate strings', () => { + const result = parseRepeatFlag('2h', 'check PR reviews'); + expect(result.interval).toBe('2h'); + expect(result.prompt).toBe('check PR reviews'); + }); + + // ─── Disambiguation: conflicting intervals ──────────────────────────── + + it('single-string with only a prompt (no schedule keywords)', () => { + const result = parseRepeatFlag('check every PR for review comments'); + // "every PR" is a quantifier, not a frequency + expect(result.interval).toBe('5m'); // default + expect(result.prompt).toContain('every PR'); + }); + + it('handles very long prompts without truncation', () => { + const longPrompt = 'check all the deployment logs and look for errors in the kubernetes pods and report back with a summary of the issues found including pod names and timestamps'; + const result = parseRepeatFlag('5m', longPrompt); + expect(result.prompt).toBe(longPrompt); + }); +}); + +// ─── buildRepeatRunConfig tests ───────────────────────────────────────────── + +describe('buildRepeatRunConfig', () => { + it('converts parsed flag to RepeatRunConfig with intervalMs', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '5m', + prompt: 'run tests', + }); + expect(config.intervalMs).toBe(5 * 60 * 1000); + expect(config.prompt).toBe('run tests'); + }); + + it('includes maxRuns in config when specified', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '10m', + prompt: 'check build', + maxRuns: 3, + }); + expect(config.maxRuns).toBe(3); + }); + + it('includes expiresInMs in config when specified', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '1h', + prompt: 'check PRs', + expiresIn: '7d', + }); + expect(config.expiresInMs).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('defaults expiresInMs to 3 days when not specified', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '5m', + prompt: 'test', + }); + const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; + expect(config.expiresInMs).toBe(THREE_DAYS_MS); + }); + + it('computes cronExpression from interval', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '5m', + prompt: 'test', + }); + expect(config.cronExpression).toBe('*/5 * * * *'); + }); + + it('computes humanReadable from interval', async () => { + const { buildRepeatRunConfig } = await import('../../src/commands/repeatCli.js'); + const config = buildRepeatRunConfig({ + interval: '2h', + prompt: 'test', + }); + expect(config.humanReadable).toContain('2 hour'); + }); +}); diff --git a/tests/ui/inputPrompt.test.ts b/tests/ui/inputPrompt.test.ts index c86b466..eec11c8 100644 --- a/tests/ui/inputPrompt.test.ts +++ b/tests/ui/inputPrompt.test.ts @@ -804,6 +804,36 @@ describe('partial escape sequence filtering', () => { expect(countResidualModifiedEnterSequences('13;2u13;2u')).toBe(2); expect(countResidualModifiedEnterSequences('hello13~')).toBe(0); }); + + // ─── BUG 3: xterm modifyOtherKeys format ────────────────────────────── + + it('detects xterm modifyOtherKeys Shift+Enter: ESC[27;2;13~', async () => { + const { isShiftEnterSequence } = await import('../../src/ui/inputPrompt.js'); + + // xterm modifyOtherKeys level 2: ESC[27;modifier;keycode~ + // modifier 2=Shift, 3=Alt, 4=Shift+Alt + expect(isShiftEnterSequence('\x1b[27;2;13~', { sequence: '\x1b[27;2;13~' } as readline.Key)).toBe(true); + expect(isShiftEnterSequence('\x1b[27;3;13~', { sequence: '\x1b[27;3;13~' } as readline.Key)).toBe(true); + expect(isShiftEnterSequence('\x1b[27;4;13~', { sequence: '\x1b[27;4;13~' } as readline.Key)).toBe(true); + }); + + it('counts raw xterm modifyOtherKeys Shift+Enter sequences', async () => { + const { countRawModifiedEnterSequences } = await import('../../src/ui/inputPrompt.js'); + + expect(countRawModifiedEnterSequences('\x1b[27;2;13~')).toBe(1); + expect(countRawModifiedEnterSequences('\x1b[27;3;13~')).toBe(1); + }); + + it('detects residual xterm modifyOtherKeys fragments', async () => { + const { isShiftEnterResidualSequence } = await import('../../src/ui/inputPrompt.js'); + + // After readline strips ESC[, residual would be "27;2;13~" + expect(isShiftEnterResidualSequence('27;2;13~')).toBe(true); + expect(isShiftEnterResidualSequence('27;3;13~')).toBe(true); + expect(isShiftEnterResidualSequence('27;4;13~')).toBe(true); + // Should NOT match non-Enter keycodes + expect(isShiftEnterResidualSequence('27;2;9~')).toBe(false); + }); }); describe('buildMultiLineRenderState', () => { @@ -1105,3 +1135,45 @@ describe('TextBuffer integration into inputPrompt', () => { expect(state.lineCount).toBe(2); }); }); + +describe('formatPromptStatusRow', () => { + const stripAnsi = (s: string) => s.replace(/\u001b\[[0-9;]*[A-Za-z]/g, ''); + + it('truncates right part when it exceeds width on narrow terminals', async () => { + const { formatPromptStatusRow } = await import('../../src/ui/inputPrompt.js'); + + // Simulate a narrow terminal (width 60) with a long "Update available!" right side + const longRight = 'Update available! Run: curl -fsSL https://autohand.ai/install.sh | sh'; + const statusLine = { left: '93% context left', right: longRight }; + const width = 60; + + const row = formatPromptStatusRow(statusLine, width); + const plainRow = stripAnsi(row); + + // The visible row must NEVER exceed the given width — overflow causes + // terminal wrapping which breaks cursor positioning + expect(plainRow.length).toBeLessThanOrEqual(width); + }); + + it('status row fits width when right part is shorter than width', async () => { + const { formatPromptStatusRow } = await import('../../src/ui/inputPrompt.js'); + + const statusLine = { left: '93% context left', right: 'v1.2.3' }; + const width = 80; + + const row = formatPromptStatusRow(statusLine, width); + const plainRow = stripAnsi(row); + + expect(plainRow.length).toBeLessThanOrEqual(width); + expect(plainRow).toContain('v1.2.3'); + }); + + it('status row handles string-only status line', async () => { + const { formatPromptStatusRow } = await import('../../src/ui/inputPrompt.js'); + + const row = formatPromptStatusRow('plan:off · 93% context left', 60); + const plainRow = stripAnsi(row); + + expect(plainRow.length).toBeLessThanOrEqual(60); + }); +});