pi-chassis 0.0.0-stage → 0.7.1
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 +118 -0
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/THIRD_PARTY_NOTICES +30 -0
- package/dist/chassis/alert.d.ts +50 -0
- package/dist/chassis/alert.js +107 -0
- package/dist/chassis/api.d.ts +411 -0
- package/dist/chassis/api.js +676 -0
- package/dist/chassis/bash-output.d.ts +89 -0
- package/dist/chassis/bash-output.js +157 -0
- package/dist/chassis/brand.d.ts +99 -0
- package/dist/chassis/brand.js +79 -0
- package/dist/chassis/client.d.ts +87 -0
- package/dist/chassis/client.js +271 -0
- package/dist/chassis/commands.d.ts +60 -0
- package/dist/chassis/commands.js +211 -0
- package/dist/chassis/editor.d.ts +70 -0
- package/dist/chassis/editor.js +213 -0
- package/dist/chassis/footer.d.ts +14 -0
- package/dist/chassis/footer.js +136 -0
- package/dist/chassis/header.d.ts +12 -0
- package/dist/chassis/header.js +38 -0
- package/dist/chassis/home.d.ts +48 -0
- package/dist/chassis/home.js +184 -0
- package/dist/chassis/icons.d.ts +203 -0
- package/dist/chassis/icons.js +190 -0
- package/dist/chassis/index.d.ts +21 -0
- package/dist/chassis/index.js +582 -0
- package/dist/chassis/layout.d.ts +40 -0
- package/dist/chassis/layout.js +264 -0
- package/dist/chassis/message-modes.d.ts +21 -0
- package/dist/chassis/message-modes.js +49 -0
- package/dist/chassis/messages.d.ts +75 -0
- package/dist/chassis/messages.js +392 -0
- package/dist/chassis/modes.d.ts +21 -0
- package/dist/chassis/modes.js +99 -0
- package/dist/chassis/palette.d.ts +84 -0
- package/dist/chassis/palette.js +235 -0
- package/dist/chassis/peek.d.ts +61 -0
- package/dist/chassis/peek.js +156 -0
- package/dist/chassis/prompt-row.d.ts +42 -0
- package/dist/chassis/prompt-row.js +68 -0
- package/dist/chassis/provider-view.d.ts +34 -0
- package/dist/chassis/provider-view.js +107 -0
- package/dist/chassis/provider.d.ts +69 -0
- package/dist/chassis/provider.js +73 -0
- package/dist/chassis/readonly-command.d.ts +19 -0
- package/dist/chassis/readonly-command.js +454 -0
- package/dist/chassis/sanitize.d.ts +42 -0
- package/dist/chassis/sanitize.js +103 -0
- package/dist/chassis/session-info.d.ts +39 -0
- package/dist/chassis/session-info.js +107 -0
- package/dist/chassis/sidebar.d.ts +48 -0
- package/dist/chassis/sidebar.js +273 -0
- package/dist/chassis/state.d.ts +38 -0
- package/dist/chassis/state.js +19 -0
- package/dist/chassis/telemetry.d.ts +60 -0
- package/dist/chassis/telemetry.js +191 -0
- package/dist/chassis/tools.d.ts +112 -0
- package/dist/chassis/tools.js +588 -0
- package/dist/chassis/version.d.ts +2 -0
- package/dist/chassis/version.js +20 -0
- package/dist/diff/git-diff.d.ts +71 -0
- package/dist/diff/git-diff.js +325 -0
- package/dist/diff/host.d.ts +34 -0
- package/dist/diff/host.js +50 -0
- package/dist/diff/index.d.ts +13 -0
- package/dist/diff/index.js +86 -0
- package/dist/diff/render.d.ts +67 -0
- package/dist/diff/render.js +196 -0
- package/dist/diff/types.d.ts +60 -0
- package/dist/diff/types.js +4 -0
- package/dist/diff/view.d.ts +118 -0
- package/dist/diff/view.js +528 -0
- package/dist/git/flows.d.ts +92 -0
- package/dist/git/flows.js +415 -0
- package/dist/git/forge.d.ts +26 -0
- package/dist/git/forge.js +41 -0
- package/dist/git/index.d.ts +20 -0
- package/dist/git/index.js +121 -0
- package/dist/git/repo.d.ts +64 -0
- package/dist/git/repo.js +186 -0
- package/dist/git/run.d.ts +38 -0
- package/dist/git/run.js +127 -0
- package/dist/git/settings.d.ts +8 -0
- package/dist/git/settings.js +38 -0
- package/dist/git/text.d.ts +51 -0
- package/dist/git/text.js +109 -0
- package/dist/widgets/container/detect.d.ts +23 -0
- package/dist/widgets/container/detect.js +67 -0
- package/dist/widgets/container/index.d.ts +9 -0
- package/dist/widgets/container/index.js +26 -0
- package/dist/widgets/mcp/index.d.ts +13 -0
- package/dist/widgets/mcp/index.js +50 -0
- package/dist/widgets/mcp/status.d.ts +50 -0
- package/dist/widgets/mcp/status.js +70 -0
- package/dist/widgets/session-todos/index.d.ts +12 -0
- package/dist/widgets/session-todos/index.js +52 -0
- package/dist/widgets/subagents/index.d.ts +16 -0
- package/dist/widgets/subagents/index.js +173 -0
- package/dist/widgets/subagents/status.d.ts +61 -0
- package/dist/widgets/subagents/status.js +198 -0
- package/dist/widgets/todo/index.d.ts +13 -0
- package/dist/widgets/todo/index.js +65 -0
- package/dist/widgets/todo/parse.d.ts +29 -0
- package/dist/widgets/todo/parse.js +122 -0
- package/docs/branding.md +25 -0
- package/docs/development.md +22 -0
- package/docs/diff-api.md +18 -0
- package/docs/git.md +23 -0
- package/docs/internals.md +13 -0
- package/docs/provider-status.md +23 -0
- package/docs/status-widgets.md +157 -0
- package/docs/tool-rows.md +50 -0
- package/docs/usage.md +62 -0
- package/docs/widgets.md +113 -0
- package/extensions/chassis/alert.ts +127 -0
- package/extensions/chassis/api.ts +967 -0
- package/extensions/chassis/bash-output.ts +186 -0
- package/extensions/chassis/brand.ts +151 -0
- package/extensions/chassis/client.ts +284 -0
- package/extensions/chassis/commands.ts +244 -0
- package/extensions/chassis/editor.ts +253 -0
- package/extensions/chassis/footer.ts +131 -0
- package/extensions/chassis/header.ts +43 -0
- package/extensions/chassis/home.ts +186 -0
- package/extensions/chassis/icons.ts +220 -0
- package/extensions/chassis/index.ts +605 -0
- package/extensions/chassis/layout.ts +298 -0
- package/extensions/chassis/message-modes.ts +48 -0
- package/extensions/chassis/messages.ts +403 -0
- package/extensions/chassis/modes.ts +108 -0
- package/extensions/chassis/palette.ts +258 -0
- package/extensions/chassis/peek.ts +167 -0
- package/extensions/chassis/prompt-row.ts +86 -0
- package/extensions/chassis/provider-view.ts +118 -0
- package/extensions/chassis/provider.ts +115 -0
- package/extensions/chassis/readonly-command.ts +440 -0
- package/extensions/chassis/sanitize.ts +108 -0
- package/extensions/chassis/session-info.ts +127 -0
- package/extensions/chassis/sidebar.ts +285 -0
- package/extensions/chassis/state.ts +50 -0
- package/extensions/chassis/telemetry.ts +222 -0
- package/extensions/chassis/tools.ts +618 -0
- package/extensions/chassis/version.ts +20 -0
- package/extensions/diff/git-diff.ts +365 -0
- package/extensions/diff/host.ts +58 -0
- package/extensions/diff/index.ts +101 -0
- package/extensions/diff/render.ts +217 -0
- package/extensions/diff/types.ts +67 -0
- package/extensions/diff/view.ts +523 -0
- package/extensions/git/flows.ts +485 -0
- package/extensions/git/forge.ts +54 -0
- package/extensions/git/index.ts +117 -0
- package/extensions/git/repo.ts +200 -0
- package/extensions/git/run.ts +143 -0
- package/extensions/git/settings.ts +38 -0
- package/extensions/git/text.ts +133 -0
- package/extensions/widgets/container/detect.ts +81 -0
- package/extensions/widgets/container/index.ts +29 -0
- package/extensions/widgets/mcp/index.ts +53 -0
- package/extensions/widgets/mcp/status.ts +94 -0
- package/extensions/widgets/session-todos/index.ts +55 -0
- package/extensions/widgets/subagents/index.ts +170 -0
- package/extensions/widgets/subagents/status.ts +237 -0
- package/extensions/widgets/todo/index.ts +66 -0
- package/extensions/widgets/todo/parse.ts +119 -0
- package/package.json +91 -4
- package/scripts/link-pi.mjs +76 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** Reading a repository: status, branches, remotes. Nothing here changes anything. */
|
|
2
|
+
|
|
3
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { git, gitOut } from "./run.ts";
|
|
6
|
+
import { plainText } from "./text.ts";
|
|
7
|
+
|
|
8
|
+
export interface RepoStatus {
|
|
9
|
+
branch?: string;
|
|
10
|
+
/** The tracked branch ("origin/feat"); absent when there is none or it no longer exists. */
|
|
11
|
+
upstream?: string;
|
|
12
|
+
/** The tracked branch's name when it was deleted on the remote (git status says "[gone]"). */
|
|
13
|
+
gone?: string;
|
|
14
|
+
ahead: number;
|
|
15
|
+
behind: number;
|
|
16
|
+
staged: number;
|
|
17
|
+
unstaged: number;
|
|
18
|
+
untracked: number;
|
|
19
|
+
conflicted: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parse `git status --porcelain=v1 --branch`. */
|
|
23
|
+
export function parseStatus(out: string): RepoStatus {
|
|
24
|
+
const s: RepoStatus = { ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
|
|
25
|
+
for (const line of out.split("\n")) {
|
|
26
|
+
if (line.startsWith("## ")) {
|
|
27
|
+
const b = line.slice(3);
|
|
28
|
+
if (b.startsWith("HEAD (no branch)")) continue;
|
|
29
|
+
const [names = "", rest = ""] = b.split(/ \[/, 2);
|
|
30
|
+
const [local = "", upstream] = names.replace(/^No commits yet on /, "").split("...");
|
|
31
|
+
s.branch = local || undefined;
|
|
32
|
+
if (upstream && /\bgone\b/.test(rest)) s.gone = upstream;
|
|
33
|
+
else s.upstream = upstream || undefined;
|
|
34
|
+
s.ahead = Number(rest.match(/ahead (\d+)/)?.[1] ?? 0);
|
|
35
|
+
s.behind = Number(rest.match(/behind (\d+)/)?.[1] ?? 0);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (line.length < 3) continue;
|
|
39
|
+
const x = line[0]!;
|
|
40
|
+
const y = line[1]!;
|
|
41
|
+
if (x === "?" && y === "?") s.untracked++;
|
|
42
|
+
else if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) s.conflicted++;
|
|
43
|
+
else {
|
|
44
|
+
if (x !== " " && x !== "!") s.staged++;
|
|
45
|
+
if (y !== " " && y !== "!") s.unstaged++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The paths `git add -A` would stage, as git status lists them (one per file, also inside new
|
|
53
|
+
* directories), for showing before anything is staged. Undefined when git could not say.
|
|
54
|
+
*/
|
|
55
|
+
export async function changedPaths(cwd: string): Promise<string[] | undefined> {
|
|
56
|
+
const r = await git(cwd, ["--no-optional-locks", "-c", "core.quotepath=off", "status", "--porcelain=v1", "-z", "-uall"]);
|
|
57
|
+
if (r.code !== 0) return undefined;
|
|
58
|
+
const parts = r.stdout.split("\0");
|
|
59
|
+
const paths: string[] = [];
|
|
60
|
+
for (let i = 0; i < parts.length; i++) {
|
|
61
|
+
const p = parts[i]!;
|
|
62
|
+
if (p.length < 4) continue;
|
|
63
|
+
paths.push(plainText(p.slice(3)));
|
|
64
|
+
if (p[0] === "R" || p[0] === "C" || p[1] === "R" || p[1] === "C") i++; // skip the rename's origin
|
|
65
|
+
}
|
|
66
|
+
return paths;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A saved copy of the index: call `staged` after your own staging, and `restore` to undo it. */
|
|
70
|
+
export interface IndexSnapshot {
|
|
71
|
+
/** Note what the index holds after the staging, so `restore` can tell if anything else changed it since. */
|
|
72
|
+
staged(): Promise<void>;
|
|
73
|
+
/** Put the index back as it was. Resolves false, leaving it alone, when something else changed it since `staged`. */
|
|
74
|
+
restore(): Promise<boolean>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Save the index (the staging area) so a command that stages files can undo that if it is
|
|
79
|
+
* dropped. The index file itself is copied, because `git write-tree` and `read-tree` would lose
|
|
80
|
+
* entries added with `git add -N`. It is put back with git's own lock file (`index.lock`), so it
|
|
81
|
+
* cannot collide with a git command running at the same time. Undefined when it cannot be read.
|
|
82
|
+
*/
|
|
83
|
+
export async function saveIndex(cwd: string): Promise<IndexSnapshot | undefined> {
|
|
84
|
+
const where = await gitOut(cwd, ["rev-parse", "--git-path", "index"]);
|
|
85
|
+
if (!where) return undefined;
|
|
86
|
+
const path = resolve(cwd, where);
|
|
87
|
+
const before = await readFile(path).catch(() => undefined); // a new repository has no index file yet
|
|
88
|
+
/** What the index holds, ignoring the file-stat data git refreshes on its own. */
|
|
89
|
+
const entries = async (): Promise<string | undefined> => {
|
|
90
|
+
const r = await git(cwd, ["ls-files", "--stage", "-z"]);
|
|
91
|
+
return r.code === 0 ? r.stdout : undefined;
|
|
92
|
+
};
|
|
93
|
+
let after: string | undefined;
|
|
94
|
+
return {
|
|
95
|
+
staged: async () => void (after = await entries()),
|
|
96
|
+
restore: async () => {
|
|
97
|
+
// Take git's lock first, so nothing can stage between the check and the swap.
|
|
98
|
+
const lock = `${path}.lock`;
|
|
99
|
+
try {
|
|
100
|
+
await writeFile(lock, before ?? "", { flag: "wx" });
|
|
101
|
+
} catch {
|
|
102
|
+
return false; // another git command holds the lock
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const now = await entries();
|
|
106
|
+
if (now === undefined || now !== after) {
|
|
107
|
+
await rm(lock, { force: true });
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (before) await rename(lock, path);
|
|
111
|
+
else {
|
|
112
|
+
await rm(path, { force: true });
|
|
113
|
+
await rm(lock, { force: true });
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
await rm(lock, { force: true }).catch(() => {});
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function repoStatus(cwd: string): Promise<RepoStatus | undefined> {
|
|
125
|
+
const r = await git(cwd, ["--no-optional-locks", "status", "--porcelain=v1", "--branch"]);
|
|
126
|
+
return r.code === 0 ? parseStatus(r.stdout) : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The current branch, or undefined when HEAD is detached. */
|
|
130
|
+
export async function currentBranch(cwd: string): Promise<string | undefined> {
|
|
131
|
+
return (await gitOut(cwd, ["branch", "--show-current"])) || undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function refExists(cwd: string, ref: string): Promise<boolean> {
|
|
135
|
+
return (await git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])).code === 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The remote a branch publishes to: its upstream's remote, else origin, else the first remote. */
|
|
139
|
+
export async function remoteName(cwd: string, upstream?: string): Promise<string | undefined> {
|
|
140
|
+
const names = (await gitOut(cwd, ["remote"]))?.split("\n").filter(Boolean) ?? [];
|
|
141
|
+
if (!names.length) return undefined;
|
|
142
|
+
const fromUpstream = upstream ? names.find((n) => upstream.startsWith(`${n}/`)) : undefined;
|
|
143
|
+
return fromUpstream ?? (names.includes("origin") ? "origin" : names[0]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Branch names tried, in this order, when the remote does not say which one is its default. */
|
|
147
|
+
const COMMON_BASES = ["main", "master"];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The branch changes are normally merged into: the remote's HEAD (so a repository whose default is
|
|
151
|
+
* develop or trunk works), else main or master on the remote, else main or master locally.
|
|
152
|
+
*/
|
|
153
|
+
export async function defaultBranch(cwd: string, remote = "origin"): Promise<string | undefined> {
|
|
154
|
+
const head = await gitOut(cwd, ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`]);
|
|
155
|
+
const prefix = `${remote}/`;
|
|
156
|
+
if (head) return head.startsWith(prefix) ? head.slice(prefix.length) : head;
|
|
157
|
+
for (const name of COMMON_BASES) if (await refExists(cwd, `refs/remotes/${remote}/${name}`)) return name;
|
|
158
|
+
for (const name of COMMON_BASES) if (await refExists(cwd, `refs/heads/${name}`)) return name;
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The ref to compare with for a base branch: the remote's copy when there is one (a local copy can be stale), else the local branch. */
|
|
163
|
+
export async function compareRef(cwd: string, remote: string, base: string): Promise<string | undefined> {
|
|
164
|
+
if (await refExists(cwd, `refs/remotes/${remote}/${base}`)) return `${remote}/${base}`;
|
|
165
|
+
return (await refExists(cwd, `refs/heads/${base}`)) ? base : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The remote and branch a local branch tracks, from its git config; undefined when it tracks nothing. */
|
|
169
|
+
export async function trackedBranch(cwd: string, branch: string): Promise<{ remote: string; branch: string } | undefined> {
|
|
170
|
+
const [remote, merge] = await Promise.all([gitOut(cwd, ["config", "--get", `branch.${branch}.remote`]), gitOut(cwd, ["config", "--get", `branch.${branch}.merge`])]);
|
|
171
|
+
if (!remote || !merge) return undefined;
|
|
172
|
+
return { remote, branch: merge.replace(/^refs\/heads\//, "") };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Commits on each side of `HEAD` and `ref`: how many only HEAD has (ahead) and only `ref` has (behind). */
|
|
176
|
+
export async function aheadBehind(cwd: string, ref: string): Promise<{ ahead: number; behind: number } | undefined> {
|
|
177
|
+
const out = await gitOut(cwd, ["rev-list", "--left-right", "--count", `HEAD...${ref}`]);
|
|
178
|
+
const [ahead, behind] = out?.split(/\s+/).map(Number) ?? [];
|
|
179
|
+
return Number.isFinite(ahead) && Number.isFinite(behind) ? { ahead: ahead!, behind: behind! } : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The host of a remote URL: https, ssh:// and scp-style (git@host:path) forms. */
|
|
183
|
+
export function remoteHost(url: string): string | undefined {
|
|
184
|
+
const u = url.trim();
|
|
185
|
+
const scheme = u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
|
|
186
|
+
if (scheme) return scheme[1]!.toLowerCase();
|
|
187
|
+
const scp = u.match(/^(?:[^@/]+@)?([^:/]+):(?!\/\/)/);
|
|
188
|
+
return scp ? scp[1]!.toLowerCase() : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Local branch names other than `except`, most recently used first. */
|
|
192
|
+
export async function localBranches(cwd: string, except?: string): Promise<string[]> {
|
|
193
|
+
const out = await gitOut(cwd, ["branch", "--sort=-committerdate", "--format=%(refname:short)"]);
|
|
194
|
+
return (out?.split("\n").filter(Boolean) ?? []).filter((b) => b !== except && !b.startsWith("("));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True when `cwd` is inside a git work tree. */
|
|
198
|
+
export async function isRepo(cwd: string): Promise<boolean> {
|
|
199
|
+
return (await gitOut(cwd, ["rev-parse", "--is-inside-work-tree"])) === "true";
|
|
200
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a program without a shell: arguments are an array, so branch names, messages and paths never
|
|
3
|
+
* need quoting and cannot inject anything. Never throws: a failure is a non-zero `code`.
|
|
4
|
+
*
|
|
5
|
+
* Programs run with no way to ask you anything. A child that keeps pi's controlling terminal can
|
|
6
|
+
* open /dev/tty and draw an ssh passphrase, credential or gpg prompt over pi's screen, then wait
|
|
7
|
+
* for a key pi will never forward. So on POSIX the child gets its own session (no controlling
|
|
8
|
+
* terminal), git is told not to prompt, and a prompt fails at once with a message instead of
|
|
9
|
+
* hanging until the timeout. stdin stays a pipe, so `commit -F -` and `gh --body-file -` work.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
13
|
+
import { AUTH_HINT, looksLikeAuthFailure, plainText } from "./text.ts";
|
|
14
|
+
|
|
15
|
+
export interface RunResult {
|
|
16
|
+
code: number;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RunOptions {
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Written to the program's stdin. */
|
|
24
|
+
input?: string;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Exit code reported when the program had to be stopped for running too long (as timeout(1) does). */
|
|
29
|
+
export const TIMED_OUT = 124;
|
|
30
|
+
const KILL_GRACE_MS = 2000;
|
|
31
|
+
const MAX_OUTPUT = 32 << 20;
|
|
32
|
+
|
|
33
|
+
/** The environment for a child: prompts off, and no pointer to pi's terminal for gpg to draw on. */
|
|
34
|
+
function childEnv(extra: Record<string, string> | undefined): NodeJS.ProcessEnv {
|
|
35
|
+
const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never", ...extra };
|
|
36
|
+
delete env.GPG_TTY;
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Children run detached (their own process group, no controlling terminal), so they would not get
|
|
42
|
+
* the terminal's signals if pi is closed. Track them and stop their groups when pi exits.
|
|
43
|
+
*/
|
|
44
|
+
const live = new Set<ChildProcess>();
|
|
45
|
+
let exitHookInstalled = false;
|
|
46
|
+
function track(child: ChildProcess, group: boolean): void {
|
|
47
|
+
if (!group) return;
|
|
48
|
+
live.add(child);
|
|
49
|
+
child.once("close", () => live.delete(child));
|
|
50
|
+
if (exitHookInstalled) return;
|
|
51
|
+
exitHookInstalled = true;
|
|
52
|
+
process.once("exit", () => {
|
|
53
|
+
for (const c of live) stop(c, "SIGTERM", true);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Stop the child and anything it started: a hook or credential helper can have children of its own. */
|
|
58
|
+
function stop(child: ChildProcess, signal: NodeJS.Signals, group: boolean): void {
|
|
59
|
+
try {
|
|
60
|
+
if (group && child.pid) process.kill(-child.pid, signal);
|
|
61
|
+
else child.kill(signal);
|
|
62
|
+
} catch {
|
|
63
|
+
// already gone
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function run(cmd: string, args: string[], cwd: string, opts: RunOptions = {}): Promise<RunResult> {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
70
|
+
const posix = process.platform !== "win32";
|
|
71
|
+
const out: Buffer[] = [];
|
|
72
|
+
const err: Buffer[] = [];
|
|
73
|
+
let size = 0;
|
|
74
|
+
let tooBig = false;
|
|
75
|
+
let timedOut = false;
|
|
76
|
+
let spawnError: NodeJS.ErrnoException | undefined;
|
|
77
|
+
let timer: NodeJS.Timeout | undefined;
|
|
78
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
79
|
+
|
|
80
|
+
const child = spawn(cmd, args, { cwd, env: childEnv(opts.env), detached: posix, stdio: ["pipe", "pipe", "pipe"] });
|
|
81
|
+
track(child, posix);
|
|
82
|
+
const collect = (into: Buffer[]) => (chunk: Buffer) => {
|
|
83
|
+
size += chunk.length;
|
|
84
|
+
if (size > MAX_OUTPUT) {
|
|
85
|
+
tooBig = true;
|
|
86
|
+
return stop(child, "SIGKILL", posix);
|
|
87
|
+
}
|
|
88
|
+
into.push(chunk);
|
|
89
|
+
};
|
|
90
|
+
child.stdout.on("data", collect(out));
|
|
91
|
+
child.stderr.on("data", collect(err));
|
|
92
|
+
child.on("error", (e) => void (spawnError = e));
|
|
93
|
+
child.on("close", (code, signal) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
clearTimeout(killTimer);
|
|
96
|
+
const stdout = Buffer.concat(out).toString("utf8");
|
|
97
|
+
const stderr = Buffer.concat(err).toString("utf8");
|
|
98
|
+
if (spawnError) {
|
|
99
|
+
const missing = spawnError.code === "ENOENT";
|
|
100
|
+
return resolve({ code: missing ? 127 : 1, stdout, stderr: missing ? `${cmd}: command not found` : spawnError.message });
|
|
101
|
+
}
|
|
102
|
+
if (timedOut) {
|
|
103
|
+
const note = `${cmd} did not finish in ${Math.round(timeoutMs / 1000)} seconds and was stopped.`;
|
|
104
|
+
return resolve({ code: TIMED_OUT, stdout, stderr: stderr ? `${stderr.trimEnd()}\n${note}` : note });
|
|
105
|
+
}
|
|
106
|
+
if (tooBig) return resolve({ code: 1, stdout, stderr: `${cmd} printed more than ${MAX_OUTPUT >> 20} MB and was stopped.` });
|
|
107
|
+
if (code === 0) return resolve({ code: 0, stdout, stderr });
|
|
108
|
+
resolve({ code: code ?? 1, stdout, stderr: stderr || (signal ? `${cmd} was stopped by ${signal}` : `${cmd} failed`) });
|
|
109
|
+
});
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
timedOut = true;
|
|
112
|
+
stop(child, "SIGTERM", posix);
|
|
113
|
+
killTimer = setTimeout(() => stop(child, "SIGKILL", posix), KILL_GRACE_MS);
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
child.stdin.on("error", () => {});
|
|
116
|
+
child.stdin.end(opts.input ?? "");
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Flags for a git diff whose output is parsed or sent to the model: no colour, no external diff
|
|
122
|
+
* program, and a/ b/ prefixes whatever diff.noprefix or diff.mnemonicPrefix says.
|
|
123
|
+
*/
|
|
124
|
+
export const PLAIN_DIFF = ["--no-color", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
125
|
+
|
|
126
|
+
export const git = (cwd: string, args: string[], opts?: RunOptions): Promise<RunResult> => run("git", args, cwd, opts);
|
|
127
|
+
|
|
128
|
+
/** stdout of a git command, trimmed; undefined when it failed. */
|
|
129
|
+
export async function gitOut(cwd: string, args: string[]): Promise<string | undefined> {
|
|
130
|
+
const r = await git(cwd, args);
|
|
131
|
+
return r.code === 0 ? r.stdout.trim() : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The useful part of a failed command, for a notification. Output from git, hooks and gh is
|
|
136
|
+
* cleaned of escape sequences first, so it cannot move the cursor or retitle the window, and a
|
|
137
|
+
* failure that reads like a missing sign-in gets a one-line hint.
|
|
138
|
+
*/
|
|
139
|
+
export function failure(r: RunResult, max = 1200): string {
|
|
140
|
+
const text = plainText(r.stderr.trim() || r.stdout.trim() || `exit ${r.code}`).trim() || `exit ${r.code}`;
|
|
141
|
+
const shown = text.length > max ? `${text.slice(0, max)}…` : text;
|
|
142
|
+
return looksLikeAuthFailure(text) ? `${shown}\n${AUTH_HINT}` : shown;
|
|
143
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The on/off switch for the git commands, saved in chassis's state file under `git: { enabled }`
|
|
3
|
+
* next to the other chassis settings. It is read on each use, so `/git off` takes effect at once and
|
|
4
|
+
* in every running session. Anything missing or malformed means on.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { statePath } from "../chassis/api.ts";
|
|
10
|
+
|
|
11
|
+
export function loadGitEnabled(path = statePath()): boolean {
|
|
12
|
+
try {
|
|
13
|
+
const enabled = JSON.parse(readFileSync(path, "utf8"))?.git?.enabled;
|
|
14
|
+
return typeof enabled === "boolean" ? enabled : true;
|
|
15
|
+
} catch {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Save into the state file, keeping its other keys. Returns false if it could not be written. */
|
|
21
|
+
export function saveGitEnabled(enabled: boolean, path = statePath()): boolean {
|
|
22
|
+
try {
|
|
23
|
+
let data: Record<string, unknown> = {};
|
|
24
|
+
if (existsSync(path)) {
|
|
25
|
+
try {
|
|
26
|
+
data = JSON.parse(readFileSync(path, "utf8")) ?? {};
|
|
27
|
+
} catch {
|
|
28
|
+
data = {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
data.git = { enabled };
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure text side of the git tools: the prompts sent to the model and cleaning what comes back.
|
|
3
|
+
* No I/O, so it is tested directly.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { stripEscapes } from "../chassis/sanitize.ts";
|
|
7
|
+
|
|
8
|
+
export const MAX_DIFF_CHARS = 30_000;
|
|
9
|
+
|
|
10
|
+
/** Cut a diff to `max` characters at a line boundary and say so, so the model knows it is partial. */
|
|
11
|
+
export function truncateDiff(diff: string, max = MAX_DIFF_CHARS): string {
|
|
12
|
+
if (diff.length <= max) return diff;
|
|
13
|
+
const cut = diff.slice(0, max);
|
|
14
|
+
const lastNl = cut.lastIndexOf("\n");
|
|
15
|
+
return `${lastNl > 0 ? cut.slice(0, lastNl) : cut}\n\n[diff truncated: ${diff.length - max} more characters not shown]`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COMMIT_SYSTEM = [
|
|
19
|
+
"You write git commit messages.",
|
|
20
|
+
"Reply with the commit message only: no code fences, no preamble, no quotes around it.",
|
|
21
|
+
"First line: an imperative summary of 72 characters or fewer, no trailing period.",
|
|
22
|
+
"If the change needs explanation, add a blank line and a short body that says why, wrapped at about 72 columns.",
|
|
23
|
+
"Follow the style of the recent commits when one is evident (for example conventional commit prefixes), otherwise plain sentences.",
|
|
24
|
+
"Describe only what the diff shows. Do not invent reasons, ticket numbers or features.",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
export interface CommitPromptInput {
|
|
28
|
+
branch?: string;
|
|
29
|
+
stat: string;
|
|
30
|
+
diff: string;
|
|
31
|
+
recent: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function commitPrompt(i: CommitPromptInput): { system: string; user: string } {
|
|
35
|
+
const parts = [
|
|
36
|
+
i.branch ? `Branch: ${i.branch}` : "",
|
|
37
|
+
i.recent.length ? `Recent commit subjects (for style):\n${i.recent.map((s) => `- ${s}`).join("\n")}` : "",
|
|
38
|
+
`Staged changes (summary):\n${i.stat.trim()}`,
|
|
39
|
+
`Staged diff:\n${truncateDiff(i.diff)}`,
|
|
40
|
+
];
|
|
41
|
+
return { system: COMMIT_SYSTEM, user: parts.filter(Boolean).join("\n\n") };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PR_SYSTEM = [
|
|
45
|
+
"You write pull request titles and descriptions.",
|
|
46
|
+
"Reply with the title on the first line, a blank line, then the description in Markdown. No code fences around the whole reply, no preamble.",
|
|
47
|
+
"Title: imperative, 72 characters or fewer, no trailing period.",
|
|
48
|
+
"Description: what changed and why, grouped sensibly, short. A brief bullet list is fine. Mention anything a reviewer should check or any risk visible in the diff.",
|
|
49
|
+
"Describe only what the commits and diff show. Do not invent testing that was not shown, ticket numbers or features.",
|
|
50
|
+
].join("\n");
|
|
51
|
+
|
|
52
|
+
export interface PrPromptInput {
|
|
53
|
+
base: string;
|
|
54
|
+
branch: string;
|
|
55
|
+
commits: string[];
|
|
56
|
+
stat: string;
|
|
57
|
+
diff: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function prPrompt(i: PrPromptInput): { system: string; user: string } {
|
|
61
|
+
const parts = [
|
|
62
|
+
`Merging ${i.branch} into ${i.base}.`,
|
|
63
|
+
`Commits (${i.commits.length}):\n${i.commits.map((c) => `- ${c}`).join("\n")}`,
|
|
64
|
+
`Files changed:\n${i.stat.trim()}`,
|
|
65
|
+
`Diff:\n${truncateDiff(i.diff)}`,
|
|
66
|
+
];
|
|
67
|
+
return { system: PR_SYSTEM, user: parts.join("\n\n") };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Strip what models add around a message: fences, a "Commit message:" label, wrapping quotes. */
|
|
71
|
+
export function cleanMessage(raw: string): string {
|
|
72
|
+
let t = raw.replace(/\r/g, "").trim();
|
|
73
|
+
const fenced = t.match(/^```[\w-]*\n([\s\S]*?)\n```$/);
|
|
74
|
+
if (fenced) t = fenced[1]!.trim();
|
|
75
|
+
t = t.replace(/^(?:here(?:'s| is)[^\n:]*:|(?:commit message|message|title)\s*:)\s*/i, "").trim();
|
|
76
|
+
if (/^(["'`]).*\1$/s.test(t) && !t.includes("\n")) t = t.slice(1, -1).trim();
|
|
77
|
+
return t;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A PR draft as one editable text: the title, a blank line, the body. */
|
|
81
|
+
export function splitPr(text: string): { title: string; body: string } {
|
|
82
|
+
const lines = cleanMessage(text).split("\n");
|
|
83
|
+
const first = lines.findIndex((l) => l.trim());
|
|
84
|
+
if (first < 0) return { title: "", body: "" };
|
|
85
|
+
const title = lines[first]!.replace(/^#+\s*/, "").replace(/^title\s*:\s*/i, "").trim();
|
|
86
|
+
return { title, body: lines.slice(first + 1).join("\n").trim() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The first line of a message. */
|
|
90
|
+
export const subject = (message: string): string => message.split("\n", 1)[0]?.trim() ?? "";
|
|
91
|
+
|
|
92
|
+
/** Arguments of /pr: an optional base branch and a --draft flag, in any order. */
|
|
93
|
+
export function parsePrArgs(args: string): { base?: string; draft: boolean } {
|
|
94
|
+
let draft = false;
|
|
95
|
+
let base: string | undefined;
|
|
96
|
+
for (const word of args.split(/\s+/).filter(Boolean)) {
|
|
97
|
+
if (word === "--draft" || word === "-d") draft = true;
|
|
98
|
+
else if (!word.startsWith("-")) base ??= word;
|
|
99
|
+
}
|
|
100
|
+
return { base, draft };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Command output made safe to show in a notification: escape sequences (colours, cursor moves,
|
|
105
|
+
* window titles) and other control characters are dropped, a tab becomes spaces, and a line that
|
|
106
|
+
* was redrawn with carriage returns (a progress bar) keeps only its last version. Newlines stay.
|
|
107
|
+
*/
|
|
108
|
+
export function plainText(raw: string): string {
|
|
109
|
+
const withoutEscapes = stripEscapes(raw);
|
|
110
|
+
return withoutEscapes
|
|
111
|
+
.replace(/\r\n/g, "\n")
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((line) => line.split("\r").reverse().find((part) => part !== "") ?? "")
|
|
114
|
+
.join("\n")
|
|
115
|
+
.replace(/\t/g, " ")
|
|
116
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]/g, "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const AUTH_FAILURE = [
|
|
120
|
+
/could not read (?:username|password)/i,
|
|
121
|
+
/terminal prompts disabled/i,
|
|
122
|
+
/authentication (?:failed|required)|requires authentication|invalid username or password/i,
|
|
123
|
+
/permission denied \(publickey/i,
|
|
124
|
+
/enter passphrase|incorrect passphrase/i,
|
|
125
|
+
/(?:HTTP|error:?|status)\s*40[13]\b/i,
|
|
126
|
+
/gpg failed to sign|pinentry|no secret key/i,
|
|
127
|
+
/not logged in|(?:gh|glab) auth login/i,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/** True when command output reads like a missing sign-in, key passphrase or gpg unlock. */
|
|
131
|
+
export const looksLikeAuthFailure = (text: string): boolean => AUTH_FAILURE.some((re) => re.test(text));
|
|
132
|
+
|
|
133
|
+
export const AUTH_HINT = "Hint: sign in with your credential helper, or unlock your key in ssh-agent or gpg-agent, from another terminal first, then try again.";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container detection (no pi imports, so tests run standalone).
|
|
3
|
+
* Moved from ~/.pi/agent/extensions/container-status.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { hostname } from "node:os";
|
|
8
|
+
|
|
9
|
+
type Env = Record<string, string | undefined>;
|
|
10
|
+
|
|
11
|
+
export interface ContainerInfo {
|
|
12
|
+
/** docker | podman | devcontainer | codespace | kubernetes | container */
|
|
13
|
+
kind: string;
|
|
14
|
+
/** Best available human name: devcontainer/codespace/pod name, else the hostname. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** name came from an explicit label (DEVCONTAINER_NAME): show it without a kind prefix. */
|
|
17
|
+
named?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Probe {
|
|
21
|
+
env: Env;
|
|
22
|
+
exists: (path: string) => boolean;
|
|
23
|
+
read: (path: string) => string | undefined;
|
|
24
|
+
hostname: () => string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const realProbe: Probe = {
|
|
28
|
+
env: process.env,
|
|
29
|
+
exists: (p) => existsSync(p),
|
|
30
|
+
read: (p) => {
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(p, "utf8");
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
hostname: () => hostname(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Podman writes `name="..."` into /run/.containerenv (rootful; often empty when rootless). */
|
|
41
|
+
function podmanName(text: string | undefined): string | undefined {
|
|
42
|
+
const m = text?.match(/^name="([^"]*)"/m);
|
|
43
|
+
return m?.[1] || undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Container runtime evidence in cgroup/mount tables (catches runtimes without marker files). */
|
|
47
|
+
function cgroupRuntime(p: Probe): string | undefined {
|
|
48
|
+
const text = `${p.read("/proc/1/cgroup") ?? ""}\n${p.read("/proc/self/mountinfo") ?? ""}`;
|
|
49
|
+
if (/kubepods/.test(text)) return "kubernetes";
|
|
50
|
+
if (/\/docker\/containers\/|\/docker[-/][0-9a-f]{12,}/.test(text)) return "docker";
|
|
51
|
+
if (/\/containers\/storage\/|libpod/.test(text)) return "podman";
|
|
52
|
+
if (/containerd|\/lxc\//.test(text)) return "container";
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function detectContainer(p: Probe = realProbe): ContainerInfo | undefined {
|
|
57
|
+
const e = p.env;
|
|
58
|
+
const host = p.hostname().split(".")[0] || "container";
|
|
59
|
+
|
|
60
|
+
// Devcontainers / Codespaces first: they are containers too, and have better names.
|
|
61
|
+
if (e.CODESPACES === "true") return { kind: "codespace", name: e.CODESPACE_NAME || host };
|
|
62
|
+
if (e.REMOTE_CONTAINERS === "true" || e.DEVCONTAINER === "true" || e.REMOTE_CONTAINERS_IPC) {
|
|
63
|
+
// An explicit DEVCONTAINER_NAME (set by a devcontainer launcher) is shown as-is, without the kind prefix.
|
|
64
|
+
if (e.DEVCONTAINER_NAME) return { kind: "devcontainer", name: e.DEVCONTAINER_NAME, named: true };
|
|
65
|
+
return { kind: "devcontainer", name: e.DEVCONTAINER_ID || host };
|
|
66
|
+
}
|
|
67
|
+
if (e.KUBERNETES_SERVICE_HOST) return { kind: "kubernetes", name: host };
|
|
68
|
+
if (p.exists("/run/.containerenv") || e.container === "podman") {
|
|
69
|
+
return { kind: "podman", name: podmanName(p.read("/run/.containerenv")) || host };
|
|
70
|
+
}
|
|
71
|
+
if (p.exists("/.dockerenv") || e.container === "docker") return { kind: "docker", name: host };
|
|
72
|
+
if (e.container) return { kind: e.container, name: host };
|
|
73
|
+
const cg = cgroupRuntime(p);
|
|
74
|
+
if (cg) return { kind: cg, name: host };
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Label for a container: the kind is prefixed unless the name already says it. */
|
|
79
|
+
export function containerLabel(c: ContainerInfo): string {
|
|
80
|
+
return c.named || c.name.toLowerCase().includes(c.kind) ? c.name : `${c.kind}:${c.name}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container widget: shows when pi itself runs inside a container, devcontainer, Codespace or
|
|
3
|
+
* Kubernetes pod (first footer segment, sidebar "Host" row). On the workstation it shows nothing.
|
|
4
|
+
*
|
|
5
|
+
* Detection is pi's own process only: a container reached through `docker exec` from a pi running
|
|
6
|
+
* on the host is not detected, because pi is still on the host.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { registerWidget } from "../../chassis/api.ts";
|
|
11
|
+
import { type IconLike, ICONS } from "../../chassis/icons.ts";
|
|
12
|
+
import { containerLabel, detectContainer } from "./detect.ts";
|
|
13
|
+
|
|
14
|
+
// Nerd Font glyphs: md-docker, md-kubernetes.
|
|
15
|
+
const ICON_CONTAINER: IconLike = { nerd: ICONS.widgetContainer.nerd, plain: ICONS.widgetContainer.plain };
|
|
16
|
+
const ICON_K8S: IconLike = { nerd: ICONS.widgetK8s.nerd, plain: ICONS.widgetK8s.plain };
|
|
17
|
+
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
// Can't change during a process's life, so detect once.
|
|
20
|
+
const info = detectContainer();
|
|
21
|
+
if (!info) return;
|
|
22
|
+
registerWidget(pi, {
|
|
23
|
+
id: "container",
|
|
24
|
+
title: "Host",
|
|
25
|
+
order: 0,
|
|
26
|
+
render: () => ({ icon: info.kind === "kubernetes" ? ICON_K8S : ICON_CONTAINER, text: containerLabel(info), color: "accent" }),
|
|
27
|
+
detail: () => [info.kind],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP widget: "<plug> MCP 6/6" = connected/enabled servers for pi's built-in MCP support.
|
|
3
|
+
* Green when all are connected, yellow when some are, red when none are; hidden when no server is
|
|
4
|
+
* enabled. See status.ts for how "connected" is inferred and its limits; /mcp is authoritative.
|
|
5
|
+
*
|
|
6
|
+
* There is no event for MCP connection changes, so it polls (an in-memory tool list and two small
|
|
7
|
+
* JSON reads). It deliberately does not handle `mcp_servers_change`: handling that event marks an
|
|
8
|
+
* extension as the one that connects registered servers, taking that job from the built-in support.
|
|
9
|
+
*
|
|
10
|
+
* Tests: node --experimental-strip-types test/mcp-status.test.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { registerWidget } from "../../chassis/api.ts";
|
|
17
|
+
import { type IconLike, ICONS } from "../../chassis/icons.ts";
|
|
18
|
+
import { connectedServers, enabledServers, formatStatus, type McpStatus, parseMcpServers, summarize } from "./status.ts";
|
|
19
|
+
|
|
20
|
+
const POLL_MS = 2000;
|
|
21
|
+
// Nerd Font "md-power-plug" (U+F06A5).
|
|
22
|
+
const ICON: IconLike = { nerd: ICONS.widgetMcp.nerd, plain: ICONS.widgetMcp.plain };
|
|
23
|
+
|
|
24
|
+
function readText(path: string): string | undefined {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(path, "utf8");
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function (pi: ExtensionAPI) {
|
|
33
|
+
let status: McpStatus | undefined;
|
|
34
|
+
|
|
35
|
+
registerWidget(pi, {
|
|
36
|
+
id: "mcp",
|
|
37
|
+
title: "MCP",
|
|
38
|
+
order: 30,
|
|
39
|
+
refreshMs: POLL_MS,
|
|
40
|
+
update: (ctx) => {
|
|
41
|
+
const global = parseMcpServers(readText(join(getAgentDir(), "mcp.json")));
|
|
42
|
+
const project = ctx.isProjectTrusted() ? parseMcpServers(readText(join(ctx.cwd, ".pi", "mcp.json"))) : {};
|
|
43
|
+
const enabled = enabledServers(global, project, pi.getMcpServers());
|
|
44
|
+
status = summarize(enabled, connectedServers(enabled, pi.getAllTools()));
|
|
45
|
+
},
|
|
46
|
+
render: () => {
|
|
47
|
+
if (!status) return undefined;
|
|
48
|
+
const level = status.level === "ok" ? "ok" : status.level === "partial" ? "warn" : "error";
|
|
49
|
+
return { icon: ICON, label: "MCP", text: formatStatus(status), level };
|
|
50
|
+
},
|
|
51
|
+
detail: () => (status?.pending.length ? status.pending.map((s) => `○ ${s} not connected`) : undefined),
|
|
52
|
+
});
|
|
53
|
+
}
|