@subshell-ai/plugin-oh-my-pi 0.0.0-stage → 0.2.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/LICENSE +203 -0
- package/README.md +12 -2
- package/dist/index.d.ts +670 -0
- package/dist/index.js +458 -0
- package/icon.svg +1 -0
- package/package.json +68 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
//#region ../../plugin-api/dist/manifest-XGvw0ugx.js
|
|
2
|
+
/** Every {@link PluginType}, for anything that has to iterate or validate them. */
|
|
3
|
+
const PLUGIN_TYPES = [
|
|
4
|
+
"agent-harness",
|
|
5
|
+
"terminal",
|
|
6
|
+
"network"
|
|
7
|
+
];
|
|
8
|
+
/** Every {@link PluginPlatform}, for validation. */
|
|
9
|
+
const PLUGIN_PLATFORMS = ["darwin", "linux"];
|
|
10
|
+
/**
|
|
11
|
+
* The MCP server name every harness registers the built-in `subshell mcp`
|
|
12
|
+
* server under: the config object key, and the `hermes mcp add|remove`
|
|
13
|
+
* argument alike. The server reports the same name in its MCP handshake, which
|
|
14
|
+
* is what harnesses surface in wire tool ids (`mcp__<name>__<tool>`).
|
|
15
|
+
*/
|
|
16
|
+
const MCP_SERVER_NAME = "subshell";
|
|
17
|
+
/**
|
|
18
|
+
* Image formats a plugin icon may be. The server maps these to a Content-Type
|
|
19
|
+
* from a fixed table rather than sniffing the file, so this list and that
|
|
20
|
+
* table are one decision: adding a format here without adding it there leaves
|
|
21
|
+
* an icon that parses and then 500s when something asks for it.
|
|
22
|
+
*/
|
|
23
|
+
const ICON_EXTENSIONS = [
|
|
24
|
+
".svg",
|
|
25
|
+
".png",
|
|
26
|
+
".webp"
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Ids become directory names under `<dataDir>/plugins/`, so they are path
|
|
30
|
+
* segments and nothing else: no separators, no traversal, no surprises from a
|
|
31
|
+
* case-insensitive filesystem.
|
|
32
|
+
*/
|
|
33
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
/** The privilege wrappers no install command may ever run, by first-word basename. */
|
|
38
|
+
const PRIVILEGED_COMMANDS = [
|
|
39
|
+
"sudo",
|
|
40
|
+
"doas",
|
|
41
|
+
"pkexec"
|
|
42
|
+
];
|
|
43
|
+
/** Shell boundaries that introduce a NEW command word: `;` `|` `&` (so `&&` and `||` too) and newline. */
|
|
44
|
+
const COMMAND_BOUNDARY_RE = /[;&|\n]+/;
|
|
45
|
+
/** A leading `NAME=…` env assignment, which `sh` strips before choosing the command. */
|
|
46
|
+
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s*/;
|
|
47
|
+
/**
|
|
48
|
+
* Whether a shell line runs one of {@link PRIVILEGED_COMMANDS} AS A COMMAND
|
|
49
|
+
* anywhere in it — not merely at the start.
|
|
50
|
+
*
|
|
51
|
+
* `install.command` is the one manifest field a host RUNS, and it runs through
|
|
52
|
+
* `sh -c` (`api/network/install-network.route.ts`), which means any boundary
|
|
53
|
+
* in the line can start the privileged command the start of the line hides:
|
|
54
|
+
* `"apt-get update && sudo apt-get install -y x"` is sudo-without-prompt on a
|
|
55
|
+
* NOPASSWD host. A line-start regex was the first gate and it was not one.
|
|
56
|
+
*
|
|
57
|
+
* The split is CONSERVATIVE, not a shell parser: segments are taken at every
|
|
58
|
+
* boundary character quote-blind (so a `;` inside a quoted string still
|
|
59
|
+
* counts, which can only over-refuse), leading env assignments are stripped
|
|
60
|
+
* (`FOO=bar sudo x` runs sudo), and each segment's first word is compared by
|
|
61
|
+
* BASENAME (`/usr/bin/sudo` is sudo, `pkexec-demo` is not). A word merely
|
|
62
|
+
* CONTAINING the token is fine — the comparison is equality on the basename.
|
|
63
|
+
* The accepted limit is a wrapper hidden inside quotes (`sh -c 'sudo …'`),
|
|
64
|
+
* which is the same limit `PluginHost.run`'s first-word check has, and it is
|
|
65
|
+
* accepted for the same reason: a plugin whose code LOADS can already run
|
|
66
|
+
* anything in this process (`docs/security.md` §11.9) — this rule refuses
|
|
67
|
+
* careless manifests deterministically, it is not a sandbox.
|
|
68
|
+
*/
|
|
69
|
+
function needsPrivileges(command) {
|
|
70
|
+
for (const segment of command.split(COMMAND_BOUNDARY_RE)) {
|
|
71
|
+
let rest = segment.trim();
|
|
72
|
+
while (ENV_ASSIGNMENT_RE.test(rest)) rest = rest.replace(ENV_ASSIGNMENT_RE, "");
|
|
73
|
+
if (rest === "") continue;
|
|
74
|
+
const ws = rest.search(/\s/);
|
|
75
|
+
const firstWord = ws === -1 ? rest : rest.slice(0, ws);
|
|
76
|
+
const base = firstWord.slice(firstWord.lastIndexOf("/") + 1);
|
|
77
|
+
if (PRIVILEGED_COMMANDS.includes(base)) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether a string may be rendered as a link in an operator's browser.
|
|
83
|
+
*
|
|
84
|
+
* Every `docsUrl` in this contract ends up as the `href` of an anchor on an
|
|
85
|
+
* admin page, and an `href` is not inert: a `javascript:` URL in one is script
|
|
86
|
+
* running on the control plane's own origin, in the session of the one person
|
|
87
|
+
* who can install plugins. So a URL this contract carries is restricted to the
|
|
88
|
+
* two schemes that navigate, and both the parser below and the host check it.
|
|
89
|
+
*
|
|
90
|
+
* It is checked in two places on purpose. A manifest is static data, so a bad
|
|
91
|
+
* value there is a plugin defect and is refused at load. A URL a plugin
|
|
92
|
+
* REPORTS at runtime is often something it read off a vendor CLI — Tailscale's
|
|
93
|
+
* `AuthURL` comes from whichever control server the operator pointed the
|
|
94
|
+
* daemon at — so the host drops that one instead, because refusing to load a
|
|
95
|
+
* working plugin over a value its control server chose would be the wrong
|
|
96
|
+
* failure.
|
|
97
|
+
*
|
|
98
|
+
* Not a reachability claim: nothing fetches these, and a docs page that 404s
|
|
99
|
+
* is a broken link rather than a hazard.
|
|
100
|
+
* @param value - the candidate URL, as written in a manifest or returned by a plugin
|
|
101
|
+
*/
|
|
102
|
+
function isDocsUrl(value) {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(value);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Reads and validates the `subshell` block of a plugin's package.json.
|
|
113
|
+
*
|
|
114
|
+
* Answers `{ error }` rather than throwing, because every caller is reporting
|
|
115
|
+
* a broken plugin to a person rather than handling an exception: the node
|
|
116
|
+
* marks the plugin broken and renders this message beside it. The strings are
|
|
117
|
+
* therefore specific on purpose.
|
|
118
|
+
* @param pkgJson - the parsed package.json of a plugin directory
|
|
119
|
+
*/
|
|
120
|
+
function parseManifest(pkgJson) {
|
|
121
|
+
if (!isRecord(pkgJson)) return { error: "package.json is not an object" };
|
|
122
|
+
const block = pkgJson.subshell;
|
|
123
|
+
if (!isRecord(block)) return { error: "package.json has no `subshell` block, so it is not a Subshell plugin" };
|
|
124
|
+
if (typeof block.apiVersion !== "number" || !Number.isInteger(block.apiVersion)) return { error: "`subshell.apiVersion` must be an integer" };
|
|
125
|
+
if (block.apiVersion < 1) return { error: `this plugin declares plugin-api ${block.apiVersion}, which is not a version; the lowest is 1 and this host implements 2` };
|
|
126
|
+
if (block.apiVersion > 2) return { error: `this plugin needs plugin-api ${block.apiVersion}, but this host implements 2; upgrade Subshell` };
|
|
127
|
+
if (typeof block.id !== "string" || !ID_RE.test(block.id)) return { error: "`subshell.id` must be lowercase letters, digits and hyphens, starting alphanumeric (it becomes a directory name)" };
|
|
128
|
+
if (typeof block.type !== "string" || !PLUGIN_TYPES.includes(block.type)) return { error: `\`subshell.type\` must be one of: ${PLUGIN_TYPES.join(", ")}` };
|
|
129
|
+
if (typeof block.name !== "string" || block.name.trim() === "") return { error: "`subshell.name` must be a non-empty string" };
|
|
130
|
+
if (typeof block.description !== "string") return { error: "`subshell.description` must be a string" };
|
|
131
|
+
if (block.icon !== void 0) {
|
|
132
|
+
if (typeof block.icon !== "string" || block.icon.trim() === "") return { error: "`subshell.icon` must be a non-empty relative path to an image in the package" };
|
|
133
|
+
if (block.icon.startsWith("/") || block.icon.split("/").includes("..")) return { error: "`subshell.icon` must stay inside the package (no leading `/` and no `..` segment)" };
|
|
134
|
+
const icon = block.icon;
|
|
135
|
+
if (!ICON_EXTENSIONS.some((ext) => icon.endsWith(ext))) return { error: `\`subshell.icon\` must end in one of: ${ICON_EXTENSIONS.join(", ")}` };
|
|
136
|
+
}
|
|
137
|
+
if (block.pickable !== void 0 && typeof block.pickable !== "boolean") return { error: "`subshell.pickable` must be a boolean when present" };
|
|
138
|
+
if (typeof block.entry !== "string" || block.entry.trim() === "") return { error: "`subshell.entry` must be a non-empty relative path" };
|
|
139
|
+
if (block.entry.startsWith("/") || block.entry.split("/").includes("..")) return { error: "`subshell.entry` must stay inside the package (no leading `/` and no `..` segment)" };
|
|
140
|
+
let detect;
|
|
141
|
+
if (block.detect !== void 0) {
|
|
142
|
+
const d = block.detect;
|
|
143
|
+
if (!isRecord(d) || typeof d.binaryName !== "string" || typeof d.envOverride !== "string" || !Array.isArray(d.knownPaths) || !d.knownPaths.every((k) => typeof k === "string")) return { error: "`subshell.detect` needs binaryName, envOverride and a knownPaths array of strings" };
|
|
144
|
+
detect = {
|
|
145
|
+
binaryName: d.binaryName,
|
|
146
|
+
envOverride: d.envOverride,
|
|
147
|
+
knownPaths: d.knownPaths
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
let install;
|
|
151
|
+
if (block.install !== void 0) {
|
|
152
|
+
const i = block.install;
|
|
153
|
+
if (!isRecord(i) || typeof i.command !== "string" || typeof i.docsUrl !== "string") return { error: "`subshell.install` needs a command and a docsUrl" };
|
|
154
|
+
if (needsPrivileges(i.command)) return { error: "`subshell.install.command` must not need sudo, doas or pkexec — the host runs it through a shell and has no terminal for a password prompt; put privileged steps in `subshell.network.privileged`" };
|
|
155
|
+
if (!isDocsUrl(i.docsUrl)) return { error: "`subshell.install.docsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
|
|
156
|
+
install = {
|
|
157
|
+
command: i.command,
|
|
158
|
+
docsUrl: i.docsUrl
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
let hostEnv;
|
|
162
|
+
if (block.hostEnv !== void 0) {
|
|
163
|
+
if (!Array.isArray(block.hostEnv) || !block.hostEnv.every((k) => typeof k === "string" && k.trim() !== "")) return { error: "`subshell.hostEnv` must be an array of environment variable names" };
|
|
164
|
+
hostEnv = [...block.hostEnv];
|
|
165
|
+
}
|
|
166
|
+
const type = block.type;
|
|
167
|
+
const parsedNetwork = parseNetworkBlock(block.network, type);
|
|
168
|
+
if (parsedNetwork !== void 0 && "error" in parsedNetwork) return parsedNetwork;
|
|
169
|
+
return {
|
|
170
|
+
apiVersion: block.apiVersion,
|
|
171
|
+
id: block.id,
|
|
172
|
+
type,
|
|
173
|
+
name: block.name,
|
|
174
|
+
description: block.description,
|
|
175
|
+
...typeof block.icon === "string" ? { icon: block.icon } : {},
|
|
176
|
+
entry: block.entry,
|
|
177
|
+
...detect ? { detect } : {},
|
|
178
|
+
...install ? { install } : {},
|
|
179
|
+
...hostEnv ? { hostEnv } : {},
|
|
180
|
+
...parsedNetwork ? { network: parsedNetwork } : {},
|
|
181
|
+
...block.pickable === false ? { pickable: false } : {}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Parses `subshell.network`, which is required for and exclusive to `network`.
|
|
186
|
+
*
|
|
187
|
+
* Both directions are refused rather than tolerated. A network plugin without
|
|
188
|
+
* the block would be unrenderable — nothing could say which platforms it runs
|
|
189
|
+
* on or what publishing it exposes, and the safe default for "what does this
|
|
190
|
+
* expose" is not a default anyone should pick silently. A harness WITH one
|
|
191
|
+
* declares facts nothing reads, which is how a manifest starts lying.
|
|
192
|
+
* @returns the parsed block, an error, or undefined when there is none to have
|
|
193
|
+
*/
|
|
194
|
+
function parseNetworkBlock(raw, type) {
|
|
195
|
+
if (type !== "network") {
|
|
196
|
+
if (raw !== void 0) return { error: `\`subshell.network\` is only for \`type: "network"\` plugins, and this one is \`${type}\`` };
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!isRecord(raw)) return { error: "`subshell.network` is required for `type: \"network\"` plugins" };
|
|
200
|
+
if (!Array.isArray(raw.platforms) || raw.platforms.length === 0 || !raw.platforms.every((p) => typeof p === "string" && PLUGIN_PLATFORMS.includes(p))) return { error: `\`subshell.network.platforms\` must be a non-empty array of: ${PLUGIN_PLATFORMS.join(", ")}` };
|
|
201
|
+
if (raw.exposure !== "private" && raw.exposure !== "public-with-gate") return { error: "`subshell.network.exposure` must be \"private\" or \"public-with-gate\"" };
|
|
202
|
+
if (raw.interactiveLogin !== void 0 && typeof raw.interactiveLogin !== "boolean") return { error: "`subshell.network.interactiveLogin` must be a boolean" };
|
|
203
|
+
if (raw.publishImplicit !== void 0 && typeof raw.publishImplicit !== "boolean") return { error: "`subshell.network.publishImplicit` must be a boolean" };
|
|
204
|
+
let privileged;
|
|
205
|
+
if (raw.privileged !== void 0) {
|
|
206
|
+
if (!isRecord(raw.privileged)) return { error: "`subshell.network.privileged` must be an object keyed by platform" };
|
|
207
|
+
privileged = {};
|
|
208
|
+
for (const [platform, steps] of Object.entries(raw.privileged)) {
|
|
209
|
+
if (!PLUGIN_PLATFORMS.includes(platform)) return { error: `\`subshell.network.privileged\` has an unknown platform "${platform}"` };
|
|
210
|
+
if (!Array.isArray(steps) || !steps.every((s) => isRecord(s) && typeof s.label === "string" && s.label.trim() !== "" && typeof s.command === "string" && s.command.trim() !== "" && (s.docsUrl === void 0 || typeof s.docsUrl === "string") && (s.group === void 0 || typeof s.group === "string"))) return { error: `\`subshell.network.privileged.${platform}\` must be an array of { label, command, docsUrl?, group? }` };
|
|
211
|
+
const badGroup = steps.find((s) => s.group !== void 0 && s.group.trim() === "");
|
|
212
|
+
if (badGroup) return { error: `\`subshell.network.privileged.${platform}\` has an empty \`group\`, which is a heading with no words: "${badGroup.label}"` };
|
|
213
|
+
const badDocs = steps.find((s) => s.docsUrl !== void 0 && !isDocsUrl(s.docsUrl));
|
|
214
|
+
if (badDocs) return { error: `\`subshell.network.privileged.${platform}\` has a docsUrl that is not an http(s) URL: "${badDocs.label}"` };
|
|
215
|
+
privileged[platform] = steps.map((s) => ({
|
|
216
|
+
label: s.label,
|
|
217
|
+
command: s.command,
|
|
218
|
+
...s.docsUrl ? { docsUrl: s.docsUrl } : {},
|
|
219
|
+
...s.group ? { group: s.group } : {}
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
let labels;
|
|
224
|
+
if (raw.labels !== void 0) {
|
|
225
|
+
if (!isRecord(raw.labels)) return { error: "`subshell.network.labels` must be an object" };
|
|
226
|
+
for (const key of ["credential", "publish"]) {
|
|
227
|
+
const value = raw.labels[key];
|
|
228
|
+
if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) return { error: `\`subshell.network.labels.${key}\` must be a non-empty string` };
|
|
229
|
+
}
|
|
230
|
+
const credDocs = raw.labels.credentialDocsUrl;
|
|
231
|
+
if (credDocs !== void 0 && (typeof credDocs !== "string" || !isDocsUrl(credDocs))) return { error: "`subshell.network.labels.credentialDocsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
|
|
232
|
+
labels = {
|
|
233
|
+
...typeof raw.labels.credential === "string" ? { credential: raw.labels.credential } : {},
|
|
234
|
+
...typeof raw.labels.publish === "string" ? { publish: raw.labels.publish } : {},
|
|
235
|
+
...typeof raw.labels.credentialDocsUrl === "string" ? { credentialDocsUrl: raw.labels.credentialDocsUrl } : {}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
platforms: [...raw.platforms],
|
|
240
|
+
exposure: raw.exposure,
|
|
241
|
+
...raw.interactiveLogin === true ? { interactiveLogin: true } : {},
|
|
242
|
+
...raw.publishImplicit === true ? { publishImplicit: true } : {},
|
|
243
|
+
...privileged ? { privileged } : {},
|
|
244
|
+
...labels && Object.keys(labels).length > 0 ? { labels } : {}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region ../../plugin-api/dist/index.js
|
|
249
|
+
/**
|
|
250
|
+
* The preset checks every built-in harness performs unchanged: a non-empty
|
|
251
|
+
* name, string env values, and flags that look like flags. Harnesses with
|
|
252
|
+
* extra rules run their own checks on top of these.
|
|
253
|
+
* @param preset - The decoded preset definition to check
|
|
254
|
+
* @returns Issues per field; `valid` is true when no issues were found
|
|
255
|
+
*/
|
|
256
|
+
function validateGenericPreset(preset) {
|
|
257
|
+
const issues = [];
|
|
258
|
+
if (!preset.name || preset.name.trim().length === 0) issues.push({
|
|
259
|
+
field: "name",
|
|
260
|
+
message: "Preset name is required."
|
|
261
|
+
});
|
|
262
|
+
for (const [key, value] of Object.entries(preset.env)) if (typeof value !== "string") issues.push({
|
|
263
|
+
field: "env",
|
|
264
|
+
message: `Env var "${key}" must be a string.`
|
|
265
|
+
});
|
|
266
|
+
for (const flag of preset.flags) if (!flag.startsWith("-")) issues.push({
|
|
267
|
+
field: "flags",
|
|
268
|
+
message: `Flag "${flag}" must start with "-".`
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
valid: issues.length === 0,
|
|
272
|
+
issues
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/manifest.ts
|
|
277
|
+
/**
|
|
278
|
+
* This package's own `subshell` block, read from its package.json so the
|
|
279
|
+
* manifest has ONE source of truth.
|
|
280
|
+
*
|
|
281
|
+
* A host that installs this plugin at runtime reads package.json off disk; a
|
|
282
|
+
* host that ships it built in imports this. Both must describe the same
|
|
283
|
+
* plugin, and the only way to guarantee that is for them to be the same bytes.
|
|
284
|
+
*/
|
|
285
|
+
const parsed = parseManifest({
|
|
286
|
+
name: "@subshell-ai/plugin-oh-my-pi",
|
|
287
|
+
description: "Oh My Pi harness plugin for Subshell",
|
|
288
|
+
version: "0.2.0",
|
|
289
|
+
license: "Apache-2.0",
|
|
290
|
+
type: "module",
|
|
291
|
+
main: "dist/index.js",
|
|
292
|
+
exports: { ".": {
|
|
293
|
+
"types": "./dist/index.d.ts",
|
|
294
|
+
"import": "./dist/index.js"
|
|
295
|
+
} },
|
|
296
|
+
types: "dist/index.d.ts",
|
|
297
|
+
homepage: "https://subshell.sh",
|
|
298
|
+
repository: {
|
|
299
|
+
"type": "git",
|
|
300
|
+
"url": "git+https://github.com/subshell-ai/subshell.git",
|
|
301
|
+
"directory": "packages/plugins/oh-my-pi"
|
|
302
|
+
},
|
|
303
|
+
bugs: { "url": "https://github.com/subshell-ai/subshell/issues" },
|
|
304
|
+
scripts: {
|
|
305
|
+
"build": "tsdown",
|
|
306
|
+
"build:dev": "hash-runner",
|
|
307
|
+
"clean": "rm -rf .turbo node_modules dist .hashes.json",
|
|
308
|
+
"lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix",
|
|
309
|
+
"lint:check": "biome check --no-errors-on-unmatched src",
|
|
310
|
+
"lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src",
|
|
311
|
+
"test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test",
|
|
312
|
+
"verify-types": "tsc --project tsconfig.json --noEmit"
|
|
313
|
+
},
|
|
314
|
+
devDependencies: {
|
|
315
|
+
"@internal/tsconfig": "workspace:*",
|
|
316
|
+
"@subshell-ai/plugin-api": "workspace:*",
|
|
317
|
+
"@types/bun": "1.3.14",
|
|
318
|
+
"hash-runner": "4.0.0",
|
|
319
|
+
"tsdown": "0.22.14",
|
|
320
|
+
"typescript": "7.0.2"
|
|
321
|
+
},
|
|
322
|
+
files: ["dist", "icon.svg"],
|
|
323
|
+
publishConfig: { "access": "public" },
|
|
324
|
+
subshell: {
|
|
325
|
+
"apiVersion": 2,
|
|
326
|
+
"id": "oh-my-pi",
|
|
327
|
+
"type": "agent-harness",
|
|
328
|
+
"name": "Oh My Pi",
|
|
329
|
+
"description": "Extensible coding agent from omp.sh (interactive TUI)",
|
|
330
|
+
"icon": "icon.svg",
|
|
331
|
+
"entry": "dist/index.js",
|
|
332
|
+
"detect": {
|
|
333
|
+
"binaryName": "omp",
|
|
334
|
+
"envOverride": "OMP_PATH",
|
|
335
|
+
"knownPaths": [".bun/bin/omp", ".local/bin/omp"]
|
|
336
|
+
},
|
|
337
|
+
"install": {
|
|
338
|
+
"command": "curl -fsSL https://omp.sh/install | sh",
|
|
339
|
+
"docsUrl": "https://github.com/can1357/oh-my-pi#install"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
if ("error" in parsed) throw new Error(`@subshell-ai/plugin-oh-my-pi has an invalid manifest: ${parsed.error}`);
|
|
344
|
+
const manifest = parsed;
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/index.ts
|
|
347
|
+
const SETTINGS = [
|
|
348
|
+
{
|
|
349
|
+
key: "model",
|
|
350
|
+
label: "Model",
|
|
351
|
+
description: "Model ID or pattern passed to omp",
|
|
352
|
+
type: "string"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
key: "provider",
|
|
356
|
+
label: "Provider",
|
|
357
|
+
description: "Provider name, such as anthropic or openai",
|
|
358
|
+
type: "string"
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
key: "thinking",
|
|
362
|
+
label: "Thinking level",
|
|
363
|
+
description: "Reasoning effort for the subshell",
|
|
364
|
+
type: "select",
|
|
365
|
+
choices: [
|
|
366
|
+
"off",
|
|
367
|
+
"minimal",
|
|
368
|
+
"low",
|
|
369
|
+
"medium",
|
|
370
|
+
"high",
|
|
371
|
+
"xhigh",
|
|
372
|
+
"max",
|
|
373
|
+
"auto"
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
];
|
|
377
|
+
/**
|
|
378
|
+
* Oh My Pi uses its own executable and native MCP discovery, independently of
|
|
379
|
+
* pi. An explicit extension directory contributes mcp.json without redirecting
|
|
380
|
+
* the user's config or credentials. Verified against omp 18.12.6.
|
|
381
|
+
*/
|
|
382
|
+
const createPlugin = () => ({
|
|
383
|
+
capabilities: () => ["mcp", "settings"],
|
|
384
|
+
buildCommand({ binary, preset, extraFlags, mcp }) {
|
|
385
|
+
const args = [binary, ...mcp?.args ?? []];
|
|
386
|
+
const settings = preset.settings ?? {};
|
|
387
|
+
for (const key of [
|
|
388
|
+
"model",
|
|
389
|
+
"provider",
|
|
390
|
+
"thinking"
|
|
391
|
+
]) {
|
|
392
|
+
const value = settings[key];
|
|
393
|
+
if (typeof value === "string" && value) args.push(`--${key}`, value);
|
|
394
|
+
}
|
|
395
|
+
args.push(...preset.flags, ...extraFlags ?? []);
|
|
396
|
+
return args;
|
|
397
|
+
},
|
|
398
|
+
mcpRegistration(launch, configPath) {
|
|
399
|
+
const fileContent = `${JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: {
|
|
400
|
+
type: "stdio",
|
|
401
|
+
command: launch.command,
|
|
402
|
+
args: launch.args
|
|
403
|
+
} } }, null, 2)}\n`;
|
|
404
|
+
return {
|
|
405
|
+
fileContent,
|
|
406
|
+
files: {
|
|
407
|
+
"package.json": JSON.stringify({
|
|
408
|
+
name: "subshell-mcp",
|
|
409
|
+
private: true,
|
|
410
|
+
type: "module",
|
|
411
|
+
omp: { extensions: ["bridge.mjs"] }
|
|
412
|
+
}),
|
|
413
|
+
"bridge.mjs": "export default function () {}\n",
|
|
414
|
+
"mcp.json": fileContent
|
|
415
|
+
},
|
|
416
|
+
args: ["--extension", `${configPath}.d`]
|
|
417
|
+
};
|
|
418
|
+
},
|
|
419
|
+
mcpSetup() {
|
|
420
|
+
return {
|
|
421
|
+
mode: "auto",
|
|
422
|
+
summary: "Subshell registers its MCP server through a per-launch Oh My Pi extension. Your existing configuration stays in place."
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
validatePreset: validateGenericPreset,
|
|
426
|
+
presetSettings: () => SETTINGS,
|
|
427
|
+
suggestedEnv: () => [{
|
|
428
|
+
key: "ANTHROPIC_API_KEY",
|
|
429
|
+
description: "Anthropic API key"
|
|
430
|
+
}, {
|
|
431
|
+
key: "OPENAI_API_KEY",
|
|
432
|
+
description: "OpenAI API key"
|
|
433
|
+
}],
|
|
434
|
+
suggestedFlags: () => [
|
|
435
|
+
{
|
|
436
|
+
flag: "--model anthropic/claude-sonnet-4-5",
|
|
437
|
+
description: "Model in provider/model form"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
flag: "--thinking high",
|
|
441
|
+
description: "Reasoning effort for the subshell"
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
flag: "--tools read,grep,find",
|
|
445
|
+
description: "Tools to enable"
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
flag: "--append-system-prompt <text>",
|
|
449
|
+
description: "Append instructions to the system prompt"
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
flag: "--no-session",
|
|
453
|
+
description: "Don't save the conversation"
|
|
454
|
+
}
|
|
455
|
+
]
|
|
456
|
+
});
|
|
457
|
+
//#endregion
|
|
458
|
+
export { createPlugin as default, manifest };
|
package/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Oh My Pi</title><rect width="64" height="64" rx="12" fill="#171717"/><path d="M12 18h14l15 28h11M38 18h14" fill="none" stroke="#fff" stroke-width="5" stroke-linejoin="round"/></svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subshell-ai/plugin-oh-my-pi",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "Oh My Pi harness plugin for Subshell",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"types": "dist/index.d.ts",
|
|
15
|
+
"homepage": "https://subshell.sh",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/subshell-ai/subshell.git",
|
|
19
|
+
"directory": "packages/plugins/oh-my-pi"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/subshell-ai/subshell/issues"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsdown",
|
|
26
|
+
"build:dev": "hash-runner",
|
|
27
|
+
"clean": "rm -rf .turbo node_modules dist .hashes.json",
|
|
28
|
+
"lint": "biome check --write --unsafe src && biome format src --write && biome lint src --fix",
|
|
29
|
+
"lint:check": "biome check --no-errors-on-unmatched src",
|
|
30
|
+
"lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src",
|
|
31
|
+
"test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test",
|
|
32
|
+
"verify-types": "tsc --project tsconfig.json --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@internal/tsconfig": "workspace:*",
|
|
36
|
+
"@subshell-ai/plugin-api": "workspace:*",
|
|
37
|
+
"@types/bun": "1.3.14",
|
|
38
|
+
"hash-runner": "4.0.0",
|
|
39
|
+
"tsdown": "0.22.14",
|
|
40
|
+
"typescript": "7.0.2"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"icon.svg"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"subshell": {
|
|
50
|
+
"apiVersion": 2,
|
|
51
|
+
"id": "oh-my-pi",
|
|
52
|
+
"type": "agent-harness",
|
|
53
|
+
"name": "Oh My Pi",
|
|
54
|
+
"description": "Extensible coding agent from omp.sh (interactive TUI)",
|
|
55
|
+
"icon": "icon.svg",
|
|
56
|
+
"entry": "dist/index.js",
|
|
57
|
+
"detect": {
|
|
58
|
+
"binaryName": "omp",
|
|
59
|
+
"envOverride": "OMP_PATH",
|
|
60
|
+
"knownPaths": [
|
|
61
|
+
".bun/bin/omp",
|
|
62
|
+
".local/bin/omp"
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"install": {
|
|
66
|
+
"command": "curl -fsSL https://omp.sh/install | sh",
|
|
67
|
+
"docsUrl": "https://github.com/can1357/oh-my-pi#install"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|