@preapexis/pi-kit 1.0.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/.github/workflows/publish.yml +30 -0
- package/.pi/settings.json +3 -0
- package/AGENTS.md +199 -0
- package/CHANGELOG.md +71 -0
- package/LICENSE +15 -0
- package/README.md +370 -0
- package/extensions/brand-ui.ts +107 -0
- package/extensions/git-guard.ts +134 -0
- package/extensions/prompts.ts +53 -0
- package/extensions/safety.ts +242 -0
- package/extensions/sound-cues.ts +88 -0
- package/extensions/status.ts +86 -0
- package/extensions/update.ts +245 -0
- package/extensions/usage-tracker.ts +154 -0
- package/package.json +41 -0
- package/prompts/commit.md +79 -0
- package/prompts/implement.md +41 -0
- package/prompts/init.md +98 -0
- package/prompts/plan.md +142 -0
- package/prompts/review-safe.md +78 -0
- package/prompts/save-plan.md +65 -0
- package/prompts/security.md +100 -0
- package/settings.json +4 -0
- package/skills/component-implementation/SKILL.md +80 -0
- package/skills/frontend-onboarding/SKILL.md +76 -0
- package/skills/frontend-quality/SKILL.md +85 -0
- package/skills/safe-coding/SKILL.md +48 -0
- package/tests/extensions.test.ts +49 -0
- package/tests/helpers.ts +104 -0
- package/tests/package.test.ts +38 -0
- package/tests/prompts.test.ts +40 -0
- package/tests/skills.test.ts +42 -0
- package/tests/themes.test.ts +49 -0
- package/themes/latte-review.json +77 -0
- package/themes/neon-guardian.json +77 -0
- package/themes/safe-dark.json +75 -0
- package/themes/tokyo-midnight.json +77 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type EventContext = Parameters<Parameters<ExtensionAPI["on"]>[1]>[1];
|
|
4
|
+
|
|
5
|
+
const PREAPEXIS_ART = [
|
|
6
|
+
"██████╗ ██████╗ ███████╗ █████╗ ██████╗ ███████╗██╗ ██╗██╗███████╗",
|
|
7
|
+
"██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝╚██╗██╔╝██║██╔════╝",
|
|
8
|
+
"██████╔╝██████╔╝█████╗ ███████║██████╔╝█████╗ ╚███╔╝ ██║███████╗",
|
|
9
|
+
"██╔═══╝ ██╔══██╗██╔══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔██╗ ██║╚════██║",
|
|
10
|
+
"██║ ██║ ██║███████╗██║ ██║██║ ███████╗██╔╝ ██╗██║███████║",
|
|
11
|
+
"╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝"
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const COMPACT_HEADER = "π PreApeXis π";
|
|
15
|
+
|
|
16
|
+
const RAINBOW = [
|
|
17
|
+
[255, 90, 90],
|
|
18
|
+
[255, 170, 70],
|
|
19
|
+
[255, 230, 90],
|
|
20
|
+
[90, 220, 130],
|
|
21
|
+
[90, 190, 255],
|
|
22
|
+
[180, 130, 255]
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
function rgb(text: string, r: number, g: number, b: number): string {
|
|
26
|
+
return `\x1b[1;38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rainbowText(text: string): string {
|
|
30
|
+
let colorIndex = 0;
|
|
31
|
+
|
|
32
|
+
return [...text]
|
|
33
|
+
.map((char) => {
|
|
34
|
+
if (char === " ") return char;
|
|
35
|
+
|
|
36
|
+
const [r, g, b] = RAINBOW[colorIndex % RAINBOW.length];
|
|
37
|
+
colorIndex += 1;
|
|
38
|
+
|
|
39
|
+
return rgb(char, r, g, b);
|
|
40
|
+
})
|
|
41
|
+
.join("");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rainbowPi(): string {
|
|
45
|
+
return rainbowText("π");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function (pi: ExtensionAPI): void {
|
|
49
|
+
function applyBrandUI(ctx: EventContext): void {
|
|
50
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
51
|
+
|
|
52
|
+
ctx.ui.setTitle("PreApeXis");
|
|
53
|
+
|
|
54
|
+
ctx.ui.setHeader((_tui, theme) => ({
|
|
55
|
+
render(width: number): string[] {
|
|
56
|
+
if (width < 90) {
|
|
57
|
+
return [
|
|
58
|
+
rainbowText(COMPACT_HEADER),
|
|
59
|
+
theme.fg("dim", "safe changes · clear plans")
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
...PREAPEXIS_ART.map((line) => rainbowText(line)),
|
|
65
|
+
theme.fg("dim", "safe changes · clear plans")
|
|
66
|
+
];
|
|
67
|
+
},
|
|
68
|
+
invalidate() {}
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
ctx.ui.setWorkingMessage("Thinking");
|
|
72
|
+
|
|
73
|
+
ctx.ui.setWorkingIndicator({
|
|
74
|
+
frames: [
|
|
75
|
+
rainbowPi(),
|
|
76
|
+
`${rainbowPi()}·`,
|
|
77
|
+
`${rainbowPi()}··`,
|
|
78
|
+
`${rainbowPi()}···`
|
|
79
|
+
],
|
|
80
|
+
intervalMs: 300
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
85
|
+
applyBrandUI(ctx);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.registerCommand("brand", {
|
|
89
|
+
description: "Re-apply PreApeXis brand UI",
|
|
90
|
+
handler: async (_args, ctx) => {
|
|
91
|
+
applyBrandUI(ctx);
|
|
92
|
+
ctx.ui.notify("PreApeXis brand UI applied.", "info");
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
pi.registerCommand("brand-reset", {
|
|
97
|
+
description: "Restore default Pi header and working indicator",
|
|
98
|
+
handler: async (_args, ctx) => {
|
|
99
|
+
if (!ctx.hasUI) return;
|
|
100
|
+
|
|
101
|
+
ctx.ui.setHeader(undefined);
|
|
102
|
+
ctx.ui.setWorkingMessage();
|
|
103
|
+
ctx.ui.setWorkingIndicator();
|
|
104
|
+
ctx.ui.notify("Default Pi UI restored.", "info");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export default function (pi: ExtensionAPI): void {
|
|
4
|
+
const CHECKPOINT_PREFIX = "pi-checkpoint";
|
|
5
|
+
|
|
6
|
+
async function git(
|
|
7
|
+
cwd: string,
|
|
8
|
+
args: string[]
|
|
9
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
10
|
+
try {
|
|
11
|
+
const result = await pi.exec("git", args, { cwd });
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
stdout: result.stdout,
|
|
15
|
+
stderr: result.stderr,
|
|
16
|
+
code: result.code
|
|
17
|
+
};
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return {
|
|
20
|
+
stdout: "",
|
|
21
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
22
|
+
code: 1
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function isGitRepo(cwd: string): Promise<boolean> {
|
|
28
|
+
const { code } = await git(cwd, ["rev-parse", "--git-dir"]);
|
|
29
|
+
return code === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getBranch(cwd: string): Promise<string | null> {
|
|
33
|
+
const { stdout, code } = await git(cwd, ["branch", "--show-current"]);
|
|
34
|
+
|
|
35
|
+
if (code !== 0) return null;
|
|
36
|
+
|
|
37
|
+
const branch = stdout.trim();
|
|
38
|
+
return branch.length > 0 ? branch : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function isDirty(cwd: string): Promise<boolean> {
|
|
42
|
+
const { stdout, code } = await git(cwd, ["status", "--porcelain"]);
|
|
43
|
+
|
|
44
|
+
if (code !== 0) return false;
|
|
45
|
+
|
|
46
|
+
return stdout.trim().length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
50
|
+
if (!ctx.hasUI || !(await isGitRepo(ctx.cwd))) return;
|
|
51
|
+
|
|
52
|
+
if (await isDirty(ctx.cwd)) {
|
|
53
|
+
ctx.ui.notify(
|
|
54
|
+
"Git working tree is dirty. Consider committing or stashing before making changes.",
|
|
55
|
+
"warning"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
61
|
+
if (event.toolName === "bash") {
|
|
62
|
+
const command = String(event.input.command ?? "");
|
|
63
|
+
|
|
64
|
+
if (/\bgit\s+push\s+.*(--force|-f)\b/i.test(command)) {
|
|
65
|
+
return {
|
|
66
|
+
block: true,
|
|
67
|
+
reason: "Force-push blocked by git-guard."
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (/\bgit\s+reset\s+--hard\b/i.test(command)) {
|
|
72
|
+
if (!ctx.hasUI) {
|
|
73
|
+
return {
|
|
74
|
+
block: true,
|
|
75
|
+
reason:
|
|
76
|
+
"git reset --hard blocked by git-guard: no UI available for confirmation."
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ok = await ctx.ui.confirm(
|
|
81
|
+
"git reset --hard detected",
|
|
82
|
+
"This will discard uncommitted changes. Are you sure?"
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (!ok) {
|
|
86
|
+
return {
|
|
87
|
+
block: true,
|
|
88
|
+
reason: "git reset --hard cancelled by user."
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
(event.toolName === "write" || event.toolName === "edit") &&
|
|
96
|
+
(await isGitRepo(ctx.cwd)) &&
|
|
97
|
+
(await isDirty(ctx.cwd))
|
|
98
|
+
) {
|
|
99
|
+
const branch = await getBranch(ctx.cwd);
|
|
100
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
101
|
+
|
|
102
|
+
const checkpointName = branch
|
|
103
|
+
? `${CHECKPOINT_PREFIX}-${branch}-${timestamp}`
|
|
104
|
+
: `${CHECKPOINT_PREFIX}-${timestamp}`;
|
|
105
|
+
|
|
106
|
+
const { code, stderr } = await git(ctx.cwd, ["branch", checkpointName]);
|
|
107
|
+
|
|
108
|
+
if (code !== 0) {
|
|
109
|
+
if (!ctx.hasUI) {
|
|
110
|
+
return {
|
|
111
|
+
block: true,
|
|
112
|
+
reason: "Blocked because git checkpoint could not be created."
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ok = await ctx.ui.confirm(
|
|
117
|
+
"Git checkpoint failed",
|
|
118
|
+
`Could not create checkpoint branch: ${stderr}\n\nContinue editing anyway?`
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!ok) {
|
|
122
|
+
return {
|
|
123
|
+
block: true,
|
|
124
|
+
reason: "Blocked because git checkpoint could not be created."
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
} else if (ctx.hasUI) {
|
|
128
|
+
ctx.ui.notify(`Git checkpoint created: ${checkpointName}`, "info");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return undefined;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export default function (pi: ExtensionAPI): void {
|
|
4
|
+
const prompts = [
|
|
5
|
+
{
|
|
6
|
+
name: "init",
|
|
7
|
+
description: "Inspect the repository and create an onboarding report",
|
|
8
|
+
usage: "/init"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: "plan",
|
|
12
|
+
description:
|
|
13
|
+
"Create a read-only plan with batches, model choice, and effort guidance",
|
|
14
|
+
usage: "/plan <your request>"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "save-plan",
|
|
18
|
+
description: "Save a generated plan as a markdown file",
|
|
19
|
+
usage: "/save-plan <plan content>"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "implement",
|
|
23
|
+
description: "Implement a saved plan or pasted plan content",
|
|
24
|
+
usage: "/implement <plan file path or plan content>"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "commit",
|
|
28
|
+
description: "Generate a git commit message from current changes",
|
|
29
|
+
usage: "/commit"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "review-safe",
|
|
33
|
+
description: "Review the project safely without editing files",
|
|
34
|
+
usage: "/review-safe"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "security",
|
|
38
|
+
description: "Run a read-only security review",
|
|
39
|
+
usage: "/security"
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
pi.registerCommand("prompts", {
|
|
44
|
+
description: "List available prompt workflows",
|
|
45
|
+
handler: async (_args, ctx) => {
|
|
46
|
+
const list = prompts
|
|
47
|
+
.map((prompt) => `${prompt.usage} - ${prompt.description}`)
|
|
48
|
+
.join("\n");
|
|
49
|
+
|
|
50
|
+
ctx.ui.notify(`Available prompts:\n\n${list}`, "info");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* General safety extension for Pi.
|
|
6
|
+
*
|
|
7
|
+
* git-guard.ts handles Git-specific safety.
|
|
8
|
+
* This file handles:
|
|
9
|
+
* - risky shell commands
|
|
10
|
+
* - secret file protection
|
|
11
|
+
* - protected generated/dependency paths
|
|
12
|
+
* - safety instructions in the system prompt
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI): void {
|
|
16
|
+
// Do NOT include git push/reset checks here.
|
|
17
|
+
// Those belong in git-guard.ts to avoid duplicate prompts.
|
|
18
|
+
const riskyCommands = [
|
|
19
|
+
// Recursive/forced remove
|
|
20
|
+
/\brm\s+(-[a-z]*[rf][a-z]*|--recursive|--force)\b/i,
|
|
21
|
+
|
|
22
|
+
// Admin / permission risky
|
|
23
|
+
/\bsudo\b/i,
|
|
24
|
+
/\bchmod\b.*\b777\b/i,
|
|
25
|
+
|
|
26
|
+
// Running remote scripts
|
|
27
|
+
/\bcurl\b.*\|\s*(sh|bash|zsh)\b/i,
|
|
28
|
+
/\bwget\b.*\|\s*(sh|bash|zsh)\b/i,
|
|
29
|
+
|
|
30
|
+
// Package install/remove
|
|
31
|
+
/\b(npm|yarn|pnpm)\s+(install|add|remove|uninstall|ci)\b/i,
|
|
32
|
+
/\b(pip|pip3)\s+(install|uninstall)\b/i,
|
|
33
|
+
/\b(apt-get|apt)\s+(install|remove|purge)\b/i,
|
|
34
|
+
/\bbrew\s+(install|uninstall|remove)\b/i,
|
|
35
|
+
|
|
36
|
+
// Docker cleanup
|
|
37
|
+
/\bdocker\s+system\s+prune/i,
|
|
38
|
+
/\bdocker\s+(container|image|volume|network)\s+prune/i,
|
|
39
|
+
|
|
40
|
+
// Disk / filesystem destructive
|
|
41
|
+
/\bdd\s+if=.+of=\/dev\/[sh]d[a-z]/i,
|
|
42
|
+
/\bmkfs\b/i,
|
|
43
|
+
/\b(fdisk|parted)\b/i,
|
|
44
|
+
/>\s*\/dev\/[sh]d[a-z]/i,
|
|
45
|
+
|
|
46
|
+
// Shutdown/reboot
|
|
47
|
+
/\bshutdown\b|\breboot\b|\bpoweroff\b/i,
|
|
48
|
+
|
|
49
|
+
// Windows destructive commands
|
|
50
|
+
/\bformat\s+[a-z]:/i,
|
|
51
|
+
/\bdiskpart\b/i,
|
|
52
|
+
/\brd\s+\/s\s+\/q\b/i,
|
|
53
|
+
/\bdel\s+\/f\s+\/s\s+\/q\b/i
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const secretFileNames = [
|
|
57
|
+
".env",
|
|
58
|
+
".env.local",
|
|
59
|
+
".env.production",
|
|
60
|
+
".env.development",
|
|
61
|
+
".env.test",
|
|
62
|
+
".envrc"
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const blockedEditSegments = [
|
|
66
|
+
".git",
|
|
67
|
+
"node_modules",
|
|
68
|
+
"dist",
|
|
69
|
+
"build",
|
|
70
|
+
"out",
|
|
71
|
+
"coverage"
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const confirmEditFileNames = [
|
|
75
|
+
"package-lock.json",
|
|
76
|
+
"pnpm-lock.yaml",
|
|
77
|
+
"yarn.lock",
|
|
78
|
+
"bun.lockb"
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
function getPathSegments(filePath: string): string[] {
|
|
82
|
+
return path
|
|
83
|
+
.normalize(filePath)
|
|
84
|
+
.replaceAll("\\", "/")
|
|
85
|
+
.split("/")
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getFileName(filePath: string): string {
|
|
90
|
+
const segments = getPathSegments(filePath);
|
|
91
|
+
return segments.at(-1) ?? "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findSecretFile(filePath: string): string | undefined {
|
|
95
|
+
const fileName = getFileName(filePath);
|
|
96
|
+
return secretFileNames.find((name) => name === fileName);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findBlockedEditSegment(filePath: string): string | undefined {
|
|
100
|
+
const segments = getPathSegments(filePath);
|
|
101
|
+
return blockedEditSegments.find((entry) => segments.includes(entry));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findConfirmEditFile(filePath: string): string | undefined {
|
|
105
|
+
const fileName = getFileName(filePath);
|
|
106
|
+
return confirmEditFileNames.find((name) => name === fileName);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isRiskyCommand(command: string): boolean {
|
|
110
|
+
return riskyCommands.some((pattern) => pattern.test(command));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pi.on("before_agent_start", async (event) => {
|
|
114
|
+
return {
|
|
115
|
+
systemPrompt:
|
|
116
|
+
event.systemPrompt +
|
|
117
|
+
`
|
|
118
|
+
Safety rules:
|
|
119
|
+
- Make small, reviewable changes.
|
|
120
|
+
- Do not read or edit secret files such as .env files.
|
|
121
|
+
- Ask before installing or removing packages.
|
|
122
|
+
- Explain risky commands before running them.
|
|
123
|
+
- Prefer safe, additive changes.
|
|
124
|
+
- Run tests or type checks after code changes when available.
|
|
125
|
+
|
|
126
|
+
Clarification rules:
|
|
127
|
+
- If the request is unclear, ask questions before editing files.
|
|
128
|
+
- Ask only important questions.
|
|
129
|
+
- Do not ask more than 5 questions.
|
|
130
|
+
- If the task is clear, continue without asking.
|
|
131
|
+
- Do not edit files until the user answers clarification questions.
|
|
132
|
+
`
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
137
|
+
if (event.toolName === "bash") {
|
|
138
|
+
const command = String(event.input.command ?? "");
|
|
139
|
+
|
|
140
|
+
if (isRiskyCommand(command)) {
|
|
141
|
+
if (!ctx.hasUI) {
|
|
142
|
+
return {
|
|
143
|
+
block: true,
|
|
144
|
+
reason:
|
|
145
|
+
"Risky shell command blocked because no UI is available to confirm it."
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const choice = await ctx.ui.select(
|
|
150
|
+
`Risky shell command detected:\n\n${command}\n\nAllow it?`,
|
|
151
|
+
["No", "Yes"]
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (choice !== "Yes") {
|
|
155
|
+
return {
|
|
156
|
+
block: true,
|
|
157
|
+
reason: "Blocked by safety extension."
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(`[safety] User approved risky command: ${command}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (event.toolName === "read") {
|
|
166
|
+
const filePath = String(event.input.path ?? "");
|
|
167
|
+
const secretHit = findSecretFile(filePath);
|
|
168
|
+
|
|
169
|
+
if (secretHit) {
|
|
170
|
+
const reason = `Reading secret file blocked: ${secretHit}`;
|
|
171
|
+
|
|
172
|
+
if (ctx.hasUI) {
|
|
173
|
+
ctx.ui.notify(reason, "warning");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { block: true, reason };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
181
|
+
const filePath = String(event.input.path ?? "");
|
|
182
|
+
|
|
183
|
+
const secretHit = findSecretFile(filePath);
|
|
184
|
+
if (secretHit) {
|
|
185
|
+
const reason = `Editing secret file blocked: ${secretHit}`;
|
|
186
|
+
|
|
187
|
+
if (ctx.hasUI) {
|
|
188
|
+
ctx.ui.notify(reason, "warning");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { block: true, reason };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const blockedSegment = findBlockedEditSegment(filePath);
|
|
195
|
+
if (blockedSegment) {
|
|
196
|
+
const reason = `Protected path blocked: ${blockedSegment}`;
|
|
197
|
+
|
|
198
|
+
if (ctx.hasUI) {
|
|
199
|
+
ctx.ui.notify(reason, "warning");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { block: true, reason };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const confirmFile = findConfirmEditFile(filePath);
|
|
206
|
+
if (confirmFile) {
|
|
207
|
+
if (!ctx.hasUI) {
|
|
208
|
+
return {
|
|
209
|
+
block: true,
|
|
210
|
+
reason: `Editing ${confirmFile} blocked because no UI is available to confirm it.`
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const choice = await ctx.ui.select(
|
|
215
|
+
`Protected file edit detected:\n\n${filePath}\n\nAllow editing ${confirmFile}?`,
|
|
216
|
+
["No", "Yes"]
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (choice !== "Yes") {
|
|
220
|
+
return {
|
|
221
|
+
block: true,
|
|
222
|
+
reason: `Editing ${confirmFile} cancelled by user.`
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(`[safety] User approved protected file edit: ${filePath}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return undefined;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
pi.registerCommand("safety", {
|
|
234
|
+
description: "Show active safety rules",
|
|
235
|
+
handler: async (_args, ctx) => {
|
|
236
|
+
ctx.ui.notify(
|
|
237
|
+
"Safety enabled: risky command confirmation, .env read/edit blocking, dependency/build path protection, and lockfile edit confirmation.",
|
|
238
|
+
"info"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type EventContext = Parameters<Parameters<ExtensionAPI["on"]>[1]>[1];
|
|
4
|
+
|
|
5
|
+
type SoundName = "start" | "done" | "need-input" | "error";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI): void {
|
|
8
|
+
let enabled = true;
|
|
9
|
+
|
|
10
|
+
function bell(count = 1): void {
|
|
11
|
+
if (!enabled) return;
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < count; i += 1) {
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
process.stdout.write("\u0007");
|
|
16
|
+
}, i * 150);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function play(sound: SoundName): void {
|
|
21
|
+
if (sound === "start") bell(1);
|
|
22
|
+
if (sound === "done") bell(2);
|
|
23
|
+
if (sound === "need-input") bell(3);
|
|
24
|
+
if (sound === "error") bell(4);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pi.on("before_agent_start", async () => {
|
|
28
|
+
play("start");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.on("agent_end", async () => {
|
|
32
|
+
play("done");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
pi.on("tool_call", async (event) => {
|
|
36
|
+
if (event.toolName === "bash") {
|
|
37
|
+
const command = String(event.input.command ?? "");
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
/\bnpm\s+install\b/i.test(command) ||
|
|
41
|
+
/\bnpm\s+update\b/i.test(command) ||
|
|
42
|
+
/\bgit\s+reset\s+--hard\b/i.test(command)
|
|
43
|
+
) {
|
|
44
|
+
play("need-input");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
pi.on("tool_result", async (event) => {
|
|
50
|
+
const maybeResult = event as {
|
|
51
|
+
result?: {
|
|
52
|
+
code?: number;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
typeof maybeResult.result?.code === "number" &&
|
|
58
|
+
maybeResult.result.code !== 0
|
|
59
|
+
) {
|
|
60
|
+
play("error");
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.registerCommand("sound-on", {
|
|
65
|
+
description: "Enable PreApeXis sound cues",
|
|
66
|
+
handler: async (_args, ctx: EventContext) => {
|
|
67
|
+
enabled = true;
|
|
68
|
+
play("done");
|
|
69
|
+
ctx.ui.notify("Sound cues enabled.", "info");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.registerCommand("sound-off", {
|
|
74
|
+
description: "Disable PreApeXis sound cues",
|
|
75
|
+
handler: async (_args, ctx: EventContext) => {
|
|
76
|
+
enabled = false;
|
|
77
|
+
ctx.ui.notify("Sound cues disabled.", "info");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
pi.registerCommand("sound-test", {
|
|
82
|
+
description: "Test PreApeXis sound cues",
|
|
83
|
+
handler: async (_args, ctx: EventContext) => {
|
|
84
|
+
play("need-input");
|
|
85
|
+
ctx.ui.notify("Sound test played.", "info");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
type EventContext = Parameters<Parameters<ExtensionAPI["on"]>[1]>[1];
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI): void {
|
|
8
|
+
const STATUS_KEY = "preapexis-kit-status";
|
|
9
|
+
|
|
10
|
+
let testStatus = "tests:none";
|
|
11
|
+
|
|
12
|
+
async function detectTests(ctx: EventContext): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
const pkgPath = path.join(ctx.cwd, "package.json");
|
|
15
|
+
const content = await fs.readFile(pkgPath, "utf-8");
|
|
16
|
+
const pkg = JSON.parse(content) as {
|
|
17
|
+
scripts?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
testStatus =
|
|
21
|
+
typeof pkg.scripts?.test === "string" ? "tests:not-run" : "tests:none";
|
|
22
|
+
} catch {
|
|
23
|
+
testStatus = "tests:none";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getRepoTrust(ctx: EventContext): string {
|
|
28
|
+
try {
|
|
29
|
+
const isProjectTrusted = (
|
|
30
|
+
ctx as {
|
|
31
|
+
isProjectTrusted?: () => boolean;
|
|
32
|
+
}
|
|
33
|
+
).isProjectTrusted;
|
|
34
|
+
|
|
35
|
+
return isProjectTrusted?.() ? "trusted" : "untrusted";
|
|
36
|
+
} catch {
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function updateStatus(ctx: EventContext): void {
|
|
42
|
+
if (!ctx.hasUI) return;
|
|
43
|
+
|
|
44
|
+
const trust = getRepoTrust(ctx);
|
|
45
|
+
|
|
46
|
+
ctx.ui.setStatus(STATUS_KEY, `kit: safe · ${trust} · ${testStatus}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function updateAll(ctx: EventContext): Promise<void> {
|
|
50
|
+
if (!ctx.hasUI) return;
|
|
51
|
+
|
|
52
|
+
await detectTests(ctx);
|
|
53
|
+
updateStatus(ctx);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
57
|
+
await updateAll(ctx);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.registerCommand("test-pass", {
|
|
61
|
+
description: "Mark tests as passed",
|
|
62
|
+
handler: async (_args, ctx) => {
|
|
63
|
+
if (!ctx.hasUI) return;
|
|
64
|
+
testStatus = "tests:passed";
|
|
65
|
+
updateStatus(ctx);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
pi.registerCommand("test-fail", {
|
|
70
|
+
description: "Mark tests as failed",
|
|
71
|
+
handler: async (_args, ctx) => {
|
|
72
|
+
if (!ctx.hasUI) return;
|
|
73
|
+
testStatus = "tests:failed";
|
|
74
|
+
updateStatus(ctx);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
pi.registerCommand("test-none", {
|
|
79
|
+
description: "Mark tests as not run",
|
|
80
|
+
handler: async (_args, ctx) => {
|
|
81
|
+
if (!ctx.hasUI) return;
|
|
82
|
+
testStatus = "tests:not-run";
|
|
83
|
+
updateStatus(ctx);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|