pub const SHIM_HEADER: &str = r#"
// --- grain-pi-compat shim (auto-generated, do not edit) ---
const pi = {
registerTool: function (opts) {
if (!opts || typeof opts !== 'object') {
throw new Error("pi.registerTool: argument must be an object");
}
grain.register_tool({
name: opts.name,
description: opts.description,
// pi calls it `parameters`; grain calls it `schema`.
schema: opts.parameters,
// pi calls it `execute`; grain calls it `run`.
run: opts.execute,
});
},
// Subscribe to an agent lifecycle event. The host bridges the
// matching `AgentEvent` variant into a JSON payload and dispatches
// here. Supported event names (Phase 2):
// "agent_start" | "agent_end" | "tool_call" | "tool_result"
// | "message_start" | "message_end"
// Unsupported names (pi has them; we don't yet): session_*,
// before_agent_start, input — subscribing is silently a no-op.
on: function (event_name, handler) {
if (typeof event_name !== 'string' || !event_name) {
throw new Error("pi.on: event_name must be a non-empty string");
}
if (typeof handler !== 'function') {
throw new Error("pi.on: handler must be a function");
}
grain.register_callback("on:" + event_name, handler);
},
// Register a slash command. pi.dev signature:
// pi.registerCommand(name, { description, handler })
// The handler is wired through grain.register_callback under
// `cmd:<name>`; the description lands in grain.register_meta so
// higher layers (the TUI's slash palette) can render it.
registerCommand: function (name, opts) {
if (typeof name !== 'string' || !name) {
throw new Error("pi.registerCommand: name (string) is required");
}
if (!opts || typeof opts !== 'object') {
throw new Error("pi.registerCommand: opts must be an object");
}
if (typeof opts.handler !== 'function') {
throw new Error("pi.registerCommand: opts.handler must be a function");
}
grain.register_meta("command", name, {
description: opts.description || "",
});
grain.register_callback("cmd:" + name, opts.handler);
},
// Register a keyboard shortcut. pi.dev signature:
// pi.registerShortcut(keys, { description?, handler })
// `keys` is a free-form spec like "ctrl+x" or "shift+alt+a";
// it's stored verbatim. The TUI is responsible for parsing the
// spec and matching incoming key events against it.
registerShortcut: function (keys, opts) {
if (typeof keys !== 'string' || !keys) {
throw new Error("pi.registerShortcut: keys (string) is required");
}
if (!opts || typeof opts !== 'object') {
throw new Error("pi.registerShortcut: opts must be an object");
}
if (typeof opts.handler !== 'function') {
throw new Error("pi.registerShortcut: opts.handler must be a function");
}
grain.register_meta("shortcut", keys, {
description: opts.description || "",
});
grain.register_callback("shortcut:" + keys, opts.handler);
},
// pi's `ctx.ui.*` lives under `pi.ui` here — we don't thread the
// `ctx` parameter into handlers in Phase 5a; `pi.ui.notify(...)`
// is callable both at module top-level and from inside handlers.
// Phase 5b adds the `ctx` argument.
ui: {
// Fire-and-forget toast. Host drains the queue on its own
// cadence (e.g. once per TUI render tick) and renders each
// payload however it wants.
notify: function (text) {
grain.push_notification({
kind: "notify",
text: typeof text === 'string' ? text : String(text),
});
},
// Synchronous yes/no modal. Blocks the worker until the host
// calls `PiExtension::resolve_modal(request_id, true|false)`.
// Returns the boolean directly to JS.
confirm: function (prompt) {
return grain.modal_request("confirm", {
prompt: typeof prompt === 'string' ? prompt : String(prompt),
});
},
// Synchronous single-line text input. Returns the string the
// host resolves with (or empty string on cancel — host's
// choice).
input: function (prompt) {
return grain.modal_request("input", {
prompt: typeof prompt === 'string' ? prompt : String(prompt),
});
},
// Synchronous pick-from-list. `items` is an array of strings;
// host resolves with the chosen string.
select: function (prompt, items) {
if (!Array.isArray(items)) {
throw new Error("pi.ui.select: items must be an array");
}
return grain.modal_request("select", {
prompt: typeof prompt === 'string' ? prompt : String(prompt),
items: items.map(String),
});
},
},
};
// --- end shim ---
"#;
pub fn transform_pi_source(source: &str) -> String {
let trimmed = source.trim_start();
if let Some(rest) = trimmed.strip_prefix("export default") {
let body = rest.trim().trim_end_matches(';').trim();
return format!("{SHIM_HEADER}\n({body})(pi);\n");
}
format!("{SHIM_HEADER}\n{source}\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn top_level_call_is_left_in_place_after_shim() {
let src = r#"pi.registerTool({ name: "x", description: "x", parameters: {}, execute: () => "" });"#;
let out = transform_pi_source(src);
assert!(out.contains("const pi = {"));
assert!(out.contains("pi.registerTool({ name: \"x\""));
let shim_pos = out.find("const pi = {").unwrap();
let usr_pos = out.find("pi.registerTool({ name").unwrap();
assert!(shim_pos < usr_pos);
}
#[test]
fn factory_entry_gets_unwrapped_and_invoked() {
let src = "export default (pi) => { pi.registerTool({ name: \"f\" }); };";
let out = transform_pi_source(src);
assert!(!out.contains("export default"));
assert!(out.contains("(pi) => { pi.registerTool({ name: \"f\" }); })(pi);"));
}
#[test]
fn factory_without_trailing_semicolon_still_works() {
let src = "export default (pi) => { pi.registerTool({ name: \"g\" }); }";
let out = transform_pi_source(src);
assert!(out.contains("})(pi);"));
}
}