@preapexis/piexis 0.2.2 → 0.2.4

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.
Files changed (149) hide show
  1. package/AGENTS.md +21 -41
  2. package/README.md +73 -139
  3. package/extensions/agents.ts +205 -0
  4. package/extensions/ask-question.ts +54 -95
  5. package/extensions/recommended-tools.ts +62 -0
  6. package/extensions/ui.ts +204 -58
  7. package/lib/agent-config.ts +55 -0
  8. package/lib/agents.ts +208 -0
  9. package/lib/question-model.ts +98 -0
  10. package/lib/question-ui.ts +214 -0
  11. package/lib/recommended-tools.ts +119 -0
  12. package/lib/subagent-runner.ts +146 -0
  13. package/lib/ui-tips.json +41 -0
  14. package/package.json +5 -13
  15. package/prompts/init.md +10 -2
  16. package/prompts/summary.md +7 -6
  17. package/schemas/agent.schema.json +48 -0
  18. package/skills/{claude-api/LICENSE.txt → caveman/LICENSE} +2 -2
  19. package/skills/caveman/LICENSE-MIT +21 -0
  20. package/skills/caveman/NOTICE +10 -0
  21. package/skills/caveman/SKILL.md +89 -0
  22. package/skills/find-skills/SKILL.md +141 -0
  23. package/skills/ponytail/LICENSE +21 -0
  24. package/skills/ponytail/NOTICE +9 -0
  25. package/skills/ponytail/SKILL.md +52 -0
  26. package/skills-lock.json +6 -0
  27. package/tests/agents.test.mjs +605 -0
  28. package/tests/ask-question-pty.test.mjs +16 -0
  29. package/tests/ask-question.test.mjs +269 -0
  30. package/tests/fixtures/agents-provider.ts +55 -0
  31. package/tests/fixtures/ask-question-pty.py +91 -0
  32. package/tests/fixtures/ask-question-terminal.mjs +69 -0
  33. package/tests/fixtures/recommended-tools-terminal.mjs +103 -0
  34. package/tests/init-workflow.test.mjs +31 -0
  35. package/tests/recommended-tools-pty.test.mjs +16 -0
  36. package/tests/recommended-tools.test.mjs +423 -0
  37. package/tests/resources.test.mjs +66 -0
  38. package/tests/skills-routing.test.mjs +7 -4
  39. package/tests/ui-tip-reload.test.mjs +70 -0
  40. package/tests/ui.test.mjs +450 -0
  41. package/tsconfig.json +1 -0
  42. package/eval/README.md +0 -72
  43. package/eval/permission-cases.json +0 -28
  44. package/eval/permissions.mjs +0 -103
  45. package/extensions/background-tasks.ts +0 -433
  46. package/extensions/background-worker.ts +0 -50
  47. package/extensions/glob.ts +0 -32
  48. package/extensions/mode.ts +0 -397
  49. package/lib/bash-approvals.ts +0 -113
  50. package/lib/bash-policy.ts +0 -164
  51. package/lib/mode-policy.ts +0 -174
  52. package/lib/path-policy.ts +0 -182
  53. package/lib/permission-classifier.ts +0 -128
  54. package/lib/permission-config.ts +0 -91
  55. package/lib/permission-denials.ts +0 -32
  56. package/lib/permission-intent.ts +0 -128
  57. package/lib/permission-taxonomy.ts +0 -15
  58. package/lib/task-broker.ts +0 -65
  59. package/lib/task-checkout.ts +0 -39
  60. package/lib/task-git.ts +0 -44
  61. package/lib/task-patch.ts +0 -106
  62. package/lib/task-permissions.ts +0 -51
  63. package/lib/task-report.ts +0 -62
  64. package/lib/task-worktree-policy.ts +0 -56
  65. package/skills/claude-api/SKILL.md +0 -572
  66. package/skills/claude-api/csharp/claude-api/README.md +0 -362
  67. package/skills/claude-api/csharp/claude-api/batches.md +0 -14
  68. package/skills/claude-api/csharp/claude-api/files-api.md +0 -25
  69. package/skills/claude-api/csharp/claude-api/streaming.md +0 -28
  70. package/skills/claude-api/csharp/claude-api/tool-use.md +0 -164
  71. package/skills/claude-api/curl/examples.md +0 -256
  72. package/skills/claude-api/curl/managed-agents.md +0 -371
  73. package/skills/claude-api/go/claude-api/README.md +0 -188
  74. package/skills/claude-api/go/claude-api/files-api.md +0 -23
  75. package/skills/claude-api/go/claude-api/streaming.md +0 -43
  76. package/skills/claude-api/go/claude-api/tool-use.md +0 -220
  77. package/skills/claude-api/go/managed-agents/README.md +0 -564
  78. package/skills/claude-api/java/claude-api/README.md +0 -238
  79. package/skills/claude-api/java/claude-api/files-api.md +0 -27
  80. package/skills/claude-api/java/claude-api/streaming.md +0 -24
  81. package/skills/claude-api/java/claude-api/tool-use.md +0 -228
  82. package/skills/claude-api/java/managed-agents/README.md +0 -443
  83. package/skills/claude-api/php/claude-api/README.md +0 -174
  84. package/skills/claude-api/php/claude-api/batches.md +0 -15
  85. package/skills/claude-api/php/claude-api/files-api.md +0 -13
  86. package/skills/claude-api/php/claude-api/streaming.md +0 -27
  87. package/skills/claude-api/php/claude-api/tool-use.md +0 -253
  88. package/skills/claude-api/php/managed-agents/README.md +0 -443
  89. package/skills/claude-api/python/claude-api/README.md +0 -565
  90. package/skills/claude-api/python/claude-api/batches.md +0 -198
  91. package/skills/claude-api/python/claude-api/files-api.md +0 -170
  92. package/skills/claude-api/python/claude-api/sdk-upgrade.md +0 -286
  93. package/skills/claude-api/python/claude-api/streaming.md +0 -179
  94. package/skills/claude-api/python/claude-api/tool-use.md +0 -627
  95. package/skills/claude-api/python/managed-agents/README.md +0 -335
  96. package/skills/claude-api/ruby/claude-api/README.md +0 -134
  97. package/skills/claude-api/ruby/claude-api/streaming.md +0 -16
  98. package/skills/claude-api/ruby/claude-api/tool-use.md +0 -41
  99. package/skills/claude-api/ruby/managed-agents/README.md +0 -394
  100. package/skills/claude-api/shared/admin-api.md +0 -179
  101. package/skills/claude-api/shared/agent-design.md +0 -101
  102. package/skills/claude-api/shared/anthropic-cli.md +0 -269
  103. package/skills/claude-api/shared/claude-platform-on-aws.md +0 -59
  104. package/skills/claude-api/shared/cost-optimization.md +0 -233
  105. package/skills/claude-api/shared/error-codes.md +0 -262
  106. package/skills/claude-api/shared/live-sources.md +0 -166
  107. package/skills/claude-api/shared/managed-agents-api-reference.md +0 -455
  108. package/skills/claude-api/shared/managed-agents-client-patterns.md +0 -220
  109. package/skills/claude-api/shared/managed-agents-core.md +0 -359
  110. package/skills/claude-api/shared/managed-agents-environments.md +0 -226
  111. package/skills/claude-api/shared/managed-agents-events.md +0 -290
  112. package/skills/claude-api/shared/managed-agents-memory.md +0 -201
  113. package/skills/claude-api/shared/managed-agents-multiagent.md +0 -260
  114. package/skills/claude-api/shared/managed-agents-onboarding.md +0 -82
  115. package/skills/claude-api/shared/managed-agents-outcomes.md +0 -108
  116. package/skills/claude-api/shared/managed-agents-overview.md +0 -77
  117. package/skills/claude-api/shared/managed-agents-scheduled-deployments.md +0 -156
  118. package/skills/claude-api/shared/managed-agents-self-hosted-sandboxes.md +0 -282
  119. package/skills/claude-api/shared/managed-agents-tools.md +0 -504
  120. package/skills/claude-api/shared/managed-agents-webhooks.md +0 -143
  121. package/skills/claude-api/shared/model-migration.md +0 -1872
  122. package/skills/claude-api/shared/models.md +0 -142
  123. package/skills/claude-api/shared/platform-availability.md +0 -53
  124. package/skills/claude-api/shared/prompt-audit.md +0 -223
  125. package/skills/claude-api/shared/prompt-caching.md +0 -292
  126. package/skills/claude-api/shared/token-counting.md +0 -56
  127. package/skills/claude-api/shared/tool-use-concepts.md +0 -526
  128. package/skills/claude-api/typescript/claude-api/README.md +0 -405
  129. package/skills/claude-api/typescript/claude-api/batches.md +0 -106
  130. package/skills/claude-api/typescript/claude-api/files-api.md +0 -98
  131. package/skills/claude-api/typescript/claude-api/streaming.md +0 -178
  132. package/skills/claude-api/typescript/claude-api/tool-use.md +0 -586
  133. package/skills/claude-api/typescript/managed-agents/README.md +0 -359
  134. package/tests/auto-permissions.test.mjs +0 -315
  135. package/tests/background-tasks.test.mjs +0 -27
  136. package/tests/background-worker.test.mjs +0 -60
  137. package/tests/bash-permissions.test.mjs +0 -276
  138. package/tests/glob.test.mjs +0 -31
  139. package/tests/mode.test.mjs +0 -62
  140. package/tests/permission-evaluation.test.mjs +0 -70
  141. package/tests/permission-foundation.test.mjs +0 -194
  142. package/tests/permission-policy.test.mjs +0 -158
  143. package/tests/runtime/README.md +0 -51
  144. package/tests/runtime/harness.mjs +0 -190
  145. package/tests/runtime/permissions.test.mjs +0 -192
  146. package/tests/runtime/tui.test.mjs +0 -60
  147. package/tests/task-integration.test.mjs +0 -324
  148. package/tests/task-permissions.test.mjs +0 -178
  149. package/tests/task-worktree-policy.test.mjs +0 -53
