pi-teams 0.9.5 → 0.9.6

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/README.md CHANGED
@@ -45,6 +45,7 @@ pi install npm:pi-teams
45
45
  - **Beautiful UI**: Optimized vertical splits in `tmux` with clear labels so you always know who is doing what.
46
46
 
47
47
  ### Advanced Features
48
+ - **Predefined Teams**: Define team templates in `teams.yaml` and spawn entire teams with a single command.
48
49
  - **Isolated OS Windows**: Launch teammates in true separate OS windows instead of panes.
49
50
  - **Persistent Window Titles**: Windows are automatically titled `[team-name]: [agent-name]` for easy identification in your window manager.
50
51
  - **Plan Approval Mode**: Require teammates to submit their implementation plans for your approval before they touch any code.
@@ -108,6 +109,95 @@ Teammates in `planning` mode will use `task_submit_plan`. As the lead, review th
108
109
 
109
110
  ---
110
111
 
112
+ ## 🏗️ Predefined Teams
113
+
114
+ Predefined teams let you define reusable team templates in a `teams.yaml` file. This is perfect for common workflows where you always want the same set of specialists.
115
+
116
+ ### Define Team Templates
117
+
118
+ Create `~/.pi/teams.yaml` (global) or `.pi/teams.yaml` in your project:
119
+
120
+ ```yaml
121
+ # Full development team
122
+ full:
123
+ - scout
124
+ - planner
125
+ - builder
126
+ - reviewer
127
+ - documenter
128
+
129
+ # Quick plan-build cycle
130
+ plan-build:
131
+ - planner
132
+ - builder
133
+ - reviewer
134
+
135
+ # Research and documentation
136
+ research:
137
+ - scout
138
+ - documenter
139
+
140
+ # Frontend specialists
141
+ frontend:
142
+ - planner
143
+ - builder
144
+ - bowser
145
+ ```
146
+
147
+ ### Define Agent Definitions
148
+
149
+ Create agent definitions in `~/.pi/agent/agents/` (global) or `.pi/agents/` (project-local):
150
+
151
+ **scout.md:**
152
+ ```markdown
153
+ ---
154
+ name: scout
155
+ description: Fast recon and codebase exploration
156
+ tools: read,grep,find,ls
157
+ ---
158
+ You are a scout agent. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, and key entry points.
159
+ ```
160
+
161
+ **builder.md:**
162
+ ```markdown
163
+ ---
164
+ name: builder
165
+ description: Implementation specialist
166
+ tools: read,write,edit,bash
167
+ model: claude-sonnet-4
168
+ thinking: medium
169
+ ---
170
+ You are a builder agent. Implement code following the plan provided. Write clean, tested code.
171
+ ```
172
+
173
+ **Agent Definition Fields:**
174
+ - `name` (required): The agent's name
175
+ - `description` (required): What the agent does
176
+ - `tools` (optional): Comma or space-separated list of allowed tools
177
+ - `model` (optional): Model to use (e.g., `claude-sonnet-4`, `gpt-4o`)
178
+ - `thinking` (optional): Thinking level (`off`, `minimal`, `low`, `medium`, `high`)
179
+
180
+ ### Use Predefined Teams
181
+
182
+ **List available team templates:**
183
+ > **You:** "List all predefined teams I can use."
184
+
185
+ **List available agent definitions:**
186
+ > **You:** "Show me all predefined agents."
187
+
188
+ **Create a team from a template:**
189
+ > **You:** "Create a team named 'my-project' from the 'plan-build' predefined team in the current directory."
190
+
191
+ This single command:
192
+ 1. Creates the team
193
+ 2. Spawns all agents defined in the template
194
+ 3. Each agent gets its predefined prompt, tools, model, and thinking settings
195
+
196
+ **With options:**
197
+ > **You:** "Create a team named 'big-team' from 'full' predefined team using 'gpt-4o' as default model and separate windows."
198
+
199
+ ---
200
+
111
201
  ## 📚 Learn More
112
202
 
113
203
  - **[Full Usage Guide](docs/guide.md)** - Detailed examples, hook system, best practices, and troubleshooting
@@ -9,6 +9,7 @@ import * as runtime from "../src/utils/runtime";
9
9
  import { Member } from "../src/utils/models";
10
10
  import { getTerminalAdapter } from "../src/adapters/terminal-registry";
11
11
  import { Iterm2Adapter } from "../src/adapters/iterm2-adapter";
12
+ import * as predefined from "../src/utils/predefined-teams";
12
13
  import * as path from "node:path";
13
14
  import * as fs from "node:fs";
14
15
  import { spawnSync } from "node:child_process";
@@ -897,4 +898,216 @@ export default function (pi: ExtensionAPI) {
897
898
  };
898
899
  },
899
900
  });
