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
174 changes: 174 additions & 0 deletions src/__tests__/e2e/toolpaths-sparse-merge-1185.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// ─── Sparse toolPaths merge over defaults (issue #1185) ─────────────────────
//
// When a team specifies a custom or partial `toolPaths` mapping in teamai.yaml
// (e.g. only defining a single in-house tool like `zcode`), TeamAI must merge
// those entries tool-by-tool over DEFAULT_TOOL_PATHS. Built-in tools like Claude,
// Cursor, Codex, and CodeBuddy must retain their default delivery paths rather
// than having their configurations wiped out.

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..', '..');
const CLI = path.join(ROOT, 'dist', 'index.js');

const FAKE_URL = 'https://git.example.com/team/toolpaths-merge.git';

const GIT_ENV = {
GIT_AUTHOR_NAME: 'TeamAI CI',
GIT_AUTHOR_EMAIL: 'ci@teamai.test',
GIT_COMMITTER_NAME: 'TeamAI CI',
GIT_COMMITTER_EMAIL: 'ci@teamai.test',
};

function git(args: string[], cwd: string, home?: string): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
env: {
...process.env,
...GIT_ENV,
...(home ? { HOME: home, USERPROFILE: home, GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig') } : {}),
},
}).trim();
}

function runCLI(args: string[], cwd: string, home: string): Promise<{ code: number | null; output: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, ...args], {
cwd,
env: {
...process.env,
...GIT_ENV,
HOME: home,
USERPROFILE: home,
GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'),
FORCE_COLOR: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (data: Buffer) => { output += data.toString(); });
child.stderr.on('data', (data: Buffer) => { output += data.toString(); });
child.on('close', (code) => resolve({ code, output }));
});
}

