pi-pstack-adapter 0.3.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/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/VERSION +1 -0
- package/adapter-skills/setup.md +67 -0
- package/adapter-skills/status.md +28 -0
- package/docs/compatibility.md +164 -0
- package/index.ts +472 -0
- package/package.json +71 -0
- package/src/activation.ts +144 -0
- package/src/agents.ts +69 -0
- package/src/capabilities.ts +85 -0
- package/src/certified.ts +13 -0
- package/src/config.ts +210 -0
- package/src/executables.ts +150 -0
- package/src/generate.ts +453 -0
- package/src/manifest.ts +189 -0
- package/src/mode.ts +25 -0
- package/src/namespace.ts +16 -0
- package/src/paths.ts +67 -0
- package/src/registry.ts +104 -0
- package/src/status.ts +215 -0
- package/src/types.ts +98 -0
- package/src/upstream.ts +362 -0
package/src/agents.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Diagnostic, GeneratedAgent } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const RUNTIME_AGENT_REGISTER_EVENT = "pi-subagents:runtime-agent-register:v1";
|
|
4
|
+
|
|
5
|
+
export interface RuntimeRegistration {
|
|
6
|
+
dispose(): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface RegisterRequest {
|
|
10
|
+
version: 1;
|
|
11
|
+
name: string;
|
|
12
|
+
definition: { description: string; systemPrompt: string; tools?: readonly string[] };
|
|
13
|
+
result?: { ok: true; registration: RuntimeRegistration } | { ok: false; error: Error };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EventBus {
|
|
17
|
+
emit(event: string, payload: unknown): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentRegistrationResult {
|
|
21
|
+
readonly registrations: readonly RuntimeRegistration[];
|
|
22
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hands the converted upstream agents to whichever installed extension owns the
|
|
27
|
+
* pi-subagents runtime registration contract. No owner means no delegation provider.
|
|
28
|
+
*/
|
|
29
|
+
export function registerAgents(
|
|
30
|
+
bus: EventBus,
|
|
31
|
+
agents: readonly GeneratedAgent[],
|
|
32
|
+
namespace: string,
|
|
33
|
+
): AgentRegistrationResult {
|
|
34
|
+
const registrations: RuntimeRegistration[] = [];
|
|
35
|
+
const diagnostics: Diagnostic[] = [];
|
|
36
|
+
|
|
37
|
+
for (const agent of agents) {
|
|
38
|
+
const request: RegisterRequest = {
|
|
39
|
+
version: 1,
|
|
40
|
+
name: agent.generatedName,
|
|
41
|
+
definition: {
|
|
42
|
+
description: `${agent.displayName}. ${agent.description}`,
|
|
43
|
+
systemPrompt: agent.systemPrompt,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
bus.emit(RUNTIME_AGENT_REGISTER_EVENT, request);
|
|
47
|
+
if (!request.result) {
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
level: "warning",
|
|
50
|
+
resource: `agent:${agent.upstreamName}`,
|
|
51
|
+
message: `No installed delegation provider accepted \`${agent.generatedName}\`, so pstack workflows cannot delegate to it.`,
|
|
52
|
+
action: `Install a provider that owns ${RUNTIME_AGENT_REGISTER_EVENT}, for example \`pi install npm:pi-subagents\`.`,
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!request.result.ok) {
|
|
57
|
+
diagnostics.push({
|
|
58
|
+
level: "error",
|
|
59
|
+
resource: `agent:${agent.upstreamName}`,
|
|
60
|
+
message: `Registering \`${agent.generatedName}\` failed: ${request.result.error.message}`,
|
|
61
|
+
action: `Check for an agent name collision under the \`${namespace}\` namespace.`,
|
|
62
|
+
});
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
registrations.push(request.result.registration);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { registrations, diagnostics };
|
|
69
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { CapabilityId, CapabilityStatus } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface ToolDescriptor {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly parameters?: unknown;
|
|
6
|
+
readonly sourceInfo?: { readonly source?: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface CapabilityProbe {
|
|
10
|
+
readonly id: CapabilityId;
|
|
11
|
+
readonly toolName: string;
|
|
12
|
+
/** Parameter names the tool must accept for the capability to be usable. */
|
|
13
|
+
readonly requiredParameters: readonly string[];
|
|
14
|
+
readonly recommendation: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PROBES: readonly CapabilityProbe[] = [
|
|
18
|
+
{
|
|
19
|
+
id: "delegation",
|
|
20
|
+
toolName: "subagent",
|
|
21
|
+
requiredParameters: ["agent", "task", "async", "model"],
|
|
22
|
+
recommendation: "Install a delegation provider that exposes named background agents with model selection, for example `pi install npm:pi-subagents`.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "structured-question",
|
|
26
|
+
toolName: "ask_user",
|
|
27
|
+
requiredParameters: ["question", "options"],
|
|
28
|
+
recommendation: "Install a structured question provider, for example `pi install npm:pi-ask-user`.",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export function detectCapabilities(tools: readonly ToolDescriptor[]): CapabilityStatus[] {
|
|
33
|
+
return PROBES.map((probe) => {
|
|
34
|
+
const tool = tools.find((candidate) => candidate.name === probe.toolName);
|
|
35
|
+
if (!tool) {
|
|
36
|
+
return { id: probe.id, available: false, recommendation: probe.recommendation };
|
|
37
|
+
}
|
|
38
|
+
const properties = parameterNames(tool.parameters);
|
|
39
|
+
const missing = probe.requiredParameters.filter((name) => !properties.has(name));
|
|
40
|
+
if (missing.length > 0) {
|
|
41
|
+
return {
|
|
42
|
+
id: probe.id,
|
|
43
|
+
available: false,
|
|
44
|
+
provider: tool.sourceInfo?.source,
|
|
45
|
+
recommendation: `The \`${probe.toolName}\` tool is present but does not accept ${missing.join(", ")}. ${probe.recommendation}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { id: probe.id, available: true, provider: tool.sourceInfo?.source, recommendation: probe.recommendation };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parameterNames(parameters: unknown): Set<string> {
|
|
53
|
+
if (typeof parameters !== "object" || parameters === null) return new Set();
|
|
54
|
+
const properties = (parameters as { properties?: unknown }).properties;
|
|
55
|
+
if (typeof properties !== "object" || properties === null) return new Set();
|
|
56
|
+
return new Set(Object.keys(properties as Record<string, unknown>));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PrerequisiteReport {
|
|
60
|
+
readonly ok: boolean;
|
|
61
|
+
readonly lines: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function checkPrerequisites(
|
|
65
|
+
required: readonly CapabilityId[],
|
|
66
|
+
statuses: readonly CapabilityStatus[],
|
|
67
|
+
executables: readonly string[],
|
|
68
|
+
hasExecutable: (name: string) => boolean,
|
|
69
|
+
/** Session problems that stop every workflow, such as a missing Bash shell. */
|
|
70
|
+
blockers: readonly string[] = [],
|
|
71
|
+
): PrerequisiteReport {
|
|
72
|
+
const lines: string[] = [...blockers];
|
|
73
|
+
for (const id of required) {
|
|
74
|
+
const status = statuses.find((candidate) => candidate.id === id);
|
|
75
|
+
if (!status || !status.available) {
|
|
76
|
+
lines.push(`Missing capability \`${id}\`. ${status?.recommendation ?? ""}`.trim());
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const executable of executables) {
|
|
80
|
+
if (!hasExecutable(executable)) {
|
|
81
|
+
lines.push(`Missing executable \`${executable}\` on PATH. Install it before starting this workflow.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { ok: lines.length === 0, lines };
|
|
85
|
+
}
|
package/src/certified.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one upstream revision this adapter release was tested against.
|
|
3
|
+
* Adapter semantic versions move independently of these values.
|
|
4
|
+
*/
|
|
5
|
+
export const CERTIFIED = {
|
|
6
|
+
repo: "https://github.com/cursor/plugins",
|
|
7
|
+
ref: "main",
|
|
8
|
+
commit: "e43c7ee26e0038c6c1fa8380dd34ce86ff94cb2a",
|
|
9
|
+
pluginPath: "pstack",
|
|
10
|
+
pstackVersion: "0.15.9",
|
|
11
|
+
skillCount: 50,
|
|
12
|
+
agentCount: 2,
|
|
13
|
+
} as const;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { CERTIFIED } from "./certified.ts";
|
|
4
|
+
import { DEFAULT_NAMESPACE, validateNamespace } from "./namespace.ts";
|
|
5
|
+
import { expandHome } from "./paths.ts";
|
|
6
|
+
import type { SourceSelection, Trust } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export interface AdapterConfig {
|
|
9
|
+
readonly namespace: string;
|
|
10
|
+
readonly repo: string;
|
|
11
|
+
readonly ref: string;
|
|
12
|
+
readonly pluginPath: string;
|
|
13
|
+
/** Absolute path to a local pstack checkout, replacing the git source. */
|
|
14
|
+
readonly localPath?: string;
|
|
15
|
+
/** Commit recorded by a previous explicit setup. Startup never advances it. */
|
|
16
|
+
readonly pinnedCommit?: string;
|
|
17
|
+
readonly allowBootstrap: boolean;
|
|
18
|
+
readonly cacheDir?: string;
|
|
19
|
+
readonly models: Readonly<Record<string, string>>;
|
|
20
|
+
readonly sources: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ConfigFile {
|
|
24
|
+
namespace?: string;
|
|
25
|
+
repo?: string;
|
|
26
|
+
ref?: string;
|
|
27
|
+
pluginPath?: string;
|
|
28
|
+
localPath?: string;
|
|
29
|
+
pinnedCommit?: string;
|
|
30
|
+
allowBootstrap?: boolean;
|
|
31
|
+
cacheDir?: string;
|
|
32
|
+
models?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConfigLayer {
|
|
36
|
+
readonly label: string;
|
|
37
|
+
readonly path?: string;
|
|
38
|
+
readonly values: ConfigFile;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NEGATIVE = new Set(["0", "false", "no", "off", ""]);
|
|
42
|
+
|
|
43
|
+
const ENV = {
|
|
44
|
+
namespace: "PISTACK_NAMESPACE",
|
|
45
|
+
repo: "PISTACK_UPSTREAM_REPO",
|
|
46
|
+
ref: "PISTACK_UPSTREAM_REF",
|
|
47
|
+
localPath: "PISTACK_UPSTREAM_PATH",
|
|
48
|
+
pluginPath: "PISTACK_UPSTREAM_PLUGIN_PATH",
|
|
49
|
+
allowBootstrap: "PISTACK_ALLOW_BOOTSTRAP",
|
|
50
|
+
cacheDir: "PISTACK_CACHE_DIR",
|
|
51
|
+
models: "PISTACK_MODELS",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export function readConfigFile(path: string): ConfigLayer | undefined {
|
|
55
|
+
let raw: string;
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync(path, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`${path} is not valid JSON: ${(error as Error).message}`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
68
|
+
throw new Error(`${path} must contain a JSON object.`);
|
|
69
|
+
}
|
|
70
|
+
return { label: path, path, values: parsed as ConfigFile };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Shallow patch. A `models` value replaces the stored map, so callers control removals. */
|
|
74
|
+
export function updateConfigFile(path: string, patch: ConfigFile): ConfigFile {
|
|
75
|
+
const current = readConfigFile(path)?.values ?? {};
|
|
76
|
+
const next: ConfigFile = { ...current, ...patch };
|
|
77
|
+
for (const [key, value] of Object.entries(next)) {
|
|
78
|
+
if (value === undefined) delete (next as Record<string, unknown>)[key];
|
|
79
|
+
}
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `role=provider/model,role2=provider/model2` */
|
|
86
|
+
export function parseModelRoles(value: string): Record<string, string> {
|
|
87
|
+
const roles: Record<string, string> = {};
|
|
88
|
+
for (const pair of value.split(",")) {
|
|
89
|
+
const trimmed = pair.trim();
|
|
90
|
+
if (trimmed === "") continue;
|
|
91
|
+
const separator = trimmed.indexOf("=");
|
|
92
|
+
if (separator <= 0) {
|
|
93
|
+
throw new Error(`Invalid model role assignment ${JSON.stringify(trimmed)}. Use role=provider/model.`);
|
|
94
|
+
}
|
|
95
|
+
roles[trimmed.slice(0, separator).trim()] = trimmed.slice(separator + 1).trim();
|
|
96
|
+
}
|
|
97
|
+
return roles;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
|
|
101
|
+
const values: ConfigFile = {};
|
|
102
|
+
if (env[ENV.namespace]) values.namespace = env[ENV.namespace];
|
|
103
|
+
if (env[ENV.repo]) values.repo = env[ENV.repo];
|
|
104
|
+
if (env[ENV.ref]) values.ref = env[ENV.ref];
|
|
105
|
+
if (env[ENV.localPath]) values.localPath = env[ENV.localPath];
|
|
106
|
+
if (env[ENV.pluginPath]) values.pluginPath = env[ENV.pluginPath];
|
|
107
|
+
if (env[ENV.cacheDir]) values.cacheDir = env[ENV.cacheDir];
|
|
108
|
+
if (env[ENV.allowBootstrap]) values.allowBootstrap = !NEGATIVE.has((env[ENV.allowBootstrap] as string).trim().toLowerCase());
|
|
109
|
+
if (env[ENV.models]) values.models = parseModelRoles(env[ENV.models] as string);
|
|
110
|
+
return { label: "environment", values };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
|
|
114
|
+
const merged: ConfigFile = {};
|
|
115
|
+
const sources: string[] = [];
|
|
116
|
+
for (const layer of layers) {
|
|
117
|
+
let contributed = false;
|
|
118
|
+
for (const [key, value] of Object.entries(layer.values)) {
|
|
119
|
+
if (value === undefined) continue;
|
|
120
|
+
// `JSON.parse` yields `__proto__` as an own key, and a plain assignment would invoke the
|
|
121
|
+
// prototype setter, letting a config file reach fields the caller deliberately removed.
|
|
122
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
|
123
|
+
// Source selection follows layer precedence: a later layer naming a git source retires an
|
|
124
|
+
// earlier local checkout, and vice versa.
|
|
125
|
+
if (key === "repo" || key === "ref") merged.localPath = undefined;
|
|
126
|
+
if (key === "localPath") {
|
|
127
|
+
merged.repo = undefined;
|
|
128
|
+
merged.ref = undefined;
|
|
129
|
+
}
|
|
130
|
+
if (key === "models") {
|
|
131
|
+
merged.models = { ...merged.models, ...(value as Record<string, string>) };
|
|
132
|
+
} else {
|
|
133
|
+
(merged as Record<string, unknown>)[key] = value;
|
|
134
|
+
}
|
|
135
|
+
contributed = true;
|
|
136
|
+
}
|
|
137
|
+
if (contributed) sources.push(layer.label);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const localPath = merged.localPath
|
|
141
|
+
? resolveLayerPath(merged.localPath, layers)
|
|
142
|
+
: undefined;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
namespace: validateNamespace(merged.namespace ?? DEFAULT_NAMESPACE),
|
|
146
|
+
repo: merged.repo ?? CERTIFIED.repo,
|
|
147
|
+
ref: merged.ref ?? CERTIFIED.ref,
|
|
148
|
+
pluginPath: merged.pluginPath ?? CERTIFIED.pluginPath,
|
|
149
|
+
localPath,
|
|
150
|
+
pinnedCommit: merged.localPath ? undefined : resolvePin(merged),
|
|
151
|
+
allowBootstrap: merged.allowBootstrap ?? false,
|
|
152
|
+
cacheDir: merged.cacheDir,
|
|
153
|
+
models: merged.models ?? {},
|
|
154
|
+
sources,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The certified commit is the default, not a floor. Naming a ref or a repository without a
|
|
160
|
+
* commit means the user asked for that ref, so the pin must get out of the way and trust
|
|
161
|
+
* must fall to `untested-*`.
|
|
162
|
+
*/
|
|
163
|
+
function resolvePin(merged: ConfigFile): string | undefined {
|
|
164
|
+
if (merged.pinnedCommit) return merged.pinnedCommit;
|
|
165
|
+
if (merged.ref) return undefined;
|
|
166
|
+
if (merged.repo && normalizeRepo(merged.repo) !== normalizeRepo(CERTIFIED.repo)) return undefined;
|
|
167
|
+
return CERTIFIED.commit;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveLayerPath(value: string, layers: readonly ConfigLayer[]): string {
|
|
171
|
+
const expanded = expandHome(value);
|
|
172
|
+
if (isAbsolute(expanded)) return expanded;
|
|
173
|
+
const owner = [...layers].reverse().find((layer) => layer.values.localPath === value && layer.path);
|
|
174
|
+
return resolve(owner?.path ? dirname(owner.path) : process.cwd(), expanded);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function selectionOf(config: AdapterConfig): SourceSelection {
|
|
178
|
+
if (config.localPath) return { kind: "local", path: config.localPath };
|
|
179
|
+
return { kind: "git", repo: config.repo, ref: config.pinnedCommit ?? config.ref };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function trustOf(config: AdapterConfig): Trust {
|
|
183
|
+
if (config.localPath) return "local";
|
|
184
|
+
if (normalizeRepo(config.repo) !== normalizeRepo(CERTIFIED.repo)) return "untested-repo";
|
|
185
|
+
if (config.pinnedCommit === CERTIFIED.commit) return "tested";
|
|
186
|
+
return "untested-ref";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function normalizeRepo(repo: string): string {
|
|
190
|
+
return repo
|
|
191
|
+
.trim()
|
|
192
|
+
.replace(/^git\+/, "")
|
|
193
|
+
.replace(/\/+$/, "")
|
|
194
|
+
.replace(/\.git$/, "")
|
|
195
|
+
.replace(/\/+$/, "")
|
|
196
|
+
.toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function trustWarning(trust: Trust, config: AdapterConfig): string | undefined {
|
|
200
|
+
switch (trust) {
|
|
201
|
+
case "tested":
|
|
202
|
+
return undefined;
|
|
203
|
+
case "untested-ref":
|
|
204
|
+
return `pstack adapter is using an untested upstream ref (${config.pinnedCommit ?? config.ref}). This release certified ${CERTIFIED.commit}. Behavior is not verified.`;
|
|
205
|
+
case "untested-repo":
|
|
206
|
+
return `pstack adapter is using a custom repository (${config.repo}). Code from this source is outside the tested trust boundary and runs with your full permissions. Only continue if you trust it.`;
|
|
207
|
+
case "local":
|
|
208
|
+
return `pstack adapter is using a local checkout (${config.localPath}). Contents are unverified and not reproducible across machines.`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { accessSync, constants, existsSync } from "node:fs";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { getShellConfig, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
/** The shell Pi's `bash` tool runs commands with, as `getShellConfig()` reports it. */
|
|
8
|
+
export interface ShellConfig {
|
|
9
|
+
readonly shell: string;
|
|
10
|
+
readonly args: readonly string[];
|
|
11
|
+
/** Legacy WSL `bash.exe` reads the command from stdin (`-s`) instead of `-c`. */
|
|
12
|
+
readonly commandTransport?: "argv" | "stdin";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Everything that decides what a command run by Pi's `bash` tool can find. */
|
|
16
|
+
export interface BashToolContext {
|
|
17
|
+
readonly shell: ShellConfig;
|
|
18
|
+
/** Pi's `shellCommandPrefix`, which runs before every command. */
|
|
19
|
+
readonly prefix?: string;
|
|
20
|
+
readonly cwd: string;
|
|
21
|
+
readonly env: NodeJS.ProcessEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ExecutableCheck = (name: string) => boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The context Pi's `bash` tool would run in for this session. Project settings count only in a
|
|
28
|
+
* trusted project, as in Pi itself, so an untrusted checkout cannot choose the executable or the
|
|
29
|
+
* prefix this spawns. Throws when Pi cannot resolve a Bash shell.
|
|
30
|
+
*/
|
|
31
|
+
export function bashToolContext(cwd: string, agentDir: string, projectTrusted: boolean): BashToolContext {
|
|
32
|
+
const settings = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
|
33
|
+
return {
|
|
34
|
+
shell: getShellConfig(settings.getShellPath()),
|
|
35
|
+
prefix: settings.getShellCommandPrefix(),
|
|
36
|
+
cwd,
|
|
37
|
+
env: piShellEnv(agentDir),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Pi's `getShellEnv`, which is not exported: its own `bin` directory goes first on PATH. */
|
|
42
|
+
function piShellEnv(agentDir: string): NodeJS.ProcessEnv {
|
|
43
|
+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
44
|
+
const current = process.env[pathKey] ?? "";
|
|
45
|
+
const binDir = join(agentDir, "bin");
|
|
46
|
+
const entries = current.split(delimiter).filter(Boolean);
|
|
47
|
+
return { ...process.env, [pathKey]: entries.includes(binDir) ? current : [binDir, current].filter(Boolean).join(delimiter) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True when `command` is an executable file on this process's PATH. */
|
|
51
|
+
export function onPath(command: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
52
|
+
const paths = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
|
|
53
|
+
const extensions = platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
54
|
+
for (const directory of paths) {
|
|
55
|
+
for (const extension of extensions) {
|
|
56
|
+
const candidate = join(directory, `${command}${extension}`);
|
|
57
|
+
if (!existsSync(candidate)) continue;
|
|
58
|
+
try {
|
|
59
|
+
accessSync(candidate, constants.X_OK);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
// Not executable by this user. Keep looking.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `$1` is a per-run nonce. Only lines carrying it count, so output from Pi's `shellCommandPrefix`
|
|
70
|
+
// cannot fake a result, and the closing `done` line proves the script ran to the end: a program
|
|
71
|
+
// that exits 0 without being a shell, or a prefix that exits early, never prints it.
|
|
72
|
+
const PROBE_SCRIPT =
|
|
73
|
+
'nonce=$1; shift; for name in "$@"; do command -v -- "$name" >/dev/null 2>&1 && printf "%s found %s\\n" "$nonce" "$name"; done; printf "%s done\\n" "$nonce"; exit 0';
|
|
74
|
+
|
|
75
|
+
export type ShellProbe =
|
|
76
|
+
| { readonly kind: "ran"; readonly found: ReadonlySet<string> }
|
|
77
|
+
| { readonly kind: "failed"; readonly reason: string };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs `command -v` for each name in the bash tool's context, with Pi's `shellCommandPrefix`
|
|
81
|
+
* first, exactly as the tool would run a command. It runs even with no names, because a shell
|
|
82
|
+
* that cannot run a command blocks every workflow. Names travel as positional arguments, never
|
|
83
|
+
* inside the script text.
|
|
84
|
+
*/
|
|
85
|
+
export function shellExecutables(context: BashToolContext, names: readonly string[]): ShellProbe {
|
|
86
|
+
const script = context.prefix ? `${context.prefix}\n${PROBE_SCRIPT}` : PROBE_SCRIPT;
|
|
87
|
+
// With `-c` the word after the script is `$0`; with `-s` the script arrives on stdin, and `--`
|
|
88
|
+
// stops bash from reading a name as an option.
|
|
89
|
+
const stdin = context.shell.commandTransport === "stdin";
|
|
90
|
+
const nonce = `pistack-probe-${randomBytes(12).toString("hex")}`;
|
|
91
|
+
const args = stdin
|
|
92
|
+
? [...context.shell.args, "--", nonce, ...names]
|
|
93
|
+
: [...context.shell.args, script, "pistack-probe", nonce, ...names];
|
|
94
|
+
const result = spawnSync(context.shell.shell, args, {
|
|
95
|
+
cwd: context.cwd,
|
|
96
|
+
env: context.env,
|
|
97
|
+
input: stdin ? script : undefined,
|
|
98
|
+
encoding: "utf8",
|
|
99
|
+
timeout: 10_000,
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
});
|
|
102
|
+
if (result.error) return { kind: "failed", reason: result.error.message };
|
|
103
|
+
if (result.status !== 0) {
|
|
104
|
+
const detail = (result.stderr ?? "").trim().split(/\r?\n/)[0];
|
|
105
|
+
return { kind: "failed", reason: `it exited ${result.status ?? result.signal}${detail ? `: ${detail}` : ""}` };
|
|
106
|
+
}
|
|
107
|
+
const lines = result.stdout.split(/\r?\n/);
|
|
108
|
+
if (!lines.includes(`${nonce} done`)) {
|
|
109
|
+
return { kind: "failed", reason: "it exited without running the probe command" };
|
|
110
|
+
}
|
|
111
|
+
const marker = `${nonce} found `;
|
|
112
|
+
const found = lines.filter((line) => line.startsWith(marker)).map((line) => line.slice(marker.length));
|
|
113
|
+
return { kind: "ran", found: new Set(found.filter((name) => names.includes(name))) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ExecutableReport {
|
|
117
|
+
readonly has: ExecutableCheck;
|
|
118
|
+
/** Set when Pi's bash shell resolved but could not run a command. */
|
|
119
|
+
readonly shellFailure?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Skill bodies run their commands through Pi's `bash` tool, so executables are looked up there:
|
|
124
|
+
* with Pi's `shellCommandPrefix` and its `bin` directory on every platform, and on native Windows
|
|
125
|
+
* inside Git Bash, whose PATH adds `/usr/bin` and `/mingw64/bin` to the PATH Pi started with.
|
|
126
|
+
* Pass no context when the tool is inactive or its shell cannot be resolved; the shell
|
|
127
|
+
* diagnostics report that, and the PATH scan answers for the executables.
|
|
128
|
+
*/
|
|
129
|
+
export function probeExecutables(
|
|
130
|
+
names: readonly string[],
|
|
131
|
+
resolveContext: (() => BashToolContext) | undefined,
|
|
132
|
+
platform: NodeJS.Platform = process.platform,
|
|
133
|
+
): ExecutableReport {
|
|
134
|
+
const scan: ExecutableCheck = (name) => onPath(name, platform);
|
|
135
|
+
if (!resolveContext) return { has: scan };
|
|
136
|
+
let context: BashToolContext;
|
|
137
|
+
try {
|
|
138
|
+
context = resolveContext();
|
|
139
|
+
} catch {
|
|
140
|
+
return { has: scan };
|
|
141
|
+
}
|
|
142
|
+
const probe = shellExecutables(context, names);
|
|
143
|
+
if (probe.kind === "failed") {
|
|
144
|
+
return {
|
|
145
|
+
has: scan,
|
|
146
|
+
shellFailure: `Pi's bash shell ${context.shell.shell} could not run a command, so pstack skills cannot run here: ${probe.reason}. Fix the shell or \`shellCommandPrefix\` in Pi settings, or set \`shellPath\` to a working Bash executable, then restart Pi.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { has: (name) => probe.found.has(name) };
|
|
150
|
+
}
|