901
+
902
+ pi.registerTool({
903
+ name: "list_predefined_teams",
904
+ label: "List Predefined Teams",
905
+ description: "List all available predefined team configurations from teams.yaml files. These are team templates that can be instantiated with create_predefined_team.",
906
+ parameters: Type.Object({}),
907
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
908
+ const projectDir = ctx.cwd;
909
+ const predefinedTeams = predefined.getAllPredefinedTeams(projectDir);
910
+ const agents = predefined.getAllAgentDefinitions(projectDir);
911
+
912
+ const result = predefinedTeams.map(team => {
913
+ const teamAgents = team.agents.map(agentName => {
914
+ const agentDef = agents.find(a => a.name === agentName);
915
+ return {
916
+ name: agentName,
917
+ description: agentDef?.description || "(agent definition not found)",
918
+ found: !!agentDef,
919
+ };
920
+ });
921
+
922
+ return {
923
+ name: team.name,
924
+ agents: teamAgents,
925
+ };
926
+ });
927
+
928
+ return {
929
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
930
+ details: { teams: result },
931
+ };
932
+ },
933
+ });
934
+
935
+ pi.registerTool({
936
+ name: "list_predefined_agents",
937
+ label: "List Predefined Agents",
938
+ description: "List all available predefined agent definitions from .md files. These can be used individually or as part of predefined teams.",
939
+ parameters: Type.Object({}),
940
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
941
+ const projectDir = ctx.cwd;
942
+ const agents = predefined.getAllAgentDefinitions(projectDir);
943
+
944
+ const result = agents.map(agent => ({
945
+ name: agent.name,
946
+ description: agent.description,
947
+ tools: agent.tools,
948
+ model: agent.model,
949
+ thinking: agent.thinking,
950
+ }));
951
+
952
+ return {
953
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
954
+ details: { agents: result },
955
+ };
956
+ },
957
+ });
958
+
959
+ pi.registerTool({
960
+ name: "create_predefined_team",
961
+ label: "Create Predefined Team",
962
+ description: "Create a team from a predefined team configuration. Spawns all agents defined in the team template from teams.yaml. Each agent is spawned with its predefined prompt, tools, and settings.",
963
+ parameters: Type.Object({
964
+ team_name: Type.String({ description: "Name for the new team instance" }),
965
+ predefined_team: Type.String({ description: "Name of the predefined team template from teams.yaml" }),
966
+ cwd: Type.String({ description: "Working directory for spawned agents" }),
967
+ default_model: Type.Optional(Type.String({ description: "Default model for agents without a specified model" })),
968
+ separate_windows: Type.Optional(Type.Boolean({ default: false, description: "Open teammates in separate OS windows instead of panes" })),
969
+ }),
970
+ async execute(toolCallId, params: any, signal, onUpdate, ctx) {
971
+ const projectDir = ctx.cwd;
972
+ const predefinedTeam = predefined.getPredefinedTeam(params.predefined_team, projectDir);
973
+
974
+ if (!predefinedTeam) {
975
+ const available = predefined.getAllPredefinedTeams(projectDir).map(t => t.name);
976
+ throw new Error(`Predefined team "${params.predefined_team}" not found. Available teams: ${available.join(", ") || "none"}`);
977
+ }
978
+
979
+ if (!terminal) {
980
+ throw new Error("No terminal adapter detected.");
981
+ }
982
+
983
+ // Create the team
984
+ const config = teams.createTeam(params.team_name, "local-session", "lead-agent", `Predefined team: ${params.predefined_team}`, params.default_model, params.separate_windows);
985
+ registerLeadSession(params.team_name);
986
+
987
+ const agentDefinitions = predefined.getAllAgentDefinitions(projectDir);
988
+ const spawnResults: Array<{ name: string; status: string; error?: string }> = [];
989
+
990
+ // Spawn each agent in the predefined team
991
+ for (const agentName of predefinedTeam.agents) {
992
+ const agentDef = agentDefinitions.find(a => a.name === agentName);
993
+
994
+ if (!agentDef) {
995
+ spawnResults.push({ name: agentName, status: "skipped", error: "Agent definition not found" });
996
+ continue;
997
+ }
998
+
999
+ try {
1000
+ const safeName = paths.sanitizeName(agentName);
1001
+ const safeTeamName = paths.sanitizeName(params.team_name);
1002
+
1003
+ let chosenModel = agentDef.model || params.default_model || config.defaultModel;
1004
+
1005
+ if (chosenModel && !chosenModel.includes('/')) {
1006
+ const resolved = resolveModelWithProvider(chosenModel);
1007
+ if (resolved) {
1008
+ chosenModel = resolved;
1009
+ } else if (config.defaultModel && config.defaultModel.includes('/')) {
1010
+ const [provider] = config.defaultModel.split('/');
1011
+ chosenModel = `${provider}/${chosenModel}`;
1012
+ }
1013
+ }
1014
+
1015
+ const useSeparateWindow = params.separate_windows ?? config.separateWindows ?? false;
1016
+ if (useSeparateWindow && !terminal.supportsWindows()) {
1017
+ throw new Error(`Separate windows mode is not supported in ${terminal.name}.`);
1018
+ }
1019
+
1020
+ const member: Member = {
1021
+ agentId: `${safeName}@${safeTeamName}`,
1022
+ name: safeName,
1023
+ agentType: "teammate",
1024
+ model: chosenModel,
1025
+ joinedAt: Date.now(),
1026
+ tmuxPaneId: "",
1027
+ cwd: params.cwd,
1028
+ subscriptions: [],
1029
+ prompt: agentDef.prompt,
1030
+ color: "blue",
1031
+ thinking: agentDef.thinking,
1032
+ };
1033
+
1034
+ await teams.addMember(safeTeamName, member);
1035
+ await messaging.sendPlainMessage(safeTeamName, "team-lead", safeName, agentDef.prompt, "Initial prompt from predefined team");
1036
+
1037
+ const piBinary = process.argv[1] ? `node ${process.argv[1]}` : "pi";
1038
+ let piCmd = piBinary;
1039
+
1040
+ if (chosenModel) {
1041
+ if (agentDef.thinking) {
1042
+ piCmd = `${piBinary} --model ${chosenModel}:${agentDef.thinking}`;
1043
+ } else {
1044
+ piCmd = `${piBinary} --model ${chosenModel}`;
1045
+ }
1046
+ } else if (agentDef.thinking) {
1047
+ piCmd = `${piBinary} --thinking ${agentDef.thinking}`;
1048
+ }
1049
+
1050
+ const env: Record<string, string> = {
1051
+ ...process.env,
1052
+ PI_TEAM_NAME: safeTeamName,
1053
+ PI_AGENT_NAME: safeName,
1054
+ };
1055
+
1056
+ let terminalId = "";
1057
+ let isWindow = false;
1058
+
1059
+ try {
1060
+ if (useSeparateWindow) {
1061
+ isWindow = true;
1062
+ terminalId = terminal.spawnWindow({
1063
+ name: safeName,
1064
+ cwd: params.cwd,
1065
+ command: piCmd,
1066
+ env: env,
1067
+ teamName: safeTeamName,
1068
+ });
1069
+ await teams.updateMember(safeTeamName, safeName, { windowId: terminalId });
1070
+ } else {
1071
+ if (terminal instanceof Iterm2Adapter) {
1072
+ const teammates = (await teams.readConfig(safeTeamName)).members.filter(m => m.agentType === "teammate" && m.tmuxPaneId.startsWith("iterm_"));
1073
+ const lastTeammate = teammates.length > 0 ? teammates[teammates.length - 1] : null;
1074
+ if (lastTeammate?.tmuxPaneId) {
1075
+ terminal.setSpawnContext({ lastSessionId: lastTeammate.tmuxPaneId.replace("iterm_", "") });
1076
+ } else {
1077
+ terminal.setSpawnContext({});
1078
+ }
1079
+ }
1080
+
1081
+ const leadMember = (await teams.readConfig(safeTeamName)).members.find(m => m.name === "team-lead");
1082
+ const anchorPaneId = terminal.name === "tmux"
1083
+ ? leadMember?.tmuxPaneId || process.env.TMUX_PANE || undefined
1084
+ : undefined;
1085
+
1086
+ terminalId = terminal.spawn({
1087
+ name: safeName,
1088
+ cwd: params.cwd,
1089
+ command: piCmd,
1090
+ env: env,
1091
+ anchorPaneId,
1092
+ });
1093
+ await teams.updateMember(safeTeamName, safeName, { tmuxPaneId: terminalId });
1094
+ }
1095
+
1096
+ spawnResults.push({ name: agentName, status: "spawned", error: undefined });
1097
+ } catch (e) {
1098
+ spawnResults.push({ name: agentName, status: "error", error: `Failed to spawn: ${e}` });
1099
+ }
1100
+ } catch (e) {
1101
+ spawnResults.push({ name: agentName, status: "error", error: String(e) });
1102
+ }
1103
+ }
1104
+
1105
+ const summary = spawnResults.map(r => `${r.name}: ${r.status}${r.error ? ` (${r.error})` : ""}`).join("\n");
1106
+
1107
+ return {
1108
+ content: [{ type: "text", text: `Team "${params.team_name}" created from predefined team "${params.predefined_team}".\n\nAgent spawn results:\n${summary}` }],
1109
+ details: { teamName: params.team_name, predefinedTeam: params.predefined_team, results: spawnResults },
1110
+ };
1111
+ },
1112
+ });
900
1113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,18 @@
9
9
  "author": "Mark Burggraf",