describe('sparse toolPaths merge over built-in defaults (issue #1185)', () => {
let sandbox: string;
let home: string;
let seed: string;
let remote: string;

beforeAll(async () => {
if (!fs.existsSync(CLI)) {
throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
}
sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-toolpaths-merge-e2e-')));
home = path.join(sandbox, 'home');
seed = path.join(sandbox, 'seed');
remote = path.join(sandbox, 'team.git');

fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(path.join(seed, 'manifest'), { recursive: true });
fs.mkdirSync(path.join(seed, 'skills', 'common', 'hello-skill'), { recursive: true });

// Seed teamai.yaml with ONLY a single custom tool defined in toolPaths
fs.writeFileSync(path.join(seed, 'teamai.yaml'), [
'team: sparse-toolpaths-e2e',
`repo: ${FAKE_URL}`,
'provider: git',
'reviewers: []',
'toolPaths:',
' zcode:',
' skills: .zcode/skills',
' rules: .zcode/rules',
'',
].join('\n'));

fs.writeFileSync(path.join(seed, 'manifest', 'roles.yaml'), [
'version: 1',
'roles:',
' - id: dev',
' resources:',
' knowledge: []',
' skills: [common]',
'',
].join('\n'));

fs.writeFileSync(path.join(seed, 'skills', 'common', 'hello-skill', 'SKILL.md'), [
'---',
'name: hello-skill',
'description: Hello world test skill',
'---',
'# Hello Skill',
].join('\n'));

git(['init', '-q', '-b', 'main'], seed);
git(['add', '-A'], seed);
git(['commit', '-q', '-m', 'seed team repository'], seed);
git(['clone', '-q', '--bare', seed, remote], sandbox);

const remotePosix = remote.replace(/\\/g, '/');
git(['config', '--global', `url.${remotePosix}/.insteadOf`, `${FAKE_URL}/`], sandbox, home);
git(['config', '--global', `url.${remotePosix}.insteadOf`, FAKE_URL], sandbox, home);
});

afterAll(() => {
if (sandbox) {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});

it('delivers skills to default Claude path even when teamai.yaml only declared zcode', async () => {
const ws1 = path.join(sandbox, 'workspace1');
fs.mkdirSync(ws1, { recursive: true });
git(['init', '-q', '-b', 'main'], ws1, home);

const initRes = await runCLI(
['init', FAKE_URL, '--provider', 'git', '--scope', 'workspace', '--role', 'dev', '--agent', 'claude', '--force'],
ws1,
home,
);
expect(initRes.code, initRes.output).toBe(0);

const pullRes = await runCLI(['pull'], ws1, home);
expect(pullRes.code, pullRes.output).toBe(0);

// Default tool Claude must retain its .claude/skills delivery path
const claudeSkillPath = path.join(ws1, '.claude', 'skills', 'hello-skill', 'SKILL.md');
expect(fs.existsSync(claudeSkillPath)).toBe(true);
expect(fs.readFileSync(claudeSkillPath, 'utf8')).toContain('# Hello Skill');
});

it('also supports custom tool zcode alongside the default tools', async () => {
const ws2 = path.join(sandbox, 'workspace2');
fs.mkdirSync(ws2, { recursive: true });
git(['init', '-q', '-b', 'main'], ws2, home);

// Initialize with both default tool claude and custom tool zcode
const initRes = await runCLI(
['init', FAKE_URL, '--provider', 'git', '--scope', 'workspace', '--role', 'dev', '--agent', 'claude,zcode', '--force'],
ws2,
home,
);
expect(initRes.code, initRes.output).toBe(0);

const pullRes = await runCLI(['pull'], ws2, home);
expect(pullRes.code, pullRes.output).toBe(0);

// Both Claude (default) and zcode (custom) receive the skill
const claudeSkillPath = path.join(ws2, '.claude', 'skills', 'hello-skill', 'SKILL.md');
const zcodeSkillPath = path.join(ws2, '.zcode', 'skills', 'hello-skill', 'SKILL.md');

expect(fs.existsSync(claudeSkillPath)).toBe(true);
expect(fs.existsSync(zcodeSkillPath)).toBe(true);
expect(fs.readFileSync(claudeSkillPath, 'utf8')).toContain('# Hello Skill');
expect(fs.readFileSync(zcodeSkillPath, 'utf8')).toContain('# Hello Skill');
});
});
18 changes: 12 additions & 6 deletions src/__tests__/source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ describe('source', () => {
it('allows a nested identity change when the old and new destinations are disjoint', async () => {
const repo = 'https://source.test/platform/repo.git';
teamConfig.sources = [{ name: 'platform', repo }];
teamConfig.toolPaths = { codex: { skills: '.codex/skills' } };
teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } };
const YAML = (await import('yaml')).default;
await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig));
await fse.ensureDir(path.join(homeDir, '.codex/skills'));
Expand Down Expand Up @@ -727,7 +727,7 @@ describe('source', () => {
it('does not claim a public skill when no configured tool is installed', async () => {
const repo = 'https://source.test/platform/repo.git';
teamConfig.sources = [{ name: 'platform', repo }];
teamConfig.toolPaths = { codex: { skills: '.codex/skills' } };
teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } };
const YAML = (await import('yaml')).default;
await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig));
await fse.outputFile(path.join(fixtureSourceRepoDir(), 'skills/shared-skill/SKILL.md'), '# Source');
Expand Down Expand Up @@ -895,7 +895,7 @@ describe('source', () => {
const previousUrl = 'https://source.test/original/repo.git';
const repo = changedRepository ? 'https://source.test/replacement/repo.git' : previousUrl;
teamConfig.sources = [{ name: 'platform', repo }];
teamConfig.toolPaths = { codex: { skills: '.codex/skills' } };
teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } };
const YAML = (await import('yaml')).default;
await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig));
await fse.ensureDir(path.join(homeDir, '.codex/skills'));
Expand Down Expand Up @@ -924,7 +924,11 @@ describe('source', () => {
expect(await fse.readFile(newPath, 'utf8')).toBe('# Replacement content');
if (hasOtherOwner) {
expect(await fse.readFile(path.join(homeDir, oldPath, 'SKILL.md'), 'utf8')).toBe('# Original content');
const otherTeam = { ...teamConfig, sources: [{ name: 'platform', repo: previousUrl }], toolPaths: { claude: { skills: '.claude/skills' } } };
const otherTeam = {
...teamConfig,
sources: [{ name: 'platform', repo: previousUrl }],
toolPaths: { claude: { skills: '.claude/skills' }, codex: {} },
};
await fse.outputFile(path.join(otherConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(otherTeam));
const otherRepo = path.join(homeDir, '.teamai', 'source-repos', previousId, 'repo');
await fse.outputFile(path.join(otherRepo, 'skills/shared-skill/SKILL.md'), '# Original refreshed');
Expand All @@ -936,7 +940,9 @@ describe('source', () => {
// Released claims must no longer prevent another producer using the old destination.
const incomingUrl = 'https://source.test/independent/repo.git';
await fse.outputFile(path.join(otherConfig.repo.localPath, 'teamai.yaml'), YAML.stringify({
...teamConfig, sources: [{ name: 'independent', repo: incomingUrl }], toolPaths: { claude: { skills: '.claude/skills' } },
...teamConfig,
sources: [{ name: 'independent', repo: incomingUrl }],
toolPaths: { claude: { skills: '.claude/skills' }, codex: {} },
}));
const incomingRepo = path.join(homeDir, '.teamai', 'source-repos', createHash('sha256').update(incomingUrl).digest('hex'), 'repo');
await fse.outputFile(path.join(incomingRepo, 'skills/shared-skill/SKILL.md'), '# Independent source');
Expand Down Expand Up @@ -1000,7 +1006,7 @@ describe('source', () => {

it('keeps the member\'s shared Codex skill that appears later, delivering to the recorded path (#993)', async () => {
teamConfig.sources = [{ name: 'platform', repo: 'git@git.woa.com:platform/repo.git' }];
teamConfig.toolPaths = { codex: { skills: '.codex/skills' } };
teamConfig.toolPaths = { claude: {}, codex: { skills: '.codex/skills' } };
const YAML = (await import('yaml')).default;
await fse.writeFile(path.join(localConfig.repo.localPath, 'teamai.yaml'), YAML.stringify(teamConfig));

Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/tool-roots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,17 @@ describe('toolRoots — re-rooting a relocated tool', () => {
expect(Object.keys(paths.claude).sort()).toEqual(['mcpProject', 'skills']);
});

it('keeps other tools at default paths when a single tool declares sparse paths (#1185)', () => {
const sparse = TeamaiConfigSchema.parse({
team: 't',
repo: 'r',
toolPaths: { claude: { skills: '.claude/skills', mcpProject: '.mcp.json' } },
});
const paths = scopedToolPaths(sparse, localConfig({}));
expect(paths.codebuddy.mcpProject).toBe('.mcp.json');
expect(paths.cursor.skills).toBe('.cursor/skills');
});

it('moves fields the team spread over several roots, every one of them', () => {
// A customized `toolPaths.claude` need not keep every field under one
// directory. The feature promises that every user-scope Claude path moves,
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
StateSchema,
LocalConfigSchema,
resolveLegacyProjectHookScope,
DEFAULT_TOOL_PATHS,
} from '../types.js';
import type { LocalConfig } from '../types.js';

Expand Down Expand Up @@ -147,6 +148,64 @@ describe('TeamaiConfigSchema', () => {
});
});

it('should export DEFAULT_TOOL_PATHS matching default parse', () => {
const result = TeamaiConfigSchema.parse({
team: 'test-team',
repo: 'https://git.woa.com/test/repo.git',
});
expect(result.toolPaths).toEqual(DEFAULT_TOOL_PATHS);
expect(DEFAULT_TOOL_PATHS.codebuddy.mcpProject).toBe('.mcp.json');
expect(DEFAULT_TOOL_PATHS.claude.mcpProject).toBe('.mcp.json');
});

it('merges custom toolPaths over defaults tool by tool (#1185)', () => {
// When a team config provides custom toolPaths for one tool,
// that tool is overridden while other built-in tools retain their defaults
const result = TeamaiConfigSchema.parse({
team: 'test-team',
repo: 'https://git.woa.com/test/repo.git',
toolPaths: {
codebuddy: {
skills: '.codebuddy/skills',
rules: '.codebuddy/rules',
mcpProject: '.mcp.json',
},
},
});

// The specified tool has the declared paths
expect(result.toolPaths.codebuddy).toEqual({
skills: '.codebuddy/skills',
rules: '.codebuddy/rules',
mcpProject: '.mcp.json',
});

// Other built-in tools remain intact with their default paths
expect(result.toolPaths.claude).toEqual(DEFAULT_TOOL_PATHS.claude);
expect(result.toolPaths.cursor).toEqual(DEFAULT_TOOL_PATHS.cursor);
expect(result.toolPaths.codex).toEqual(DEFAULT_TOOL_PATHS.codex);
expect(result.toolPaths.trae).toEqual(DEFAULT_TOOL_PATHS.trae);
});

it('retains default toolPaths when custom tool adds a third-party agent', () => {
const result = TeamaiConfigSchema.parse({
team: 'test-team',
repo: 'https://git.woa.com/test/repo.git',
toolPaths: {
'custom-agent': {
skills: '.custom/skills',
rules: '.custom/rules',
},
},
});

expect(result.toolPaths['custom-agent']).toEqual({
skills: '.custom/skills',
rules: '.custom/rules',
});
expect(result.toolPaths.codebuddy).toEqual(DEFAULT_TOOL_PATHS.codebuddy);
expect(result.toolPaths.claude).toEqual(DEFAULT_TOOL_PATHS.claude);
});
});

describe('TeamaiConfigSchema reviewers', () => {
Expand Down
Loading
Loading