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 +35 -0
- package/extensions/connection-setup.ts +162 -0
- package/extensions/plugin-picker.ts +227 -0
- package/extensions/runline-context/index.ts +334 -0
- package/extensions/runline-resolve.ts +78 -0
- package/package.json +25 -0
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
|
+
}
|