pi-runline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # pi-runline
2
+
3
+ A [pi](https://github.com/mariozechner/pi) extension that gives coding agents first-class access to runline.
4
+
5
+ ## What it does
6
+
7
+ On session start, if the current project has a `.runline/` directory (or one is configured globally via `~/.pi/agent/runline.json`), the extension:
8
+
9
+ 1. **Injects a context message** listing every installed plugin, its actions, and their input schemas.
10
+ 2. **Sets a status bar line** — `⚡ runline: N plugins, M actions`.
11
+ 3. **Registers two tools** the agent can call:
12
+ - `list_runline_actions` — enumerate the action catalog (optionally filtered to one plugin).
13
+ - `execute_runline` — run JavaScript against the runline sandbox. Plugins are globals; `return` surfaces the result.
14
+
15
+ ## Configuration
16
+
17
+ ### Per-project — `.runline/config.json`
18
+
19
+ ```json
20
+ { "showStatus": false }
21
+ ```
22
+
23
+ Silences the status bar for this project.
24
+
25
+ ### Global — `~/.pi/agent/runline.json`
26
+
27
+ ```json
28
+ { "project": "~/Projects/my-runline-project" }
29
+ ```
30
+
31
+ Fall-back used when the current working directory has no `.runline/` in its ancestry. Useful when you want the runline tools available in every pi session without putting `.runline/` everywhere.
32
+
33
+ ## Install
34
+
35
+ Published as `pi-runline` on npm. The package declares itself via `pi.extensions` and `pi.skills` in `package.json`, so pi picks it up automatically once installed.
@@ -0,0 +1,162 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
4
+
5
+ type ConnectionSchemaField = {
6
+ type: string;
7
+ required?: boolean;
8
+ description?: string;
9
+ default?: unknown;
10
+ env?: string;
11
+ };
12
+
13
+ type PluginSummary = {
14
+ name: string;
15
+ connectionConfigSchema?: Record<string, ConnectionSchemaField>;
16
+ };
17
+
18
+ type Connection = {
19
+ name: string;
20
+ plugin: string;
21
+ config: Record<string, unknown>;
22
+ };
23
+
24
+ function readConfig(runlineDir: string): Record<string, unknown> {
25
+ const configPath = path.join(runlineDir, "config.json");
26
+ if (!fs.existsSync(configPath)) return {};
27
+ try {
28
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
29
+ } catch {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ function writeConfig(
35
+ runlineDir: string,
36
+ config: Record<string, unknown>,
37
+ ): void {
38
+ const configPath = path.join(runlineDir, "config.json");
39
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
40
+ }
41
+
42
+ function getConnections(config: Record<string, unknown>): Connection[] {
43
+ const raw = config.connections;
44
+ return Array.isArray(raw) ? (raw as Connection[]) : [];
45
+ }
46
+
47
+ function connectionFor(
48
+ connections: Connection[],
49
+ plugin: string,
50
+ ): Connection | undefined {
51
+ return connections.find((c) => c.plugin === plugin);
52
+ }
53
+
54
+ function isSchemaEmpty(schema: PluginSummary["connectionConfigSchema"]): boolean {
55
+ return !schema || Object.keys(schema).length === 0;
56
+ }
57
+
58
+ function envOrSchemaDefault(
59
+ field: ConnectionSchemaField,
60
+ ): string | undefined {
61
+ if (field.env && process.env[field.env]) return process.env[field.env];
62
+ if (field.default !== undefined) return String(field.default);
63
+ return undefined;
64
+ }
65
+
66
+ /**
67
+ * Walk through newly-enabled plugins and prompt for any credentials that
68
+ * don't already have a connection (and aren't already resolvable via env).
69
+ * Skips plugins with no connection schema.
70
+ *
71
+ * Returns the list of plugin names that ended up with a saved connection.
72
+ */
73
+ export async function promptForCredentials(
74
+ ctx: ExtensionCommandContext,
75
+ runlineDir: string,
76
+ plugins: PluginSummary[],
77
+ newlyEnabled: string[],
78
+ ): Promise<string[]> {
79
+ const config = readConfig(runlineDir);
80
+ const connections = getConnections(config);
81
+ const saved: string[] = [];
82
+
83
+ for (const name of newlyEnabled) {
84
+ const plugin = plugins.find((p) => p.name === name);
85
+ if (!plugin) continue;
86
+
87
+ const schema = plugin.connectionConfigSchema;
88
+ if (isSchemaEmpty(schema)) continue; // no creds needed
89
+
90
+ if (connectionFor(connections, name)) continue; // already configured
91
+
92
+ // Check env — if every required field has an env var set, skip the prompt.
93
+ const requiredFields = Object.entries(schema!).filter(
94
+ ([, f]) => f.required,
95
+ );
96
+ const allFromEnv = requiredFields.every(
97
+ ([, f]) => f.env && process.env[f.env],
98
+ );
99
+ if (requiredFields.length > 0 && allFromEnv) continue;
100
+
101
+ const wantSetup = await ctx.ui.confirm(
102
+ `Set up ${name}?`,
103
+ `${name} needs credentials. Configure now?`,
104
+ );
105
+ if (!wantSetup) continue;
106
+
107
+ const values: Record<string, unknown> = {};
108
+ let cancelled = false;
109
+ for (const [key, field] of Object.entries(schema!)) {
110
+ const placeholder = field.env
111
+ ? `${field.description ?? key} (env: ${field.env})`
112
+ : (field.description ?? key);
113
+ const existing = envOrSchemaDefault(field);
114
+ const prompt = existing
115
+ ? `${key} [${existing.slice(0, 8)}…]`
116
+ : `${key}${field.required ? " *" : ""}`;
117
+
118
+ const answer = await ctx.ui.input(prompt, placeholder);
119
+ if (answer === undefined) {
120
+ cancelled = true;
121
+ break;
122
+ }
123
+ const trimmed = answer.trim();
124
+ if (trimmed) {
125
+ values[key] = coerce(trimmed, field.type);
126
+ } else if (field.required && !existing) {
127
+ ctx.ui.notify(`${key} is required — skipping ${name}`, "warning");
128
+ cancelled = true;
129
+ break;
130
+ }
131
+ }
132
+
133
+ if (cancelled) continue;
134
+
135
+ const conn: Connection = {
136
+ name,
137
+ plugin: name,
138
+ config: values,
139
+ };
140
+ connections.push(conn);
141
+ saved.push(name);
142
+ }
143
+
144
+ if (saved.length > 0) {
145
+ config.connections = connections;
146
+ writeConfig(runlineDir, config);
147
+ }
148
+
149
+ return saved;
150
+ }
151
+
152
+ function coerce(value: string, type: string): unknown {
153
+ if (type === "number") {
154
+ const n = Number(value);
155
+ return Number.isFinite(n) ? n : value;
156
+ }
157
+ if (type === "boolean") {
158
+ if (value === "true") return true;
159
+ if (value === "false") return false;
160
+ }
161
+ return value;
162
+ }
@@ -0,0 +1,227 @@
1
+ import type { Component, TUI } from "@mariozechner/pi-tui";
2
+ import { fuzzyFilter, Input, visibleWidth } from "@mariozechner/pi-tui";
3
+ import type { Theme } from "@mariozechner/pi-coding-agent";
4
+
5
+ export interface PluginPickerItem {
6
+ name: string;
7
+ actionCount: number;
8
+ }
9
+
10
+ export interface PluginPickerResult {
11
+ /** undefined = cancelled */
12
+ selected?: string[];
13
+ }
14
+
15
+ /**
16
+ * Multi-select fuzzy picker for runline plugin names.
17
+ *
18
+ * Keys:
19
+ * ↑ / ↓ — move highlight
20
+ * space — toggle current item
21
+ * Ctrl-A — toggle all (filtered view)
22
+ * enter — save and close
23
+ * esc / C-c — cancel
24
+ * type — fuzzy filter
25
+ */
26
+ export class PluginPicker implements Component {
27
+ private readonly items: PluginPickerItem[];
28
+ private readonly selected: Set<string>;
29
+ private readonly input: Input;
30
+ private readonly theme: Theme;
31
+ private readonly onDone: (result: PluginPickerResult) => void;
32
+ private filtered: PluginPickerItem[];
33
+ private cursor = 0;
34
+ private readonly maxRows = 18;
35
+
36
+ constructor(
37
+ items: PluginPickerItem[],
38
+ initiallySelected: Iterable<string>,
39
+ theme: Theme,
40
+ onDone: (result: PluginPickerResult) => void,
41
+ ) {
42
+ this.items = [...items].sort((a, b) => a.name.localeCompare(b.name));
43
+ this.selected = new Set(initiallySelected);
44
+ this.filtered = this.items;
45
+ this.theme = theme;
46
+ this.onDone = onDone;
47
+
48
+ this.input = new Input();
49
+ this.input.focused = true;
50
+ this.input.onSubmit = () => this.confirm();
51
+ this.input.onEscape = () => this.cancel();
52
+ }
53
+
54
+ invalidate(): void {
55
+ this.input.invalidate();
56
+ }
57
+
58
+ render(width: number): string[] {
59
+ const theme = this.theme;
60
+ // Reserve two columns for the border and one space of padding on each side.
61
+ const inner = Math.max(10, width - 4);
62
+ const body: string[] = [];
63
+
64
+ body.push(
65
+ theme.fg(
66
+ "mdHeading",
67
+ `runline plugins · ${this.selected.size}/${this.items.length} enabled`,
68
+ ),
69
+ );
70
+ body.push(
71
+ theme.fg(
72
+ "dim",
73
+ "type to filter · space toggle · ^A toggle all · enter save · esc cancel",
74
+ ),
75
+ );
76
+ body.push("");
77
+
78
+ // Search input
79
+ const searchPrefix = theme.fg("dim", "filter ❯ ");
80
+ const inputLines = this.input.render(Math.max(10, inner - 10));
81
+ body.push(searchPrefix + (inputLines[0] ?? ""));
82
+ body.push("");
83
+
84
+ // List — always render exactly maxRows item rows plus one status row so the
85
+ // overlay's height is stable while the filter narrows results.
86
+ const total = this.filtered.length;
87
+ const start =
88
+ total <= this.maxRows
89
+ ? 0
90
+ : Math.max(
91
+ 0,
92
+ Math.min(
93
+ this.cursor - Math.floor(this.maxRows / 2),
94
+ total - this.maxRows,
95
+ ),
96
+ );
97
+ const end = Math.min(start + this.maxRows, total);
98
+
99
+ for (let i = start; i < end; i++) {
100
+ const item = this.filtered[i];
101
+ if (!item) continue;
102
+ const isSel = this.selected.has(item.name);
103
+ const isCur = i === this.cursor;
104
+ const box = isSel ? "◉" : "◯";
105
+ const boxColored = isSel
106
+ ? theme.fg("success", box)
107
+ : theme.fg("dim", box);
108
+ const name = isCur ? theme.bold(item.name) : item.name;
109
+ const count = theme.fg("dim", ` ${item.actionCount} actions`);
110
+ const arrow = isCur ? theme.fg("accent", "❯ ") : " ";
111
+ body.push(`${arrow}${boxColored} ${name}${count}`);
112
+ }
113
+ for (let i = end - start; i < this.maxRows; i++) body.push("");
114
+
115
+ body.push(
116
+ total === 0
117
+ ? theme.fg("dim", " no matches")
118
+ : theme.fg("dim", ` ${this.cursor + 1}/${total}`),
119
+ );
120
+
121
+ return this.drawBorder(body, width);
122
+ }
123
+
124
+ /**
125
+ * Wrap body lines in a Unicode box border with 1-column horizontal padding.
126
+ * The width of every line is normalized to inner+2 so the right border aligns.
127
+ */
128
+ private drawBorder(body: string[], width: number): string[] {
129
+ const theme = this.theme;
130
+ const inner = Math.max(10, width - 4);
131
+ const top = theme.fg("dim", `╭${"─".repeat(inner + 2)}╮`);
132
+ const bot = theme.fg("dim", `╰${"─".repeat(inner + 2)}╯`);
133
+ const side = theme.fg("dim", "│");
134
+ const out: string[] = [top];
135
+ for (const raw of body) {
136
+ const visible = visibleWidth(raw);
137
+ const pad = Math.max(0, inner - visible);
138
+ out.push(`${side} ${raw}${" ".repeat(pad)} ${side}`);
139
+ }
140
+ out.push(bot);
141
+ return out;
142
+ }
143
+
144
+ handleInput(data: string): void {
145
+ // Navigation + toggle keys — check before routing to the text input,
146
+ // otherwise arrow keys and space would just type characters.
147
+ if (data === "\x1b[A" || data === "\x1b[Z") {
148
+ // up / shift-tab
149
+ if (this.filtered.length > 0) {
150
+ this.cursor =
151
+ this.cursor === 0 ? this.filtered.length - 1 : this.cursor - 1;
152
+ }
153
+ return;
154
+ }
155
+ if (data === "\x1b[B" || data === "\t") {
156
+ // down / tab
157
+ if (this.filtered.length > 0) {
158
+ this.cursor =
159
+ this.cursor === this.filtered.length - 1 ? 0 : this.cursor + 1;
160
+ }
161
+ return;
162
+ }
163
+ if (data === " ") {
164
+ const item = this.filtered[this.cursor];
165
+ if (item) {
166
+ if (this.selected.has(item.name)) this.selected.delete(item.name);
167
+ else this.selected.add(item.name);
168
+ }
169
+ return;
170
+ }
171
+ if (data === "\x01") {
172
+ // Ctrl-A — toggle all visible
173
+ const allSelected = this.filtered.every((i) => this.selected.has(i.name));
174
+ for (const i of this.filtered) {
175
+ if (allSelected) this.selected.delete(i.name);
176
+ else this.selected.add(i.name);
177
+ }
178
+ return;
179
+ }
180
+ if (data === "\r" || data === "\n") {
181
+ this.confirm();
182
+ return;
183
+ }
184
+ if (data === "\x1b" || data === "\x03") {
185
+ this.cancel();
186
+ return;
187
+ }
188
+
189
+ // Everything else → text input (typing filters)
190
+ const before = this.input.getValue();
191
+ this.input.handleInput(data);
192
+ const after = this.input.getValue();
193
+ if (before !== after) {
194
+ this.applyFilter(after);
195
+ }
196
+ }
197
+
198
+ private applyFilter(query: string): void {
199
+ this.filtered = query
200
+ ? fuzzyFilter(this.items, query, (i) => i.name)
201
+ : this.items;
202
+ this.cursor = 0;
203
+ }
204
+
205
+ private confirm(): void {
206
+ this.onDone({ selected: [...this.selected].sort() });
207
+ }
208
+
209
+ private cancel(): void {
210
+ this.onDone({});
211
+ }
212
+ }
213
+
214
+ /** Factory wrapper that matches the ctx.ui.custom signature. */
215
+ export function createPluginPickerFactory(
216
+ items: PluginPickerItem[],
217
+ initiallySelected: Iterable<string>,
218
+ ) {
219
+ return (
220
+ _tui: TUI,
221
+ theme: Theme,
222
+ _keybindings: unknown,
223
+ done: (result: PluginPickerResult) => void,
224
+ ): Component => {
225
+ return new PluginPicker(items, initiallySelected, theme, done);
226
+ };
227
+ }
@@ -0,0 +1,334 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Markdown, Text } from "@mariozechner/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { Runline } from "runline";
5
+ import { promptForCredentials } from "../connection-setup.js";
6
+ import { createPluginPickerFactory } from "../plugin-picker.js";
7
+ import {
8
+ findRunlineDir,
9
+ loadExtConfig,
10
+ savePiPlugins,
11
+ } from "../runline-resolve.js";
12
+
13
+ type ActionEntry = {
14
+ plugin: string;
15
+ action: string;
16
+ description?: string;
17
+ inputSchema?: Record<
18
+ string,
19
+ { type: string; required?: boolean; description?: string }
20
+ >;
21
+ };
22
+
23
+ function filterByAllowlist<T extends { name?: string; plugin?: string }>(
24
+ items: T[],
25
+ allow: string[] | undefined,
26
+ ): T[] {
27
+ if (!allow) return [];
28
+ const set = new Set(allow);
29
+ return items.filter((i) => set.has((i.name ?? i.plugin) as string));
30
+ }
31
+
32
+ function formatActions(actions: ActionEntry[]): string {
33
+ const grouped = new Map<string, ActionEntry[]>();
34
+ for (const a of actions) {
35
+ const list = grouped.get(a.plugin) ?? [];
36
+ list.push(a);
37
+ grouped.set(a.plugin, list);
38
+ }
39
+
40
+ const lines: string[] = [];
41
+ for (const [plugin, entries] of grouped) {
42
+ lines.push(`### ${plugin}`);
43
+ for (const a of entries) {
44
+ const inputs = a.inputSchema
45
+ ? Object.entries(a.inputSchema)
46
+ .map(([k, v]) => `${k}: ${v.type}${v.required ? "" : "?"}`)
47
+ .join(", ")
48
+ : "";
49
+ const sig = inputs
50
+ ? `\`${plugin}.${a.action}({ ${inputs} })\``
51
+ : `\`${plugin}.${a.action}()\``;
52
+ const desc = a.description ? ` — ${a.description}` : "";
53
+ lines.push(`- ${sig}${desc}`);
54
+ }
55
+ lines.push("");
56
+ }
57
+ return lines.join("\n");
58
+ }
59
+
60
+ const runlineCache = new Map<string, Promise<Runline>>();
61
+
62
+ async function getRunline(cwd: string): Promise<Runline> {
63
+ let pending = runlineCache.get(cwd);
64
+ if (!pending) {
65
+ pending = Runline.fromProject(cwd).then((rl) => {
66
+ if (!rl) throw new Error("No .runline/ found — run `runline init` first");
67
+ return rl;
68
+ });
69
+ // Drop failed loads so the next call retries instead of caching the error.
70
+ pending.catch(() => runlineCache.delete(cwd));
71
+ runlineCache.set(cwd, pending);
72
+ }
73
+ return pending;
74
+ }
75
+
76
+ export default function (pi: ExtensionAPI) {
77
+ pi.registerMessageRenderer(
78
+ "runline-context",
79
+ (message, { expanded }, theme) => {
80
+ if (!expanded) {
81
+ const label = theme.fg("customMessageLabel", "⚡ runline actions");
82
+ const hint = theme.fg("dim", " — Ctrl+O to expand");
83
+ return new Text(label + hint, 1, 0);
84
+ }
85
+ const content =
86
+ typeof message.content === "string"
87
+ ? message.content
88
+ : message.content
89
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
90
+ .map((c) => c.text)
91
+ .join("\n");
92
+ return new Markdown(
93
+ content,
94
+ 1,
95
+ 0,
96
+ {
97
+ heading: (t) => theme.fg("mdHeading", t),
98
+ link: (t) => theme.fg("mdLink", t),
99
+ linkUrl: (t) => theme.fg("mdLinkUrl", t),
100
+ code: (t) => theme.fg("mdCode", t),
101
+ codeBlock: (t) => theme.fg("mdCodeBlock", t),
102
+ codeBlockBorder: (t) => theme.fg("mdCodeBlockBorder", t),
103
+ quote: (t) => theme.fg("mdQuote", t),
104
+ quoteBorder: (t) => theme.fg("mdQuoteBorder", t),
105
+ hr: (t) => theme.fg("mdHr", t),
106
+ listBullet: (t) => theme.fg("mdListBullet", t),
107
+ bold: (t) => theme.bold(t),
108
+ italic: (t) => theme.italic(t),
109
+ strikethrough: (t) => theme.strikethrough(t),
110
+ underline: (t) => theme.underline(t),
111
+ },
112
+ { color: (t) => theme.fg("customMessageText", t) },
113
+ );
114
+ },
115
+ );
116
+
117
+ pi.on("session_start", async (_event, ctx) => {
118
+ const runlineDir = findRunlineDir(ctx.cwd);
119
+ if (!runlineDir) return;
120
+
121
+ const { showStatus } = loadExtConfig(runlineDir);
122
+
123
+ let rl: Runline;
124
+ try {
125
+ rl = await getRunline(ctx.cwd);
126
+ } catch {
127
+ if (ctx.hasUI && showStatus) {
128
+ ctx.ui.setStatus(
129
+ "runline",
130
+ ctx.ui.theme.fg("dim", "runline: load failed"),
131
+ );
132
+ }
133
+ return;
134
+ }
135
+
136
+ const { piPlugins } = loadExtConfig(runlineDir);
137
+ const actions = filterByAllowlist(rl.actions(), piPlugins);
138
+ const plugins = filterByAllowlist(rl.plugins(), piPlugins);
139
+
140
+ if (plugins.length === 0) {
141
+ if (ctx.hasUI && showStatus) {
142
+ const hint = piPlugins
143
+ ? "runline: no plugins enabled"
144
+ : "runline: /runline-plugins to enable";
145
+ ctx.ui.setStatus("runline", ctx.ui.theme.fg("dim", hint));
146
+ }
147
+ return;
148
+ }
149
+
150
+ const alreadyInjected = ctx.sessionManager
151
+ .getEntries()
152
+ .some(
153
+ (e) =>
154
+ e.type === "custom_message" && e.customType === "runline-context",
155
+ );
156
+
157
+ if (!alreadyInjected) {
158
+ const header =
159
+ "## Runline actions\n\n" +
160
+ "This project has runline installed. You have two tools:\n" +
161
+ "- `list_runline_actions` — show the full action catalog with input schemas\n" +
162
+ "- `execute_runline` — run JavaScript in a sandbox where each plugin is a top-level global. " +
163
+ "Chain actions, await results, return a value.\n\n" +
164
+ `**${plugins.length} plugins, ${actions.length} actions available.**\n\n` +
165
+ "Example:\n" +
166
+ "```js\n" +
167
+ 'return await github.issue.create({ owner: "acme", repo: "api", title: "Bug" })\n' +
168
+ "```\n\n";
169
+
170
+ pi.sendMessage({
171
+ customType: "runline-context",
172
+ content: header + formatActions(actions),
173
+ display: true,
174
+ });
175
+ }
176
+
177
+ if (ctx.hasUI && showStatus) {
178
+ const theme = ctx.ui.theme;
179
+ ctx.ui.setStatus(
180
+ "runline",
181
+ `⚡${theme.fg("dim", ` runline: ${plugins.length} plugins, ${actions.length} actions`)}`,
182
+ );
183
+ }
184
+ });
185
+
186
+ // ── Tools ───────────────────────────────────────────────────────
187
+
188
+ pi.registerTool({
189
+ name: "execute_runline",
190
+ label: "Runline Exec",
191
+ description:
192
+ "Execute JavaScript in the runline sandbox. Each installed plugin is a top-level global " +
193
+ "(e.g. `github`, `slack`). Use `return` to surface the result. Async/await supported.",
194
+ promptSnippet:
195
+ "Run JS against runline plugins — chain actions, transform data, return a value",
196
+ parameters: Type.Object({
197
+ code: Type.String({
198
+ description:
199
+ "JavaScript code to execute. Plugins are globals. Use `return` for the final value.",
200
+ }),
201
+ }),
202
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
203
+ const rl = await getRunline(ctx.cwd);
204
+ // Note: the sandbox currently exposes every registered plugin as a
205
+ // global. The allowlist drives what the agent is told about in its
206
+ // injected context and list_runline_actions output, which is the only
207
+ // practical route for the agent to know what exists. Plumbing the
208
+ // allowlist through to the sandbox globals is a future improvement.
209
+ const result = await rl.execute(params.code);
210
+
211
+ const logs = result.logs?.length
212
+ ? `\n\nLogs:\n${result.logs.join("\n")}`
213
+ : "";
214
+
215
+ if (result.error) {
216
+ return {
217
+ content: [{ type: "text", text: `Error: ${result.error}${logs}` }],
218
+ isError: true,
219
+ details: result,
220
+ };
221
+ }
222
+
223
+ const value =
224
+ typeof result.result === "string"
225
+ ? result.result
226
+ : JSON.stringify(result.result, null, 2);
227
+
228
+ return {
229
+ content: [{ type: "text", text: value + logs }],
230
+ details: result,
231
+ };
232
+ },
233
+ });
234
+
235
+ pi.registerTool({
236
+ name: "list_runline_actions",
237
+ label: "Runline Actions",
238
+ description:
239
+ "List every available runline action with its plugin, description, and input schema.",
240
+ promptSnippet:
241
+ "Discover runline plugin actions and their input shapes before calling execute_runline",
242
+ parameters: Type.Object({
243
+ plugin: Type.Optional(
244
+ Type.String({
245
+ description: "Filter to a single plugin (e.g. 'github')",
246
+ }),
247
+ ),
248
+ }),
249
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
250
+ const rl = await getRunline(ctx.cwd);
251
+ const runlineDir = findRunlineDir(ctx.cwd);
252
+ const allow = runlineDir
253
+ ? loadExtConfig(runlineDir).piPlugins
254
+ : undefined;
255
+ let actions = filterByAllowlist(rl.actions(), allow);
256
+ if (params.plugin) {
257
+ actions = actions.filter((a) => a.plugin === params.plugin);
258
+ }
259
+ const text = formatActions(actions);
260
+ return {
261
+ content: [{ type: "text", text: text || "No actions enabled." }],
262
+ details: { actions },
263
+ };
264
+ },
265
+ });
266
+
267
+ // ── Commands ────────────────────────────────────────────────────
268
+
269
+ pi.registerCommand("runline-plugins", {
270
+ description: "Pick which runline plugins the agent can use",
271
+ handler: async (_args, ctx) => {
272
+ if (!ctx.hasUI) return;
273
+
274
+ const runlineDir = findRunlineDir(ctx.cwd);
275
+ if (!runlineDir) {
276
+ ctx.ui.notify("no .runline/ directory — run `runline init`", "error");
277
+ return;
278
+ }
279
+
280
+ let rl: Runline;
281
+ try {
282
+ rl = await getRunline(ctx.cwd);
283
+ } catch (err) {
284
+ ctx.ui.notify(
285
+ `runline failed to load: ${(err as Error).message}`,
286
+ "error",
287
+ );
288
+ return;
289
+ }
290
+
291
+ const items = rl.plugins().map((p) => ({
292
+ name: p.name,
293
+ actionCount: p.actions.length,
294
+ }));
295
+ const { piPlugins } = loadExtConfig(runlineDir);
296
+ const initial = piPlugins ?? [];
297
+
298
+ const result = await ctx.ui.custom(
299
+ createPluginPickerFactory(items, initial),
300
+ { overlay: true, overlayOptions: { width: "80%", maxHeight: "80%" } },
301
+ );
302
+
303
+ if (!result.selected) {
304
+ ctx.ui.notify("plugin selection cancelled", "info");
305
+ return;
306
+ }
307
+
308
+ savePiPlugins(runlineDir, result.selected);
309
+
310
+ const previous = new Set(initial);
311
+ const newlyEnabled = result.selected.filter((n) => !previous.has(n));
312
+
313
+ ctx.ui.notify(
314
+ `saved — ${result.selected.length} plugin(s) enabled`,
315
+ "info",
316
+ );
317
+
318
+ if (newlyEnabled.length > 0) {
319
+ const saved = await promptForCredentials(
320
+ ctx,
321
+ runlineDir,
322
+ rl.plugins(),
323
+ newlyEnabled,
324
+ );
325
+ if (saved.length > 0) {
326
+ ctx.ui.notify(
327
+ `credentials saved for ${saved.length} plugin(s)`,
328
+ "info",
329
+ );
330
+ }
331
+ }
332
+ },
333
+ });
334
+ }
@@ -0,0 +1,78 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ /**
6
+ * Find the .runline/ config directory.
7
+ *
8
+ * Resolution order:
9
+ * 1. Walk up from cwd looking for a project-local `.runline/`
10
+ * 2. Fall back to the global project configured in ~/.pi/agent/runline.json
11
+ */
12
+ export function findRunlineDir(cwd: string): string | null {
13
+ let dir = cwd;
14
+ while (dir !== path.dirname(dir)) {
15
+ const runlineDir = path.join(dir, ".runline");
16
+ if (fs.existsSync(runlineDir)) return runlineDir;
17
+ dir = path.dirname(dir);
18
+ }
19
+ return getGlobalRunlineDir();
20
+ }
21
+
22
+ function getGlobalRunlineDir(): string | null {
23
+ const homeDir = os.homedir();
24
+ const configPath = path.join(homeDir, ".pi", "agent", "runline.json");
25
+ if (!fs.existsSync(configPath)) return null;
26
+
27
+ try {
28
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
29
+ if (!raw.project) return null;
30
+
31
+ const projectPath: string = raw.project.startsWith("~")
32
+ ? path.join(homeDir, raw.project.slice(1))
33
+ : path.resolve(raw.project);
34
+
35
+ const runlineDir = path.join(projectPath, ".runline");
36
+ if (fs.existsSync(runlineDir)) return runlineDir;
37
+ } catch {
38
+ // invalid config
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export interface RunlineExtConfig {
44
+ showStatus: boolean;
45
+ /** Allowlist of plugin names exposed to the agent. undefined = none. */
46
+ piPlugins?: string[];
47
+ }
48
+
49
+ export function loadExtConfig(runlineDir: string): RunlineExtConfig {
50
+ const configPath = path.join(runlineDir, "config.json");
51
+ if (!fs.existsSync(configPath)) return { showStatus: true };
52
+ try {
53
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
54
+ return {
55
+ showStatus: raw.showStatus !== false,
56
+ piPlugins: Array.isArray(raw.piPlugins) ? raw.piPlugins : undefined,
57
+ };
58
+ } catch {
59
+ return { showStatus: true };
60
+ }
61
+ }
62
+
63
+ export function savePiPlugins(
64
+ runlineDir: string,
65
+ piPlugins: string[],
66
+ ): void {
67
+ const configPath = path.join(runlineDir, "config.json");
68
+ let raw: Record<string, unknown> = {};
69
+ if (fs.existsSync(configPath)) {
70
+ try {
71
+ raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
72
+ } catch {
73
+ raw = {};
74
+ }
75
+ }
76
+ raw.piPlugins = [...piPlugins].sort();
77
+ fs.writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`);
78
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "pi-runline",
3
+ "version": "0.1.0",
4
+ "description": "Code mode for pi",
5
+ "type": "commonjs",
6
+ "keywords": ["pi-package"],
7
+ "files": ["extensions", "README.md"],
8
+ "pi": {
9
+ "extensions": ["extensions/runline-context"]
10
+ },
11
+ "scripts": {
12
+ "build": "echo 'no build'",
13
+ "test": "echo 'no tests'",
14
+ "lint": "biome check extensions/",
15
+ "lint:fix": "biome check --write extensions/"
16
+ },
17
+ "peerDependencies": {
18
+ "@mariozechner/pi-coding-agent": "*",
19
+ "@mariozechner/pi-tui": "*",
20
+ "@sinclair/typebox": "*",
21
+ "runline": ">=0.2.0"
22
+ },
23
+ "author": "michaelliv",
24
+ "license": "MIT"
25
+ }