diff --git a/docs/product-overview.md b/docs/product-overview.md index ac33bec11..4b5206a44 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -40,6 +40,7 @@ This document explains TeamAI's product architecture, supported agents, and core CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓ OpenCode✓✓✓✓✓✓✓✓✓✓——— + Pi Coding Agent✓✓✓——✓*——————— OpenClaw✓✓✓✓———✓✓✓——— Hermes✓—✓✓———✓✓✓——— DeepSeek Harness✓—✓————✓✓✓——— @@ -51,6 +52,8 @@ This document explains TeamAI's product architecture, supported agents, and core +\* Pi hook support is limited to TeamAI's built-in lifecycle bridge. Custom team hooks and built-in overrides from `hooks/hooks.yaml` are not applied to Pi. + **Git providers** — GitHub · GitLab · GitCode · CNB · TGit · private Git service. ### Distribution Controls diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index f3396d898..a38ff746f 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -40,6 +40,7 @@ CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓ OpenCode✓✓✓✓✓✓✓✓✓✓——— + Pi Coding Agent✓✓✓——✓*——————— OpenClaw✓✓✓✓———✓✓✓——— Hermes✓—✓✓———✓✓✓——— DeepSeek Harness✓—✓————✓✓✓——— @@ -51,6 +52,8 @@ +\* Pi 的 Hooks 支持仅限 TeamAI 内置生命周期桥接;`hooks/hooks.yaml` 中的自定义团队 Hooks 和内置 Hook 覆盖不会应用到 Pi。 + **Git 托管平台** —— GitHub · GitLab · GitCode · CNB · TGit · 私有 Git 服务。 ### 分发策略 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 35f53d26d..ca96bdc95 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -4,7 +4,7 @@ > **teamai-cli** — the team collaboration layer for AI agents > -> **Make every team continuously smarter with AI.** Define how agents work (Team Execution), give them team knowledge (Team Context), and turn real sessions into shared capability (Team Improvement). TeamAI manages Skills, Rules, Docs, Env, MCP, and more across Claude Code, Codex, GitHub Copilot CLI, CodeBuddy, WorkBuddy, OpenCode, Cursor, and other supported agents. +> **Make every team continuously smarter with AI.** Define how agents work (Team Execution), give them team knowledge (Team Context), and turn real sessions into shared capability (Team Improvement). TeamAI manages Skills, Rules, Docs, Env, MCP, and more across Claude Code, Codex, GitHub Copilot CLI, CodeBuddy, WorkBuddy, OpenCode, Pi, Cursor, and other supported agents. --- @@ -467,7 +467,7 @@ teamai skill show hai-deploy-test # View a single skill's source / contributor `teamai init` already injected Hooks into your AI tools. **`teamai pull` runs automatically every time you start an AI session** — no manual action needed. In project scope, that SessionStart hook first creates the current agent's project root (e.g. `/.claude` when Claude Code opens the repo) if it is missing, then pulls. -*(Note: Automatic sync on session start requires an agent that supports lifecycle hooks, such as [CC], Codex, GitHub Copilot CLI, Cursor, CodeBuddy, WorkBuddy, Qoder, Kiro, OpenCode, Oh My Pi, Hermes, or OpenClaw. Kiro runs the hook when a TeamAI-rendered custom agent is activated in an interactive CLI session; its in-memory built-in default agent is not writable, and non-interactive mode does not fire `agentSpawn`. For tools without a teamai-writable hooks surface such as JoyCode or Gemini CLI, run `teamai pull` manually.)* +*(Note: Automatic sync on session start requires an agent that supports lifecycle hooks, such as [CC], Codex, GitHub Copilot CLI, Cursor, CodeBuddy, WorkBuddy, Qoder, Kiro, OpenCode, Oh My Pi, Pi, Hermes, or OpenClaw. Kiro runs the hook when a TeamAI-rendered custom agent is activated in an interactive CLI session; its in-memory built-in default agent is not writable, and non-interactive mode does not fire `agentSpawn`. For tools without a teamai-writable hooks surface such as JoyCode or Gemini CLI, run `teamai pull` manually.)* If you need to sync immediately, you can run it manually: @@ -1441,7 +1441,7 @@ On Windows, the built-in hook dispatch commands that shell out through bash (e.g ### Team Hooks Declaration -A team can declare custom hooks in the repo's `hooks/hooks.yaml`; `teamai pull` automatically distributes them to all members' AI tools: +A team can declare custom hooks in the repo's `hooks/hooks.yaml`; `teamai pull` automatically distributes them to supported hook adapters. Pi is currently limited to TeamAI's built-in lifecycle bridge: custom hooks and built-in overrides from this file are not applied to Pi. ```yaml hooks: @@ -1528,6 +1528,17 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th - **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry — OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shelling out to the same `teamai hook-dispatch` entry point every other tool uses. The event mapping mirrors the Claude built-in set: `session.created` → session-start, `session.idle` → stop, `chat.message` → prompt-submit, `tool.execute.after` → post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `tool_name`, `tool_input`, `prompt`), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-.ts`) install into the same user plugin dir. - **MCP** servers live under the `mcp` key of the shared `opencode.json` (see the MCP section above). +### Pi Coding Agent + +[Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) is supported through its documented skills, instruction, and extension surfaces: + +- **Scopes.** Project skills and TeamAI-managed rules are written to `.pi/skills/` and `.pi/rules/`. User-scope copies use `~/.pi/agent/skills/` and `~/.pi/agent/rules/`. +- **Instructions.** Project instructions use `AGENTS.md`; user instructions use `~/.pi/agent/AGENTS.md`. Pi also accepts `CLAUDE.md` as a project instruction file, but TeamAI keeps the canonical TeamAI block in `AGENTS.md`. +- **Hooks.** TeamAI generates one user-scoped `teamai-hooks.ts` under `~/.pi/agent/extensions/`. It maps `session_start` → session-start, `before_agent_start` → prompt-submit, and `agent_settled` → stop; `tool_execution_start` caches the tool's input, and `tool_execution_end` dispatches post-tool-use forwarding that cached input as `tool_input` (no separate result/output field — matching the OMP adapter's post-tool-use payload). Pi loads both user and project extension roots, so TeamAI never creates a project copy — a second copy would double-dispatch every event, the same single-copy policy as the OMP adapter. An older TeamAI-managed project copy is removed during the next sync, and injection never overwrites a same-named file that lacks the TeamAI marker. Pi has no settings file for self mode to commit, so a fresh clone still needs one `teamai init`/`pull` on that machine before Pi hooks are active there. Any targeted removal — the explicit `teamai hooks remove` command, or a scoped `teamai uninstall --agent pi` — deletes this shared extension outright, the same single-file removal semantics as the OMP adapter: Pi has no way to scope one shared file to a single project, so it doesn't pretend to preserve it for other projects while the extension keeps firing for this one anyway; files without the TeamAI marker are never removed. `teamai hooks list` always reports this global path. Pi profile overrides (`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported — same as the OMP adapter — and the default `~/.pi/agent/` layout is used. Because the extension is one shared file rather than a per-project one, a scoped removal is not durable in a multi-project setup: the next `teamai init`/`pull` in any other scope where Pi is still enabled re-creates it, and hook dispatch has no per-project exclusion check, so hooks can resume firing in the project that was just uninstalled from. This is the same trade-off the OMP adapter already ships with. +- **Team hooks boundary.** The Pi adapter installs only the built-in lifecycle bridge. Custom team hooks and built-in hook overrides declared in `hooks/hooks.yaml` are skipped with a warning. Full team-hook and per-project ownership semantics require a separate cross-adapter design and are deferred to a follow-up PR. +- **Server-pushed agent hooks.** HTTP-source hooks are installed as `teamai-agent-.ts` extensions in the same global extension directory. Unsupported lifecycle events are skipped with a warning. +- **MCP and subagents.** Pi has no adapter in this phase for MCP or TeamAI custom subagent files. + ### Qoder Qoder is available as a built-in target. TeamAI deploys skills, rules, and subagents to `.qoder/skills/`, `.qoder/rules/`, and `.qoder/agents/`. Hooks and MCP servers are merged into the scope-specific `.qoder/settings.json`, preserving unrelated user settings. The paths match Qoder's user and project configuration contracts. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d0fa0ee4b..25e564de0 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -4,7 +4,7 @@ > **teamai-cli** — AI Agents 的团队协作层 > -> **让每个团队通过 AI 持续变得更聪明。** 统一工作方式(Team Execution)、共享团队 Context(Team Context),并把真实 Session 沉淀成团队能力(Team Improvement)。TeamAI 统一管理 Claude Code、Codex、GitHub Copilot CLI、CodeBuddy、WorkBuddy、OpenCode、Cursor 及其他受支持 Agent 的 Skills、Rules、Docs、Env、MCP 等资源。 +> **让每个团队通过 AI 持续变得更聪明。** 统一工作方式(Team Execution)、共享团队 Context(Team Context),并把真实 Session 沉淀成团队能力(Team Improvement)。TeamAI 统一管理 Claude Code、Codex、GitHub Copilot CLI、CodeBuddy、WorkBuddy、OpenCode、Pi、Cursor 及其他受支持 Agent 的 Skills、Rules、Docs、Env、MCP 等资源。 --- @@ -444,7 +444,7 @@ teamai skill show hai-deploy-test # 看单个 skill 的来源 / 贡献者 / `teamai init` 时已注入 Hooks 到你的 AI 工具中。**每次启动 AI 会话时会自动执行 `teamai pull`**,无需手动操作。在 project scope 下,该 SessionStart hook 会先为当前 Agent 创建项目根目录(例如用 Claude Code 打开仓库时创建 `/.claude`),然后再 pull。 -*(注:会话启动自动同步依赖工具的生命周期 Hooks 支持,如 [CC]、Codex、GitHub Copilot CLI、Cursor、CodeBuddy、WorkBuddy、Qoder、Kiro、OpenCode、Oh My Pi、Hermes、OpenClaw 等。Kiro 仅在交互式 CLI 会话激活由 TeamAI 渲染的自定义 agent 时触发该 Hook;其内存中的内置默认 agent 无法写入,非交互模式也不会触发 `agentSpawn`。对于暂无 teamai 可写入 Hooks 的工具(如 JoyCode、Gemini CLI 等),需手动执行 `teamai pull`。)* +*(注:会话启动自动同步依赖工具的生命周期 Hooks 支持,如 [CC]、Codex、GitHub Copilot CLI、Cursor、CodeBuddy、WorkBuddy、Qoder、Kiro、OpenCode、Oh My Pi、Pi、Hermes、OpenClaw 等。Kiro 仅在交互式 CLI 会话激活由 TeamAI 渲染的自定义 agent 时触发该 Hook;其内存中的内置默认 agent 无法写入,非交互模式也不会触发 `agentSpawn`。对于暂无 teamai 可写入 Hooks 的工具(如 JoyCode、Gemini CLI 等),需手动执行 `teamai pull`。)* 如果需要立即同步,可以手动执行: @@ -1400,7 +1400,7 @@ inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根 ### 团队 Hooks 声明 -团队可在仓库 `hooks/hooks.yaml` 中声明自定义 hooks,`teamai pull` 自动分发到所有成员的 AI 工具: +团队可在仓库 `hooks/hooks.yaml` 中声明自定义 hooks,`teamai pull` 会自动分发到支持团队 Hooks 的适配器。Pi 目前仅支持 TeamAI 内置生命周期桥接;此文件中的自定义 Hooks 和内置 Hook 覆盖不会应用到 Pi。 ```yaml hooks: @@ -1487,6 +1487,17 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 - **Hooks** 以 OpenCode *plugin* 形式交付,而非配置文件条目——OpenCode 没有 `hooks` 数组,它会**同时**加载 `~/.config/opencode/plugin/` 和 `/.opencode/plugin/` 下的 JS/TS 插件。两个目录都有插件时会被加载两次,每个事件也就派发两次,因此 teamai 只保留一份:写在用户目录的 `teamai-hooks.ts`,覆盖所有项目;早期布局残留的项目级副本会在下次同步时被删除。这与其他工具一致——它们的 `settings.json` hooks 同样放在 HOME,靠传给 `hook-dispatch` 的 `cwd` 做作用域判断。插件订阅 OpenCode 自己的事件,并 shell 到其他所有工具共用的 `teamai hook-dispatch` 入口。事件映射对齐 Claude 内置集合:`session.created` → session-start、`session.idle` → stop、`chat.message` → prompt-submit、`tool.execute.after` → post-tool-use。插件会转发与其他工具一致的 STDIN 负载(`cwd`、`tool_name`、`tool_input`、`prompt`),并把 OpenCode 的小写工具 id(`skill`、`todowrite`)映射回 handler 注册表期望的 PascalCase matcher。OpenCode 无法把 hook 的 stdout 回注到会话,因此 hooks 只为副作用运行(状态上报 / 同步 / 更新)。注意 OpenCode 会 **await** 它的具名 hook(`chat.message`、`tool.execute.after`),所以这两个事件的派发会短暂等待 `teamai` 子进程后 agent 才继续;错误始终被吞掉,hook 永远不会让会话失败。服务端下发的 agent hook(`teamai-agent-.ts`)同样装在这个用户级 plugin 目录下。 - **MCP** server 位于共享 `opencode.json` 的 `mcp` 键下(详见上文 MCP 章节)。 +### Pi Coding Agent + +[Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) 通过其公开的 Skills、指令文件和扩展机制接入: + +- **作用域。** 项目级 Skills 和 TeamAI 管理的 Rules 写入 `.pi/skills/`、`.pi/rules/`;用户级副本写入 `~/.pi/agent/skills/`、`~/.pi/agent/rules/`。 +- **指令文件。** 项目级使用 `AGENTS.md`,用户级使用 `~/.pi/agent/AGENTS.md`。Pi 也接受项目级 `CLAUDE.md`,但 TeamAI 将规范的 TeamAI 区块保留在 `AGENTS.md`。 +- **Hooks。** TeamAI 只在用户级 `~/.pi/agent/extensions/` 生成一份 `teamai-hooks.ts`,把 `session_start` 映射为 session-start、`before_agent_start` 映射为 prompt-submit、`agent_settled` 映射为 stop;`tool_execution_start` 缓存工具输入,`tool_execution_end` 派发 post-tool-use 时把缓存的输入转发为 `tool_input`(不带单独的结果/输出字段,与 OMP 适配器的 post-tool-use payload 一致)。Pi 会同时加载用户级与项目级扩展目录,因此 TeamAI 不创建项目副本——第二份副本会导致每个事件被派发两次,这与 OMP 适配器的单副本策略一致。早期版本遗留且带 TeamAI 标记的项目副本会在下次同步时移除,注入逻辑也不会覆盖没有 TeamAI 标记的同名文件。Pi 没有可供 self mode 提交的设置文件,所以 fresh clone 仍需在该机器上手动跑一次 `teamai init`/`pull` 才能激活 Pi hooks。任何一次显式移除——`teamai hooks remove`,或者某个 scope 下的 `teamai uninstall --agent pi`——都会直接删除这份共享扩展,和 OMP 适配器的单文件删除语义完全一致:Pi 没有办法把一份共享文件限定在某一个项目里,所以不会假装"为其他项目保留"却让这份扩展继续对当前项目触发;没有 TeamAI 标记的同名文件不会被删除。`teamai hooks list` 始终显示这个全局路径。Pi 的 profile 覆盖项(`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,与 OMP 适配器一致,使用默认的 `~/.pi/agent/` 布局。由于这份扩展是机器级共享的单个文件而非按项目隔离,某个 scope 下的移除在多项目场景中并不持久:只要 Pi 在其他任意 scope 仍处于启用状态,下一次在那里执行 `teamai init`/`pull` 就会把它重新生成,而 hook 派发本身没有按项目排除的检查,因此刚被卸载的项目里 hooks 仍可能重新触发。这与 OMP 适配器早已上线的取舍完全一致。 +- **团队 Hooks 边界。** Pi 适配器只安装内置生命周期桥接。`hooks/hooks.yaml` 声明的自定义团队 Hooks 和内置 Hook 覆盖会被跳过并给出警告。完整团队 Hooks 与逐项目归属语义需要单独的跨适配器设计,留待后续 PR。 +- **服务端下发的 Agent Hooks。** HTTP source hooks 会以同一用户级扩展目录中的 `teamai-agent-.ts` 形式安装。不支持的生命周期事件会警告并跳过。 +- **MCP 与 Subagents。** 本阶段没有为 Pi 接入 MCP 或 TeamAI 自定义 subagent 文件适配器。 + ### Qoder Qoder 已作为内置目标支持。TeamAI 会将 Skills、Rules 和 Subagents 分别下发到 `.qoder/skills/`、`.qoder/rules/` 和 `.qoder/agents/`。Hooks 与 MCP Server 会合并进对应作用域的 `.qoder/settings.json`,并保留用户已有的其他设置;这些路径与 Qoder 的用户级和项目级配置约定一致。 diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 4c7150e4c..7aa036724 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -1,4 +1,6 @@ import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; // ── Mocks ──────────────────────────────────────────────── @@ -642,7 +644,7 @@ describe('hooksRemove', () => { expect.any(String), [], expect.stringContaining('managed-hooks.json'), - { removeAll: true }, + { removeAll: true, scope: 'user', installedBaseDir: undefined }, ); expect(mockedLog.success).toHaveBeenCalledWith(expect.stringContaining('Hooks removed')); }); @@ -662,7 +664,11 @@ describe('hooksRemove', () => { // cleanup is delegated to the shared sweep helper. expect(mockedReconcile).toHaveBeenCalledTimes(1); expect(mockedReconcile).toHaveBeenCalledWith( - mockTeamConfig.toolPaths, '/home/testuser', [], expect.any(String), { removeAll: true }, + mockTeamConfig.toolPaths, + '/home/testuser', + [], + expect.any(String), + { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project' }, ); const userManifest = mockedReconcile.mock.calls[0][3] as string; expect(userManifest).toContain('/home/testuser'); @@ -695,10 +701,34 @@ describe('hooksRemove', () => { // returns null for self mode (see types.test.ts). expect(mockedReconcile).toHaveBeenCalledTimes(1); expect(mockedReconcile).toHaveBeenCalledWith( - mockTeamConfig.toolPaths, '/path/to/project', [], expect.any(String), { removeAll: true }, + mockTeamConfig.toolPaths, + '/path/to/project', + [], + expect.any(String), + { removeAll: true, scope: 'project', installedBaseDir: '/path/to/project' }, ); }); + it('explicit project removal deletes the shared Pi extension', async () => { + const tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-hooks-remove-pi-')); + const restoreHome = mockHome(tmp); + const globalPiHook = path.join(tmp, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + await fse.ensureDir(path.dirname(globalPiHook)); + await fse.writeFile(globalPiHook, '// [teamai] hooks extension'); + mockedAutoDetectInit.mockResolvedValue({ + localConfig: { ...mockLocalConfig, scope: 'project', projectRoot: '/path/to/project' }, + teamConfig: { toolPaths: { pi: { skills: '.pi/skills' } } }, + }); + + try { + await hooksRemove({}); + expect(await fse.pathExists(globalPiHook)).toBe(false); + } finally { + restoreHome(); + await fse.remove(tmp); + } + }); + it('removes standalone Copilot hooks under COPILOT_HOME', async () => { const originalCopilotHome = process.env.COPILOT_HOME; process.env.COPILOT_HOME = COPILOT_HOME_FIXTURE; diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 1cf62dc63..479c17061 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -1776,6 +1776,23 @@ describe('local-agent: cmds[] migration', () => { expect(mine[0].hooks[0].timeout).toBe(45); }); + it('passes the configured timeout through to a Pi agent hook extension', async () => { + const acks = await runResponse({ + cmds: [{ + id: 45, type: 'install_hook_rule', handle_type: 'hook', slug: 'pi-slow', + event: 'SessionStart', cmd: 'echo slow', timeout: 45, scope: 'user', + }], + }, undefined, 'pi'); + expect(acks.find((a) => a.id === 45)?.status).toBe('success'); + const extension = await fse.readFile( + path.join(tmpDir, '.pi', 'agent', 'extensions', 'teamai-agent-pi-slow.ts'), + 'utf8', + ); + expect(extension).toContain('}, 45000);'); + const manifest = await fse.readJson(path.join(tmpDir, '.teamai', 'local-agent', 'agent-hooks.json')); + expect(manifest['pi-slow']).toMatchObject({ tool: 'pi', timeout: 45 }); + }); + it('install_hook_rule writes a codex hook (no description, command-matched)', async () => { await fse.ensureDir(path.join(tmpDir, '.codex')); const cmd23 = { diff --git a/src/__tests__/pi-adapter.test.ts b/src/__tests__/pi-adapter.test.ts new file mode 100644 index 000000000..5a067dd3e --- /dev/null +++ b/src/__tests__/pi-adapter.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { KNOWN_AGENTS, SELF_MODE_AGENT_CHOICES } from '../known-agents.js'; +import { TeamaiConfigSchema, scopedToolPaths } from '../types.js'; + +describe('Pi adapter configuration', () => { + it('declares native project and user resource paths', () => { + const config = TeamaiConfigSchema.parse({ team: 'test', repo: 'test/repo' }); + expect(config.toolPaths.pi).toEqual({ + skills: '.pi/skills', + rules: '.pi/rules', + claudemd: 'AGENTS.md', + userScope: { + skills: '.pi/agent/skills', + rules: '.pi/agent/rules', + claudemd: '.pi/agent/AGENTS.md', + }, + }); + + expect(scopedToolPaths(config, { scope: 'project' }).pi).toEqual(config.toolPaths.pi); + expect(scopedToolPaths(config, { scope: 'user' }).pi).toMatchObject({ + skills: '.pi/agent/skills', + rules: '.pi/agent/rules', + claudemd: '.pi/agent/AGENTS.md', + }); + }); + + it('registers Pi for discovery and single-repo selection', () => { + expect(KNOWN_AGENTS.find((agent) => agent.id === 'pi')).toMatchObject({ + displayName: 'Pi Coding Agent', + skillsPath: '.pi/skills', + }); + expect(SELF_MODE_AGENT_CHOICES).toContain('pi'); + }); +}); diff --git a/src/__tests__/pi-hooks.test.ts b/src/__tests__/pi-hooks.test.ts new file mode 100644 index 000000000..e1c4a98a1 --- /dev/null +++ b/src/__tests__/pi-hooks.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import vm from 'node:vm'; +import { EventEmitter } from 'node:events'; +import fse from 'fs-extra'; + +function assertValidJs(src: string): void { + const body = src.replace(/^import .*;$/gm, '').replace(/export default /g, 'const __x = '); + expect(() => new vm.Script(body)).not.toThrow(); +} + +async function runSessionStartWithBrokenStdin(src: string): Promise { + const handlers = new Map Promise>(); + const child = new EventEmitter() as EventEmitter & { + stdin: EventEmitter & { end: (value: string) => void }; + kill: () => void; + }; + const stdin = new EventEmitter() as EventEmitter & { end: (value: string) => void }; + stdin.end = () => { + stdin.emit('error', Object.assign(new Error('broken pipe'), { code: 'EPIPE' })); + queueMicrotask(() => child.emit('close', 1)); + }; + child.stdin = stdin; + child.kill = () => undefined; + const body = src.replace(/^import .*;$/gm, '').replace(/export default /g, 'const __x = '); + const extension = vm.runInNewContext(`${body}\n__x`, { + spawn: () => child, + process: { platform: 'linux' }, + setTimeout, + clearTimeout, + }) as (pi: { on: (event: string, handler: (event: unknown, ctx: { cwd: string }) => Promise) => void }) => void; + extension({ on: (event, handler) => handlers.set(event, handler) }); + await handlers.get('session_start')?.({}, { cwd: '/tmp/project' }); +} + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +import { + resolvePiExtensionsDir, + resolvePiProjectExtensionsDir, + injectPiHooks, + removePiHooks, + buildPiExtensionSource, + buildPiAgentHookExtensionSource, + applyPiAgentHook, + removePiAgentHook, + hasPiAgentHook, + PI_HOOK_FILE, +} from '../pi-hooks.js'; +import { reconcileHooksToAllTools } from '../hooks.js'; +import { log } from '../utils/logger.js'; +import type { HookDef } from '../types.js'; + +describe('Pi hook extension', () => { + let tmp: string; + let previousHome: string | undefined; + + beforeEach(async () => { + vi.clearAllMocks(); + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pi-hooks-')); + previousHome = process.env.HOME; + process.env.HOME = tmp; + }); + + afterEach(async () => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fse.remove(tmp); + }); + + it('resolves global and project extension directories', () => { + expect(resolvePiExtensionsDir()).toBe(path.join(tmp, '.pi', 'agent', 'extensions')); + expect(resolvePiProjectExtensionsDir('/repo')).toBe(path.join('/repo', '.pi', 'extensions')); + }); + + it('maps Pi lifecycle events to TeamAI dispatch events', () => { + const src = buildPiExtensionSource(); + expect(src).toContain('pi.on("session_start"'); + expect(src).toContain('dispatch("session-start"'); + expect(src).toContain('pi.on("agent_settled"'); + expect(src).toContain('dispatch("stop"'); + expect(src).toContain('pi.on("before_agent_start"'); + expect(src).toContain('dispatch("prompt-submit"'); + expect(src).toContain('pi.on("tool_execution_end"'); + expect(src).toContain('dispatch("post-tool-use"'); + expect(src).toContain('toolInputs.get(event.toolCallId)'); + }); + + it('forwards cwd, prompt and tool payload through a child stdin pipe', () => { + const src = buildPiExtensionSource(); + expect(src).toContain('["hook-dispatch", event, "--tool", "pi"]'); + expect(src).toContain('shell: process.platform === "win32"'); + expect(src).toContain('JSON.stringify({ cwd'); + expect(src.indexOf('child.stdin?.on("error"')).toBeLessThan(src.indexOf('child.stdin?.end(stdin)')); + expect(src).toContain('child.stdin?.end(stdin)'); + expect(src).toContain('event.prompt'); + expect(src).toContain('tool_name'); + expect(src).toContain('tool_input'); + expect(src).toContain('catch {'); + }); + + it('is syntactically valid JavaScript', () => { + assertValidJs(buildPiExtensionSource()); + }); + + it('swallows asynchronous EPIPE from lifecycle and HTTP hook stdin', async () => { + await expect(runSessionStartWithBrokenStdin(buildPiExtensionSource())).resolves.toBeUndefined(); + await expect(runSessionStartWithBrokenStdin( + buildPiAgentHookExtensionSource('start', 'SessionStart', 'echo start'), + )).resolves.toBeUndefined(); + }); + + it('renders supported HTTP-source agent hooks as Pi extensions', () => { + const src = buildPiAgentHookExtensionSource('scan', 'PreToolUse', 'echo hooked', 'Bash'); + expect(src).toContain('pi.on("tool_execution_start"'); + expect(src).toContain('echo hooked'); + expect(src).toContain('toolName'); + expect(src.indexOf('child.stdin?.on("error"')).toBeLessThan( + src.indexOf('child.stdin?.end(JSON.stringify({ cwd'), + ); + expect(src).toContain('child.stdin?.end(JSON.stringify({ cwd'); + assertValidJs(src); + expect(buildPiAgentHookExtensionSource('scan', 'SessionStart', 'echo started')) + .toContain('pi.on("session_start"'); + const postTool = buildPiAgentHookExtensionSource('post', 'PostToolUse', 'echo post'); + expect(postTool).toContain('toolInputs.set(event.toolCallId, event.args || {})'); + expect(postTool).toContain('tool_input: toolInputs.get(event.toolCallId) || {}'); + expect(postTool).toContain('toolInputs.delete(event.toolCallId)'); + assertValidJs(postTool); + const matchedPostTool = buildPiAgentHookExtensionSource('matched', 'PostToolUse', 'echo post', 'Bash'); + expect(matchedPostTool).toContain( + 'if (String(event.toolName || \'\').toLowerCase() !== "bash") { toolInputs.delete(event.toolCallId); return; }', + ); + expect(buildPiAgentHookExtensionSource('scan', 'UnknownEvent', 'echo nope')).toBe(''); + }); + + it('installs and removes a Pi agent-hook extension safely', async () => { + await applyPiAgentHook({ slug: 'scan', event: 'PostToolUse', command: 'echo hooked', matcher: 'Bash' }); + const file = path.join(tmp, '.pi', 'agent', 'extensions', 'teamai-agent-scan.ts'); + expect(await fse.pathExists(file)).toBe(true); + await removePiAgentHook('scan'); + expect(await fse.pathExists(file)).toBe(false); + await expect(applyPiAgentHook({ slug: '../escape', event: 'Stop', command: 'echo nope' })) + .rejects.toThrow('Invalid Pi agent-hook slug'); + }); + + it('does not overwrite or delete a same-named agent-hook file without the TeamAI marker, and rejects instead of skipping silently', async () => { + const file = path.join(tmp, '.pi', 'agent', 'extensions', 'teamai-agent-scan.ts'); + await fse.ensureDir(path.dirname(file)); + await fse.writeFile(file, '// user-owned extension'); + + expect(await hasPiAgentHook('scan')).toBe(false); + + await expect(applyPiAgentHook({ slug: 'scan', event: 'PostToolUse', command: 'echo hooked' })) + .rejects.toThrow('without the TeamAI marker'); + expect(await fse.readFile(file, 'utf8')).toBe('// user-owned extension'); + + await removePiAgentHook('scan'); + expect(await fse.readFile(file, 'utf8')).toBe('// user-owned extension'); + }); + + it('rejects instead of skipping silently when Pi has no equivalent for the requested event', async () => { + await expect(applyPiAgentHook({ slug: 'scan', event: 'UnknownEvent', command: 'echo nope' })) + .rejects.toThrow('Pi does not support event "UnknownEvent"'); + const file = path.join(tmp, '.pi', 'agent', 'extensions', 'teamai-agent-scan.ts'); + expect(await fse.pathExists(file)).toBe(false); + }); + + it('injects and removes an idempotent global extension', async () => { + const file = path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE); + await injectPiHooks(); + const first = await fse.readFile(file, 'utf8'); + expect(first).toContain('[teamai] hooks extension'); + await injectPiHooks(); + expect(await fse.readFile(file, 'utf8')).toBe(first); + await removePiHooks(); + expect(await fse.pathExists(file)).toBe(false); + }); + + it('preserves a same-named global extension without the TeamAI marker', async () => { + const file = path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE); + await fse.ensureDir(path.dirname(file)); + await fse.writeFile(file, '// user-owned extension'); + await removePiHooks(); + expect(await fse.readFile(file, 'utf8')).toBe('// user-owned extension'); + }); + + it('does not overwrite a same-named global extension without the TeamAI marker', async () => { + const file = path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE); + await fse.ensureDir(path.dirname(file)); + await fse.writeFile(file, '// user-owned extension'); + await injectPiHooks(); + expect(await fse.readFile(file, 'utf8')).toBe('// user-owned extension'); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('without the TeamAI marker')); + }); + + it('does not create a Pi directory when the tool is not installed', async () => { + await reconcileHooksToAllTools({ pi: { skills: '.pi/skills' } } as Record, tmp, [], path.join(tmp, 'manifest.json')); + expect(await fse.pathExists(path.join(tmp, '.pi'))).toBe(false); + }); + + it('injects the global extension when the Pi root is installed', async () => { + await fse.ensureDir(path.join(tmp, '.pi', 'agent')); + await reconcileHooksToAllTools({ pi: { skills: '.pi/skills' } } as Record, tmp, [], path.join(tmp, 'manifest.json')); + expect(await fse.pathExists(path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE))).toBe(true); + }); + + it('uses one global extension for self-mode style bases', async () => { + const project = path.join(tmp, 'project'); + await fse.ensureDir(path.join(project, '.pi')); + await reconcileHooksToAllTools( + { pi: { skills: '.pi/skills' } } as Record, + project, + [], + path.join(tmp, 'manifest.json'), + { scope: 'project', installedBaseDir: project }, + ); + expect(await fse.pathExists(path.join(project, '.pi', 'extensions', PI_HOOK_FILE))).toBe(false); + expect(await fse.pathExists(path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE))).toBe(true); + }); + + it('removes a managed project copy before installing the global extension', async () => { + const project = path.join(tmp, 'project'); + const legacy = path.join(project, '.pi', 'extensions', PI_HOOK_FILE); + await fse.ensureDir(path.dirname(legacy)); + await fse.writeFile(legacy, buildPiExtensionSource()); + await reconcileHooksToAllTools( + { pi: { skills: '.pi/skills' } } as Record, + project, + [], + path.join(tmp, 'manifest.json'), + ); + expect(await fse.pathExists(legacy)).toBe(false); + expect(await fse.pathExists(path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE))).toBe(true); + }); + + it('project removal deletes both the legacy project copy and the global extension', async () => { + // Mirrors OMP: Pi has no way to scope a shared file to one project, so a + // removeAll pass — scoped uninstall or explicit `hooks remove` — deletes + // the single global copy outright rather than pretending to preserve it + // for other projects while it keeps firing for this one anyway. + const project = path.join(tmp, 'project'); + const global = path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE); + const legacy = path.join(project, '.pi', 'extensions', PI_HOOK_FILE); + await injectPiHooks(); + await fse.ensureDir(path.dirname(legacy)); + await fse.writeFile(legacy, buildPiExtensionSource()); + + await reconcileHooksToAllTools( + { pi: { skills: '.pi/skills' } } as Record, + tmp, + [], + path.join(tmp, 'manifest.json'), + { removeAll: true, scope: 'project', installedBaseDir: project }, + ); + + expect(await fse.pathExists(legacy)).toBe(false); + expect(await fse.pathExists(global)).toBe(false); + }); + + it('user removal owns and deletes the global extension', async () => { + const global = path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE); + await injectPiHooks(); + + await reconcileHooksToAllTools( + { pi: { skills: '.pi/skills' } } as Record, + tmp, + [], + path.join(tmp, 'manifest.json'), + { removeAll: true, scope: 'user' }, + ); + + expect(await fse.pathExists(global)).toBe(false); + }); + + it('refreshes an existing global TeamAI extension from project scope', async () => { + const project = path.join(tmp, 'project'); + await fse.ensureDir(path.join(tmp, '.pi', 'agent', 'extensions')); + await fse.writeFile( + path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE), + `${buildPiExtensionSource()}\n// stale`, + ); + await fse.ensureDir(path.join(project, '.pi')); + await reconcileHooksToAllTools( + { pi: { skills: '.pi/skills' } } as Record, + project, + [], + path.join(tmp, 'manifest.json'), + ); + const global = await fse.readFile(path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE), 'utf8'); + expect(global).toBe(buildPiExtensionSource()); + expect(await fse.pathExists(path.join(project, '.pi', 'extensions', PI_HOOK_FILE))).toBe(false); + }); + + it('skips custom team hooks and reports the built-in-only boundary', async () => { + await fse.ensureDir(path.join(tmp, '.pi', 'agent')); + const customHook: HookDef = { + source: 'team', + key: 'custom-stop', + event: 'Stop', + command: 'echo custom-team-hook', + description: '[teamai:hook:custom-stop] custom stop', + }; + await reconcileHooksToAllTools( + { pi: { skills: '.pi/skills' } } as Record, + tmp, + [customHook], + path.join(tmp, 'manifest.json'), + { + scope: 'user', + builtinOverride: { disabled: ['Hook dispatch stop'] }, + }, + ); + const source = await fse.readFile(path.join(tmp, '.pi', 'agent', 'extensions', PI_HOOK_FILE), 'utf8'); + expect(source).not.toContain(customHook.command); + expect(log.warn).toHaveBeenCalledWith( + 'Pi supports built-in lifecycle hooks only; skipping 1 custom team hook(s) from hooks/hooks.yaml', + ); + expect(log.warn).toHaveBeenCalledWith( + 'Pi supports built-in lifecycle hooks only; skipping 1 built-in hook override(s) from hooks/hooks.yaml', + ); + }); + + it('does not warn about skipped team hooks when Pi is not installed', async () => { + // No .pi directory anywhere — a teammate who never uses Pi must not see + // this warning just because the team defines a Pi-targeted hook. + const customHook: HookDef = { + source: 'team', + key: 'custom-stop', + event: 'Stop', + command: 'echo custom-team-hook', + description: '[teamai:hook:custom-stop] custom stop', + }; + await reconcileHooksToAllTools( + { pi: { skills: '.pi/skills' } } as Record, + tmp, + [customHook], + path.join(tmp, 'manifest.json'), + { + scope: 'user', + builtinOverride: { disabled: ['Hook dispatch stop'] }, + }, + ); + expect(await fse.pathExists(path.join(tmp, '.pi'))).toBe(false); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('renders the configured HTTP agent-hook timeout in milliseconds', () => { + expect(buildPiAgentHookExtensionSource('slow', 'SessionStart', 'echo slow', undefined, 45)) + .toContain('}, 45000);'); + expect(buildPiAgentHookExtensionSource('default', 'SessionStart', 'echo default')) + .toContain('}, 10000);'); + }); +}); diff --git a/src/__tests__/self-mode-agents.test.ts b/src/__tests__/self-mode-agents.test.ts index 5301b461e..e37a3399e 100644 --- a/src/__tests__/self-mode-agents.test.ts +++ b/src/__tests__/self-mode-agents.test.ts @@ -59,7 +59,7 @@ describe('detectHomeInstalledAgents', () => { await fse.ensureDir(path.join(home, '.codex')); await fse.ensureDir(path.join(home, '.claude')); const found = await detectHomeInstalledAgents(); - // candidate order is claude, codex, cursor, copilot, joycode, codebuddy, workbuddy + // candidate order is claude, codex, cursor, copilot, pi, joycode, codebuddy, workbuddy expect(found).toEqual(['claude', 'codex']); }); @@ -69,12 +69,13 @@ describe('detectHomeInstalledAgents', () => { expect(await detectHomeInstalledAgents(['cursor'])).toEqual(['cursor']); }); - it('SELF_MODE_AGENT_CHOICES includes Copilot and JoyCode among the common coding agents', () => { + it('SELF_MODE_AGENT_CHOICES includes Pi, Copilot and JoyCode among the common coding agents', () => { expect([...SELF_MODE_AGENT_CHOICES]).toEqual([ 'claude', 'codex', 'cursor', 'copilot', + 'pi', 'joycode', 'codebuddy', 'workbuddy', @@ -172,8 +173,8 @@ describe('resolveSelfModeSelection (interactive picker: option 1 = Auto)', () => }); it('multiple specific tools preserve choice order', () => { - // indices 6 (codebuddy) + 2 (codex) → order follows the input - expect(resolveSelfModeSelection([6, 2], detected)).toEqual(['codebuddy', 'codex']); + // indices 7 (codebuddy) + 2 (codex) → order follows the input + expect(resolveSelfModeSelection([7, 2], detected)).toEqual(['codebuddy', 'codex']); }); it('Auto + a specific tool merges detected first, then extras, deduped', () => { diff --git a/src/__tests__/self-mode-prompt.test.ts b/src/__tests__/self-mode-prompt.test.ts index c6d001d93..36e6ff013 100644 --- a/src/__tests__/self-mode-prompt.test.ts +++ b/src/__tests__/self-mode-prompt.test.ts @@ -71,11 +71,11 @@ describe('promptForSelfModeAgents — interactive Auto option', () => { expect(result).toEqual(['cursor']); }); - it('offers exactly 8 options (Auto + 7 tools)', async () => { + it('offers exactly 9 options (Auto + 8 tools)', async () => { vi.mocked(askSelection).mockResolvedValueOnce([0]); await promptForSelfModeAgents({}); const [, itemCount] = vi.mocked(askSelection).mock.calls[0]; - expect(itemCount).toBe(8); + expect(itemCount).toBe(9); }); it('renders "none detected" when HOME has no known tools, and Auto → claude', async () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 26cc0e4c8..36b36ec43 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -55,6 +55,7 @@ const TEAMAI_RECALL_RULES_START = ''; const TEAMAI_RECALL_RULES_END = ''; const TEAMAI_ENV_START = '# [teamai:env:start]'; const TEAMAI_ENV_END = '# [teamai:env:end]'; +const TEAMAI_PI_HOOK = '// [teamai] hooks extension — generated by teamai'; function makeTeamConfig(overrides?: Partial): TeamaiConfig { return { @@ -394,6 +395,207 @@ describe('uninstall', () => { expect(bashrcAfter).not.toContain(TEAMAI_ENV_START); }); + it('project-scope Pi uninstall also removes the global extension', async () => { + // Mirrors OMP: Pi has no way to scope its single shared extension to one + // project — the generated extension fires for every Pi session + // machine-wide — so a scoped uninstall removes it outright rather than + // preserving a file that would keep firing for this project anyway. + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const globalPiHook = path.join(homeDir, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + const projectPiHook = path.join(projectRoot, '.pi', 'extensions', 'teamai-hooks.ts'); + await fse.ensureDir(path.dirname(globalPiHook)); + await fse.ensureDir(path.dirname(projectPiHook)); + await fse.writeFile(globalPiHook, TEAMAI_PI_HOOK); + await fse.writeFile(projectPiHook, TEAMAI_PI_HOOK); + + const teamConfig = makeTeamConfig({ + toolPaths: { + pi: { + skills: '.pi/skills', + rules: '.pi/rules', + claudemd: 'AGENTS.md', + userScope: { + skills: '.pi/agent/skills', + rules: '.pi/agent/rules', + claudemd: '.pi/agent/AGENTS.md', + }, + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'pi' }); + + expect(await fse.pathExists(projectPiHook)).toBe(false); + expect(await fse.pathExists(globalPiHook)).toBe(false); + }); + + it('user-scope Pi uninstall removes a server-pushed agent hook even without the main extension', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + // No teamai-hooks.ts here — only an HTTP-source agent hook, mirroring a + // user who only ever got a server-pushed hook and never the lifecycle + // bridge (or removed it by hand). + const agentHookFile = path.join(homeDir, '.pi', 'agent', 'extensions', 'teamai-agent-scan.ts'); + await fse.ensureDir(path.dirname(agentHookFile)); + await fse.writeFile(agentHookFile, '// [teamai] agent hook [scan] — generated by teamai, do not edit by hand.'); + + const teamConfig = makeTeamConfig({ + toolPaths: { pi: { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' } }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'pi' }); + + expect(await fse.pathExists(agentHookFile)).toBe(false); + }); + + it('does not delete a same-named agent-hook file without the TeamAI marker', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const agentHookFile = path.join(homeDir, '.pi', 'agent', 'extensions', 'teamai-agent-scan.ts'); + await fse.ensureDir(path.dirname(agentHookFile)); + await fse.writeFile(agentHookFile, '// user-owned extension'); + + const teamConfig = makeTeamConfig({ + toolPaths: { pi: { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' } }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'pi' }); + + expect(await fse.readFile(agentHookFile, 'utf8')).toBe('// user-owned extension'); + }); + + it('does not delete a same-named project Pi extension without the TeamAI marker', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const projectPiHook = path.join(projectRoot, '.pi', 'extensions', 'teamai-hooks.ts'); + await fse.ensureDir(path.dirname(projectPiHook)); + await fse.writeFile(projectPiHook, '// user-owned extension'); + + const teamConfig = makeTeamConfig({ + toolPaths: { pi: { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' } }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'pi' }); + + expect(await fse.readFile(projectPiHook, 'utf8')).toBe('// user-owned extension'); + }); + + it('targeted Pi uninstall preserves a shared AGENTS.md used by another installed agent', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const sharedInstructions = path.join(projectRoot, 'AGENTS.md'); + const projectPiHook = path.join(projectRoot, '.pi', 'extensions', 'teamai-hooks.ts'); + await fse.ensureDir(path.dirname(projectPiHook)); + // WorkBuddy is actually installed here (its own resource dir exists), + // not just present in toolPaths — see the sibling "not installed" test. + await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); + await fse.writeFile(sharedInstructions, `${TEAMAI_RULES_START}\nteam rules\n${TEAMAI_RULES_END}\n`); + await fse.writeFile(projectPiHook, TEAMAI_PI_HOOK); + + const teamConfig = makeTeamConfig({ + toolPaths: { + pi: { + skills: '.pi/skills', + rules: '.pi/rules', + claudemd: 'AGENTS.md', + userScope: { + skills: '.pi/agent/skills', + rules: '.pi/agent/rules', + claudemd: '.pi/agent/AGENTS.md', + }, + }, + workbuddy: { + skills: '.workbuddy/skills', + rules: '.workbuddy/rules', + settings: '.workbuddy/settings.json', + claudemd: 'AGENTS.md', + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + enabledAgents: ['pi', 'workbuddy'], + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'pi' }); + + expect(await fse.pathExists(projectPiHook)).toBe(false); + expect(await fse.pathExists(sharedInstructions)).toBe(true); + expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_RULES_START); + }); + + it('targeted Pi uninstall removes shared AGENTS.md when the other tool sharing it was never installed', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const sharedInstructions = path.join(projectRoot, 'AGENTS.md'); + const projectPiHook = path.join(projectRoot, '.pi', 'extensions', 'teamai-hooks.ts'); + await fse.ensureDir(path.dirname(projectPiHook)); + await fse.writeFile(sharedInstructions, `${TEAMAI_RULES_START}\nteam rules\n${TEAMAI_RULES_END}\n`); + await fse.writeFile(projectPiHook, TEAMAI_PI_HOOK); + // No .workbuddy directory is ever created — Hermes/WorkBuddy default to + // the same project AGENTS.md as Pi in toolPaths, but neither is actually + // set up here, so they must not block cleanup of Pi's shared instructions. + + const teamConfig = makeTeamConfig({ + toolPaths: { + pi: { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' }, + workbuddy: { + skills: '.workbuddy/skills', + rules: '.workbuddy/rules', + settings: '.workbuddy/settings.json', + claudemd: 'AGENTS.md', + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'pi' }); + + expect(await fse.pathExists(projectPiHook)).toBe(false); + expect(await fse.pathExists(sharedInstructions)).toBe(false); + }); + // Regression: Cursor rules are `.mdc`; matching only `.md` left every team // rule on disk after uninstall, still injected into each Cursor session. it('removes cursor .mdc rules (and a legacy .md copy) on uninstall', async () => { diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 057939287..6fff5c20d 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -430,6 +430,19 @@ const ADAPTER_BUILTIN_HOOKS: Record // opencode-hooks.ts covers the same four events plus the matcher-scoped // post-tool-use passes (TOOL_MATCHER), i.e. the whole built-in set. opencode: { keys: BUILTIN_HOOK_SPECS.map((spec) => spec.key) }, + // pi-hooks.ts maps the same four lifecycle events as OMP (session_start, + // agent_settled, tool_execution_end, before_agent_start); Pi has no + // Skill/TodoWrite matcher concept, so post-tool-use is wildcard-only there + // too. tool_execution_start only caches the tool input for the later + // post-tool-use dispatch — it never calls hook-dispatch itself. + pi: { + keys: [ + 'Hook dispatch session-start', + 'Hook dispatch stop', + 'Hook dispatch post-tool-use wildcard', + 'Hook dispatch prompt-submit', + ], + }, // openclaw-hooks.ts EVENT_MAP maps session:start and command:new only, and // its generated handler spawns the dispatcher with argv. Only `openclaw`: // the other claw variants share its workspace resolver, so reconciliation diff --git a/src/doctor.ts b/src/doctor.ts index 91ae901c4..ea1426410 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -32,6 +32,7 @@ import { * an auth probe on every sync. */ export type CheckSource = 'local' | 'provider'; +import { hasPiHooks } from './pi-hooks.js'; export interface Check { name: string; @@ -176,6 +177,17 @@ async function buildHookChecks( ): Promise { const checks: Check[] = []; for (const [tool, paths] of Object.entries(toolPaths)) { + if (tool === 'pi') { + const installed = await isToolInstalledForConfig(tool, paths.skills ?? '.pi/skills', localConfig); + if (!installed) continue; + checks.push({ + name: 'teamai hooks in pi extension', + source: 'local', + check: async () => hasPiHooks(), + fix: 'Run `teamai hooks inject` to inject/update hooks', + }); + continue; + } // A standalone hooks file (Copilot) is injected at the config's own scope // (`reconcileTeamHooksForConfig` joins resolveToolBaseDir with the // config-scoped `hooks`), so it is probed from `toolPaths`. Settings-based diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 87698e7e2..840507e38 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -15,6 +15,7 @@ import { } from './types.js'; import { getUserHome } from './utils/home.js'; import { pathExists } from './utils/fs.js'; +import { hasPiHooks, removePiHooks, resolvePiExtensionsDir, PI_HOOK_FILE } from './pi-hooks.js'; type HookListStatus = HookStatus | 'not configured'; @@ -152,6 +153,16 @@ export async function hooksList(_options: GlobalOptions): Promise { for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (isAgentExcluded(localConfig, tool)) continue; + if (tool === 'pi') { + const hookPath = path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); + rows.push({ + tool, + status: await hasPiHooks() ? 'installed' : 'missing', + settingsPath: formatDisplayPath(hookPath), + builtinDefs: installedBuiltinHookDefs(tool, false), + }); + continue; + } const hookPath = paths.hooks ? path.join(resolveToolBaseDir(tool, localConfig), paths.hooks) : hookScopedPaths[tool]?.settings @@ -256,7 +267,11 @@ export async function hooksRemove(_options: GlobalOptions): Promise { // Removal must target the same paths injection used. A non-self project // scope injects into HOME, so resolving the project-scope paths here would // miss (and leave behind) every tool whose user-scope prefix differs. - await reconcileHooksToAllTools(scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }), baseDir, [], manifestPath, { removeAll: true }); + await reconcileHooksToAllTools(scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }), baseDir, [], manifestPath, { + removeAll: true, + scope: localConfig.scope, + installedBaseDir: localConfig.scope === 'project' ? localConfig.projectRoot : undefined, + }); const copilotPaths = scopedToolPaths(teamConfig, localConfig)[COPILOT_TOOL_ID]; if (copilotPaths?.hooks) { @@ -278,5 +293,12 @@ export async function hooksRemove(_options: GlobalOptions): Promise { // re-running on the primary target in self mode. await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); + // Pi has one shared user extension. `hooks remove` is an explicit global + // hook-disable action even when invoked from a project; project uninstall + // follows scope ownership separately and preserves this file. + if (teamConfig.toolPaths.pi) { + await removePiHooks(); + } + log.success('Hooks removed from all AI tool settings'); } diff --git a/src/hooks.ts b/src/hooks.ts index e7ae74736..f3e41e271 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -16,7 +16,7 @@ import { resolveToolBaseDir, scopedToolPaths, } from './types.js'; -import type { HookDef, TeamaiConfig, LocalConfig } from './types.js'; +import type { HookDef, TeamaiConfig, LocalConfig, Scope } from './types.js'; import { isSelfMode } from './types.js'; import { resolveMembership } from './membership.js'; import { builtinHookDefs, applyBuiltinOverride, skipToolsWithoutShell, toolUsesCmdShell } from './builtin-hooks.js'; @@ -1369,6 +1369,46 @@ async function reconcileOmpExtension(removeAll = false): Promise { } } +/** True when Pi looks installed at the user or the resolved project root. */ +async function isPiInstalled(baseDir: string, installedBaseDir?: string): Promise { + return await pathExists(path.join(getUserHome(), '.pi')) + || await pathExists(path.join(installedBaseDir ?? baseDir, '.pi')); +} + +/** + * Reconcile the single TeamAI Pi extension in the user agent directory. Pi + * also auto-loads a project extensions dir with absolute-path dedup, so + * writing a second copy there would dispatch every event twice (the same + * single-copy policy as the OMP adapter) — only the user-scope copy is ever + * written. A TeamAI-marked project copy left by an earlier revision is + * cleaned up when reconciling that project. Mirrors the OMP adapter on + * removal too: any `removeAll` pass — a scoped `uninstall --agent pi` or the + * explicit `hooks remove` command — deletes the single global copy outright. + * Pi has no way to scope a shared file to one project, so a "preserve for + * other projects" guarantee was never actually enforceable at dispatch time + * anyway (the generated extension fires for every Pi session regardless of + * which project asked to be excluded). + */ +async function reconcilePiExtension( + baseDir: string, + removeAll = false, + installedBaseDir?: string, +): Promise { + const home = getUserHome(); + const { injectPiHooks, removePiHooks, removePiProjectHooks } = await import('./pi-hooks.js'); + const inferredProjectScope = path.resolve(baseDir) !== path.resolve(home); + const projectRoot = installedBaseDir ?? (inferredProjectScope ? baseDir : undefined); + + if (projectRoot && path.resolve(projectRoot) !== path.resolve(home)) { + await removePiProjectHooks(projectRoot); + } + if (removeAll) { + await removePiHooks(); + return; + } + if (await isPiInstalled(baseDir, installedBaseDir)) await injectPiHooks(); +} + /** * Inject teamai built-in hooks into all AI tool settings. * Only writes to tools whose root directory already exists on disk, @@ -1382,7 +1422,13 @@ export async function injectHooksToAllTools(toolPaths: Record { // Removal is JSON editing and needs no shell, so the gate only applies to // injection passes — otherwise tools without a shell could never clean up @@ -1536,6 +1582,34 @@ export async function reconcileHooksToAllTools( } continue; } + if (tool === 'pi') { + if (opts.settingsOnly) continue; + try { + // Only warn about skipped team/override hooks when Pi is actually + // installed — Pi is in every team's default toolPaths, so without this + // gate teammates who never use Pi see this warning on every + // reconcile whenever the team defines a Pi-targeted hook. + if (!opts.removeAll && await isPiInstalled(baseDir, opts.installedBaseDir)) { + const applicableTeamDefs = teamDefsForTool(teamDefs, 'pi'); + if (applicableTeamDefs.length > 0) { + log.warn( + `Pi supports built-in lifecycle hooks only; skipping ${applicableTeamDefs.length} custom team hook(s) from hooks/hooks.yaml`, + ); + } + const builtinOverrideCount = (opts.builtinOverride?.disabled?.length ?? 0) + + Object.keys(opts.builtinOverride?.overrides ?? {}).length; + if (builtinOverrideCount > 0) { + log.warn( + `Pi supports built-in lifecycle hooks only; skipping ${builtinOverrideCount} built-in hook override(s) from hooks/hooks.yaml`, + ); + } + } + await reconcilePiExtension(baseDir, opts.removeAll, opts.installedBaseDir); + } catch (e) { + log.warn(`Failed to reconcile Pi hooks: ${(e as Error).message}`); + } + continue; + } // DeepSeek Harness has no settings-file hook surface. Its official // Claude-hook bridge is a Cordis plugin loaded through a user-supplied // profile patch, so keep the generated config and patch in ~/.teamai. @@ -1643,6 +1717,14 @@ export async function sweepLegacyProjectHooks( log.warn(`Failed to remove legacy OpenCode project plugin: ${(e as Error).message}`); } } + if (toolPaths.pi) { + try { + const { removePiProjectHooks } = await import('./pi-hooks.js'); + await removePiProjectHooks(legacy.baseDir); + } catch (e) { + log.warn(`Failed to remove legacy Pi project extension: ${(e as Error).message}`); + } + } } /** @@ -1684,6 +1766,7 @@ export async function reconcileTeamHooksForConfig( ? localConfig.projectRoot : undefined, installedBaseDir: localConfig.scope === 'project' ? (localConfig.projectRoot ?? baseDir) : undefined, + scope: localConfig.scope, }); const copilotExcluded = disabled?.includes(COPILOT_TOOL_ID) ?? false; diff --git a/src/known-agents.ts b/src/known-agents.ts index 218303831..6de0b8344 100644 --- a/src/known-agents.ts +++ b/src/known-agents.ts @@ -18,7 +18,7 @@ import { getUserHome } from './utils/home.js'; * against the user's HOME in non-interactive contexts. Order is the display order. * Kept small on purpose — the common coding agents, not the full KNOWN_AGENTS list. */ -export const SELF_MODE_AGENT_CHOICES = ['claude', 'codex', 'cursor', 'copilot', 'joycode', 'codebuddy', 'workbuddy'] as const; +export const SELF_MODE_AGENT_CHOICES = ['claude', 'codex', 'cursor', 'copilot', 'pi', 'joycode', 'codebuddy', 'workbuddy'] as const; /** * Normalize the `--agent` option into a deduplicated id list. @@ -93,6 +93,7 @@ export const KNOWN_AGENTS: KnownAgent[] = [ { id: 'amp', displayName: 'Amp', category: 'coding', skillsPath: '.amp/skills' }, { id: 'augment', displayName: 'Augment', category: 'coding', skillsPath: '.augment/skills' }, { id: 'copilot', displayName: 'Copilot', category: 'coding', skillsPath: '.copilot/skills' }, + { id: 'pi', displayName: 'Pi Coding Agent', category: 'coding', skillsPath: '.pi/skills' }, { id: 'factory', displayName: 'Factory Droid', category: 'coding', skillsPath: '.factory/skills' }, { id: 'hermes', displayName: 'Hermes', category: 'coding', skillsPath: '.hermes/skills' }, { id: 'junie', displayName: 'Junie', category: 'coding', skillsPath: '.junie/skills' }, diff --git a/src/local-agent.ts b/src/local-agent.ts index dc940c142..450c84d60 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2690,6 +2690,9 @@ async function runHookRuleCommand( } else if (rec.tool === 'opencode') { const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (rec.tool === 'pi') { + const { removePiAgentHook } = await import('./pi-hooks.js'); + await removePiAgentHook(slug); } else { const settingsPath = resolveToolSettingsPath(config, rec.tool); await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); @@ -2730,6 +2733,9 @@ async function runHookRuleCommand( } else if (prior.tool === 'opencode') { const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (prior.tool === 'pi') { + const { removePiAgentHook } = await import('./pi-hooks.js'); + await removePiAgentHook(slug); } else { const priorPath = resolveToolSettingsPath(config, prior.tool); await removeAgentHook(priorPath, prior.tool, { slug, command: prior.command }); @@ -2749,6 +2755,9 @@ async function runHookRuleCommand( // OpenCode loads plugins from ~/.config/opencode/plugin (user scope). const { applyOpencodeAgentHook } = await import('./opencode-hooks.js'); await applyOpencodeAgentHook({ slug, event, command: cmd, baseDir: getUserHome(), scope: 'user', matcher }); + } else if (tool === 'pi') { + const { applyPiAgentHook } = await import('./pi-hooks.js'); + await applyPiAgentHook({ slug, event, command: cmd, matcher, timeout }); } else { const settingsPath = resolveToolSettingsPath(config, tool); await applyAgentHook(settingsPath, tool, { slug, event, command: cmd, matcher, timeout }); @@ -3363,6 +3372,9 @@ export async function removeAllAgentHooks(): Promise { } else if (rec.tool === 'opencode') { const { removeOpencodeAgentHook } = await import('./opencode-hooks.js'); await removeOpencodeAgentHook({ slug, baseDir: getUserHome(), scope: 'user' }); + } else if (rec.tool === 'pi') { + const { removePiAgentHook } = await import('./pi-hooks.js'); + await removePiAgentHook(slug); } else { const settingsPath = resolveToolSettingsPath(config, rec.tool); await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); diff --git a/src/pi-hooks.ts b/src/pi-hooks.ts new file mode 100644 index 000000000..633e425ae --- /dev/null +++ b/src/pi-hooks.ts @@ -0,0 +1,257 @@ +/** + * Pi Coding Agent hook injection. + * + * Pi discovers TypeScript extensions from ~/.pi/agent/extensions and from a + * project .pi/extensions directory, auto-loading both. Only ONE copy is ever + * written, in the user agent dir — a project copy would double-dispatch every + * lifecycle event (the same single-copy policy as the OMP adapter). A + * TeamAI-marked project copy left by an earlier revision is removed during + * the next sync of that project. Pi has no settings file for self mode to + * commit, so a fresh clone still needs one `teamai init`/`pull` on that + * machine before Pi hooks are active there. The extension always dispatches + * — hook-dispatch has no per-project exclusion check for any tool, so a + * single shared file has no way to skip a project that has excluded Pi; the + * `cwd` on STDIN only lets downstream handlers resolve the right project + * context, not opt a project out. A targeted removal (scoped `uninstall` or + * `hooks remove`) therefore deletes this one global copy outright, same as + * the OMP adapter. The extension bridges TeamAI's built-in lifecycle events + * only; custom team hooks declared in hooks/hooks.yaml are outside this + * adapter's current scope. Pi profile overrides (`PI_CODING_AGENT_DIR` / + * `PI_CONFIG_DIR`), which relocate the agent directory, are not supported — + * same as the OMP adapter — and the default `~/.pi/agent/` layout is used. + */ + +import path from 'node:path'; +import { writeFile, ensureDir, pathExists, remove, readFileSafe } from './utils/fs.js'; +import { getUserHome } from './utils/home.js'; +import { log } from './utils/logger.js'; + +/** Filename of the TeamAI-managed Pi extension. */ +export const PI_HOOK_FILE = 'teamai-hooks.ts'; + +const TEAMAI_MARKER = '[teamai]'; + +const CLAUDE_TO_PI_EVENTS: Record = { + SessionStart: 'session_start', + UserPromptSubmit: 'before_agent_start', + PreToolUse: 'tool_execution_start', + PostToolUse: 'tool_execution_end', + Stop: 'agent_settled', +}; + +/** The global Pi agent extension directory. */ +export function resolvePiExtensionsDir(): string { + return path.join(getUserHome(), '.pi', 'agent', 'extensions'); +} + +/** Project-local Pi extension directory used only to clean up legacy copies. */ +export function resolvePiProjectExtensionsDir(baseDir: string): string { + return path.join(baseDir, '.pi', 'extensions'); +} + +/** Build the generated Pi extension source. */ +export function buildPiExtensionSource(): string { + return `// ${TEAMAI_MARKER} hooks extension — generated by teamai, do not edit by hand. +// +// Bridges Pi extension events to the shared teamai hook-dispatch entry +// point. The child process receives the same JSON payload as shell-based hooks +// (cwd / tool_name / tool_input / prompt). Errors and timeouts are swallowed so +// a missing teamai binary never blocks the Pi session. + +import { spawn } from "node:child_process"; + +/** @param {any} pi Pi ExtensionAPI */ +export default function teamaiHooks(pi) { + const toolInputs = new Map(); + const dispatch = async (event, cwd, payload) => { + try { + const command = process.platform === "win32" ? "teamai.cmd" : "teamai"; + const args = ["hook-dispatch", event, "--tool", "pi"]; + const child = spawn(command, args, { + cwd, + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + shell: process.platform === "win32", + }); + const stdin = JSON.stringify({ cwd, ...(payload || {}) }); + child.stdin?.on("error", () => {}); + child.stdin?.end(stdin); + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(undefined); + }; + const timer = setTimeout(() => { + try { child.kill(); } catch {} + finish(); + }, 15000); + child.once("close", finish); + child.once("error", finish); + }); + } catch { + // never block the agent + } + }; + + pi.on("session_start", async (_event, ctx) => { + await dispatch("session-start", ctx.cwd); + }); + + pi.on("agent_settled", async (_event, ctx) => { + await dispatch("stop", ctx.cwd); + }); + + pi.on("before_agent_start", async (event, ctx) => { + await dispatch("prompt-submit", ctx.cwd, { prompt: event.prompt }); + }); + + pi.on("tool_execution_start", async (event) => { + toolInputs.set(event.toolCallId, event.args); + }); + + pi.on("tool_execution_end", async (event, ctx) => { + await dispatch("post-tool-use", ctx.cwd, { + tool_name: event.toolName, + tool_input: toolInputs.get(event.toolCallId) || {}, + }); + toolInputs.delete(event.toolCallId); + }); +} +`; +} + +/** + * Inject or refresh the single global TeamAI Pi extension. Mirrors the + * ownership check `removePiHooks` already applies: a same-named file without + * TeamAI's marker is left untouched rather than overwritten. + */ +export async function injectPiHooks(): Promise { + const dir = resolvePiExtensionsDir(); + const file = path.join(dir, PI_HOOK_FILE); + if (await pathExists(file) && !await hasPiHooks()) { + log.warn(`Skipping Pi hook injection: ${file} exists without the TeamAI marker`); + return; + } + await ensureDir(dir); + await writeFile(file, buildPiExtensionSource()); + log.success(`Injected teamai Pi hook into ${file}`); +} + +/** Remove the generated global Pi extension if present. */ +export async function removePiHooks(): Promise { + const file = path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); + if (!await hasPiHooks()) return; + await remove(file); + log.success(`Removed teamai Pi hook from ${file}`); +} + +/** Remove a TeamAI-managed project copy written by an earlier revision. */ +export async function removePiProjectHooks(baseDir: string): Promise { + const file = path.join(resolvePiProjectExtensionsDir(baseDir), PI_HOOK_FILE); + if (!await hasPiHooks(baseDir)) return; + await remove(file); + log.success(`Removed legacy project Pi hook from ${file}`); +} + +function assertSafeSlug(slug: string): void { + if (!slug || slug.includes('/') || slug.includes('\\') || slug.includes('..') || path.isAbsolute(slug)) { + throw new Error(`Invalid Pi agent-hook slug: ${slug}`); + } +} + +/** Build a Pi extension for one HTTP-source agent hook. */ +export function buildPiAgentHookExtensionSource( + slug: string, + event: string, + command: string, + matcher?: string, + timeout = 10, +): string { + const piEvent = CLAUDE_TO_PI_EVENTS[event]; + if (!piEvent) return ''; + const toolEvent = piEvent === 'tool_execution_start' || piEvent === 'tool_execution_end'; + const matcherGate = toolEvent && matcher && matcher !== '*' + ? piEvent === 'tool_execution_end' + ? `\n if (String(event.toolName || '').toLowerCase() !== ${JSON.stringify(matcher.toLowerCase())}) { toolInputs.delete(event.toolCallId); return; }` + : `\n if (String(event.toolName || '').toLowerCase() !== ${JSON.stringify(matcher.toLowerCase())}) return;` + : ''; + const timeoutMs = Math.max(1, timeout) * 1000; + const captureToolStart = piEvent === 'tool_execution_end' + ? ' pi.on("tool_execution_start", async (event) => {\n toolInputs.set(event.toolCallId, event.args || {});\n });\n' + : ''; + const payload = piEvent === 'before_agent_start' + ? '{ prompt: event.prompt }' + : piEvent === 'tool_execution_start' + ? '{ tool_name: event.toolName, tool_input: event.args || {} }' + : piEvent === 'tool_execution_end' + ? '{ tool_name: event.toolName, tool_input: toolInputs.get(event.toolCallId) || {} }' + : '{}'; + const cleanupToolInput = piEvent === 'tool_execution_end' + ? '\n toolInputs.delete(event.toolCallId);' + : ''; + return `// ${TEAMAI_MARKER} agent hook [${slug}] — generated by teamai, do not edit by hand.\nimport { spawn } from "node:child_process";\n\nexport default function teamaiAgentHook(pi) {\n const toolInputs = new Map();\n const run = async (cwd, payload) => {\n try {\n const shell = process.platform === "win32" ? "cmd.exe" : "sh";\n const args = process.platform === "win32" ? ["/d", "/s", "/c", ${JSON.stringify(command)}] : ["-c", ${JSON.stringify(command)}];\n const child = spawn(shell, args, { cwd, stdio: ["pipe", "ignore", "ignore"], windowsHide: true });\n child.stdin?.on("error", () => {});\n child.stdin?.end(JSON.stringify({ cwd, ...(payload || {}) }));\n await new Promise((resolve) => {\n const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(undefined); }, ${timeoutMs});\n child.once("close", () => { clearTimeout(timer); resolve(undefined); });\n child.once("error", () => { clearTimeout(timer); resolve(undefined); });\n });\n } catch {}\n };\n${captureToolStart} pi.on(${JSON.stringify(piEvent)}, async (event, ctx) => {${matcherGate}\n await run(ctx.cwd, ${payload});${cleanupToolInput}\n });\n}\n`; +} + +/** Filename of one HTTP-source agent-hook extension, keyed by slug. */ +function piAgentHookFile(slug: string): string { + return path.join(resolvePiExtensionsDir(), `teamai-agent-${slug}.ts`); +} + +/** + * Check whether a specific `teamai-agent-.ts` file carries TeamAI's + * per-slug marker. Mirrors `hasPiHooks` for the main extension — write/remove + * on this file must never touch a same-named file a user authored by hand. + */ +export async function hasPiAgentHook(slug: string): Promise { + const content = await readFileSafe(piAgentHookFile(slug)); + return content?.includes(`${TEAMAI_MARKER} agent hook [${slug}]`) ?? false; +} + +/** + * Install one HTTP-source agent hook as a Pi extension. + * + * Throws instead of warn-and-skip on both the unsupported-event and + * marker-collision cases: the caller (runHookRuleCommand) acks this back to + * the server and writes the local manifest only after this resolves, so a + * silent no-op here would leave both sides believing the hook is installed + * when the file was never touched. + */ +export async function applyPiAgentHook(def: { + slug: string; + event: string; + command: string; + matcher?: string; + timeout?: number; +}): Promise { + assertSafeSlug(def.slug); + const source = buildPiAgentHookExtensionSource(def.slug, def.event, def.command, def.matcher, def.timeout); + if (!source) { + throw new Error(`Pi does not support event "${def.event}" — skipping hook [${def.slug}]`); + } + const file = piAgentHookFile(def.slug); + if (await pathExists(file) && !await hasPiAgentHook(def.slug)) { + throw new Error(`Skipping Pi agent hook [${def.slug}]: ${file} exists without the TeamAI marker`); + } + await ensureDir(resolvePiExtensionsDir()); + await writeFile(file, source); + log.success(`Installed Pi agent hook [${def.slug}]`); +} + +/** Remove one HTTP-source agent-hook extension. */ +export async function removePiAgentHook(slug: string): Promise { + assertSafeSlug(slug); + if (!await hasPiAgentHook(slug)) return; + await remove(piAgentHookFile(slug)); + log.success(`Removed Pi agent hook [${slug}]`); +} + +/** Check whether a global or legacy project extension has TeamAI's marker. */ +export async function hasPiHooks(baseDir?: string): Promise { + const file = path.join(baseDir ? resolvePiProjectExtensionsDir(baseDir) : resolvePiExtensionsDir(), PI_HOOK_FILE); + if (!await pathExists(file)) return false; + const content = await readFileSafe(file); + return content?.includes(`${TEAMAI_MARKER} hooks extension`) ?? false; +} diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 88eb9bd33..36cbd9123 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -147,11 +147,10 @@ export class RulesHandler extends ResourceHandler { } } else { // File does not exist in team repo — candidate for "new". - // Native rule directories can also contain personal rules created by - // the target tool. Unknown files there are user-owned and stay local: - // the .mdc and Copilot-instructions dirs, plus OMP's native rules dir - // (the same ownership policy the pull-side sweep applies to OMP). - if (isMdcTool || isCopilotTool || tool === 'omp') continue; + // Native rule directories can contain personal rules created by the + // target tool. Keep unknown files in the .mdc, Copilot-instructions, + // OMP, and Pi rule directories local. + if (isMdcTool || isCopilotTool || tool === 'omp' || tool === 'pi') continue; const existing = candidates.get(name); if (!existing) { const mtime = await getFileMtime(localFilePath); @@ -499,13 +498,12 @@ export class RulesHandler extends ResourceHandler { const ruleName = ruleStemFromFilename(localFile); if (ruleName === null) continue; - // JoyCode's, OMP's, and Copilot's rules directories are shared with - // user-authored rules (OMP's native rules dirs are exactly where its - // users keep personal rules). Absence from the current team set is not - // proof of TeamAI ownership (including legacy .md files). Only explicit - // team removals authorize cleanup. Cursor is deliberately absent — - // teamai owns .cursor/rules and sweeps it. - if ((tool === 'joycode' || tool === 'omp' || usesCopilotInstructions(tool)) && !tombstones.has(ruleName)) continue; + // JoyCode, OMP, Pi, and Copilot rule directories are shared with + // user-authored rules. Absence from the current team set is not proof + // of TeamAI ownership (including legacy .md files); only explicit team + // removals authorize cleanup. Cursor is deliberately absent — teamai + // owns .cursor/rules and sweeps it. + if ((tool === 'joycode' || tool === 'omp' || tool === 'pi' || usesCopilotInstructions(tool)) && !tombstones.has(ruleName)) continue; // `.mdc` tools only read `.mdc`, so any `.md` here is inert leftover from the // layout that predates it — removed whether or not the rule is still diff --git a/src/types.ts b/src/types.ts index 6dffa6045..b7847891c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -441,6 +441,23 @@ export const TeamaiConfigSchema = z.object({ agents: '.omp/agent/agents', }, }, + // Pi Coding Agent: skills/rules/extensions live under the agent root. Pi + // discovers global context from ~/.pi/agent/AGENTS.md and project context + // from AGENTS.md/CLAUDE.md walking up the workspace tree. Hooks are + // TypeScript extensions rather than a settings hook list, so the adapter + // keeps one user extension and forwards the active cwd to hook-dispatch. + // Profile overrides (PI_CODING_AGENT_DIR / PI_CONFIG_DIR) that relocate + // the agent dir are not supported, same as the OMP adapter. + pi: { + skills: '.pi/skills', + rules: '.pi/rules', + claudemd: 'AGENTS.md', + userScope: { + skills: '.pi/agent/skills', + rules: '.pi/agent/rules', + claudemd: '.pi/agent/AGENTS.md', + }, + }, codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents', mcp: '.codebuddy/mcp.json', mcpProject: '.mcp.json' }, openclaw: { skills: '.openclaw/skills', rules: '.openclaw/rules', claudemd: '.openclaw/workspace/AGENTS.md' }, hermes: { skills: '.hermes/skills', claudemd: 'AGENTS.md' }, diff --git a/src/uninstall.ts b/src/uninstall.ts index 69781ef70..1f89c38aa 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -20,6 +20,7 @@ import { TEAMAI_ENV_END, getDataHome, getManagedHooksPath, + isAgentExcluded, managedMcpManifestPath, resolveBaseDir, resolveHookScope, @@ -37,6 +38,7 @@ import { ruleStemFromFilename } from './resources/rule-format.js'; import { agentStemFromFilename } from './resources/agent-format.js'; import { resolveDocsDestination } from './resources/docs.js'; import { listTeamAgentDirs } from './resources/agents.js'; +import { isToolInstalledForConfig } from './resources/base.js'; import { BUILTIN_AGENT_NAMES } from './builtin-agents.js'; import { BUILTIN_SKILL_NAMES } from './builtin-skills.js'; import { @@ -77,6 +79,8 @@ interface RemovalPlan { opencodeHookScopes: Array<{ baseDir: string; scope: Scope }>; /** teamai-managed OMP extension file (~/.omp/agent/extensions/teamai-hooks.ts), if present. */ ompHookFile: string | null; + /** Pi extension files owned by this scope (global for user, legacy project copy for project). */ + piHookFiles: string[]; /** TeamAI-managed DeepSeek Harness patch (~/.teamai/dsh/cordis.patch.yml), if present. */ dshHookFile: string | null; /** Manifest used by the primary hook injection scope. */ @@ -113,6 +117,7 @@ interface ToolResources { openclawHookDirs: Array<{ hooksDir: string; tool: string }>; opencodeHookScopes: Array<{ baseDir: string; scope: Scope }>; ompHookFile: string | null; + piHookFiles: string[]; dshHookFile: string | null; claudeMdFiles: string[]; skillDirs: string[]; @@ -126,6 +131,7 @@ function hasToolResources(r: ToolResources): boolean { r.openclawHookDirs.length > 0 || r.opencodeHookScopes.length > 0 || r.ompHookFile !== null || + r.piHookFiles.length > 0 || r.dshHookFile !== null || r.claudeMdFiles.length > 0 || r.skillDirs.length > 0 || @@ -252,8 +258,8 @@ async function discoverToolResources( hookSettingsPath?: string, ): Promise { const res: ToolResources = { - hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, dshHookFile: null, claudeMdFiles: [], - skillDirs: [], ruleFiles: [], agentFiles: [], + hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, + claudeMdFiles: [], skillDirs: [], ruleFiles: [], agentFiles: [], }; // (a) Hooks — settings.json / hooks.json @@ -298,6 +304,42 @@ async function discoverToolResources( if (await pathExists(extFile)) { res.ompHookFile = extFile; } + } else if (tool === 'pi') { + const { + hasPiHooks, + hasPiAgentHook, + resolvePiExtensionsDir, + resolvePiProjectExtensionsDir, + PI_HOOK_FILE, + } = await import('./pi-hooks.js'); + // Mirrors OMP: a targeted uninstall removes the single global extension + // outright, regardless of scope. Pi has no way to scope a shared file to + // one project — the generated extension fires for every Pi session + // machine-wide — so a scoped "preserve for other projects" guarantee was + // never actually enforceable, and pretending otherwise just left Pi still + // firing hooks for a project that had supposedly uninstalled it. + if (await hasPiHooks()) { + res.piHookFiles.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); + } + // Server-pushed agent hooks (teamai-agent-.ts) always install into + // the global extension dir and can exist without the main lifecycle + // extension — mirrors OpenCode's discovery, which scans for the same + // leftover-plugin pattern so a Pi-only agent-hook install isn't missed. + // Each match is marker-checked by its own slug so a same-named file a + // user authored by hand is never swept up. + for (const file of await listFiles(resolvePiExtensionsDir())) { + const base = path.basename(file); + if (!base.startsWith('teamai-agent-') || !base.endsWith('.ts')) continue; + const slug = base.slice('teamai-agent-'.length, -'.ts'.length); + if (await hasPiAgentHook(slug)) { + res.piHookFiles.push(path.join(resolvePiExtensionsDir(), file)); + } + } + // Clean up a TeamAI-marked legacy project copy left by an earlier + // revision, when this discovery pass is scoped to an actual project. + if (path.resolve(baseDir) !== path.resolve(getUserHome()) && await hasPiHooks(baseDir)) { + res.piHookFiles.push(path.join(resolvePiProjectExtensionsDir(baseDir), PI_HOOK_FILE)); + } } else if (toolPath.settings) { // Hooks live where resolveHookScope injected them (HOME for a non-self // project scope, per #370) — plus any legacy copy. Scan every @@ -473,6 +515,22 @@ async function buildRemovalPlan( ); } + // A tool only still "uses" a shared resource (AGENTS.md, .teamai/) if it is + // actually enabled and installed. Several tools default to the same shared + // path — e.g. Hermes/WorkBuddy default to the same project AGENTS.md as Pi — + // so a schema entry that merely shares a path must not block cleanup for a + // tool that was never enabled or set up. The probe path must be a + // tool-specific root (skills/rules/settings), never `claudemd`: that's + // exactly the shared, ambiguous path this check exists to disambiguate. + const activeTools = new Set(); + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (isAgentExcluded(localConfig, tool)) continue; + const probePath = toolPath.skills ?? toolPath.rules ?? toolPath.settings ?? toolPath.claudemd; + if (probePath && await isToolInstalledForConfig(tool, probePath, localConfig)) { + activeTools.add(tool); + } + } + // Decide which tools to merge and whether to include shared resources let includeShared: boolean; let toolsToMerge: string[]; @@ -482,7 +540,7 @@ async function buildRemovalPlan( const targetHasResources = targetRes ? hasToolResources(targetRes) : false; // Other tools still have teamai resources → keep shared resources. const othersHaveResources = [...perTool.entries()] - .some(([t, r]) => t !== agentFilter && hasToolResources(r)); + .some(([t, r]) => t !== agentFilter && activeTools.has(t) && hasToolResources(r)); // Remove shared resources only when the target itself has resources AND is // the last tool using teamai. Targeting a tool with no teamai resources is a // no-op for shared resources (plan will be empty → "Nothing to uninstall"). @@ -497,6 +555,7 @@ async function buildRemovalPlan( openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, + piHookFiles: [], dshHookFile: null, hookManifestPath: hookTargets[0].manifestPath, claudeMdFiles: [], @@ -513,6 +572,18 @@ async function buildRemovalPlan( scope: localConfig.scope, }; + // A single instruction file can be the native target for several agents + // (for example project `AGENTS.md` is shared by Pi, Hermes, and WorkBuddy). + // Keep its TeamAI blocks when another enabled, installed agent still + // references the same file; a targeted uninstall must not remove + // instructions owned by that remaining agent. + const retainedInstructionFiles = new Set(); + for (const [tool, resources] of perTool) { + if (!toolsToMerge.includes(tool) && activeTools.has(tool)) { + for (const file of resources.claudeMdFiles) retainedInstructionFiles.add(file); + } + } + // Merge tool-specific resources for selected tools for (const tool of toolsToMerge) { const res = perTool.get(tool); @@ -521,8 +592,13 @@ async function buildRemovalPlan( plan.openclawHookDirs.push(...res.openclawHookDirs); plan.opencodeHookScopes.push(...res.opencodeHookScopes); if (res.ompHookFile) plan.ompHookFile = res.ompHookFile; + plan.piHookFiles.push(...res.piHookFiles); if (res.dshHookFile) plan.dshHookFile = res.dshHookFile; - plan.claudeMdFiles.push(...res.claudeMdFiles); + for (const file of res.claudeMdFiles) { + if (!retainedInstructionFiles.has(file) && !plan.claudeMdFiles.includes(file)) { + plan.claudeMdFiles.push(file); + } + } plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); plan.agentFiles.push(...res.agentFiles); @@ -596,6 +672,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.openclawHookDirs.length === 0 && plan.opencodeHookScopes.length === 0 && plan.ompHookFile === null && + plan.piHookFiles.length === 0 && plan.dshHookFile === null && plan.claudeMdFiles.length === 0 && plan.skillDirs.length === 0 && @@ -650,6 +727,11 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(` ${plan.ompHookFile}`); console.log(''); } + if (plan.piHookFiles.length > 0) { + console.log(` Pi Hooks (${plan.piHookFiles.length} files):`); + for (const p of plan.piHookFiles) console.log(` ${p}`); + console.log(''); + } if (plan.dshHookFile !== null) { console.log(' DeepSeek Harness hook patch:'); @@ -733,8 +815,10 @@ async function teardownPlugins(): Promise { async function executeRemoval(plan: RemovalPlan): Promise { // (a) Remove hooks from tool settings (built-in A + team B via the manifest). - // Each entry carries the manifest for its own location (HOME/user or a legacy - // /project copy), so team hooks are stripped correctly at both. + // Each settings entry carries the manifest for its own location (HOME/user + // or a legacy /project copy), so team hooks are stripped at the + // location that owns them. File-based adapters apply their own scope rules + // below; in particular, project uninstall never owns Pi's global extension. for (const { path: settingsPath, tool, manifestPath } of plan.hookFiles) { try { await reconcileHooks(settingsPath, tool, [], { removeAll: true, manifestPath }); @@ -780,6 +864,16 @@ async function executeRemoval(plan: RemovalPlan): Promise { } } + // (a2c) Remove the generated Pi extension. + for (const hookFile of plan.piHookFiles) { + try { + await remove(hookFile); + log.success(`Removed Pi hook from ${hookFile}`); + } catch (e) { + log.warn(`Failed to remove Pi hook ${hookFile}: ${(e as Error).message}`); + } + } + // (a2d) Remove the DSH bridge config and profile patch through the same // adapter used by `teamai hooks remove`, preserving unrelated hook entries. if (plan.dshHookFile !== null) {