@andares/pi-toolkits 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andares Cui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @andares/pi-toolkits
2
+
3
+ Integrated toolkit extension for [pi](https://github.com/earendil-works/pi-coding-agent) — a single npm package that grows new capabilities over time. Currently provides **ask mode**.
4
+
5
+ ## Features
6
+
7
+ ### ask — read-only Q&A mode
8
+
9
+ Toggle with `/ask` (press again to exit). While active:
10
+
11
+ - **`write`/`edit` hard-disabled** — removed from the active tool set; pi's runtime rejects calls to non-active tools, so file modification is genuinely impossible
12
+ - **`bash` kept but sandboxed to read-only commands** — shell-quote tokenization with per-segment validation plus write-intent detection (`curl -o/-O`, `wget -O`, `dd of=`, `tee`, redirects); heuristic, not a security boundary
13
+ - **System prompt banner** appended describing the current mode and its restrictions
14
+ - **Gray `ask` footer status** while active
15
+
16
+ Exiting restores the exact tool set from before ask mode was entered.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # local development
22
+ pi install .
23
+ # or one-off session
24
+ pi -e ./src/index.ts
25
+ ```
26
+
27
+ ## Development
28
+
29
+ ```bash
30
+ pnpm install # install deps (pnpm is the only supported toolchain)
31
+ pnpm typecheck # tsc --noEmit
32
+ pnpm test # vitest (bash sandbox boundary cases)
33
+ ```
34
+
35
+ ## Project structure
36
+
37
+ ```text
38
+ src/
39
+ ├── index.ts # entry point: aggregates feature modules
40
+ ├── lib/ # shared infrastructure (tool-set snapshot/restore)
41
+ └── features/ # feature modules — one directory per feature
42
+ └── ask/ # ask mode: command + state machine, bash sandbox,
43
+ # system prompt banner, tests
44
+ ```
45
+
46
+ Adding a new feature = create `src/features/<name>/` exporting `registerXxx(pi)`
47
+ and call it from `src/index.ts`. Existing modules stay untouched.
48
+
49
+ ## License
50
+
51
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@andares/pi-toolkits",
3
+ "version": "0.1.0",
4
+ "description": "Integrated toolkit extension for pi — currently provides ask mode (read-only Q&A); more features to come",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/andares/pi-toolkits.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "keywords": [
18
+ "pi",
19
+ "pi-extension",
20
+ "ask-mode",
21
+ "readonly",
22
+ "toolkit"
23
+ ],
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*"
26
+ },
27
+ "dependencies": {
28
+ "@dreki-gg/pi-command-sandbox": "^0.4.3"
29
+ },
30
+ "devDependencies": {
31
+ "@earendil-works/pi-coding-agent": "^0.84.0",
32
+ "typescript": "^5.6.0",
33
+ "vitest": "^4.1.10"
34
+ },
35
+ "pi": {
36
+ "extensions": [
37
+ "./src/index.ts"
38
+ ]
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest run"
43
+ }
44
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Boundary tests for the ask-mode read-only bash sandbox.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { isReadOnlyCommand } from "./bash-sandbox.js";
6
+
7
+ describe("isReadOnlyCommand — allowed (read-only)", () => {
8
+ const allowed = [
9
+ "echo hi",
10
+ "echo a && echo b",
11
+ "ls -la",
12
+ "cat package.json",
13
+ "head -20 README.md",
14
+ "git status",
15
+ "git log --oneline -5",
16
+ "git diff --stat",
17
+ "grep -r foo src",
18
+ "find . -name '*.ts'",
19
+ "cd src && ls",
20
+ "node --version",
21
+ "npm list --depth=0",
22
+ "curl -s https://example.com",
23
+ "curl -o - https://example.com", // stdout, allowed
24
+ "wget -O - https://example.com", // stdout, allowed
25
+ "echo 'hello && world'", // quoted operator is data, not control
26
+ ];
27
+
28
+ it.each(allowed)("allows: %s", (command) => {
29
+ expect(isReadOnlyCommand(command)).toBe(true);
30
+ });
31
+ });
32
+
33
+ describe("isReadOnlyCommand — blocked (mutating/destructive)", () => {
34
+ const blocked = [
35
+ "rm -rf /",
36
+ "echo a && rm -rf /",
37
+ "mv a b",
38
+ "cp a b",
39
+ "mkdir -p foo",
40
+ "touch foo",
41
+ "npm install",
42
+ "npm run build",
43
+ "git commit -m x",
44
+ "git push",
45
+ "git checkout main",
46
+ "cat x > out.txt",
47
+ "echo hi >> log",
48
+ "echo $(rm -rf /)", // command substitution
49
+ "ls | rm -rf /", // pipeline with destructive command
50
+ "curl -o file https://example.com",
51
+ "curl -O https://example.com/x",
52
+ "curl --output file https://example.com",
53
+ "wget -O file https://example.com",
54
+ "wget --output-document file https://example.com",
55
+ "wget -qO- https://example.com", // sandbox rejects (only wget -O - is safe); fine, be strict
56
+ "dd if=/dev/zero of=/tmp/x bs=1 count=1",
57
+ "echo x | tee file",
58
+ "vim README.md",
59
+ "sudo ls",
60
+ "kill -9 1",
61
+ ];
62
+
63
+ it.each(blocked)("blocks: %s", (command) => {
64
+ expect(isReadOnlyCommand(command)).toBe(false);
65
+ });
66
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ask feature — read-only bash sandbox.
3
+ *
4
+ * Delegates to @dreki-gg/pi-command-sandbox: proper shell-quote tokenization,
5
+ * per-segment validation (&& | ; etc.), command-substitution rejection and
6
+ * deny-by-default for unmodeled operators.
7
+ *
8
+ * On top of that, this wrapper adds write-intent detection for known blind
9
+ * spots: allowlisted commands whose arguments can write files (curl -o/-O,
10
+ * wget -O, dd of=, tee). Heuristic only — not a security boundary.
11
+ */
12
+ import { isSafeCommand } from "@dreki-gg/pi-command-sandbox";
13
+
14
+ const WRITE_INTENT_PATTERNS: RegExp[] = [
15
+ // curl --output FILE
16
+ /\bcurl\b[^|;&]*\s--output\b/i,
17
+ // curl -o FILE (curl -o - / -o- = stdout, allowed)
18
+ /\bcurl\b[^|;&]*\s-o\s+(?!-)\S+/,
19
+ // curl -O (uppercase: save to disk under remote name)
20
+ /\bcurl\b[^|;&]*\s-O\b/,
21
+ // wget --output-document FILE
22
+ /\bwget\b[^|;&]*\s--output-document\b/i,
23
+ // wget -O FILE (wget -O - / -qO- = stdout, allowed)
24
+ /\bwget\b[^|;&]*\s-O\s+(?!-)\S+/,
25
+ // dd of=FILE
26
+ /\bdd\b[^|;&]*\sof=/i,
27
+ // tee FILE (writes to stdout AND file)
28
+ /\btee\b/i,
29
+ ];
30
+
31
+ function hasWriteIntent(command: string): boolean {
32
+ return WRITE_INTENT_PATTERNS.some((pattern) => pattern.test(command));
33
+ }
34
+
35
+ /** True when `command` is considered read-only by the sandbox. */
36
+ export function isReadOnlyCommand(command: string): boolean {
37
+ if (hasWriteIntent(command)) return false;
38
+ return isSafeCommand(command);
39
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ask feature — constants (tool names, UI keys, labels).
3
+ */
4
+
5
+ /** Tools hard-removed from the active set while ask mode is on. */
6
+ export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
7
+ "write",
8
+ "edit",
9
+ ]);
10
+
11
+ /** Footer status key (extension-scoped) and label. */
12
+ export const ASK_STATUS_KEY = "ask-mode";
13
+ export const ASK_STATUS_LABEL = "ask";
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ask feature — read-only Q&A mode.
3
+ *
4
+ * Design notes (see PLAN.md):
5
+ * - Hard guarantee: write/edit are removed from the active tool set via
6
+ * pi.setActiveTools(); pi's runtime rejects calls to non-active tools
7
+ * ("Tool not found") so nothing executes.
8
+ * - bash stays available but is gated by a read-only sandbox
9
+ * (see bash-sandbox.ts) via the tool_call hook.
10
+ * - Mode banner appended to the system prompt via before_agent_start
11
+ * chained systemPrompt (see prompt.ts).
12
+ */
13
+ import type {
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ restoreActiveTools,
19
+ snapshotActiveTools,
20
+ withoutTools,
21
+ } from "../../lib/tools.js";
22
+ import {
23
+ ASK_BLOCKED_TOOLS,
24
+ ASK_STATUS_KEY,
25
+ ASK_STATUS_LABEL,
26
+ } from "./constants.js";
27
+ import { isReadOnlyCommand } from "./bash-sandbox.js";
28
+ import { appendAskBanner } from "./prompt.js";
29
+
30
+ export function registerAsk(pi: ExtensionAPI): void {
31
+ let enabled = false;
32
+ /** Active tools before ask mode was entered (for exact restore on exit). */
33
+ let snapshot: string[] = [];
34
+
35
+ /** Gray footer status while ask mode is active; cleared on exit. */
36
+ function updateStatus(ctx: ExtensionContext): void {
37
+ ctx.ui.setStatus(
38
+ ASK_STATUS_KEY,
39
+ enabled ? ctx.ui.theme.fg("muted", ASK_STATUS_LABEL) : undefined,
40
+ );
41
+ }
42
+
43
+ function enter(ctx: ExtensionContext): void {
44
+ if (enabled) return;
45
+ enabled = true;
46
+ snapshot = snapshotActiveTools(pi);
47
+ pi.setActiveTools(withoutTools(snapshot, ASK_BLOCKED_TOOLS));
48
+ updateStatus(ctx);
49
+ ctx.ui.notify(
50
+ "Ask mode ON — read-only (write/edit disabled). /ask to exit.",
51
+ "info",
52
+ );
53
+ }
54
+
55
+ function exit(ctx: ExtensionContext): void {
56
+ if (!enabled) return;
57
+ enabled = false;
58
+ restoreActiveTools(pi, snapshot);
59
+ snapshot = [];
60
+ updateStatus(ctx);
61
+ ctx.ui.notify("Ask mode OFF — full tool access restored.", "info");
62
+ }
63
+
64
+ function toggle(ctx: ExtensionContext): void {
65
+ if (enabled) {
66
+ exit(ctx);
67
+ } else {
68
+ enter(ctx);
69
+ }
70
+ }
71
+
72
+ pi.registerCommand("ask", {
73
+ description: "Toggle ask mode (read-only Q&A)",
74
+ handler: async (_args, ctx) => {
75
+ toggle(ctx);
76
+ },
77
+ });
78
+
79
+ // Defense-in-depth gate while ask mode is active:
80
+ // - write/edit are already removed from the active set (runtime rejects
81
+ // them), this blocks them again in case anything re-enables them.
82
+ // - bash is kept available but only read-only commands pass the sandbox.
83
+ pi.on("tool_call", (event) => {
84
+ if (!enabled) return;
85
+
86
+ if (event.toolName === "write" || event.toolName === "edit") {
87
+ return {
88
+ block: true,
89
+ reason:
90
+ "Ask mode: write/edit are disabled. Use /ask to exit ask mode first.",
91
+ };
92
+ }
93
+
94
+ if (event.toolName === "bash") {
95
+ const command = event.input.command as string;
96
+ if (!isReadOnlyCommand(command)) {
97
+ return {
98
+ block: true,
99
+ reason: `Ask mode: only read-only bash commands are allowed.\nCommand: ${command}\nUse /ask to exit ask mode first.`,
100
+ };
101
+ }
102
+ }
103
+ });
104
+
105
+ // Mode banner: chained append to the current system prompt while enabled.
106
+ pi.on("before_agent_start", (event) => {
107
+ if (!enabled) return;
108
+ // event.systemPrompt is the current (possibly already modified by other
109
+ // extensions) system prompt — append, don't replace.
110
+ return { systemPrompt: appendAskBanner(event.systemPrompt) };
111
+ });
112
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ask feature — system prompt banner.
3
+ *
4
+ * Appended to the system prompt (chained append, not replacement) while
5
+ * ask mode is active so the model knows the current mode and its
6
+ * restrictions. Do NOT use the removed `systemPromptAppend` field or
7
+ * wholesale systemPrompt replacement (see PLAN.md).
8
+ */
9
+
10
+ export const ASK_BANNER = `[ASK MODE]
11
+ You are in ask mode — a read-only Q&A mode. You may read, search, and analyze the codebase, but you MUST NOT modify anything.
12
+
13
+ Restrictions:
14
+ - write and edit are disabled (hard-blocked; calls will fail)
15
+ - bash is restricted to read-only commands (ls, cat, grep, git status/log/diff, etc.)
16
+
17
+ Answer the user's question directly. If a question requires changing files, say so and suggest exiting ask mode with /ask.`;
18
+
19
+ /** Append the ask-mode banner to an existing system prompt. */
20
+ export function appendAskBanner(systemPrompt: string): string {
21
+ return `${systemPrompt}\n\n${ASK_BANNER}`;
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @andares/pi-toolkits — integrated pi extension entry point.
3
+ *
4
+ * Registers every feature module. Add new features by creating
5
+ * src/features/<name>/ with a registerXxx(pi) export and calling it here.
6
+ *
7
+ * Current features:
8
+ * - ask — read-only Q&A mode (/ask)
9
+ */
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { registerAsk } from "./features/ask/index.js";
12
+
13
+ export default function piToolkits(pi: ExtensionAPI): void {
14
+ registerAsk(pi);
15
+ // registerXxx(pi); // future features
16
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared tool-set management helpers (feature-agnostic).
3
+ */
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ /** Snapshot the currently active tool names (for later restore). */
7
+ export function snapshotActiveTools(pi: ExtensionAPI): string[] {
8
+ return [...pi.getActiveTools()];
9
+ }
10
+
11
+ /** Restore a previously snapshotted tool set. No-op when the snapshot is empty. */
12
+ export function restoreActiveTools(pi: ExtensionAPI, snapshot: string[]): void {
13
+ if (snapshot.length === 0) return;
14
+ pi.setActiveTools([...snapshot]);
15
+ }
16
+
17
+ /** Return `active` minus the tools in `removed` (order preserved). */
18
+ export function withoutTools(
19
+ active: readonly string[],
20
+ removed: ReadonlySet<string>,
21
+ ): string[] {
22
+ return active.filter((name) => !removed.has(name));
23
+ }