@@ -0,0 +1,423 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import recommendedToolsExtension from "../extensions/recommended-tools.ts";
7
+ import { agentConfigSchemaUrl } from "../lib/agent-config.ts";
8
+ import { hasToolOverrides, readSetupSettings, saveSetupChoice, supportsAdditiveTools, toolsToOffer } from "../lib/recommended-tools.ts";
9
+
10
+ const base = ["read", "bash", "edit", "write", "AskQuestion"];
11
+ const recommended = ["grep", "find", "ls"];
12
+
13
+ async function fixture(work) {
14
+ const root = await mkdtemp(join(tmpdir(), "piexis-setup-"));
15
+ const cwd = join(root, "project");
16
+ const agentDir = join(root, "agent");
17
+ await mkdir(cwd);
18
+ await mkdir(agentDir);
19
+ const projectPath = join(cwd, ".pi", "settings.json");
20
+ const globalPath = join(agentDir, "settings.json");
21
+ async function project(value) {
22
+ await mkdir(join(cwd, ".pi"), { recursive: true });
23
+ await writeFile(projectPath, typeof value === "string" ? value : JSON.stringify(value), { mode: 0o640 });
24
+ }
25
+ async function global(value) { await writeFile(globalPath, JSON.stringify(value)); }
26
+ try { await work({ cwd, agentDir, projectPath, globalPath, project, global }); }
27
+ finally { await rm(root, { recursive: true, force: true }); }
28
+ }
29
+
30
+ function policy(project = {}, global = {}) {
31
+ return { project: { settings: project }, global: { settings: global } };
32
+ }
33
+
34
+ test("offers only available inactive recommendations, not active tools or prior choices", () => {
35
+ assert.deepEqual(toolsToOffer(policy(), recommended, base, true), recommended);
36
+ assert.deepEqual(toolsToOffer(policy(), ["grep", "find"], [...base, "grep"], true), ["find"]);
37
+ assert.deepEqual(toolsToOffer(policy(), recommended, [...base, ...recommended], true), []);
38
+ for (const choice of ["enabled", "disabled"]) {
39
+ assert.deepEqual(toolsToOffer(policy({ piexis: { recommendedTools: choice } }), recommended, base, true), []);
40
+ }
41
+ });
42
+
43
+ test("preserves global/project allowlists, empty lists and ordered exclusions", () => {
44
+ for (const list of [[], ["read"], ["read", "+find"], ["grep", "find", "ls"]]) {
45
+ assert.deepEqual(toolsToOffer(policy({ defaultTools: list }), recommended, base, true), []);
46
+ assert.deepEqual(toolsToOffer(policy({}, { defaultTools: list }), recommended, base, true), []);
47
+ }
48
+ assert.deepEqual(toolsToOffer(policy({ defaultTools: ["-find"] }), recommended, base, true), ["grep", "ls"]);
49
+ assert.deepEqual(toolsToOffer(policy({}, { defaultTools: ["-grep"] }), recommended, base, true), ["find", "ls"]);
50
+ assert.deepEqual(toolsToOffer(policy({ defaultTools: ["+find"] }, { defaultTools: ["-find"] }), recommended, base, true), recommended);
51
+ assert.deepEqual(toolsToOffer(policy({ defaultTools: ["+find", "-find"] }), recommended, base, true), ["grep", "ls"]);
52
+ assert.deepEqual(toolsToOffer(policy({ defaultTools: ["+find"] }), recommended, base, false), []);
53
+ });
54
+
55
+ test("CLI overrides skip setup and -- terminates option detection", () => {
56
+ for (const flag of ["--tools", "--tools=read", "-t", "--exclude-tools", "--exclude-tools=find", "-xt", "--no-builtin-tools", "-nbt", "--no-tools", "-nt"]) {
57
+ assert.equal(hasToolOverrides([flag]), true);
58
+ }
59
+ assert.equal(hasToolOverrides(["--", "--tools"]), false);
60
+ assert.equal(hasToolOverrides(["--model", "sonnet", "--extension", "./extension.ts"]), false);
61
+ });
62
+
63
+ test("enable safely merges additive entries, unknown settings, namespace and file mode", async () => {
64
+ await fixture(async (f) => {
65
+ await f.global({ defaultTools: ["-write", "+codemode"], theme: "dark" });
66
+ await f.project({ defaultTools: ["-find", "+custom"], packages: ["npm:example"], piexis: { existing: 42 }, custom: { nested: true } });
67
+ const settings = await readSetupSettings(f.cwd, f.agentDir);
68
+ const originalGlobal = await readFile(f.globalPath, "utf8");
69
+ const mode = (await stat(f.projectPath)).mode & 0o777;
70
+ await saveSetupChoice(settings, "enabled", ["grep", "ls"], true);
71
+ const saved = JSON.parse(await readFile(f.projectPath, "utf8"));
72
+ assert.deepEqual(saved.defaultTools, ["-find", "+custom", "+grep", "+ls"]);
73
+ assert.deepEqual(saved.piexis, { existing: 42, recommendedTools: "enabled" });
74
+ assert.deepEqual(saved.custom, { nested: true });
75
+ assert.deepEqual(saved.packages, ["npm:example"]);
76
+ assert.equal(await readFile(f.globalPath, "utf8"), originalGlobal);
77
+ assert.equal((await stat(f.projectPath)).mode & 0o777, mode);
78
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), ["settings.json"]);
79
+ });
80
+ });
81
+
82
+ test("fresh projects use additive configuration, with a valid plain-list fallback for older Pi", async () => {
83
+ for (const additive of [true, false]) {
84
+ await fixture(async (f) => {
85
+ await saveSetupChoice(await readSetupSettings(f.cwd, f.agentDir), "enabled", recommended, additive);
86
+ const saved = JSON.parse(await readFile(f.projectPath, "utf8"));
87
+ assert.deepEqual(saved.defaultTools, additive ? ["+grep", "+find", "+ls"] : [...base.slice(0, 4), ...recommended]);
88
+ assert.equal((await stat(f.projectPath)).mode & 0o777, 0o600);
89
+ });
90
+ }
91
+ });
92
+
93
+ test("Don't ask again records only a project preference and leaves defaultTools untouched", async () => {
94
+ await fixture(async (f) => {
95
+ await f.project({ defaultTools: ["-find"], theme: "orange" });
96
+ await saveSetupChoice(await readSetupSettings(f.cwd, f.agentDir), "disabled", recommended, true);
97
+ const saved = JSON.parse(await readFile(f.projectPath, "utf8"));
98
+ assert.deepEqual(saved.defaultTools, ["-find"]);
99
+ assert.equal(saved.theme, "orange");
100
+ assert.equal(saved.piexis.recommendedTools, "disabled");
101
+ assert.deepEqual(toolsToOffer(await readSetupSettings(f.cwd, f.agentDir), recommended, base, true), []);
102
+ });
103
+ });
104
+
105
+ test("malformed settings and non-regular files are never replaced", async () => {
106
+ for (const invalid of ["{ broken", "[]", "null", '{"defaultTools":"read"}', '{"defaultTools":[3]}', '{"piexis":false}']) {
107
+ await fixture(async (f) => {
108
+ await f.project(invalid);
109
+ await assert.rejects(readSetupSettings(f.cwd, f.agentDir));
110
+ assert.equal(await readFile(f.projectPath, "utf8"), invalid);
111
+ });
112
+ }
113
+ await fixture(async (f) => {
114
+ await f.global({ theme: "dark" });
115
+ await mkdir(join(f.cwd, ".pi"));
116
+ await symlink(f.globalPath, f.projectPath);
117
+ await assert.rejects(readSetupSettings(f.cwd, f.agentDir), /Not a regular/);
118
+ assert.deepEqual(JSON.parse(await readFile(f.globalPath, "utf8")), { theme: "dark" });
119
+ });
120
+ });
121
+
122
+ test("project settings cannot alias the user settings directory", async () => {
123
+ await fixture(async (f) => {
124
+ await f.global({ theme: "dark" });
125
+ await assert.rejects(readSetupSettings(f.cwd, join(f.cwd, ".pi")), /share a path/);
126
+ await symlink(f.agentDir, join(f.cwd, ".pi"), "dir");
127
+ await assert.rejects(readSetupSettings(f.cwd, f.agentDir), /share a path/);
128
+ assert.deepEqual(JSON.parse(await readFile(f.globalPath, "utf8")), { theme: "dark" });
129
+ });
130
+ });
131
+
132
+ test("settings changes during the prompt and another writer's lock prevent overwrites", async () => {
133
+ for (const scope of ["project", "global"]) {
134
+ await fixture(async (f) => {
135
+ await f.project({ theme: "dark" });
136
+ const settings = await readSetupSettings(f.cwd, f.agentDir);
137
+ await f[scope]({ theme: "orange" });
138
+ const latest = await readFile(f.projectPath, "utf8");
139
+ await assert.rejects(saveSetupChoice(settings, "enabled", recommended, true), /Settings changed/);
140
+ assert.equal(await readFile(f.projectPath, "utf8"), latest);
141
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), ["settings.json"]);
142
+ });
143
+ }
144
+ await fixture(async (f) => {
145
+ await f.project({ theme: "dark" });
146
+ const settings = await readSetupSettings(f.cwd, f.agentDir);
147
+ await mkdir(`${f.projectPath}.lock`);
148
+ await assert.rejects(saveSetupChoice(settings, "enabled", recommended, true), { code: "EEXIST" });
149
+ assert.equal((await stat(`${f.projectPath}.lock`)).isDirectory(), true);
150
+ assert.deepEqual(JSON.parse(await readFile(f.projectPath, "utf8")), { theme: "dark" });
151
+ });
152
+ });
153
+
154
+ test("an aborted save does not create project settings", async () => {
155
+ await fixture(async (f) => {
156
+ const controller = new AbortController();
157
+ controller.abort();
158
+ await assert.rejects(saveSetupChoice(await readSetupSettings(f.cwd, f.agentDir), "enabled", recommended, true, controller.signal), { name: "AbortError" });
159
+ await assert.rejects(stat(join(f.cwd, ".pi")), { code: "ENOENT" });
160
+ });
161
+ });
162
+
163
+ async function environment(f, work) {
164
+ const oldDir = process.env.PI_CODING_AGENT_DIR;
165
+ const oldArgv = process.argv;
166
+ const streams = [process.stdin, process.stdout];
167
+ const saved = streams.map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
168
+ process.env.PI_CODING_AGENT_DIR = f.agentDir;
169
+ process.argv = [process.execPath, "pi"];
170
+ streams.forEach((stream) => Object.defineProperty(stream, "isTTY", { configurable: true, value: true }));
171
+ try { await work(); } finally {
172
+ process.argv = oldArgv;
173
+ if (oldDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
174
+ else process.env.PI_CODING_AGENT_DIR = oldDir;
175
+ streams.forEach((stream, i) => saved[i] ? Object.defineProperty(stream, "isTTY", saved[i]) : delete stream.isTTY);
176
+ }
177
+ }
178
+
179
+ function harness(f, select = async () => "Enable") {
180
+ const handlers = new Map();
181
+ const notifications = [];
182
+ let active = [...base], prompts = 0, activations = 0;
183
+ let available = [...base.slice(0, 4), ...recommended].map((name) => ({ name, sourceInfo: { source: "builtin" } }));
184
+ available.push({ name: "subagent", sourceInfo: { source: "extension" } });
185
+ const pi = { on(name, handler) { handlers.set(name, handler); }, getActiveTools() { return active; }, getAllTools() { return available; }, setActiveTools(next) { active = next; activations++; } };
186
+ recommendedToolsExtension(pi);
187
+ const ctx = { cwd: f.cwd, mode: "tui", hasUI: true, model: { provider: "test", id: "default" }, isProjectTrusted: () => true, ui: {
188
+ async select(...args) { prompts++; return select(...args); },
189
+ notify(...args) { notifications.push(args); }
190
+ } };
191
+ return { ctx, notifications, start: () => handlers.get("session_start")({}, ctx), shutdown: () => handlers.get("session_shutdown")(),
192
+ set available(value) { available = value; }, get active() { return active; }, get prompts() { return prompts; }, get activations() { return activations; } };
193
+ }
194
+
195
+ test("automatic startup approval enables immediately, preserves active extensions and remembers choice", async () => {
196
+ await fixture(async (f) => environment(f, async () => {
197
+ const h = harness(f, async (title, options) => {
198
+ assert.match(title, /grep · find · ls/);
199
+ assert.deepEqual(options, ["Not now", "Enable", "Don't ask again"]);
200
+ return "Enable";
201
+ });
202
+ await h.start();
203
+ assert.deepEqual(h.active, [...base, ...recommended]);
204
+ assert.equal(h.activations, 1);
205
+ assert.equal(h.notifications[0][1], "info");
206
+ const saved = JSON.parse(await readFile(f.projectPath, "utf8"));
207
+ assert.equal(saved.piexis.recommendedTools, "enabled");
208
+ assert.deepEqual(JSON.parse(await readFile(join(f.cwd, ".pi", "agent.json"), "utf8")), {
209
+ $schema: agentConfigSchemaUrl, model: "test/default", small_model: "test/default", subagent_model: "test/default", delegate_exploration: false,
210
+ subagent_variant_overrides: {
211
+ plan: { model: "test/default", effort: "high" },
212
+ orchestrator: { model: "test/default", effort: "high" },
213
+ debug: { model: "test/default", effort: "high" },
214
+ ask: { model: "test/default", effort: "low" },
215
+ code: { model: "test/default", effort: "medium" },
216
+ }
217
+ });
218
+ assert.deepEqual(saved.defaultTools, supportsAdditiveTools ? recommended.map((name) => `+${name}`) : [...base.slice(0, 4), ...recommended]);
219
+ await h.start();
220
+ assert.equal(h.prompts, 1);
221
+ const restarted = harness(f);
222
+ await restarted.start();
223
+ assert.equal(restarted.prompts, 0);
224
+ }));
225
+ });
226
+
227
+ test("previously enabled projects are offered missing agent setup without changing tool policy", async () => {
228
+ await fixture(async (f) => environment(f, async () => {
229
+ const existing = { defaultTools: [...base.slice(0, 4), ...recommended], piexis: { recommendedTools: "enabled", custom: 42 }, theme: "orange" };
230
+ await f.project(existing);
231
+ const h = harness(f, async (title, options) => {
232
+ assert.match(title, /agent/i);
233
+ assert.deepEqual(options, ["Not now", "Enable", "Don't ask again"]);
234
+ return "Enable";
235
+ });
236
+ await h.start();
237
+ assert.equal(h.prompts, 1);
238
+ assert.equal(h.activations, 0);
239
+ const saved = JSON.parse(await readFile(f.projectPath, "utf8"));
240
+ assert.deepEqual(saved.defaultTools, existing.defaultTools);
241
+ assert.equal(saved.piexis.recommendedTools, "enabled");
242
+ assert.equal(saved.piexis.custom, 42);
243
+ assert.equal(saved.theme, "orange");
244
+ assert.equal(JSON.parse(await readFile(join(f.cwd, ".pi", "agent.json"), "utf8")).model, "test/default");
245
+ const restarted = harness(f);
246
+ await restarted.start();
247
+ assert.equal(restarted.prompts, 0);
248
+ }));
249
+ });
250
+
251
+ test("agent-only setup cancellation and dismissal preserve the earlier tool choice", async () => {
252
+ for (const choice of ["Not now", undefined, "Don't ask again"]) {
253
+ await fixture(async (f) => environment(f, async () => {
254
+ await f.project({ defaultTools: [], piexis: { recommendedTools: "enabled" }, custom: 42 });
255
+ const before = await readFile(f.projectPath, "utf8");
256
+ const h = harness(f, async () => choice);
257
+ await h.start();
258
+ assert.equal(h.prompts, 1);
259
+ assert.equal(h.activations, 0);
260
+ await assert.rejects(stat(join(f.cwd, ".pi", "agent.json")), { code: "ENOENT" });
261
+ if (choice === "Don't ask again") {
262
+ assert.deepEqual(JSON.parse(await readFile(f.projectPath, "utf8")), {
263
+ defaultTools: [], piexis: { recommendedTools: "enabled", agentSetup: "disabled" }, custom: 42
264
+ });
265
+ const restarted = harness(f);
266
+ await restarted.start();
267
+ assert.equal(restarted.prompts, 0);
268
+ } else assert.equal(await readFile(f.projectPath, "utf8"), before);
269
+ }));
270
+ }
271
+ });
272
+
273
+ test("agent-only approval preserves restrictive and older tool policies, including a prior tool decline", async () => {
274
+ for (const additive of [true, false]) {
275
+ await fixture(async (f) => {
276
+ const original = { defaultTools: [], piexis: { recommendedTools: "disabled" }, other: 42 };
277
+ await f.project(original);
278
+ await saveSetupChoice(await readSetupSettings(f.cwd, f.agentDir), "enabled", [], additive, undefined, "enabled");
279
+ assert.deepEqual(JSON.parse(await readFile(f.projectPath, "utf8")), { ...original, piexis: { ...original.piexis, agentSetup: "enabled" } });
280
+ });
281
+ }
282
+ await fixture(async (f) => environment(f, async () => {
283
+ await f.project({ defaultTools: [], piexis: { recommendedTools: "disabled" } });
284
+ const h = harness(f);
285
+ await h.start();
286
+ assert.equal(h.prompts, 1);
287
+ assert.equal(h.activations, 0);
288
+ assert.deepEqual(JSON.parse(await readFile(f.projectPath, "utf8")), {
289
+ defaultTools: [], piexis: { recommendedTools: "disabled", agentSetup: "enabled" }
290
+ });
291
+ assert.equal(JSON.parse(await readFile(join(f.cwd, ".pi", "agent.json"), "utf8")).model, "test/default");
292
+ }));
293
+ });
294
+
295
+ test("completed, unavailable or explicitly declined agent setup does not offer another prompt", async () => {
296
+ for (const name of ["agent.json", "agents.json", "unavailable", "declined"]) {
297
+ await fixture(async (f) => environment(f, async () => {
298
+ await f.project({ piexis: { recommendedTools: "enabled", ...(name === "declined" ? { agentSetup: "disabled" } : {}) } });
299
+ if (name.endsWith(".json")) await writeFile(join(f.cwd, ".pi", name), '{"model":"test/default"}');
300
+ const h = harness(f);
301
+ if (name === "unavailable") h.available = [...recommended].map((name) => ({ name, sourceInfo: { source: "builtin" } }));
302
+ await h.start();
303
+ assert.equal(h.prompts, 0);
304
+ }));
305
+ }
306
+ });
307
+
308
+ test("Enable preserves existing agent configuration and warns on initialization failures without undoing tools", async () => {
309
+ for (const name of ["agent.json", "agents.json"]) {
310
+ await fixture(async (f) => environment(f, async () => {
311
+ await mkdir(join(f.cwd, ".pi"));
312
+ const path = join(f.cwd, ".pi", name);
313
+ await writeFile(path, "{existing malformed configuration}");
314
+ const h = harness(f);
315
+ await h.start();
316
+ assert.equal(await readFile(path, "utf8"), "{existing malformed configuration}");
317
+ assert.deepEqual(h.active, [...base, ...recommended]);
318
+ assert.equal(h.notifications.some((notice) => notice[1] === "warning"), false);
319
+ if (name === "agents.json") await assert.rejects(stat(join(f.cwd, ".pi", "agent.json")), { code: "ENOENT" });
320
+ }));
321
+ }
322
+ for (const missingModel of [true, false]) {
323
+ await fixture(async (f) => environment(f, async () => {
324
+ const h = harness(f);
325
+ if (missingModel) h.ctx.model = undefined;
326
+ else await writeFile(join(f.agentDir, "agent.json"), "{bad");
327
+ await h.start();
328
+ assert.deepEqual(h.active, [...base, ...recommended]);
329
+ assert.equal(JSON.parse(await readFile(f.projectPath, "utf8")).piexis.recommendedTools, "enabled");
330
+ await assert.rejects(stat(join(f.cwd, ".pi", "agent.json")), { code: "ENOENT" });
331
+ assert.match(h.notifications.find((notice) => notice[1] === "warning")[0], /Agent configuration was not created/);
332
+ }));
333
+ }
334
+ });
335
+
336
+ test("Not now/Esc makes no writes; Don't ask again persists without activating tools", async () => {
337
+ for (const choice of ["Not now", undefined, "Don't ask again"]) {
338
+ await fixture(async (f) => environment(f, async () => {
339
+ const h = harness(f, async () => choice);
340
+ await h.start();
341
+ assert.equal(h.activations, 0);
342
+ await h.start();
343
+ assert.equal(h.prompts, 1);
344
+ await assert.rejects(stat(join(f.cwd, ".pi", "agent.json")), { code: "ENOENT" });
345
+ if (choice === "Don't ask again") {
346
+ const saved = JSON.parse(await readFile(f.projectPath, "utf8"));
347
+ assert.deepEqual(saved, { piexis: { recommendedTools: "disabled", agentSetup: "disabled" } });
348
+ const restarted = harness(f);
349
+ await restarted.start();
350
+ assert.equal(restarted.prompts, 0);
351
+ } else await assert.rejects(stat(join(f.cwd, ".pi")), { code: "ENOENT" });
352
+ }));
353
+ }
354
+ });
355
+
356
+ test("headless, non-TTY, untrusted, CLI-restricted and unavailable tools skip startup setup", async () => {
357
+ await fixture(async (f) => environment(f, async () => {
358
+ for (const patch of [{ mode: "print" }, { mode: "json" }, { mode: "rpc" }, { hasUI: false }, { isProjectTrusted: () => false }]) {
359
+ const h = harness(f);
360
+ Object.assign(h.ctx, patch);
361
+ await h.start();
362
+ assert.equal(h.prompts, 0);
363
+ }
364
+ Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
365
+ const noTTY = harness(f);
366
+ await noTTY.start();
367
+ assert.equal(noTTY.prompts, 0);
368
+ Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
369
+ process.argv.push("--no-builtin-tools");
370
+ const cli = harness(f);
371
+ await cli.start();
372
+ assert.equal(cli.prompts, 0);
373
+ process.argv.pop();
374
+ const unavailable = harness(f);
375
+ unavailable.available = [];
376
+ await unavailable.start();
377
+ assert.equal(unavailable.prompts, 0);
378
+ await assert.rejects(stat(join(f.cwd, ".pi")), { code: "ENOENT" });
379
+ }));
380
+ });
381
+
382
+ test("startup shutdown cancels pending dialog without activation or writes", async () => {
383
+ await fixture(async (f) => environment(f, async () => {
384
+ let shown;
385
+ const ready = new Promise((resolve) => { shown = resolve; });
386
+ const h = harness(f, (_title, _options, { signal }) => new Promise((resolve) => {
387
+ signal.addEventListener("abort", () => resolve(undefined), { once: true });
388
+ shown();
389
+ }));
390
+ const waiting = h.start();
391
+ await ready;
392
+ await h.start();
393
+ assert.equal(h.prompts, 1);
394
+ await h.shutdown();
395
+ await waiting;
396
+ assert.equal(h.activations, 0);
397
+ await assert.rejects(stat(join(f.cwd, ".pi")), { code: "ENOENT" });
398
+ }));
399
+ });
400
+
401
+ test("startup errors or changed tool availability warn without changing runtime tools", async () => {
402
+ await fixture(async (f) => environment(f, async () => {
403
+ await f.project("{ broken");
404
+ const invalid = harness(f);
405
+ await invalid.start();
406
+ assert.equal(invalid.prompts, 0);
407
+ assert.equal(invalid.notifications[0][1], "warning");
408
+ assert.equal(await readFile(f.projectPath, "utf8"), "{ broken");
409
+ await f.project({});
410
+ const changed = harness(f, async () => { changed.available = []; return "Enable"; });
411
+ await changed.start();
412
+ assert.equal(changed.activations, 0);
413
+ assert.deepEqual(JSON.parse(await readFile(f.projectPath, "utf8")), {});
414
+ assert.equal(changed.notifications[0][1], "warning");
415
+ await mkdir(`${f.projectPath}.lock`);
416
+ const locked = harness(f);
417
+ await locked.start();
418
+ assert.equal(locked.activations, 0);
419
+ assert.equal(locked.notifications[0][1], "warning");
420
+ assert.deepEqual(JSON.parse(await readFile(f.projectPath, "utf8")), {});
421
+ assert.equal((await stat(`${f.projectPath}.lock`)).isDirectory(), true);
422
+ }));
423
+ });
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { access, readFile, readdir } from "node:fs/promises";
3
+ import test from "node:test";
4
+ import askQuestion from "../extensions/ask-question.ts";
5
+ import ui from "../extensions/ui.ts";
6
+
7
+ const root = new URL("../", import.meta.url);
8
+
9
+ test("manifest loads only retained extensions and resources", async () => {
10
+ const manifest = JSON.parse(await readFile(new URL("package.json", root), "utf8"));
11
+ assert.deepEqual(manifest.pi.extensions, ["./extensions/ask-question.ts", "./extensions/ui.ts", "./extensions/recommended-tools.ts", "./extensions/agents.ts"]);
12
+ for (const paths of Object.values(manifest.pi)) {
13
+ for (const path of paths) await access(new URL(path, root));
14
+ }
15
+ assert.deepEqual((await readdir(new URL("extensions/", root))).sort(), ["agents.ts", "ask-question.ts", "recommended-tools.ts", "ui.ts"]);
16
+ assert.equal(manifest.dependencies?.["@anthropic-ai/sandbox-runtime"], undefined);
17
+ for (const removed of ["test:runtime", "test:sandbox", "eval:permissions"]) {
18
+ assert.equal(manifest.scripts[removed], undefined);
19
+ }
20
+ });
21
+
22
+ test("AskQuestion works independently and retains non-TUI handling", async () => {
23
+ let tool;
24
+ askQuestion({ on() {}, registerTool(value) { tool = value; } });
25
+ assert.equal(tool.name, "AskQuestion");
26
+ assert.doesNotMatch(tool.description, /permission|mode commands/i);
27
+ const params = { questions: [{ question: "Which?", options: [{ label: "A" }] }] };
28
+ const headless = await tool.execute("id", params, undefined, undefined, { mode: "json" });
29
+ assert.equal(headless.details.status, "requires_interaction");
30
+ assert.deepEqual(headless.details.answers, []);
31
+ assert.equal(headless.details.cancelled, true);
32
+ });
33
+
34
+ test("UI renders without mode controls or permission status dependencies", async () => {
35
+ const handlers = new Map();
36
+ ui({ on(name, handler) { handlers.set(name, handler); }, getThinkingLevel: () => "off" });
37
+ let header, footer;
38
+ const theme = { fg: (_color, text) => text, bold: (text) => text };
39
+ const ctx = {
40
+ mode: "tui", cwd: "/tmp/project", model: { id: "test-model" },
41
+ sessionManager: { getBranch: () => [], getEntries: () => [] },
42
+ getContextUsage: () => ({ percent: 12, tokens: 24_000, contextWindow: 200_000 }),
43
+ ui: {
44
+ setTitle() {},
45
+ setHeader(factory) { header = factory({}, theme); },
46
+ setFooter(factory) {
47
+ footer = factory({ requestRender() {} }, theme, {
48
+ onBranchChange: () => () => {}, getGitBranch: () => "main"
49
+ });
50
+ }
51
+ }
52
+ };
53
+ await handlers.get("session_start")({}, ctx);
54
+ for (const width of [60, 120]) {
55
+ assert.doesNotMatch(header.render(width).join("\n"), /Alt\+M|\/plan|permission/i);
56
+ const dashboard = footer.render(width).join("\n");
57
+ for (const value of [/ready/, /main/, /test-model/, /think off/, /ctx .*12%/]) {
58
+ assert.match(dashboard, value);
59
+ }
60
+ }
61
+ await handlers.get("tool_call")({ toolName: "read" });
62
+ assert.match(footer.render(120)[0], /read/);
63
+ await handlers.get("agent_end")();
64
+ assert.match(footer.render(120)[0], /ready/);
65
+ footer.dispose();
66
+ });
@@ -17,8 +17,11 @@ const cases = [
17
17
  ["Implement this test-first.", "tdd", /test-first/i],
18
18
  ["Verify whether this implementation meets the acceptance criteria. Do not modify it.", "verify-and-stop", /acceptance conditions/i],
19
19
  ["Build an MCP server.", "mcp-builder", /MCP .*servers/i],
20
+ ["Find a skill for deployment.", "find-skills", /discover and install agent skills/i],
20
21
  ["Create an Excel workbook.", "xlsx", /spreadsheet/i],
21
22
  ["Design the frontend for this dashboard.", "frontend-design", /product web interface/i],
23
+ ["Keep chat responses brief without losing technical facts.", "caveman", /every technical fact kept/i],
24
+ ["Build the smallest complete solution.", "ponytail", /smallest change that fully solves the task/i],
22
25
  ];
23
26
 
24
27
  async function skillText(name) {
@@ -37,15 +40,15 @@ test("active canonical skills do not require removed setup workflows", async ()
37
40
  assert.doesNotMatch(text, /issue-tracker\.md/i);
38
41
  });
39
42
 
40
- test("only the consolidated canonical skills remain active", async () => {
43
+ test("only canonical workflows and bundled voice/build skills remain active", async () => {
41
44
  const active = (await readdir(new URL("../skills/", import.meta.url), { withFileTypes: true }))
42
45
  .filter((entry) => entry.isDirectory())
43
46
  .map((entry) => entry.name)
44
47
  .sort();
45
48
  assert.deepEqual(active, [
46
- "claude-api", "code-review", "codebase-design", "diagnosing-bugs", "docx",
47
- "frontend-design", "grilling", "lean-build", "mcp-builder", "migration", "pdf",
48
- "pptx", "safe-refactor", "tdd", "verify-and-stop", "webapp-testing",
49
+ "caveman", "code-review", "codebase-design", "diagnosing-bugs", "docx",
50
+ "find-skills", "frontend-design", "grilling", "lean-build", "mcp-builder", "migration", "pdf",
51
+ "ponytail", "pptx", "safe-refactor", "tdd", "verify-and-stop", "webapp-testing",
49
52
  "writing-for-agents", "xlsx",
50
53
  ]);
51
54
  });
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import test from "node:test";
8
+
9
+ test("UI tip JSON reload bypasses Pi-loader legacy data and rejects invalid tips", async () => {
10
+ const hostRequire = createRequire(import.meta.resolve("@earendil-works/pi-coding-agent"));
11
+ const jitiPackage = pathToFileURL(hostRequire.resolve("jiti/package.json"));
12
+ const { createJiti } = await import(new URL("./lib/jiti-static.mjs", jitiPackage).href);
13
+ const root = await mkdtemp(join(tmpdir(), "piexis-ui-reload-"));
14
+ const entry = join(root, "extensions", "ui.ts");
15
+ const asset = join(root, "lib", "ui-tips.json");
16
+ const load = () => createJiti(import.meta.url, {
17
+ moduleCache: false,
18
+ alias: {
19
+ "@earendil-works/pi-tui": import.meta.resolve("@earendil-works/pi-tui"),
20
+ "@earendil-works/pi-coding-agent": import.meta.resolve("@earendil-works/pi-coding-agent")
21
+ }
22
+ }).import(entry, { default: true });
23
+ async function render(factory) {
24
+ const handlers = new Map();
25
+ let header;
26
+ factory({ on(name, handler) { handlers.set(name, handler); } });
27
+ await handlers.get("session_start")({}, {
28
+ mode: "tui", cwd: "/projects/demo",
29
+ ui: {
30
+ setTitle() {}, setFooter() {},
31
+ setHeader(make) { header = make({}, { fg: (_color, text) => text, bold: (text) => text }); }
32
+ }
33
+ });
34
+ return header.render(200).join("\n");
35
+ }
36
+ try {
37
+ await mkdir(join(root, "extensions"));
38
+ await mkdir(join(root, "lib"));
39
+ await writeFile(asset, JSON.stringify(["Legacy cached tip"]));
40
+ await writeFile(join(root, "lib", "agent-config.ts"), await readFile(new URL("../lib/agent-config.ts", import.meta.url), "utf8"));
41
+ await writeFile(join(root, "lib", "agents.ts"), await readFile(new URL("../lib/agents.ts", import.meta.url), "utf8"));
42
+ await writeFile(entry, 'import tips from "../lib/ui-tips.json" with { type: "json" }; export default () => tips;');
43
+ assert.deepEqual((await load())(), ["Legacy cached tip"]);
44
+
45
+ const tips = [
46
+ { category: "Coding", text: "Fresh coding tip" },
47
+ { category: "Style", text: "Fresh style tip" },
48
+ { category: "Planning", text: "Fresh planning tip" }
49
+ ];
50
+ await writeFile(asset, JSON.stringify(tips));
51
+ await writeFile(entry, await readFile(new URL("../extensions/ui.ts", import.meta.url), "utf8"));
52
+ const text = await render(await load());
53
+ for (const tip of tips) assert.ok(text.includes(`[${tip.category}] ${tip.text}`));
54
+ assert.doesNotMatch(text, /undefined|Legacy cached tip/);
55
+
56
+ const updated = tips.map((tip) => ({ ...tip, text: `Updated ${tip.text}` }));
57
+ await writeFile(asset, JSON.stringify(updated));
58
+ const factory = await load();
59
+ const reloaded = await render(factory);
60
+ for (const tip of updated) assert.ok(reloaded.includes(tip.text));
61
+ assert.doesNotMatch(reloaded, /undefined/);
62
+
63
+ for (const invalid of [null, [null], ["Legacy tip"], [{ category: "Coding" }], [{ category: "", text: "Tip" }]]) {
64
+ await writeFile(asset, JSON.stringify(invalid));
65
+ assert.throws(() => factory({ on() {} }), /Invalid ui-tips.json/);
66
+ }
67
+ } finally {
68
+ await rm(root, { recursive: true, force: true });
69
+ }
70
+ });