- Implemented `/undo` command to revert the most recent completed turn, restoring all modified files and rewinding message history. - Introduced `FileMutation` type to capture file changes for undo operations. - Enhanced session management to track and manage undo stack with a maximum of 20 turns. - Updated command parsing to include user-defined commands from `.shiro/commands.md`, allowing for custom command definitions and substitutions. - Added documentation for the new `/undo` command and user commands feature. - Implemented tests for undo functionality and user command parsing to ensure reliability.
153 lines
5.0 KiB
TypeScript
153 lines
5.0 KiB
TypeScript
import { join } from 'node:path';
|
|
|
|
/**
|
|
* User-defined slash commands, loaded from a markdown file.
|
|
*
|
|
* Every comparable agent CLI has file-defined commands; this closes that gap.
|
|
* A command is a `## <name>` heading followed by a body that is the prompt
|
|
* template sent to the model. Distinct from built-ins, which live in commands.ts
|
|
* as code. A user command whose name collides with a built-in is shadowed (the
|
|
* built-in wins) so a project cannot hijack `/model` or `/help`.
|
|
*
|
|
* The body supports substitutions that make a static template useful:
|
|
* $ARGUMENTS - everything typed after the command name, verbatim
|
|
* $1..$9 - the nth whitespace-separated argument (empty when absent)
|
|
* !`cmd` - replaced with the trimmed stdout of running `cmd` in the shell
|
|
* @path - replaced with the contents of the (workspace-rooted) file
|
|
*/
|
|
|
|
export type UserCommand = {
|
|
name: string;
|
|
summary: string;
|
|
body: string;
|
|
};
|
|
|
|
export const commandFileName = () => 'commands.md';
|
|
export const commandFileDir = (cwd: string) => join(cwd, '.shiro');
|
|
|
|
/** Raw .ts/.tsx/.js source of a `!`cmd` successive expansion step. */
|
|
const SHELL = /!`((?:[^`\\]|\\.)*)`/g;
|
|
const FILE = /@([^\s"']+)/g;
|
|
const TAG = /\$(\d+|\{ARGS\}|ARGUMENTS)/g;
|
|
|
|
function trimBody(body: string): string {
|
|
return body
|
|
.split('\n')
|
|
.map((l) => l.trimEnd())
|
|
.join('\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
}
|
|
|
|
/** Parses the raw file text into commands. Exported for the test suite. */
|
|
export function parseUserCommands(text: string): UserCommand[] {
|
|
const commands: UserCommand[] = [];
|
|
let current: UserCommand | undefined;
|
|
|
|
for (const rawLine of text.split('\n')) {
|
|
const line = rawLine.trimEnd();
|
|
const heading = /^##\s+([^\s]+)/.exec(line);
|
|
if (heading) {
|
|
if (current) commands.push(current);
|
|
const name = heading[1]!.toLowerCase();
|
|
current = { name, summary: '', body: '' };
|
|
continue;
|
|
}
|
|
if (!current) continue; // prose before the first heading is ignored
|
|
if (current.summary === '' && current.body === '' && line.startsWith('>')) {
|
|
current.summary = line.replace(/^>\s?/, '').trim();
|
|
continue;
|
|
}
|
|
if (line === '---' || line === '```') {
|
|
current.body += '\n';
|
|
continue;
|
|
}
|
|
current.body += `${line}\n`;
|
|
}
|
|
if (current) commands.push(current);
|
|
|
|
return commands.map((c) => ({ ...c, body: trimBody(c.body) }));
|
|
}
|
|
|
|
export async function loadUserCommands(cwd: string): Promise<UserCommand[]> {
|
|
const file = Bun.file(join(commandFileDir(cwd), commandFileName()));
|
|
if (!(await file.exists())) return [];
|
|
try {
|
|
return parseUserCommands(await file.text());
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expands a command body against the typed arguments and workspace.
|
|
*
|
|
* Substitutions are applied in a safe order: shell reads first (they produce
|
|
* text that may itself contain `$` or `@` that must not be re-read), then file
|
|
* reads, then `$n` tags. `$ARGUMENTS` is the verbatim tail, `$n` the nth
|
|
* whitespace term. A missing file or a failing shell line keeps its literal
|
|
* text plus a bracketed note rather than throwing, so a stale command still
|
|
* reaches the model with the failure visible.
|
|
*/
|
|
export async function expandCommand(
|
|
cmd: UserCommand,
|
|
args: string,
|
|
cwd: string,
|
|
): Promise<string> {
|
|
const terms = args.split(/\s+/).filter(Boolean);
|
|
let out = await expandShell(cmd.body, cwd);
|
|
out = await expandFiles(out, cwd);
|
|
out = out.replace(TAG, (m, g: string) => {
|
|
if (g === 'ARGUMENTS' || g === '{ARGS}') return args;
|
|
const idx = Number(g);
|
|
return terms[idx - 1] ?? '';
|
|
});
|
|
return out.trim();
|
|
}
|
|
|
|
async function expandShell(body: string, cwd: string): Promise<string> {
|
|
const parts: string[] = [];
|
|
let last = 0;
|
|
for (const m of body.matchAll(SHELL)) {
|
|
parts.push(body.slice(last, m.index));
|
|
const command = m[1]!.trim();
|
|
try {
|
|
const proc = Bun.spawn(['bash', '-c', command], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
const stdoutPromise = new Response(proc.stdout).text();
|
|
const exitedPromise = proc.exited;
|
|
const stdout = await stdoutPromise;
|
|
const code = await exitedPromise;
|
|
parts.push(code === 0 ? stdout.trim() : `[!shell exit ${code}: ${command}]`);
|
|
} catch (e) {
|
|
parts.push(`[!shell failed: ${e instanceof Error ? e.message : String(e)}]`);
|
|
}
|
|
last = (m.index ?? 0) + m[0].length;
|
|
}
|
|
parts.push(body.slice(last));
|
|
return parts.join('');
|
|
}
|
|
|
|
async function expandFiles(body: string, cwd: string): Promise<string> {
|
|
const parts: string[] = [];
|
|
let last = 0;
|
|
for (const m of body.matchAll(FILE)) {
|
|
parts.push(body.slice(last, m.index));
|
|
const path = m[1]!;
|
|
const file = Bun.file(join(cwd, path));
|
|
if (await file.exists()) {
|
|
try {
|
|
parts.push((await file.text()).trimEnd());
|
|
} catch {
|
|
parts.push(`[@file unreadable: ${path}]`);
|
|
}
|
|
} else {
|
|
parts.push(`[@file missing: ${path}]`);
|
|
}
|
|
last = (m.index ?? 0) + m[0].length;
|
|
}
|
|
parts.push(body.slice(last));
|
|
return parts.join('');
|
|
}
|
|
|
|
export const USER_COMMAND_MARKER = '## ';
|