diff --git a/src/__tests__/init-agent-initial-sync.test.ts b/src/__tests__/init-agent-initial-sync.test.ts new file mode 100644 index 00000000..ff471843 --- /dev/null +++ b/src/__tests__/init-agent-initial-sync.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', () => ({ + loadTeamConfig: vi.fn(), + loadStateForScope: vi.fn().mockResolvedValue({ lastPull: null }), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/git.js', () => ({ + pullRepo: vi.fn().mockResolvedValue('Already up to date.'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +import { seedEnabledAgentDirs } from '../known-agents.js'; +import { pullForScope } from '../pull.js'; +import { loadTeamConfig } from '../config.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; + +describe('init --agent: directory seeding + initial sync (#574/#585)', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-init-agent-sync-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'onboarding.md'), '# Onboarding Rule\n'); + + vi.stubEnv('HOME', homeDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('reproduces the fresh-clone scenario from #574: no manual mkdir, no directories exist before init', async () => { + expect(await fse.pathExists(path.join(homeDir, '.claude'))).toBe(false); + }); + + it('seeding then syncing writes real rule files into a freshly-declared --agent tool, with no manual mkdir', async () => { + const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'github', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + }, + }; + + const localConfig: LocalConfig = { + repo: { localPath: repoPath, remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['claude'], + }; + + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + + // Step 1: this is what init/initHttp now call — seed the declared agent's dir. + const seeded = await seedEnabledAgentDirs(localConfig, teamConfig); + expect(seeded).toEqual(['claude']); + expect(await fse.pathExists(path.join(homeDir, '.claude/skills'))).toBe(true); + + // Step 2: this is the "initial sync" half — pullForScope actually writes files + // now that the dir exists, instead of leaving it empty until a hook fires. + await pullForScope(localConfig, {}); + + expect(await fse.pathExists(path.join(homeDir, '.claude/rules/onboarding.md'))).toBe(true); + }); + + it('init without --agent (enabledAgents unset) creates nothing — regression guard from #585 acceptance criteria', async () => { + const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'github', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + }, + }; + + const localConfig: LocalConfig = { + repo: { localPath: repoPath, remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + // enabledAgents deliberately omitted — no --agent was passed. + }; + + const seeded = await seedEnabledAgentDirs(localConfig, teamConfig); + + expect(seeded).toEqual([]); + expect(await fse.pathExists(path.join(homeDir, '.claude'))).toBe(false); + }); +}); diff --git a/src/__tests__/pull-rules-sync-count.test.ts b/src/__tests__/pull-rules-sync-count.test.ts new file mode 100644 index 00000000..cfc0fb99 --- /dev/null +++ b/src/__tests__/pull-rules-sync-count.test.ts @@ -0,0 +1,350 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', () => ({ + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null }), + saveState: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + loadStateForScope: vi.fn().mockResolvedValue({ lastPull: null }), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/git.js', () => ({ + pullRepo: vi.fn().mockResolvedValue('Already up to date.'), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +import { pull } from '../pull.js'; +import { loadLocalConfigForScope, loadTeamConfig, detectProjectConfig } from '../config.js'; +import { log } from '../utils/logger.js'; +import type { TeamaiConfig, LocalConfig } from '../types.js'; + +describe('pull — rules sync count reflects actual writes (#574/#585)', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + + function config(): LocalConfig { + return { + repo: { localPath: repoPath, remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + } + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-rules-count-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), '# My Rule\n'); + + vi.stubEnv('HOME', homeDir); + + const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'github', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + }, + }; + + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config()); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('does NOT claim success when the only configured tool is not installed', async () => { + // Deliberately do NOT create .claude — reproduces #574's exact scenario. + await pull({}); + + expect(await fse.pathExists(path.join(homeDir, '.claude'))).toBe(false); + + const successCalls = vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); + expect(successCalls.some((m) => /Synced \d+ rule\(s\)/.test(m))).toBe(false); + + const warnCalls = vi.mocked(log.warn).mock.calls.map(([msg]) => String(msg)); + expect(warnCalls.some((m) => /rule\(s\) available but no installed tool directory found/.test(m))).toBe(true); + }); + + it('DOES report success and writes the file when the tool is installed', async () => { + await fse.ensureDir(path.join(homeDir, '.claude')); + + await pull({}); + + expect(await fse.pathExists(path.join(homeDir, '.claude/rules/my-rule.md'))).toBe(true); + + const successCalls = vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); + expect(successCalls.some((m) => /Synced 1 rule\(s\)/.test(m))).toBe(true); + }); +}); + + +describe('pull — --force bypasses isToolInstalled for rules (#574/#585)', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + + function config(force?: boolean): LocalConfig { + return { + repo: { localPath: repoPath, remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + } + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-force-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), '# My Rule\n'); + + vi.stubEnv('HOME', homeDir); + + const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'github', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + }, + }; + + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config()); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('without --force: does NOT create the missing .claude dir (unchanged prior behavior)', async () => { + await pull({}); + expect(await fse.pathExists(path.join(homeDir, '.claude'))).toBe(false); + }); + + it('with --force: creates the missing .claude dir and writes the rule file', async () => { + expect(await fse.pathExists(path.join(homeDir, '.claude'))).toBe(false); + + await pull({ force: true }); + + expect(await fse.pathExists(path.join(homeDir, '.claude/rules/my-rule.md'))).toBe(true); + + const successCalls = vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); + expect(successCalls.some((m) => /Synced 1 rule\(s\)/.test(m))).toBe(true); + }); +}); + +describe('pull — same phantom-success gate applied to skills and agents (#574/#585 follow-up)', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + + function config(): LocalConfig { + return { + repo: { localPath: repoPath, remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + } + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-skills-agents-count-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + + await fse.ensureDir(path.join(repoPath, 'skills', 'my-skill')); + await fse.writeFile(path.join(repoPath, 'skills', 'my-skill', 'SKILL.md'), '# My Skill\n'); + + vi.stubEnv('HOME', homeDir); + + const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'github', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + }, + }; + + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config()); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('skills: does NOT claim success when no tool is installed', async () => { + await pull({}); + + expect(await fse.pathExists(path.join(homeDir, '.claude'))).toBe(false); + + const successCalls = vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); + expect(successCalls.some((m) => /Synced \d+ skills/.test(m))).toBe(false); + + const warnCalls = vi.mocked(log.warn).mock.calls.map(([msg]) => String(msg)); + expect(warnCalls.some((m) => /skills available but no installed tool directory found/.test(m))).toBe(true); + }); + + it('skills: DOES sync and writes the file when the tool is installed', async () => { + await fse.ensureDir(path.join(homeDir, '.claude')); + + await pull({}); + + expect(await fse.pathExists(path.join(homeDir, '.claude/skills/my-skill/SKILL.md'))).toBe(true); + }); +}); + +describe('pull — success message names which tools actually received the write (review follow-up)', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + + function config(): LocalConfig { + return { + repo: { localPath: repoPath, remote: 'https://example.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + } + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-partial-install-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + + // agents (not skills) hits the plain log.success line the reviewer's diff + // touched — skills always goes through logSyncDetail instead. + await fse.ensureDir(path.join(repoPath, 'agents')); + await fse.writeFile(path.join(repoPath, 'agents', 'my-agent.yaml'), 'name: my-agent\ndescription: test\ninstructions: test\n'); + + // claude installed, codex is not — the exact partial-install scenario. + await fse.ensureDir(path.join(homeDir, '.claude')); + + vi.stubEnv('HOME', homeDir); + + const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.com/test/repo.git', + provider: 'github', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: true }, + }, + toolPaths: { + claude: { skills: '.claude/skills', rules: '.claude/rules', agents: '.claude/agents' }, + codex: { skills: '.codex/skills', rules: '.codex/rules', agents: '.codex/agents' }, + }, + }; + + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config()); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.mocked(log.success).mockClear(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('names only claude, not codex, when only claude is installed', async () => { + await pull({}); + + // claude actually got the file. + expect(await fse.pathExists(path.join(homeDir, '.claude/agents/my-agent.md'))).toBe(true); + // codex silently got nothing — this is the exact gap the reviewer flagged. + expect(await fse.pathExists(path.join(homeDir, '.codex'))).toBe(false); + + const successCalls = vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); + const agentsMsg = successCalls.find((m) => /Synced \d+ agents/.test(m)); + + expect(agentsMsg).toBeDefined(); + expect(agentsMsg).toContain('claude'); + expect(agentsMsg).not.toContain('codex'); + }); +}); diff --git a/src/__tests__/self-mode-agents.test.ts b/src/__tests__/self-mode-agents.test.ts index 5301b461..b217765e 100644 --- a/src/__tests__/self-mode-agents.test.ts +++ b/src/__tests__/self-mode-agents.test.ts @@ -10,7 +10,7 @@ vi.mock('../utils/logger.js', () => ({ import { normalizeAgentList, detectHomeInstalledAgents, - seedSelfModeToolDirs, + seedEnabledAgentDirs, SELF_MODE_AGENT_CHOICES, } from '../known-agents.js'; import { resolveSelfModeSelection } from '../init.js'; @@ -82,7 +82,7 @@ describe('detectHomeInstalledAgents', () => { }); }); -describe('seedSelfModeToolDirs (no hardcoded claude default)', () => { +describe('seedEnabledAgentDirs (no hardcoded claude default)', () => { let tmp: string; let repoRoot: string; let teamConfig: TeamaiConfig; @@ -118,26 +118,26 @@ describe('seedSelfModeToolDirs (no hardcoded claude default)', () => { }); it('seeds nothing when enabledAgents is empty (no default claude)', async () => { - const seeded = await seedSelfModeToolDirs(makeConfig([]), teamConfig); + const seeded = await seedEnabledAgentDirs(makeConfig([]), teamConfig); expect(seeded).toEqual([]); expect(await fse.pathExists(path.join(repoRoot, '.claude'))).toBe(false); }); it('seeds nothing when enabledAgents is undefined (no default claude)', async () => { - const seeded = await seedSelfModeToolDirs(makeConfig(undefined), teamConfig); + const seeded = await seedEnabledAgentDirs(makeConfig(undefined), teamConfig); expect(seeded).toEqual([]); expect(await fse.pathExists(path.join(repoRoot, '.claude'))).toBe(false); }); it('seeds exactly the enabled agents, and no others', async () => { - const seeded = await seedSelfModeToolDirs(makeConfig(['codex']), teamConfig); + const seeded = await seedEnabledAgentDirs(makeConfig(['codex']), teamConfig); expect(seeded).toEqual(['codex']); expect(await fse.pathExists(path.join(repoRoot, '.codex/skills'))).toBe(true); expect(await fse.pathExists(path.join(repoRoot, '.claude'))).toBe(false); }); it('seeds multiple selected agents', async () => { - const seeded = await seedSelfModeToolDirs(makeConfig(['claude', 'cursor']), teamConfig); + const seeded = await seedEnabledAgentDirs(makeConfig(['claude', 'cursor']), teamConfig); expect(new Set(seeded)).toEqual(new Set(['claude', 'cursor'])); expect(await fse.pathExists(path.join(repoRoot, '.claude/skills'))).toBe(true); expect(await fse.pathExists(path.join(repoRoot, '.cursor/skills'))).toBe(true); @@ -146,7 +146,7 @@ describe('seedSelfModeToolDirs (no hardcoded claude default)', () => { it('never seeds an explicitly disabled agent', async () => { const config = makeConfig(['claude', 'codex']); config.disabledAgents = ['codex']; - const seeded = await seedSelfModeToolDirs(config, teamConfig); + const seeded = await seedEnabledAgentDirs(config, teamConfig); expect(seeded).toEqual(['claude']); expect(await fse.pathExists(path.join(repoRoot, '.codex'))).toBe(false); }); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 885a7655..be6527ae 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -191,8 +191,8 @@ export async function bootstrapSelfRepo( // Seed the tool skills-dir so hooks + skills inject on this fresh clone // (isToolInstalled would otherwise skip everything — no /.claude yet). try { - const { seedSelfModeToolDirs } = await import('./known-agents.js'); - await seedSelfModeToolDirs(localConfig, teamConfig); + const { seedEnabledAgentDirs } = await import('./known-agents.js'); + await seedEnabledAgentDirs(localConfig, teamConfig); } catch (e) { log.debug(`[bootstrap] tool-dir seeding skipped: ${(e as Error).message}`); } diff --git a/src/doctor.ts b/src/doctor.ts index 231fe9c4..c2d7137e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -476,4 +476,4 @@ export async function doctor(options: DoctorOptions): Promise { log.warn('Some checks failed. See suggestions above.'); } return allPassed; -} +} \ No newline at end of file diff --git a/src/init.ts b/src/init.ts index aa685f9a..090d700a 100644 --- a/src/init.ts +++ b/src/init.ts @@ -476,6 +476,23 @@ export async function initHttp( log.debug(`Local agent init: ${(e as Error).message}`); } + // Step 6.5: init --agent explicitly declares which tools to set up, so + // seed their dirs and do one real sync now instead of waiting for a + // SessionStart hook that may never fire (issue #574/#585). + if (requestedAgents.length > 0) { + try { + const { seedEnabledAgentDirs } = await import('./known-agents.js'); + const seeded = await seedEnabledAgentDirs(localConfig, teamConfig); + if (seeded.length > 0) { + const { pullForScope } = await import('./pull.js'); + await pullForScope(localConfig, {}); + log.success(`Synced skills, rules, and docs for: ${seeded.join(', ')}`); + } + } catch (e) { + log.warn(`Initial sync skipped: ${(e as Error).message}`); + } + } + log.success('teamai initialized (HTTP read-only)!'); log.info('Skills/rules will auto-sync on each session start via report/sync. This team is read-only (no push).'); closePrompt(); @@ -880,7 +897,7 @@ export async function initSelfRepo(options: GlobalOptions & { // Which AI tools to set up in this repo (create skills dir + inject hooks + // commit their settings.json). Resolved from --agent, else HOME detection // (non-interactive), else an interactive picker. Written to enabledAgents, - // which drives seedSelfModeToolDirs and hook injection alike. + // which drives seedEnabledAgentDirs and hook injection alike. const selectedAgents = await promptForSelfModeAgents(options); if (selectedAgents.length > 0) { const existing = await loadLocalConfigForScope('project', businessRepoRoot); @@ -911,8 +928,8 @@ export async function initSelfRepo(options: GlobalOptions & { // otherwise skip everything). const filterAgents = selectedAgents.length > 0 ? selectedAgents : undefined; try { - const { seedSelfModeToolDirs } = await import('./known-agents.js'); - const seeded = await seedSelfModeToolDirs(localConfig, teamConfig); + const { seedEnabledAgentDirs } = await import('./known-agents.js'); + const seeded = await seedEnabledAgentDirs(localConfig, teamConfig); if (seeded.length > 0) log.debug(`Seeded tool dirs for: ${seeded.join(', ')}`); } catch (e) { log.debug(`Tool-dir seeding skipped: ${(e as Error).message}`); @@ -1621,6 +1638,23 @@ export async function init(options: GlobalOptions & { } catch (e) { log.debug(`Built-in skills deployment skipped: ${(e as Error).message}`); } + + // Step 7.6: init --agent explicitly declares which tools to set up, so + // seed their dirs and do one real sync now instead of waiting for a + // SessionStart hook that may never fire (issue #574/#585). + if (requestedAgents.length > 0) { + try { + const { seedEnabledAgentDirs } = await import('./known-agents.js'); + const seeded = await seedEnabledAgentDirs(localConfig, reloadedTeamConfig); + if (seeded.length > 0) { + const { pullForScope } = await import('./pull.js'); + await pullForScope(localConfig, {}); + log.success(`Synced skills, rules, and docs for: ${seeded.join(', ')}`); + } + } catch (e) { + log.warn(`Initial sync skipped: ${(e as Error).message}`); + } + } } log.success('teamai initialized successfully!'); diff --git a/src/known-agents.ts b/src/known-agents.ts index 2499b921..17e6145b 100644 --- a/src/known-agents.ts +++ b/src/known-agents.ts @@ -158,7 +158,7 @@ export interface ResolvedAgent extends KnownAgent { * * Returns the list of agent ids whose dirs were ensured. */ -export async function seedSelfModeToolDirs( +export async function seedEnabledAgentDirs( localConfig: LocalConfig, teamConfig: TeamaiConfig, ): Promise { diff --git a/src/pull.ts b/src/pull.ts index 98f8cdac..c1d26d31 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -581,6 +581,28 @@ function logSyncDetail( * this set alongside the revision prevents a pull for one tool from suppressing * the first resource sync for another. */ +/** + * Tool ids whose directory currently exists for this scope, excluding + * agents disabled via enabledAgents/disabledAgents. The same walk (installed + * dir → not excluded) previously repeated once per resource type; shared here + * so rules/skills/agents' "did anything actually get written" checks and the + * revision-cache target list all agree on one answer (issue #574/#585). + */ +async function installedToolsFor( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + field: 'rules' | 'skills' | 'agents', +): Promise { + const found: string[] = []; + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (isAgentExcluded(localConfig, tool)) continue; + const dir = toolPath[field]; + if (!dir) continue; + if (await isToolInstalledForConfig(tool, dir, localConfig)) found.push(tool); + } + return found; +} + async function getInstalledResourceTargets( teamConfig: TeamaiConfig, localConfig: LocalConfig, @@ -701,7 +723,10 @@ async function warnIfEnvYamlShapeIsWrong( } } -async function pullForScope( +// Exported so `init --agent` can trigger one real sync immediately after +// seeding a tool's directories, rather than only promising the next +// SessionStart hook will do it (issue #585). +export async function pullForScope( localConfig: LocalConfig, options: GlobalOptions, /** @@ -865,12 +890,17 @@ async function pullForScope( // stale local rule files and deactivates the OpenCode instructions glob // when the team's last rule is removed. Guarding on items.length > 0 // would leak those artifacts on the machine after upstream deletion. - await rulesHandler.pullAllRules(freshConfig, localConfig, items); + await rulesHandler.pullAllRules(freshConfig, localConfig, items, options.force); if (items.length > 0) { - log.success(`[${scopeLabel}] Synced ${items.length} rule(s)${skippedByTags > 0 ? ` (skipped ${skippedByTags} by tags)` : ''}`); + const installed = await installedToolsFor(freshConfig, localConfig, 'rules'); + if (installed.length === 0) { + log.warn(`[${scopeLabel}] ${items.length} rule(s) available but no installed tool directory found — nothing written. Create the tool's directory (e.g. mkdir .claude) and pull again, or use teamai init --agent.`); + } else { + log.success(`[${scopeLabel}] Synced ${items.length} rule(s) → ${installed.join(', ')}${skippedByTags > 0 ? ` (skipped ${skippedByTags} by tags)` : ''}`); + totalSynced += items.length; + } } } - totalSynced += items.length; continue; } @@ -951,18 +981,22 @@ async function pullForScope( } } } else { - for (const item of items) { - await handler.pullItem(item, freshConfig, localConfig); - } - - if (type === 'skills') { - logSyncDetail(type, items, existingNames, !!options.verbose, scopeLabel, skippedByTags); + const installed = await installedToolsFor(freshConfig, localConfig, type as 'skills' | 'agents'); + if (installed.length === 0) { + log.warn(`[${scopeLabel}] ${items.length} ${type} available but no installed tool directory found — nothing written. Create the tool's directory and pull again, or use teamai init --agent.`); } else { - log.success(`[${scopeLabel}] Synced ${items.length} ${type}`); + for (const item of items) { + await handler.pullItem(item, freshConfig, localConfig); + } + + if (type === 'skills') { + logSyncDetail(type, items, existingNames, !!options.verbose, scopeLabel, skippedByTags); + } else { + log.success(`[${scopeLabel}] Synced ${items.length} ${type} → ${installed.join(', ')}`); + } + totalSynced += items.length; } } - - totalSynced += items.length; } // Step 3: Clean up tombstoned resources @@ -1292,7 +1326,6 @@ async function pullForScope( } } -/** /** * Compile culture.md frontmatter + body into a CLAUDE.md injection block. * diff --git a/src/resources/agent-format.ts b/src/resources/agent-format.ts index 26ab0c29..367cb4e0 100644 --- a/src/resources/agent-format.ts +++ b/src/resources/agent-format.ts @@ -47,6 +47,25 @@ export function agentStemFromFilename(filename: string): string | null { return null; } +/** + * Every extension an agent render may carry on disk. + * + * Writers use `agentFileExtensionForTool`. Scanners and deleters use this list, + * so a removal clears a name on every tool whatever format that tool renders. + */ +export const AGENT_FILE_EXTENSIONS = ['.md', '.toml', '.json'] as const satisfies readonly AgentFileExtension[]; + +/** + * Extract an agent name stem from a filename. + * Accepts every native agent extension; returns null for other files. + */ +export function agentStemFromFilename(filename: string): string | null { + for (const ext of AGENT_FILE_EXTENSIONS) { + if (filename.endsWith(ext)) return filename.slice(0, -ext.length); + } + return null; +} + export function agentFileExtensionForTool(tool: ToolName): AgentFileExtension { switch (tool) { case 'copilot': diff --git a/src/resources/rules.ts b/src/resources/rules.ts index bd4a0fdf..0eee2ad4 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -297,6 +297,7 @@ export class RulesHandler extends ResourceHandler { teamConfig: TeamaiConfig, localConfig: LocalConfig, filteredRules?: ResourceItem[], + force?: boolean, ): Promise { const rules = filteredRules ?? await this.scanTeamForPull(teamConfig, localConfig); @@ -326,6 +327,13 @@ export class RulesHandler extends ResourceHandler { if (rules.length === 0) return; // 1. Distribute rule files to each tool's rules/ directory + if (force) { + const baseDir = resolveBaseDir(localConfig); + for (const [, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!toolPath.rules) continue; + await ensureDir(path.join(baseDir, toolPath.rules)); + } + } for (const rule of rules) { await this.pullItem(rule, teamConfig, localConfig); }