10
10
  "license": "MIT",
11
11
  "keywords": [
12
- "pi-package"
12
+ "pi-package",
13
+ "ai-agent",
14
+ "multi-agent",
15
+ "agent-teams",
16
+ "agent-coordination",
17
+ "autonomous-agents",
18
+ "task-management",
19
+ "team-collaboration",
20
+ "agent-orchestration",
21
+ "coding-assistant",
22
+ "terminal",
23
+ "tmux"
13
24
  ],
14
25
  "scripts": {
15
26
  "test": "vitest run"
@@ -0,0 +1,389 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import {
6
+ parseAgentFrontmatter,
7
+ parseTeamsYaml,
8
+ discoverAgents,
9
+ discoverTeams,
10
+ getAllAgentDefinitions,
11
+ getAllPredefinedTeams,
12
+ getAgentDefinition,
13
+ getPredefinedTeam,
14
+ } from "./predefined-teams";
15
+
16
+ describe("parseAgentFrontmatter", () => {
17
+ it("parses a valid agent definition with all fields", () => {
18
+ const content = `---
19
+ name: scout
20
+ description: Fast recon and codebase exploration
21
+ tools: read,grep,find,ls
22
+ model: claude-sonnet-4
23
+ thinking: high
24
+ ---
25
+ You are a scout agent. Investigate the codebase quickly.`;
26
+
27
+ const result = parseAgentFrontmatter(content, "/test/scout.md");
28
+
29
+ expect(result).not.toBeNull();
30
+ expect(result?.name).toBe("scout");
31
+ expect(result?.description).toBe("Fast recon and codebase exploration");
32
+ expect(result?.tools).toEqual(["read", "grep", "find", "ls"]);
33
+ expect(result?.model).toBe("claude-sonnet-4");
34
+ expect(result?.thinking).toBe("high");
35
+ expect(result?.prompt).toBe("You are a scout agent. Investigate the codebase quickly.");
36
+ expect(result?.filePath).toBe("/test/scout.md");
37
+ });
38
+
39
+ it("parses an agent definition with space-separated tools", () => {
40
+ const content = `---
41
+ name: builder
42
+ description: Code builder
43
+ tools: read write edit bash
44
+ ---
45
+ You build things.`;
46
+
47
+ const result = parseAgentFrontmatter(content, "/test/builder.md");
48
+
49
+ expect(result).not.toBeNull();
50
+ expect(result?.tools).toEqual(["read", "write", "edit", "bash"]);
51
+ });
52
+
53
+ it("parses an agent definition without optional fields", () => {
54
+ const content = `---
55
+ name: simple
56
+ description: Simple agent
57
+ ---
58
+ You are simple.`;
59
+
60
+ const result = parseAgentFrontmatter(content, "/test/simple.md");
61
+
62
+ expect(result).not.toBeNull();
63
+ expect(result?.name).toBe("simple");
64
+ expect(result?.description).toBe("Simple agent");
65
+ expect(result?.tools).toBeUndefined();
66
+ expect(result?.model).toBeUndefined();
67
+ expect(result?.thinking).toBeUndefined();
68
+ expect(result?.prompt).toBe("You are simple.");
69
+ });
70
+
71
+ it("returns null for content without frontmatter", () => {
72
+ const content = "This is just regular markdown without frontmatter.";
73
+ const result = parseAgentFrontmatter(content, "/test/no-frontmatter.md");
74
+ expect(result).toBeNull();
75
+ });
76
+
77
+ it("returns null for frontmatter without name", () => {
78
+ const content = `---
79
+ description: Missing name field
80
+ ---
81
+ Some prompt`;
82
+ const result = parseAgentFrontmatter(content, "/test/no-name.md");
83
+ expect(result).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe("parseTeamsYaml", () => {
88
+ it("parses a valid teams.yaml content", () => {
89
+ const content = `
90
+ full:
91
+ - scout
92
+ - planner
93
+ - builder
94
+
95
+ plan-build:
96
+ - planner
97
+ - builder
98
+ - reviewer
99
+ `;
100
+
101
+ const result = parseTeamsYaml(content);
102
+
103
+ expect(result).toHaveLength(2);
104
+ expect(result[0].name).toBe("full");
105
+ expect(result[0].agents).toEqual(["scout", "planner", "builder"]);
106
+ expect(result[1].name).toBe("plan-build");
107
+ expect(result[1].agents).toEqual(["planner", "builder", "reviewer"]);
108
+ });
109
+
110
+ it("handles comments and empty lines", () => {
111
+ const content = `
112
+ # This is a comment
113
+ full:
114
+ - scout
115
+ # Another comment
116
+ - planner
117
+
118
+ # Empty line above
119
+ minimal:
120
+ - scout
121
+ `;
122
+
123
+ const result = parseTeamsYaml(content);
124
+
125
+ expect(result).toHaveLength(2);
126
+ expect(result[0].agents).toEqual(["scout", "planner"]);
127
+ expect(result[1].agents).toEqual(["scout"]);
128
+ });
129
+
130
+ it("returns empty array for empty content", () => {
131
+ expect(parseTeamsYaml("")).toEqual([]);
132
+ expect(parseTeamsYaml("# Just comments\n\n")).toEqual([]);
133
+ });
134
+
135
+ it("handles tab indentation", () => {
136
+ const content = `
137
+ full:
138
+ \t- scout
139
+ \t- planner
140
+ `;
141
+
142
+ const result = parseTeamsYaml(content);
143
+
144
+ expect(result).toHaveLength(1);
145
+ expect(result[0].agents).toEqual(["scout", "planner"]);
146
+ });
147
+ });
148
+
149
+ describe("discoverAgents", () => {
150
+ const testDir = path.join(os.tmpdir(), "pi-teams-test-agents-" + Date.now());
151
+
152
+ beforeEach(() => {
153
+ if (fs.existsSync(testDir)) {
154
+ fs.rmSync(testDir, { recursive: true });
155
+ }
156
+ fs.mkdirSync(testDir, { recursive: true });
157
+ });
158
+
159
+ afterEach(() => {
160
+ if (fs.existsSync(testDir)) {
161
+ fs.rmSync(testDir, { recursive: true });
162
+ }
163
+ });
164
+
165
+ it("discovers agent definitions from markdown files", () => {
166
+ fs.writeFileSync(path.join(testDir, "scout.md"), `---
167
+ name: scout
168
+ description: Scout agent
169
+ ---
170
+ Scout prompt`);
171
+
172
+ fs.writeFileSync(path.join(testDir, "builder.md"), `---
173
+ name: builder
174
+ description: Builder agent
175
+ ---
176
+ Builder prompt`);
177
+
178
+ const result = discoverAgents(testDir);
179
+
180
+ expect(result).toHaveLength(2);
181
+ expect(result.find(a => a.name === "scout")).toBeDefined();
182
+ expect(result.find(a => a.name === "builder")).toBeDefined();
183
+ });
184
+
185
+ it("discovers agents from SKILL.md in subdirectories", () => {
186
+ const subDir = path.join(testDir, "special-agent");
187
+ fs.mkdirSync(subDir, { recursive: true });
188
+
189
+ fs.writeFileSync(path.join(subDir, "SKILL.md"), `---
190
+ name: special
191
+ description: Special agent
192
+ ---
193
+ Special prompt`);
194
+
195
+ const result = discoverAgents(testDir);
196
+
197
+ expect(result).toHaveLength(1);
198
+ expect(result[0].name).toBe("special");
199
+ });
200
+
201
+ it("returns empty array for non-existent directory", () => {
202
+ const result = discoverAgents("/non/existent/path");
203
+ expect(result).toEqual([]);
204
+ });
205
+
206
+ it("ignores files without valid frontmatter", () => {
207
+ fs.writeFileSync(path.join(testDir, "invalid.md"), "No frontmatter here");
208
+ fs.writeFileSync(path.join(testDir, "valid.md"), `---
209
+ name: valid
210
+ description: Valid agent
211
+ ---
212
+ Valid prompt`);
213
+
214
+ const result = discoverAgents(testDir);
215
+
216
+ expect(result).toHaveLength(1);
217
+ expect(result[0].name).toBe("valid");
218
+ });
219
+ });
220
+
221
+ describe("discoverTeams", () => {
222
+ const testDir = path.join(os.tmpdir(), "pi-teams-test-teams-" + Date.now());
223
+
224
+ beforeEach(() => {
225
+ if (fs.existsSync(testDir)) {
226
+ fs.rmSync(testDir, { recursive: true });
227
+ }
228
+ fs.mkdirSync(testDir, { recursive: true });
229
+ });
230
+
231
+ afterEach(() => {
232
+ if (fs.existsSync(testDir)) {
233
+ fs.rmSync(testDir, { recursive: true });
234
+ }
235
+ });
236
+
237
+ it("discovers teams from teams.yaml", () => {
238
+ fs.writeFileSync(path.join(testDir, "teams.yaml"), `
239
+ full:
240
+ - scout
241
+ - planner
242
+ `);
243
+
244
+ const result = discoverTeams(testDir);
245
+
246
+ expect(result).toHaveLength(1);
247
+ expect(result[0].name).toBe("full");
248
+ expect(result[0].agents).toEqual(["scout", "planner"]);
249
+ });
250
+
251
+ it("returns empty array when teams.yaml does not exist", () => {
252
+ const result = discoverTeams(testDir);
253
+ expect(result).toEqual([]);
254
+ });
255
+ });
256
+
257
+ describe("getAllAgentDefinitions and getAllPredefinedTeams", () => {
258
+ const globalDir = path.join(os.homedir(), ".pi", "agent", "agents");
259
+ const globalTeamsDir = path.join(os.homedir(), ".pi", "agent");
260
+ const projectDir = path.join(os.tmpdir(), "pi-teams-test-project-" + Date.now());
261
+ const projectAgentsDir = path.join(projectDir, ".pi", "agents");
262
+ const projectTeamsDir = path.join(projectDir, ".pi");
263
+
264
+ // Store original files to restore later
265
+ let originalGlobalAgents: string[] = [];
266
+ let originalGlobalTeams: string | null = null;
267
+
268
+ beforeEach(() => {
269
+ // Create project directory
270
+ if (fs.existsSync(projectDir)) {
271
+ fs.rmSync(projectDir, { recursive: true });
272
+ }
273
+ fs.mkdirSync(projectAgentsDir, { recursive: true });
274
+
275
+ // Backup global files if they exist
276
+ if (fs.existsSync(globalDir)) {
277
+ originalGlobalAgents = fs.readdirSync(globalDir);
278
+ }
279
+ if (fs.existsSync(path.join(globalTeamsDir, "teams.yaml"))) {
280
+ originalGlobalTeams = fs.readFileSync(path.join(globalTeamsDir, "teams.yaml"), "utf-8");
281
+ }
282
+ });
283
+
284
+ afterEach(() => {
285
+ if (fs.existsSync(projectDir)) {
286
+ fs.rmSync(projectDir, { recursive: true });
287
+ }
288
+ });
289
+
290
+ it("combines global and project-local agents", () => {
291
+ // Create project-local agent
292
+ fs.writeFileSync(path.join(projectAgentsDir, "project-agent.md"), `---
293
+ name: project-agent
294
+ description: Project local agent
295
+ ---
296
+ Project prompt`);
297
+
298
+ const result = getAllAgentDefinitions(projectDir);
299
+
300
+ // Should include project-local agent
301
+ expect(result.find(a => a.name === "project-agent")).toBeDefined();
302
+ });
303
+
304
+ it("project-local agents override global agents", () => {
305
+ // Create project-local agent with same name as global
306
+ fs.writeFileSync(path.join(projectAgentsDir, "scout.md"), `---
307
+ name: scout
308
+ description: Project override scout
309
+ ---
310
+ Project scout prompt`);
311
+
312
+ const result = getAllAgentDefinitions(projectDir);
313
+ const scout = result.find(a => a.name === "scout");
314
+
315
+ expect(scout).toBeDefined();
316
+ expect(scout?.description).toBe("Project override scout");
317
+ });
318
+
319
+ it("combines global and project-local teams", () => {
320
+ // Create project-local teams.yaml
321
+ fs.writeFileSync(path.join(projectTeamsDir, "teams.yaml"), `
322
+ custom:
323
+ - agent1
324
+ - agent2
325
+ `);
326
+
327
+ const result = getAllPredefinedTeams(projectDir);
328
+
329
+ // Should include project-local team
330
+ expect(result.find(t => t.name === "custom")).toBeDefined();
331
+ expect(result.find(t => t.name === "custom")?.agents).toEqual(["agent1", "agent2"]);
332
+ });
333
+ });
334
+
335
+ describe("getAgentDefinition and getPredefinedTeam", () => {
336
+ const projectDir = path.join(os.tmpdir(), "pi-teams-test-get-" + Date.now());
337
+ const projectAgentsDir = path.join(projectDir, ".pi", "agents");
338
+ const projectTeamsDir = path.join(projectDir, ".pi");
339
+
340
+ beforeEach(() => {
341
+ if (fs.existsSync(projectDir)) {
342
+ fs.rmSync(projectDir, { recursive: true });
343
+ }
344
+ fs.mkdirSync(projectAgentsDir, { recursive: true });
345
+
346
+ fs.writeFileSync(path.join(projectAgentsDir, "test-agent.md"), `---
347
+ name: test-agent
348
+ description: Test agent
349
+ ---
350
+ Test prompt`);
351
+
352
+ fs.writeFileSync(path.join(projectTeamsDir, "teams.yaml"), `
353
+ test-team:
354
+ - test-agent
355
+ `);
356
+ });
357
+
358
+ afterEach(() => {
359
+ if (fs.existsSync(projectDir)) {
360
+ fs.rmSync(projectDir, { recursive: true });
361
+ }
362
+ });
363
+
364
+ it("gets a specific agent definition by name", () => {
365
+ const result = getAgentDefinition("test-agent", projectDir);
366
+
367
+ expect(result).toBeDefined();
368
+ expect(result?.name).toBe("test-agent");
369
+ expect(result?.description).toBe("Test agent");
370
+ });
371
+
372
+ it("returns undefined for non-existent agent", () => {
373
+ const result = getAgentDefinition("non-existent", projectDir);
374
+ expect(result).toBeUndefined();
375
+ });
376
+
377
+ it("gets a specific predefined team by name", () => {
378
+ const result = getPredefinedTeam("test-team", projectDir);
379
+
380
+ expect(result).toBeDefined();
381
+ expect(result?.name).toBe("test-team");
382
+ expect(result?.agents).toEqual(["test-agent"]);
383
+ });
384
+
385
+ it("returns undefined for non-existent team", () => {
386
+ const result = getPredefinedTeam("non-existent", projectDir);
387
+ expect(result).toBeUndefined();
388
+ });
389
+ });
@@ -0,0 +1,261 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ /**
6
+ * Represents an agent definition from a .md file
7
+ */
8
+ export interface AgentDefinition {
9
+ name: string;
10
+ description: string;
11
+ tools?: string[];
12
+ model?: string;
13
+ thinking?: "off" | "minimal" | "low" | "medium" | "high";
14
+ prompt: string;
15
+ filePath: string;
16
+ }
17
+
18
+ /**
19
+ * Represents a predefined team from teams.yaml
20
+ */
21
+ export interface PredefinedTeam {
22
+ name: string;
23
+ agents: string[];
24
+ description?: string;
25
+ }
26
+
27
+ /**
28
+ * Parse frontmatter from a markdown file
29
+ */
30
+ export function parseAgentFrontmatter(content: string, filePath: string): AgentDefinition | null {
31
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
32
+ if (!frontmatterMatch) {
33
+ return null;
34
+ }
35
+
36
+ const frontmatterStr = frontmatterMatch[1];
37
+ const prompt = frontmatterMatch[2].trim();
38
+ const frontmatter: Record<string, string> = {};
39
+
40
+ // Parse YAML-like frontmatter (simple key: value format)
41
+ for (const line of frontmatterStr.split("\n")) {
42
+ const colonIndex = line.indexOf(":");
43
+ if (colonIndex > 0) {
44
+ const key = line.slice(0, colonIndex).trim();
45
+ const value = line.slice(colonIndex + 1).trim();
46
+ frontmatter[key] = value;
47
+ }
48
+ }
49
+
50
+ const name = frontmatter.name;
51
+ if (!name) {
52
+ return null;
53
+ }
54
+
55
+ const description = frontmatter.description || "";
56
+
57
+ // Parse tools (comma-separated or space-separated)
58
+ let tools: string[] | undefined;
59
+ if (frontmatter.tools) {
60
+ tools = frontmatter.tools.split(/[,\s]+/).map(t => t.trim()).filter(t => t);
61
+ }
62
+
63
+ const thinking = frontmatter.thinking as AgentDefinition["thinking"];
64
+ const model = frontmatter.model;
65
+
66
+ return {
67
+ name,
68
+ description,
69
+ tools,
70
+ model,
71
+ thinking,
72
+ prompt,
73
+ filePath,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Discover agent definitions from a directory
79
+ */
80
+ export function discoverAgents(dir: string): AgentDefinition[] {
81
+ const agents: AgentDefinition[] = [];
82
+
83
+ if (!fs.existsSync(dir)) {
84
+ return agents;
85
+ }
86
+
87
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
88
+
89
+ for (const entry of entries) {
90
+ const fullPath = path.join(dir, entry.name);
91
+
92
+ if (entry.isFile() && entry.name.endsWith(".md")) {
93
+ const content = fs.readFileSync(fullPath, "utf-8");
94
+ const agent = parseAgentFrontmatter(content, fullPath);
95
+ if (agent) {
96
+ agents.push(agent);
97
+ }
98
+ } else if (entry.isDirectory()) {
99
+ // Check for SKILL.md style (agent-name/SKILL.md)
100
+ const skillPath = path.join(fullPath, "SKILL.md");
101
+ if (fs.existsSync(skillPath)) {
102
+ const content = fs.readFileSync(skillPath, "utf-8");
103
+ const agent = parseAgentFrontmatter(content, skillPath);
104
+ if (agent) {
105
+ agents.push(agent);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ return agents;
112
+ }
113
+
114
+ /**
115
+ * Parse teams.yaml content into PredefinedTeam array
116
+ */
117
+ export function parseTeamsYaml(content: string): PredefinedTeam[] {
118
+ const teams: PredefinedTeam[] = [];
119
+ const lines = content.split("\n");
120
+ let currentTeam: PredefinedTeam | null = null;
121
+
122
+ for (const line of lines) {
123
+ // Skip empty lines and comments
124
+ if (!line.trim() || line.trim().startsWith("#")) {
125
+ continue;
126
+ }
127
+
128
+ // Check for team definition (no leading spaces, ends with colon)
129
+ if (!line.startsWith(" ") && !line.startsWith("\t") && line.endsWith(":")) {
130
+ // Save previous team
131
+ if (currentTeam && currentTeam.agents.length > 0) {
132
+ teams.push(currentTeam);
133
+ }
134
+ currentTeam = {
135
+ name: line.slice(0, -1).trim(),
136
+ agents: [],
137
+ };
138
+ } else if (currentTeam && (line.startsWith(" ") || line.startsWith("\t"))) {
139
+ // Agent entry (indented line starting with -)
140
+ const agentMatch = line.match(/^\s*-\s*(.+)$/);
141
+ if (agentMatch) {
142
+ currentTeam.agents.push(agentMatch[1].trim());
143
+ }
144
+ }
145
+ }
146
+
147
+ // Save last team
148
+ if (currentTeam && currentTeam.agents.length > 0) {
149
+ teams.push(currentTeam);
150
+ }
151
+
152
+ return teams;
153
+ }
154
+
155
+ /**
156
+ * Discover predefined teams from teams.yaml files
157
+ */
158
+ export function discoverTeams(dir: string): PredefinedTeam[] {
159
+ const teamsPath = path.join(dir, "teams.yaml");
160
+
161
+ if (!fs.existsSync(teamsPath)) {
162
+ return [];
163
+ }
164
+
165
+ try {
166
+ const content = fs.readFileSync(teamsPath, "utf-8");
167
+ return parseTeamsYaml(content);
168
+ } catch {
169
+ return [];
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Get all agent definitions from all locations
175
+ * Priority: project-local > global
176
+ */
177
+ export function getAllAgentDefinitions(projectDir?: string): AgentDefinition[] {
178
+ const agents: AgentDefinition[] = [];
179
+ const seenNames = new Set<string>();
180
+
181
+ // Global agent definitions
182
+ const globalDir = path.join(os.homedir(), ".pi", "agent", "agents");
183
+ for (const agent of discoverAgents(globalDir)) {
184
+ if (!seenNames.has(agent.name)) {
185
+ seenNames.add(agent.name);
186
+ agents.push(agent);
187
+ }
188
+ }
189
+
190
+ // Project-local agent definitions
191
+ if (projectDir) {
192
+ const projectAgentsDir = path.join(projectDir, ".pi", "agents");
193
+ for (const agent of discoverAgents(projectAgentsDir)) {
194
+ if (!seenNames.has(agent.name)) {
195
+ seenNames.add(agent.name);
196
+ agents.push(agent);
197
+ } else {
198
+ // Override global with project-local
199
+ const idx = agents.findIndex(a => a.name === agent.name);
200
+ if (idx >= 0) {
201
+ agents[idx] = agent;
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ return agents;
208
+ }
209
+
210
+ /**
211
+ * Get all predefined teams from all locations
212
+ * Priority: project-local > global
213
+ */
214
+ export function getAllPredefinedTeams(projectDir?: string): PredefinedTeam[] {
215
+ const teams: PredefinedTeam[] = [];
216
+ const seenNames = new Set<string>();
217
+
218
+ // Global teams
219
+ const globalDir = path.join(os.homedir(), ".pi", "agent");
220
+ for (const team of discoverTeams(globalDir)) {
221
+ if (!seenNames.has(team.name)) {
222
+ seenNames.add(team.name);
223
+ teams.push(team);
224
+ }
225
+ }
226
+
227
+ // Project-local teams
228
+ if (projectDir) {
229
+ const projectDirPath = path.join(projectDir, ".pi");
230
+ for (const team of discoverTeams(projectDirPath)) {
231
+ if (!seenNames.has(team.name)) {
232
+ seenNames.add(team.name);
233
+ teams.push(team);
234
+ } else {
235
+ // Override global with project-local
236
+ const idx = teams.findIndex(t => t.name === team.name);
237
+ if (idx >= 0) {
238
+ teams[idx] = team;
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ return teams;
245
+ }
246
+
247
+ /**
248
+ * Get a specific agent definition by name
249
+ */
250
+ export function getAgentDefinition(name: string, projectDir?: string): AgentDefinition | undefined {
251
+ const agents = getAllAgentDefinitions(projectDir);
252
+ return agents.find(a => a.name === name);
253
+ }
254
+
255
+ /**
256
+ * Get a specific predefined team by name
257
+ */
258
+ export function getPredefinedTeam(name: string, projectDir?: string): PredefinedTeam | undefined {
259
+ const teams = getAllPredefinedTeams(projectDir);
260
+ return teams.find(t => t.name === name);
261
+ }