Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ teamai remove rules <name> --force

`doctor` exits 1 when any check fails. `stats` adds a recall section when that scope has recall runs. See [Recall adoption and upvotes](./knowledge.md#recall-adoption-and-upvotes).

Generated-hook checks compare the handler logic and accept an existing absolute launcher recorded by another installation of the same build. Running `doctor` from a different CLI path alone does not require reinstalling hooks. A missing saved launcher or changed handler still requires `teamai hooks inject` or `teamai pull`.
Generated-hook checks accept a launcher from another installation when the handler logic matches. Running `doctor` from a different CLI path alone does not require reinstalling hooks. The saved paths must be files, and the runtime must be named `node`, `nodejs`, or `bun`. Node names may have a numeric version suffix such as `node-22` or `nodejs-22`; `.exe` names are also accepted. An invalid saved launcher or changed handler still requires `teamai hooks inject` or `teamai pull`.

If a file occupies a tool's skills directory, `pull` keeps it, names the blocked path, and continues delivering to other tools. `doctor` fails the skills-directory check instead of treating the skipped tool as delivered. Move the file aside, then run `teamai pull` again to deliver the missing skills. `uninstall` preserves the blocking file and continues cleaning the tool's other managed resources.

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/zh-CN/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ teamai remove rules <name> --force

`doctor` 有一项没过就以状态码 1 退出。`stats` 在有 recall 记录时会多一节,见 [Recall 采纳与 upvote](./knowledge.md#recall-采纳与-upvote)。

生成 hook 的检查会比较处理逻辑,并接受同一构建的另一份安装记录的有效绝对启动路径。仅换一个 CLI 路径运行 `doctor` 不需要重装 hooks;记录的启动入口不存在或处理逻辑发生变化时,仍需运行 `teamai hooks inject` 或 `teamai pull`。
生成 hook 的检查会比较处理逻辑,并接受处理逻辑相同的另一份安装记录的启动路径。仅换一个 CLI 路径运行 `doctor` 不需要重装 hooks;记录的路径必须指向文件,运行时名称必须为 `node`、`nodejs` 或 `bun`。Node 名称可带数字版本后缀,例如 `node-22` 或 `nodejs-22`;也接受带 `.exe` 的名称。启动入口无效或处理逻辑发生变化时,仍需运行 `teamai hooks inject` 或 `teamai pull`。

如果工具的 skills 目录被普通文件占用,`pull` 会保留该文件、指出阻塞路径,并继续向其他工具分发。`doctor` 会将该目录检查标为失败,避免把跳过的投递误报为成功。移开该文件后,再运行 `teamai pull` 即可补齐缺失的技能。`uninstall` 会保留该文件,并继续清理该工具的其他托管资源。

Expand Down
2 changes: 1 addition & 1 deletion skill-data/core/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ Session archive writers (`session push`, `migrate --push`, and `session pull`) w

## OpenCode hooks cannot start the CLI

If OpenCode hooks stopped after moving or reinstalling Node or TeamAI, run `teamai hooks inject` or `teamai pull` and restart OpenCode. The plugin records the installed Node and CLI paths so it also works without `teamai` on the service PATH. Doctor accepts those live paths when another installation of the same build inspects the plugin; missing saved paths or a changed handler still fail the check.
If OpenCode hooks stopped after moving or reinstalling Node or TeamAI, run `teamai hooks inject` or `teamai pull` and restart OpenCode. The plugin records the installed Node and CLI paths so it also works without `teamai` on the service PATH. Doctor accepts those live paths when another installation with the same handler inspects the plugin; invalid saved launcher files or a changed handler still fail the check.

## ZCode registered hooks but missing project rules

Expand Down
20 changes: 18 additions & 2 deletions src/__tests__/e2e/installed-launcher-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import YAML from 'yaml';
import { expect, it } from 'vitest';
const CLI = fileURLToPath(new URL('../../../dist/index.js', import.meta.url));
it('doctor accepts the live launcher saved by another entry to the same built CLI', () => {
it.each(['node-22', 'nodejs'])('doctor accepts a live launcher installed through %s and inspected through another CLI entry', name => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-readiness-cli-'));
try {
const home = path.join(root, 'home');
Expand All @@ -27,19 +27,35 @@ it('doctor accepts the live launcher saved by another entry to the same built CL
execFileSync('git', ['add', '.'], { cwd: team, env });
execFileSync('git', ['commit', '-qm', 'fixture'], { cwd: team, env });
const cli = (entry: string, ...args: string[]) => spawnSync(process.execPath, [entry, ...args], { cwd: project, env, encoding: 'utf8', timeout: 30000 });
const installed = cli(first, 'hooks', 'inject');
const runtime = path.join(root, process.platform === 'win32' ? `${name}.exe` : name);
fs.copyFileSync(process.execPath, runtime);
const installed = spawnSync(runtime, [first, 'hooks', 'inject'], { cwd: project, env, encoding: 'utf8', timeout: 30000 });
expect(installed.status, installed.stdout + installed.stderr).toBe(0);
const file = path.join(home, '.pi/agent/extensions/teamai-hooks.ts');
const before = fs.readFileSync(file, 'utf8');
expect(before).toContain(first);
const runtimeLine = `const NODE = ${JSON.stringify(fs.realpathSync(runtime))};`;
expect(before).toContain(runtimeLine);
const ready = () => {
const run = cli(second, 'doctor', '--json');
const report = JSON.parse(run.stdout) as { checks: Array<{ name: string; ok: boolean }> };
return report.checks.find(check => check.name.startsWith('pi adds the team instructions'))?.ok;
};
expect(ready()).toBe(true);
expect(fs.readFileSync(file, 'utf8')).toBe(before);
const shell = path.join(root, 'sh');
fs.writeFileSync(shell, '#!/bin/sh\n');
fs.writeFileSync(file, before.replace(runtimeLine, `const NODE = ${JSON.stringify(shell)};`));
expect(ready()).toBe(false);
fs.writeFileSync(file, before.replace(runtimeLine, `const NODE = ${JSON.stringify(first)};`)
.replace(`const ENTRY = ${JSON.stringify(first)};`, `const ENTRY = ${JSON.stringify(runtime)};`));
expect(ready()).toBe(false);
fs.writeFileSync(file, before);
expect(ready()).toBe(true);
fs.unlinkSync(first);
fs.mkdirSync(first);
expect(ready()).toBe(false);
fs.rmdirSync(first);
expect(ready()).toBe(false);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
}, 60000);
1 change: 1 addition & 0 deletions src/__tests__/helpers/windows-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const WINDOWS_TESTS = [
'src/__tests__/hooks-shell-check.test.ts',
'src/__tests__/hooks-skill-retire.test.ts',
'src/__tests__/hooks-windows-bash.test.ts',
'src/__tests__/installed-launcher-readiness.test.ts',
'src/__tests__/joycode.test.ts',
'src/__tests__/locate-agents.test.ts',
'src/__tests__/locate-blocks.test.ts',
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/installed-launcher-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ let entry: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-launcher-check-'));
vi.stubEnv('HOME', home); vi.stubEnv('USERPROFILE', home);
vi.stubEnv('PI_CODING_AGENT_DIR', '');
vi.stubEnv('OPENCODE_CONFIG_DIR', ''); vi.stubEnv('XDG_CONFIG_HOME', '');
entry = path.join(home, 'other install.mjs');
fs.writeFileSync(entry, '// CLI entry in another installation');
Expand Down Expand Up @@ -46,6 +47,45 @@ it.each(['bun', 'hermes'])('recognizes %s launcher metadata without ignoring han
: `${value.slice(0, -1)}, "hook-dispatch", "instructions", "--tool", "hermes"]`;
expect(await hookSourceMatches(source(launcher), source(current))).toBe(true);
expect(await hookSourceMatches(source(launcher) + '\nchanged();', source(current))).toBe(false);
expect(await hookSourceMatches(source(JSON.stringify([entry, process.execPath])), source(current))).toBe(false);
fs.unlinkSync(entry);
fs.mkdirSync(entry);
expect(await hookSourceMatches(source(launcher), source(current))).toBe(false);
fs.rmdirSync(entry);
expect(await hookSourceMatches(source(launcher), source(current))).toBe(false);
});

it.each(['node', 'node-22', 'nodejs', 'nodejs-22', 'node.exe', 'node-22.exe', 'bun', 'bun.exe'])('accepts a Pi launcher saved with the %s runtime name', async name => {
const runtime = path.join(home, name);
fs.writeFileSync(runtime, 'runtime fixture');
const file = path.join(hookAdapterDir('pi-extension'), PI_HOOK_FILE);
fs.mkdirSync(path.dirname(file), { recursive: true });
const source = buildPiExtensionSource(entry)
.replace(`const NODE = ${JSON.stringify(process.execPath)};`, `const NODE = ${JSON.stringify(runtime)};`);
fs.writeFileSync(file, source);
expect((await channelReadiness('pi', 'pi-extension')).ready).toBe(true);
expect(fs.readFileSync(file, 'utf8')).toBe(source);
});

it.each(['entry directory', 'runtime directory', 'swapped paths', 'shell runtime'])('rejects a Pi launcher with %s', async kind => {
const file = path.join(hookAdapterDir('pi-extension'), PI_HOOK_FILE);
fs.mkdirSync(path.dirname(file), { recursive: true });
let source = buildPiExtensionSource(entry);
if (kind === 'entry directory') {
fs.unlinkSync(entry);
fs.mkdirSync(entry);
} else if (kind === 'runtime directory') {
const runtime = path.join(home, 'node');
fs.mkdirSync(runtime);
source = source.replace(`const NODE = ${JSON.stringify(process.execPath)};`, `const NODE = ${JSON.stringify(runtime)};`);
} else if (kind === 'swapped paths') {
source = source.replace(`const NODE = ${JSON.stringify(process.execPath)};`, `const NODE = ${JSON.stringify(entry)};`)
.replace(`const ENTRY = ${JSON.stringify(entry)};`, `const ENTRY = ${JSON.stringify(process.execPath)};`);
} else {
const runtime = path.join(home, 'sh');
fs.writeFileSync(runtime, 'shell runtime fixture');
source = source.replace(`const NODE = ${JSON.stringify(process.execPath)};`, `const NODE = ${JSON.stringify(runtime)};`);
}
fs.writeFileSync(file, source);
expect((await channelReadiness('pi', 'pi-extension')).ready).toBe(false);
});
19 changes: 12 additions & 7 deletions src/utils/hook-source.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import path from 'node:path';
import { pathExists } from './fs.js';
import { stat } from 'node:fs/promises';

/** Compare generated handlers without requiring the inspecting CLI's launcher. */
export async function hookSourceMatches(actual: string | null, expected: string): Promise<boolean> {
if (actual === null) return false;
if (actual === expected) return true;
const normalize = (source: string): { body: string; paths: string[] } | null => {
const normalize = (source: string): { body: string; paths: string[]; runtimes: string[] } | null => {
const paths: string[] = [];
const runtimes: string[] = [];
let valid = true;
const launcher = (encoded: string): void => {
const launcher = (encoded: string, runtime = true): void => {
try {
const parsed: unknown = JSON.parse(encoded);
const values: unknown = typeof parsed === 'string' ? [parsed] : parsed;
if (!Array.isArray(values)
|| !values.every((value): value is string => typeof value === 'string' && path.isAbsolute(value))) {
valid = false;
} else paths.push(...values);
} else {
paths.push(...values);
if (runtime && values[0]) runtimes.push(values[0]);
}
} catch { valid = false; }
};
const jsonString = '"(?:[^"\\\\]|\\\\.)*"';
let body = source.replace(new RegExp(`^const (NODE|ENTRY) = (${jsonString});$`, 'gm'), (_line, name: string, value: string) => {
launcher(value);
launcher(value, name === 'NODE');
return `const ${name} = "<installed-launcher>";`;
});
body = body.replace(/^const launcher = (\[[^\n]+\]);$/gm, (_line, values: string) => {
Expand All @@ -31,11 +35,12 @@ export async function hookSourceMatches(actual: string | null, expected: string)
launcher(`[${node},${entry}]`);
return '["<installed-launcher>", "hook-dispatch", "instructions", "--tool", "hermes"]';
});
return valid ? { body, paths } : null;
return valid ? { body, paths, runtimes } : null;
};
const installed = normalize(actual);
const current = normalize(expected);
return installed !== null && current !== null && installed.paths.length > 0
&& installed.paths.length === current.paths.length && installed.body === current.body
&& (await Promise.all(installed.paths.map(pathExists))).every(Boolean);
&& installed.runtimes.every(file => /^(node(js)?(-\d+)?|bun)(\.exe)?$/i.test(path.basename(file)))
&& (await Promise.all(installed.paths.map(file => stat(file).then(info => info.isFile(), () => false)))).every(Boolean);
}
Loading