From d55ab8f1159bbf547bc284cdfda4b29ef911d9c3 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:39 +0200 Subject: [PATCH 01/26] fix(dry-run): load mcp inject and mcp list without persisting a migration (#893) mcp inject loaded its scope bare, so --dry-run still saved a pending role migration, partition rename or self-mode bootstrap. It now forwards dryRun; mcp list is read-only and loads with dryRun: true unconditionally, as status and list do since #866. --- src/mcp-cmd.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mcp-cmd.ts b/src/mcp-cmd.ts index 5f9a3378f..0f3c5c103 100644 --- a/src/mcp-cmd.ts +++ b/src/mcp-cmd.ts @@ -24,7 +24,8 @@ function displayPath(p: string): string { /** Print team MCP servers, their secret requirements, and where they are installed. */ export async function mcpList(_options: GlobalOptions): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: true }); const resolution = await resolveEntriesFor(mcpEntryReader, localConfig); if (resolution.kind === 'failed') { log.error(describeEntryFailure(resolution.failure)); @@ -95,7 +96,7 @@ function reportChanges(changes: McpChange[]): void { export async function mcpInject( options: GlobalOptions & { dryRun?: boolean; force?: boolean }, ): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); const { changes, wrote, unresolved } = await reconcileMcpForConfig(teamConfig, localConfig, { dryRun: options.dryRun, force: options.force, From 3d20f8033e93896f7c5a7450890f42a9ec0a81fa Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:39 +0200 Subject: [PATCH 02/26] fix(dry-run): forward dryRun to the loader in roles init/add/remove/update (#893) roles list is read-only and loads with dryRun: true unconditionally. --- src/roles-cmd.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/roles-cmd.ts b/src/roles-cmd.ts index bcad0fc14..f63034567 100644 --- a/src/roles-cmd.ts +++ b/src/roles-cmd.ts @@ -22,7 +22,7 @@ function parseNamespaces(input: string): string[] { // ─── roles init ───────────────────────────────────────── export async function rolesInit(options: GlobalOptions): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); const repoPath = localConfig.repo.localPath; const selfMode = localConfig.repo.kind === 'self'; @@ -140,7 +140,8 @@ export async function rolesInit(options: GlobalOptions): Promise { // ─── roles list ───────────────────────────────────────── export async function rolesList(): Promise { - const { localConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { localConfig } = await autoDetectInit(undefined, { dryRun: true }); const repoPath = localConfig.repo.localPath; let manifest; @@ -257,7 +258,7 @@ export async function rolesAdd( return; } - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Roles', async (repoPath, editConfig) => { if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); @@ -317,7 +318,7 @@ export async function rolesRemove( roleId: string, options: GlobalOptions, ): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Roles', async (repoPath, editConfig) => { if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); @@ -386,7 +387,7 @@ export async function rolesUpdate( return; } - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Roles', async (repoPath, editConfig) => { if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); From 16900d58ed734872ecaa6b1a8cb35f5d02586f91 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:48 +0200 Subject: [PATCH 03/26] fix(dry-run): forward dryRun to the loader in projects add/update/remove (#893) projects list and projects members are read-only and load with dryRun: true. --- src/projects-cmd.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/projects-cmd.ts b/src/projects-cmd.ts index f136a6cb1..67c37f6d4 100644 --- a/src/projects-cmd.ts +++ b/src/projects-cmd.ts @@ -43,7 +43,8 @@ function parseIds(input: string[]): string[] { // ─── projects list ────────────────────────────────────── export async function projectsList(_options: GlobalOptions): Promise { - const { localConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { localConfig } = await autoDetectInit(undefined, { dryRun: true }); const repoPath = localConfig.repo.localPath; const manifest = await loadProjectsManifest(repoPath); @@ -134,7 +135,8 @@ export async function projectsMembers( projectId: string, _options: GlobalOptions, ): Promise { - const { localConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { localConfig } = await autoDetectInit(undefined, { dryRun: true }); // Members live on the teamai-reports orphan branch for non-HTTP repos; the // projects manifest is knowledge on the default branch. Split the two roots: @@ -240,7 +242,7 @@ async function editProjectsManifest( } | null, afterWrite?: () => void, ): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); await runManifestEdit(localConfig, 'Projects', async (repoPath, editConfig) => { if (editConfig.repo.kind !== 'self') await pullLatest(repoPath); From 9b474809ba04a0cbe848159d4def2a47e362bdb8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:48 +0200 Subject: [PATCH 04/26] fix(dry-run): forward dryRun to the loader in tags add/remove (#893) tags list is read-only and loads with dryRun: true. --- src/tags.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tags.ts b/src/tags.ts index 59d1b5035..678c54b25 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -49,7 +49,8 @@ async function saveTagsScopeConfig(localConfig: LocalConfig): Promise { * Shows tag name, skill count, and rule count. */ export async function tagsList(): Promise { - const localConfig = await resolveTagsScope(); + // Read-only: the load never persists a migration (#893). + const localConfig = await resolveTagsScope({ dryRun: true }); const tagsConfig = await loadTagsConfig(localConfig.repo.localPath); if (!tagsConfig) { @@ -186,7 +187,7 @@ export async function tagsAdd( return; } - const localConfig = await resolveTagsScope(); + const localConfig = await resolveTagsScope(options); const repoPath = localConfig.repo.localPath; let tagsConfig = await loadTagsConfig(repoPath); @@ -225,7 +226,7 @@ export async function tagsRemove( return; } - const localConfig = await resolveTagsScope(); + const localConfig = await resolveTagsScope(options); const repoPath = localConfig.repo.localPath; const tagsConfig = await loadTagsConfig(repoPath); From dbaba5d4ca8638cd67cb086d59411fe3b5b8d1c9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:48 +0200 Subject: [PATCH 05/26] fix(dry-run): forward dryRun to the loader in source add/remove/add-http (#893) source list and source browse are read-only and load with dryRun: true. source list also reached a second bare load through loadLocalAgentConfig, whose HTTP backfill reads only repo.kind and repo.url; it now loads with dryRun: true, and the migration persists on the next command that writes. --- src/local-agent.ts | 3 ++- src/source.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/local-agent.ts b/src/local-agent.ts index 99c50ab1e..79730acbb 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -629,7 +629,8 @@ export async function loadLocalAgentConfig(): Promise { // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. const { loadLocalConfig } = await import('./config.js'); const { resolveApiKey } = await import('./api-key.js'); - const legacy = await loadLocalConfig(); + // Only `repo` is read here; a role migration persists on the command that writes (#893). + const legacy = await loadLocalConfig({ dryRun: true }); if (legacy?.repo?.kind === 'http' && legacy.repo.url) { const endpoint = normalizeEndpoint(legacy.repo.url); const token = resolveApiKey() ?? undefined; diff --git a/src/source.ts b/src/source.ts index e37ddcee3..e84419ff7 100644 --- a/src/source.ts +++ b/src/source.ts @@ -118,7 +118,7 @@ async function ensureSourceRepo(source: SourceConfig, force: boolean): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); const repoPath = localConfig.repo.localPath; // Derive name from repo URL if not provided @@ -189,7 +189,7 @@ export async function sourceAdd(repoUrl: string, options: { name?: string } & Gl * Remove a source from teamai.yaml and clean up local cache. */ export async function sourceRemove(name: string, options: GlobalOptions): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); const repoPath = localConfig.repo.localPath; const existing = teamConfig.sources ?? []; @@ -238,7 +238,8 @@ export async function sourceList(): Promise { // the HTTP bypass is independent of the team repo, so still show it. let gitSources: SourceConfig[] = []; try { - const { teamConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { teamConfig } = await autoDetectInit(undefined, { dryRun: true }); gitSources = teamConfig.sources ?? []; } catch { // Not initialized (no team repo) — only the HTTP bypass may exist. @@ -295,7 +296,7 @@ export async function sourceAddHttp( // Guard: if the main repo is already an HTTP backend, it owns the single // local-agent config — refuse rather than overwrite its endpoint. - const mainConfig = (await detectProjectConfig()) ?? (await loadLocalConfig()); + const mainConfig = (await detectProjectConfig(undefined, undefined, options)) ?? (await loadLocalConfig(options)); if (mainConfig?.repo.kind === 'http') { log.error('Your main team repo is already an HTTP backend, which owns the HTTP source config.'); log.info('An HTTP bypass is only for git-based main repos. Nothing changed.'); @@ -330,7 +331,8 @@ export async function sourceRemoveHttp(options: GlobalOptions): Promise { * Browse public skills from a source. */ export async function sourceBrowse(name: string, options: GlobalOptions): Promise { - const { teamConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { teamConfig } = await autoDetectInit(undefined, { dryRun: true }); const sources = teamConfig.sources ?? []; const source = sources.find((s) => s.name === name); From b5b616c91b9abfb8c7543b6da6669ec867ad0545 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:57 +0200 Subject: [PATCH 06/26] fix(dry-run): forward dryRun to the loader in remove and uninstall (#893) --- src/remove.ts | 2 +- src/uninstall.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/remove.ts b/src/remove.ts index a9dc99609..760acd91e 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -35,7 +35,7 @@ export async function remove( } // Auto-detect scope - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); assertNotReadOnly(localConfig, 'teamai remove'); // Single-repo mode: run the removal PR in an isolated knowledge worktree so the diff --git a/src/uninstall.ts b/src/uninstall.ts index 9a6607865..0f1e85a53 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1126,7 +1126,7 @@ export async function uninstall(opts: UninstallOptions): Promise { let teamConfig: TeamaiConfig | null = null; try { - const result = await autoDetectInit(); + const result = await autoDetectInit(undefined, { dryRun: opts.dryRun }); localConfig = result.localConfig; teamConfig = result.teamConfig; } catch { From dd93eb1ba922580204ded01bde20fce915428f0f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:57 +0200 Subject: [PATCH 07/26] fix(dry-run): forward dryRun to the loader in packages install (#893) --- src/pkg/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/commands.ts b/src/pkg/commands.ts index 2f6f83528..528878906 100644 --- a/src/pkg/commands.ts +++ b/src/pkg/commands.ts @@ -185,7 +185,7 @@ export async function pkgInstall( target: string | undefined, options: GlobalOptions, ): Promise { - const { localConfig } = await autoDetectInit(); + const { localConfig } = await autoDetectInit(undefined, { dryRun: options.dryRun }); const manifest = await loadPackageManifest(localConfig.repo.localPath); const cwd = process.cwd(); const lockDir = getTeamaiHome(localConfig.scope, localConfig.projectRoot); From 85da8f387e004e858fb30f9b1ebe5f4b81451864 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:57 +0200 Subject: [PATCH 08/26] fix(dry-run): forward dryRun to the loader in import --from-iwiki/mr/claude/repo (#893) --from-repo-list and --from-org reach the same load through importFromRepo. --- src/import-repo.ts | 2 +- src/import.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/import-repo.ts b/src/import-repo.ts index ad1cc6940..e0c69c5b2 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -287,7 +287,7 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise let mrLocalConfig: { repo: { remote: string; localPath: string }; username: string; provider?: string } | null = null; try { const { autoDetectInit } = await import('./config.js'); - const { localConfig: lc, teamConfig: tc } = await autoDetectInit(); + const { localConfig: lc, teamConfig: tc } = await autoDetectInit(undefined, { dryRun }); teamRepoDir = lc.repo.localPath; teamRepoRemote = lc.repo.remote; mrTeamConfig = { repo: tc.repo, provider: tc.provider, reviewers: tc.reviewers }; diff --git a/src/import.ts b/src/import.ts index c96a0332a..216e38450 100644 --- a/src/import.ts +++ b/src/import.ts @@ -213,7 +213,7 @@ export async function importCmd(opts: ImportOptions): Promise { return; } else if (opts.fromIwiki) { // 分支 0:--from-iwiki,从 iWiki Space 或单页批量导入 - const { localConfig } = await autoDetectInit(); + const { localConfig } = await autoDetectInit(undefined, { dryRun: opts.dryRun }); await importFromIWiki({ input: opts.fromIwiki, all: opts.all, @@ -240,7 +240,7 @@ export async function importCmd(opts: ImportOptions): Promise { } } else if (opts.fromMr) { // 分支 1:--from-mr ,提取 learning + 增量更新 teamwiki - const { localConfig, teamConfig } = await autoDetectInit(); + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: opts.dryRun }); // Its learning is published the way `teamai contribute` publishes, which a // read-only (HTTP) source refuses; a dry run or --output publishes nothing. if (!opts.dryRun && !opts.output) assertNotReadOnly(localConfig, 'teamai import --from-mr'); @@ -468,7 +468,7 @@ export async function importCmd(opts: ImportOptions): Promise { } const classified = await classifyWithAI(candidates); const session = await interactiveReview(classified, { all: opts.all, resume: opts.resume }); - const { localConfig } = await autoDetectInit(); + const { localConfig } = await autoDetectInit(undefined, { dryRun: opts.dryRun }); const { pushed } = await pushAccepted(session, localConfig.repo.localPath, { dryRun: opts.dryRun, outputDir: opts.output, From 8abe2ad65b980e3399d9a55249b687e6d1af9da9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:57 +0200 Subject: [PATCH 09/26] fix(dry-run): forward dryRun to the codebase loader; --lint and --status load read-only (#893) --- src/codebase-cmd.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/codebase-cmd.ts b/src/codebase-cmd.ts index 626e9d4a9..51ea00872 100644 --- a/src/codebase-cmd.ts +++ b/src/codebase-cmd.ts @@ -80,7 +80,9 @@ export async function codebaseCmd(opts: CodebaseCmdOptions): Promise { } else { try { const { autoDetectInit } = await import('./config.js'); - const { localConfig: lc } = await autoDetectInit(); + // `--lint` alone only reads, so it never persists a migration (#893). + const readOnly = !opts.reconcile && !opts.deepEnrich; + const { localConfig: lc } = await autoDetectInit(undefined, { dryRun: opts.dryRun || readOnly }); teamwikiDir = path.join(lc.repo.localPath, 'teamwiki'); } catch { teamwikiDir = path.join(cwd, '.teamai', 'team-repo', 'teamwiki'); @@ -200,7 +202,8 @@ async function printCodebaseStatus(opts: CodebaseCmdOptions): Promise { } else { try { const { autoDetectInit } = await import('./config.js'); - const { localConfig: lc } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { localConfig: lc } = await autoDetectInit(undefined, { dryRun: true }); teamwikiDir = path.join(lc.repo.localPath, 'teamwiki'); } catch { teamwikiDir = path.join(cwd, '.teamai', 'team-repo', 'teamwiki'); From 64110ca1d6aeef65077d7bf70827fa19e84febf7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:57 +0200 Subject: [PATCH 10/26] fix(dry-run): forward dryRun to the loader in models switch; models list loads read-only (#893) --- src/models-cmd.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/models-cmd.ts b/src/models-cmd.ts index 3932b06c8..4b107ddda 100644 --- a/src/models-cmd.ts +++ b/src/models-cmd.ts @@ -61,10 +61,10 @@ interface TeamModelsContext { * (a broken file, one id in two active namespaces): that is reported here as * the command's error, and the command stops. */ -async function teamContext(): Promise { +async function teamContext(options: { dryRun?: boolean } = {}): Promise { let initialized: Awaited>; try { - initialized = await autoDetectInit(); + initialized = await autoDetectInit(undefined, options); } catch { return { team: { version: 1, profiles: [] } }; } @@ -131,12 +131,12 @@ async function apiKeyFromOptions(options: ApiKeyOptions): Promise { - const [context, local] = await Promise.all([teamContext(), loadLocalProfiles()]); + const [context, local] = await Promise.all([teamContext(options), loadLocalProfiles()]); if (!context) return null; const ref = resolveProfileRef(reference, context.team, local); if (ref.source === 'team' && context.localConfig) ref.team = getTeamIdentity(context.localConfig); @@ -181,7 +181,10 @@ function printResults(results: ModelSwitchResult[], explicitAgents: boolean): vo * pass a profile to see just that one. API keys are never printed. */ export async function modelsList(reference?: string): Promise { - const [context, local, active] = await Promise.all([teamContext(), loadLocalProfiles(), activeModelProfiles()]); + // Read-only: the load never persists a migration (#893). + const [context, local, active] = await Promise.all([ + teamContext({ dryRun: true }), loadLocalProfiles(), activeModelProfiles(), + ]); if (!context) return; const team = context.localConfig ? getTeamIdentity(context.localConfig) : undefined; let refs: ProfileRef[]; @@ -362,7 +365,7 @@ interface SwitchOptions { } export async function modelsSwitch(reference: string, options: SwitchOptions): Promise { - const found = await findProfile(reference); + const found = await findProfile(reference, { dryRun: options.dryRun }); if (!found) return; const { ref, context } = found; const key = profileRefName(ref); From b66dcb9294b153741cda5c4332cf5e9c903e8aba Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:58 +0200 Subject: [PATCH 11/26] fix(dry-run): load read-only commands without persisting a migration (#893) hooks list, members, exclude list, recall status and doctor load with dryRun: true unconditionally, as status and list do since #866. --- src/doctor.ts | 5 +++-- src/exclude.ts | 9 +++++---- src/hooks-cmd.ts | 3 ++- src/members.ts | 5 +++-- src/recall-toggle.ts | 3 ++- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/doctor.ts b/src/doctor.ts index b6fdd483d..11f42887c 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -305,8 +305,9 @@ async function hasInstalledCodexHooks(toolPaths: TeamaiConfig['toolPaths'], base * when TeamAI is not initialized here — the caller decides how to report that. */ export async function resolveDoctorContext(): Promise { - const projectConfig = await detectProjectConfig(); - const localConfig = projectConfig ?? (await loadLocalConfig()); + // Read-only: the load never persists a migration (#893). + const projectConfig = await detectProjectConfig(undefined, undefined, { dryRun: true }); + const localConfig = projectConfig ?? (await loadLocalConfig({ dryRun: true })); if (!localConfig) return null; const teamConfig = await loadTeamConfig(localConfig.repo.localPath); diff --git a/src/exclude.ts b/src/exclude.ts index 055a3b1f7..7294f5de1 100644 --- a/src/exclude.ts +++ b/src/exclude.ts @@ -9,9 +9,9 @@ import { import { log } from './utils/logger.js'; import type { GlobalOptions, LocalConfig } from './types.js'; -async function resolveExcludeScope(): Promise { - const projectConfig = await detectProjectConfig(); - return projectConfig ?? (await requireInit()).localConfig; +async function resolveExcludeScope(options: { dryRun?: boolean } = {}): Promise { + const projectConfig = await detectProjectConfig(undefined, undefined, options); + return projectConfig ?? (await requireInit(options)).localConfig; } async function saveExcludeScopeConfig(localConfig: LocalConfig): Promise { @@ -33,7 +33,8 @@ async function saveExcludeScopeConfig(localConfig: LocalConfig): Promise { } export async function excludeList(_options: GlobalOptions): Promise { - const config = await resolveExcludeScope(); + // Read-only: the load never persists a migration (#893). + const config = await resolveExcludeScope({ dryRun: true }); const excludedSkills = config.excludedSkills ?? []; if (excludedSkills.length === 0) { log.info('No excluded skills. (pull syncs all role skills)'); diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 766a56925..937e41758 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -130,7 +130,8 @@ export async function hooksInject(options: GlobalOptions): Promise { * and team (B) hook definitions. */ export async function hooksList(_options: GlobalOptions): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: true }); const { baseDir, scope: hookScope } = resolveHookScope(localConfig); // The settings file must be resolved at the scope hooks were injected into, // not at the config's scope: a non-self project scope injects into HOME, and a diff --git a/src/members.ts b/src/members.ts index f5b0afba6..c62463aee 100644 --- a/src/members.ts +++ b/src/members.ts @@ -87,8 +87,9 @@ export function mergeMemberConfig( } export async function listMembers(options: GlobalOptions): Promise { - const projectConfig = await detectProjectConfig(); - const localConfig = projectConfig ?? (await requireInit()).localConfig; + // Read-only: the load never persists a migration (#893). + const projectConfig = await detectProjectConfig(undefined, undefined, { dryRun: true }); + const localConfig = projectConfig ?? (await requireInit({ dryRun: true })).localConfig; // Members live on the teamai-reports orphan branch for non-HTTP repos; read // them from the reports worktree (refreshed from origin). Members registered diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index a2142ff29..341e1c9f9 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -143,7 +143,8 @@ export async function recallEnable(_opts: GlobalOptions): Promise { } export async function recallStatus(_opts: GlobalOptions): Promise { - const { localConfig, teamConfig } = await autoDetectInit(); + // Read-only: the load never persists a migration (#893). + const { localConfig, teamConfig } = await autoDetectInit(undefined, { dryRun: true }); const effective = isRecallEnabled(localConfig, teamConfig); const teamSetting = teamConfig.sharing?.recall?.enabled ?? false; From 7725828ad4e72431d5ab4cbee8ead315b9eab146 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:58 +0200 Subject: [PATCH 12/26] docs(designs): name which commands take the dry-run detection path (#893) --- docs/designs/data-directory-layout.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 3a3ffe3a0..455c2a492 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -397,8 +397,9 @@ stays in the checkout's `.teamai/`. self-heal bootstrap — which now writes the config into the PARTITION — then reads it back FROM the partition (`selfHealAndReadPartition`). A pre-P2 install whose config still sits in `/.teamai` is read via the legacy branch (double-read - compat) until migration relocates it. A `--dry-run` detection - (`roles set`, `tags subscribe`, `tags unsubscribe`) previews the bootstrap + compat) until migration relocates it. A `--dry-run` detection (a command that + forwards `--dry-run` to its loader, or a read-only one such as `status`, `list` + or a `* list` subcommand, which loads this way unconditionally) previews the bootstrap instead (`previewSelfBootstrap`): it builds the config it would write, keeps it in memory, and prints `[dry-run] Would bootstrap ...` without locking, writing, injecting hooks or registering the member. It makes no provider auth call From 10a35060aa17718edd52b44831b579c95f204e12 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:17:58 +0200 Subject: [PATCH 13/26] test(dry-run): pin that every loader on a dry-run or read-only path migrates nothing (#893) LOAD_ONLY_COMMANDS gains the read-only commands and the previews that stay clean on the legacy-role fixture. PREVIEWS covers the commands that fail past the loader on that fixture: it asserts only config.yaml, with the same call without --dry-run as the positive control that the load is on the path. --- src/__tests__/dry-run-load-path.test.ts | 88 ++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/src/__tests__/dry-run-load-path.test.ts b/src/__tests__/dry-run-load-path.test.ts index e95e124c1..aaeb60754 100644 --- a/src/__tests__/dry-run-load-path.test.ts +++ b/src/__tests__/dry-run-load-path.test.ts @@ -29,14 +29,27 @@ vi.mock('../utils/reports-branch.js', async (importOriginal) => ({ updateReports: vi.fn(), })); +import { codebaseCmd } from '../codebase-cmd.js'; import { contribute } from '../contribute.js'; import { loadLocalConfigForScope } from '../config.js'; +import { resolveDoctorContext } from '../doctor.js'; +import { excludeList } from '../exclude.js'; +import { hooksList } from '../hooks-cmd.js'; +import { importCmd } from '../import.js'; +import { mcpInject, mcpList } from '../mcp-cmd.js'; +import { modelsList, modelsSwitch } from '../models-cmd.js'; +import { pkgInstall } from '../pkg/commands.js'; +import { projectsAdd, projectsList } from '../projects-cmd.js'; import { pull } from '../pull.js'; import { push } from '../push.js'; import { recall } from '../recall.js'; -import { rolesSet } from '../roles-cmd.js'; +import { recallStatus } from '../recall-toggle.js'; +import { remove } from '../remove.js'; +import { rolesAdd, rolesInit, rolesList, rolesRemove, rolesSet, rolesUpdate } from '../roles-cmd.js'; +import { sourceAdd, sourceAddHttp, sourceList, sourceRemove } from '../source.js'; import { list, status } from '../status.js'; -import { tagsSubscribe, tagsUnsubscribe } from '../tags.js'; +import { tagsAdd, tagsList, tagsRemove, tagsSubscribe, tagsUnsubscribe } from '../tags.js'; +import { uninstall } from '../uninstall.js'; import { updateReports } from '../utils/reports-branch.js'; import { log } from '../utils/logger.js'; import { legacyProjectSlug } from '../utils/partition.js'; @@ -254,6 +267,20 @@ describe('--dry-run through the loaders the commands share (#850)', () => { ['push --dry-run', () => push({ dryRun: true })], ['status', () => status({})], ['list', () => list(undefined, {})], + ['mcp inject --dry-run', () => mcpInject({ dryRun: true })], + ['mcp list', () => mcpList({})], + ['roles list', () => rolesList()], + ['projects list', () => projectsList({})], + ['tags list', () => tagsList()], + ['source list', () => sourceList()], + ['hooks list', () => hooksList({})], + ['exclude list', () => excludeList({})], + ['recall status', () => recallStatus({})], + ['doctor', async () => { await resolveDoctorContext(); }], + ['models list', () => modelsList()], + ['codebase --status', () => codebaseCmd({ status: true })], + ['uninstall --dry-run', () => uninstall({ dryRun: true, force: true })], + ['packages install --dry-run', () => pkgInstall(undefined, { dryRun: true })], ]; it.each(LOAD_ONLY_COMMANDS)('%s migrates nothing it loads (#850)', async (_command, run) => { @@ -267,6 +294,63 @@ describe('--dry-run through the loaders the commands share (#850)', () => { expect(providerCalls).toEqual([]); }); + // The rest of #893: commands that honour `--dry-run` in their own logic but + // loaded the scope bare. Each one reaches the loader before anything this + // fixture lacks (a team repo remote, a provider, a network) stops it, so the + // test asserts only what the loader decides: whether `config.yaml` is + // rewritten. The same call without the flag is the positive control: it DOES + // migrate, so an unchanged file under the flag is a result, not a command that + // failed before it loaded anything. Writes these commands make on their own + // dry-run path, past the loader, are out of scope here. + const PREVIEWS: Array<[string, (dryRun: boolean) => Promise]> = [ + ['mcp inject', (dryRun) => mcpInject({ dryRun })], + ['roles init', (dryRun) => rolesInit({ dryRun })], + ['roles add', (dryRun) => rolesAdd('ops', { dryRun, namespaces: 'ops' })], + ['roles remove', (dryRun) => rolesRemove('pm', { dryRun })], + ['roles update', (dryRun) => rolesUpdate('pm', { dryRun, description: 'x' })], + ['projects add', (dryRun) => projectsAdd('checkout', { dryRun, namespaces: 'checkout' })], + ['tags add', (dryRun) => tagsAdd('skills', 'x', ['a'], { dryRun })], + ['tags remove', (dryRun) => tagsRemove('skills', 'x', ['a'], { dryRun })], + ['source add', (dryRun) => sourceAdd('https://github.com/acme/other.git', { dryRun })], + ['source remove', (dryRun) => sourceRemove('x', { dryRun })], + ['source add-http', (dryRun) => sourceAddHttp('https://h.test', { dryRun, token: 't' })], + ['remove', (dryRun) => remove('skills', ['x'], { dryRun })], + ['packages install', (dryRun) => pkgInstall(undefined, { dryRun })], + ['import --from-iwiki', (dryRun) => importCmd({ fromIwiki: 'x', dryRun })], + ['import --from-mr', (dryRun) => importCmd({ fromMr: 'https://github.com/acme/app/pull/1', dryRun })], + ['codebase --reconcile', (dryRun) => codebaseCmd({ reconcile: true, dryRun })], + ['codebase --deep-enrich', (dryRun) => codebaseCmd({ deepEnrich: true, project: 'x', dryRun })], + ['models switch', (dryRun) => modelsSwitch('p', { dryRun })], + ]; + + describe.each(PREVIEWS)('%s', (_command, run) => { + beforeEach(() => { + // Past the loader these commands may exit or set an exit code; neither is under test. + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + process.exitCode = undefined; + // `source add` reaches the provider's clone after the load; not what this asserts. + providerCalls.length = 0; + }); + + it('--dry-run leaves a config pending the role migration as it was (#893)', async () => { + const { configPath } = legacyRoot(); + const before = fs.readFileSync(configPath, 'utf-8'); + await run(true).catch(() => {}); + expect(fs.readFileSync(configPath, 'utf-8')).toBe(before); + }); + + it('without the flag, the same call migrates it, so the load is on this path', async () => { + const { configPath } = legacyRoot(); + await run(false).catch(() => {}); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('primaryRole: hai'); + }); + }); + /** A git project whose partition still carries its pre-#546 name, i.e. project scope. */ function projectRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dry-run-project-')); From 6517e61e75a8abb68f8139f4bd315ea3704db8ef Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:35:05 +0200 Subject: [PATCH 14/26] fix(dry-run): forward dryRun through resolveMemberToolRoots for import --from-claude (#893) scanCandidates resolves Claude's tool root before the loader import.ts already fixed, through a bare load in resolveMemberToolRoots, so the dry run still saved the migration. resolveConfigForDir and findUnreadableProjectConfig take the same optional LoadOptions for the read-only callers below; hook and usage callers pass nothing and behave as before. --- src/config.ts | 21 ++++++++++++--------- src/import-local.ts | 3 ++- src/import.ts | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/config.ts b/src/config.ts index 5dd1e7ee7..c36062e31 100644 --- a/src/config.ts +++ b/src/config.ts @@ -373,13 +373,13 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string * resolveHookConfig (dashboard-collector.ts), which gives such a payload the * scope its session last recorded (#810). */ -export async function resolveConfigForDir(dir?: string): Promise { +export async function resolveConfigForDir(dir?: string, options: LoadOptions = {}): Promise { const target = dir ?? process.cwd(); - if (!(await pathExists(target))) return loadLocalConfig(); + if (!(await pathExists(target))) return loadLocalConfig(options); let unreadable = false; - const project = await detectProjectConfig(target, () => { unreadable = true; }); + const project = await detectProjectConfig(target, () => { unreadable = true; }, options); if (unreadable) return null; - return project ?? loadLocalConfig(); + return project ?? loadLocalConfig(options); } /** @@ -391,11 +391,14 @@ export async function resolveConfigForDir(dir?: string): Promise | undefined> { +export async function resolveMemberToolRoots( + dir?: string, + options: LoadOptions = {}, +): Promise | undefined> { // A hook can report a directory that no longer exists (a deleted worktree); // git probing there throws, so it means user scope — as resolveConfigForDir. - const project = dir === undefined || await pathExists(dir) ? await detectProjectConfig(dir) : null; - return project?.toolRoots ?? (await loadLocalConfig())?.toolRoots; + const project = dir === undefined || await pathExists(dir) ? await detectProjectConfig(dir, undefined, options) : null; + return project?.toolRoots ?? (await loadLocalConfig(options))?.toolRoots; } /** @@ -611,9 +614,9 @@ function describeConfigError(e: unknown): string { * the wrong one. So a broken higher-priority file is reported even when a later * candidate loads. */ -export async function findUnreadableProjectConfig(cwd?: string): Promise { +export async function findUnreadableProjectConfig(cwd?: string, options: LoadOptions = {}): Promise { let problem: string | null = null; - await detectProjectConfig(cwd, (configPath, error) => { problem ??= `${configPath}: ${error}`; }); + await detectProjectConfig(cwd, (configPath, error) => { problem ??= `${configPath}: ${error}`; }, options); return problem; } diff --git a/src/import-local.ts b/src/import-local.ts index 46036370d..e91574b1d 100644 --- a/src/import-local.ts +++ b/src/import-local.ts @@ -212,6 +212,7 @@ async function persistSession(session: ImportSession, sessionPath: string): Prom export async function scanCandidates(opts: { dir?: string; fromClaude?: boolean; + dryRun?: boolean; }): Promise> { const results: Array<{ path: string; rawContent: string }> = []; @@ -247,7 +248,7 @@ export async function scanCandidates(opts: { // Claude's rules follow a relocated root (CLAUDE_CONFIG_DIR); Cursor's do not move. const { resolveMemberToolRoots } = await import('./config.js'); const { resolveToolRootDir, CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT } = await import('./types.js'); - const claudeRoot = resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await resolveMemberToolRoots()); + const claudeRoot = resolveToolRootDir(CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, await resolveMemberToolRoots(undefined, { dryRun: opts.dryRun })); const rulesBaseDirs = [ path.join(claudeRoot, 'rules'), expandHome('~/.cursor/rules'), diff --git a/src/import.ts b/src/import.ts index 216e38450..048fa27af 100644 --- a/src/import.ts +++ b/src/import.ts @@ -461,7 +461,7 @@ export async function importCmd(opts: ImportOptions): Promise { log.success(`Local directory ${slug} import complete`); } else if (opts.fromClaude) { // 分支 3b:--from-claude,扫描规则文件并交互式导入 - const candidates = await scanCandidates({ fromClaude: true }); + const candidates = await scanCandidates({ fromClaude: true, dryRun: opts.dryRun }); if (candidates.length === 0) { log.info('no importable files found'); return; From 08584a043cd13e866dac76629f50ac3e3a978e94 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:35:05 +0200 Subject: [PATCH 15/26] fix(dry-run): pass dryRun into loadLocalAgentConfig instead of forcing it (#893) Forcing dryRun there made real hook runs print the [dry-run] migration preview and stop persisting it. source list now passes it through describeLocalAgent; every other caller is unchanged. source add-http forwards { dryRun: options.dryRun } like the rest of the file. --- src/local-agent.ts | 9 ++++----- src/source.ts | 5 +++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/local-agent.ts b/src/local-agent.ts index 79730acbb..2ab9c332e 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -581,7 +581,7 @@ function mergeWorkspaceBindings( return base; } -export async function loadLocalAgentConfig(): Promise { +export async function loadLocalAgentConfig(options: { dryRun?: boolean } = {}): Promise { const fileConfig = await readJson(getConfigPath()); if (fileConfig?.endpoint) { const config = { @@ -629,8 +629,7 @@ export async function loadLocalAgentConfig(): Promise { // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. const { loadLocalConfig } = await import('./config.js'); const { resolveApiKey } = await import('./api-key.js'); - // Only `repo` is read here; a role migration persists on the command that writes (#893). - const legacy = await loadLocalConfig({ dryRun: true }); + const legacy = await loadLocalConfig(options); if (legacy?.repo?.kind === 'http' && legacy.repo.url) { const endpoint = normalizeEndpoint(legacy.repo.url); const token = resolveApiKey() ?? undefined; @@ -3359,8 +3358,8 @@ export interface LocalAgentSummary { * none is configured. Used by `teamai source list` to show the HTTP side channel * alongside git cross-team sources. */ -export async function describeLocalAgent(): Promise { - const config = await loadLocalAgentConfig(); +export async function describeLocalAgent(options: { dryRun?: boolean } = {}): Promise { + const config = await loadLocalAgentConfig(options); if (!config) return null; const boundProjects = Object.entries(config.workspaceBindings) diff --git a/src/source.ts b/src/source.ts index e84419ff7..01576b5f0 100644 --- a/src/source.ts +++ b/src/source.ts @@ -246,7 +246,7 @@ export async function sourceList(): Promise { } const { describeLocalAgent } = await import('./local-agent.js'); - const httpSource = await describeLocalAgent(); + const httpSource = await describeLocalAgent({ dryRun: true }); if (gitSources.length === 0 && !httpSource) { log.info('No sources configured. Use `teamai source add ` or `teamai source add-http `.'); @@ -296,7 +296,8 @@ export async function sourceAddHttp( // Guard: if the main repo is already an HTTP backend, it owns the single // local-agent config — refuse rather than overwrite its endpoint. - const mainConfig = (await detectProjectConfig(undefined, undefined, options)) ?? (await loadLocalConfig(options)); + const mainConfig = (await detectProjectConfig(undefined, undefined, { dryRun: options.dryRun })) + ?? (await loadLocalConfig({ dryRun: options.dryRun })); if (mainConfig?.repo.kind === 'http') { log.error('Your main team repo is already an HTTP backend, which owns the HTTP source config.'); log.info('An HTTP bypass is only for git-based main repos. Nothing changed.'); From 9dc7572848f4bb5e9f71f974015f4132fffdc689 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:35:05 +0200 Subject: [PATCH 16/26] fix(dry-run): load skill list/show, webhook list, stats and digest read-only (#893) detectTeam takes optional LoadOptions; the Stop-hook share gate passes none. --- src/digest.ts | 5 +++-- src/exclude.ts | 2 +- src/skill-cmd.ts | 6 ++++-- src/skill-content.ts | 8 ++++---- src/stats.ts | 7 ++++--- src/webhook.ts | 4 ++-- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/digest.ts b/src/digest.ts index 49de982e4..102aa3b07 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -407,8 +407,9 @@ export function formatTrendLines(periods: { current: TrendPeriod; previous: Tren */ export async function generateDigest(): Promise { try { - const projectConfig = await detectProjectConfig(); - const localConfig = projectConfig ?? (await requireInit()).localConfig; + // Read-only: the load never persists a migration (#893). + const projectConfig = await detectProjectConfig(undefined, undefined, { dryRun: true }); + const localConfig = projectConfig ?? (await requireInit({ dryRun: true })).localConfig; const repoPath = localConfig.repo.localPath; // Knowledge (learnings, skill git-log) lives under localPath on the default diff --git a/src/exclude.ts b/src/exclude.ts index 7294f5de1..236f7dd58 100644 --- a/src/exclude.ts +++ b/src/exclude.ts @@ -9,7 +9,7 @@ import { import { log } from './utils/logger.js'; import type { GlobalOptions, LocalConfig } from './types.js'; -async function resolveExcludeScope(options: { dryRun?: boolean } = {}): Promise { +async function resolveExcludeScope(options: GlobalOptions = {}): Promise { const projectConfig = await detectProjectConfig(undefined, undefined, options); return projectConfig ?? (await requireInit(options)).localConfig; } diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index e8c69ace3..9b3aeb314 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -51,7 +51,8 @@ type LocatedSkill = ResolvedSkill | BlockedSkill; export async function skillShow(name: string, options: GlobalOptions): Promise { // One config load for both the gate and the lookup, so a broken config is // reported once. - const team = await detectTeam(); + // Read-only: the load never persists a migration (#893). + const team = await detectTeam(undefined, { dryRun: true }); const served = await resolveServableSkill(name, undefined, team); if (team.kind !== 'team') { // Only the package can answer without a team: it ships with the CLI, so @@ -148,7 +149,8 @@ export async function skillShow(name: string, options: GlobalOptions): Promise { // One config load for the catalog's gate and the team listing, so a broken // config is reported once. - const team = await detectTeam(); + // Read-only: the load never persists a migration (#893). + const team = await detectTeam(undefined, { dryRun: true }); const catalog = await skillCatalog(undefined, team); if (options.json) { diff --git a/src/skill-content.ts b/src/skill-content.ts index c7ca5d129..148abe330 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -97,7 +97,7 @@ export type TeamDetection = | { kind: 'none' } | { kind: 'unusable'; detail: string }; -export async function detectTeam(cwd?: string): Promise { +export async function detectTeam(cwd?: string, options: { dryRun?: boolean } = {}): Promise { const { autoDetectInit, findUnreadableProjectConfig, requireInit, NotInitializedError, describeUnreadableConfig } = await import('./config.js'); // Loading the config can migrate it and say so with `log.info`. That line @@ -114,14 +114,14 @@ export async function detectTeam(cwd?: string): Promise { // other failure (no permission, a path through a file) leaves the // project unknown, not absent. if (typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT') { - return { kind: 'team', init: await requireInit() }; + return { kind: 'team', init: await requireInit(options) }; } return { kind: 'unusable', detail: `${cwd} cannot be checked: ${e instanceof Error ? e.message : String(e)}` }; } } - const unreadable = await findUnreadableProjectConfig(cwd); + const unreadable = await findUnreadableProjectConfig(cwd, options); if (unreadable) return { kind: 'unusable', detail: describeUnreadableConfig(unreadable) }; - return { kind: 'team', init: await autoDetectInit(cwd) }; + return { kind: 'team', init: await autoDetectInit(cwd, options) }; } catch (e) { if (e instanceof NotInitializedError) return { kind: 'none' }; return { kind: 'unusable', detail: firstLine(e instanceof Error ? e.message : String(e)) }; diff --git a/src/stats.ts b/src/stats.ts index f427fd8f0..465f99fc9 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -49,7 +49,8 @@ export function aggregateUsage(events: UsageEvent[]): SkillStats[] { */ async function loadReportedStats(): Promise { try { - const config = await resolveConfigForDir(); + // Read-only: the load never persists a migration (#893). + const config = await resolveConfigForDir(undefined, { dryRun: true }); if (!config) return null; // Non-HTTP: stats live on the teamai-reports orphan branch worktree. // Leftover stats/ on the default-branch clone is ignored. Read-only: never @@ -219,8 +220,8 @@ export interface ShowStatsOptions { */ export async function showStats(options: ShowStatsOptions = {}): Promise { // The same scope loadReportedStats reads, so local and reported totals match; - // a directory without teamai has no usage of its own (#748). - const config = await resolveConfigForDir(); + // a directory without teamai has no usage of its own (#748). Read-only, as there (#893). + const config = await resolveConfigForDir(undefined, { dryRun: true }); const events = config ? await readUsageEvents(config) : []; const localStats = aggregateUsage(events); const reported = await loadReportedStats(); diff --git a/src/webhook.ts b/src/webhook.ts index 6fe95e37b..73535ecff 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -147,8 +147,8 @@ export async function loadWebhookConfig(localConfig?: LocalConfig): Promise { - const config = await loadWebhookConfig(); - return config.endpoints; + // Read-only: the load never persists a migration (#893). + return getWebhookSharing((await autoDetectInit(undefined, { dryRun: true })).teamConfig).endpoints; } /** From 7544453d44418aa9b3ab54bb615ff7fb26046cc6 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:35:05 +0200 Subject: [PATCH 17/26] docs(designs): name read-only commands by example, not as every list subcommand (#893) --- docs/designs/data-directory-layout.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 455c2a492..834a11805 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -398,8 +398,8 @@ stays in the checkout's `.teamai/`. it back FROM the partition (`selfHealAndReadPartition`). A pre-P2 install whose config still sits in `/.teamai` is read via the legacy branch (double-read compat) until migration relocates it. A `--dry-run` detection (a command that - forwards `--dry-run` to its loader, or a read-only one such as `status`, `list` - or a `* list` subcommand, which loads this way unconditionally) previews the bootstrap + forwards `--dry-run` to its loader, or a read-only one such as `status`, `list`, + `doctor` or `mcp list`, which loads this way unconditionally) previews the bootstrap instead (`previewSelfBootstrap`): it builds the config it would write, keeps it in memory, and prints `[dry-run] Would bootstrap ...` without locking, writing, injecting hooks or registering the member. It makes no provider auth call From f31241f6078d5a3687608280a587a789d3def57f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:35:05 +0200 Subject: [PATCH 18/26] test(dry-run): prove each row reached the load, and cover the remaining changed sites (#893) Every dry-run row now asserts the loader logged its migration preview, so an early return cannot pass. Adds source browse, codebase --lint, skill list/show, webhook list, digest, projects update/remove, import --from-claude, and a config-only table for members, projects members and stats. --- src/__tests__/dry-run-load-path.test.ts | 47 +++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/__tests__/dry-run-load-path.test.ts b/src/__tests__/dry-run-load-path.test.ts index aaeb60754..e4e996beb 100644 --- a/src/__tests__/dry-run-load-path.test.ts +++ b/src/__tests__/dry-run-load-path.test.ts @@ -31,6 +31,7 @@ vi.mock('../utils/reports-branch.js', async (importOriginal) => ({ import { codebaseCmd } from '../codebase-cmd.js'; import { contribute } from '../contribute.js'; +import { generateDigest } from '../digest.js'; import { loadLocalConfigForScope } from '../config.js'; import { resolveDoctorContext } from '../doctor.js'; import { excludeList } from '../exclude.js'; @@ -39,17 +40,21 @@ import { importCmd } from '../import.js'; import { mcpInject, mcpList } from '../mcp-cmd.js'; import { modelsList, modelsSwitch } from '../models-cmd.js'; import { pkgInstall } from '../pkg/commands.js'; -import { projectsAdd, projectsList } from '../projects-cmd.js'; +import { listMembers } from '../members.js'; +import { projectsAdd, projectsList, projectsMembers, projectsRemove, projectsUpdate } from '../projects-cmd.js'; import { pull } from '../pull.js'; import { push } from '../push.js'; import { recall } from '../recall.js'; import { recallStatus } from '../recall-toggle.js'; import { remove } from '../remove.js'; import { rolesAdd, rolesInit, rolesList, rolesRemove, rolesSet, rolesUpdate } from '../roles-cmd.js'; -import { sourceAdd, sourceAddHttp, sourceList, sourceRemove } from '../source.js'; +import { skillList, skillShow } from '../skill-cmd.js'; +import { sourceAdd, sourceAddHttp, sourceBrowse, sourceList, sourceRemove } from '../source.js'; +import { showStats } from '../stats.js'; import { list, status } from '../status.js'; import { tagsAdd, tagsList, tagsRemove, tagsSubscribe, tagsUnsubscribe } from '../tags.js'; import { uninstall } from '../uninstall.js'; +import { listWebhooks } from '../webhook.js'; import { updateReports } from '../utils/reports-branch.js'; import { log } from '../utils/logger.js'; import { legacyProjectSlug } from '../utils/partition.js'; @@ -209,6 +214,7 @@ describe('--dry-run through the loaders the commands share (#850)', () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); process.chdir(originalCwd); + process.exitCode = undefined; for (const dir of roots.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); @@ -281,13 +287,25 @@ describe('--dry-run through the loaders the commands share (#850)', () => { ['codebase --status', () => codebaseCmd({ status: true })], ['uninstall --dry-run', () => uninstall({ dryRun: true, force: true })], ['packages install --dry-run', () => pkgInstall(undefined, { dryRun: true })], + ['source browse', () => sourceBrowse('x', {})], + ['codebase --lint', () => codebaseCmd({ lint: true })], + ['skill list', () => skillList({})], + ['skill show', () => skillShow('core', {})], + ['webhook list', async () => { await listWebhooks(); }], + ['digest', () => generateDigest()], ]; + // The loader logs this line when it previews the migration, so it proves the + // row reached the load: an early return would leave the file unchanged too. + const PREVIEWED = expect.stringContaining('[dry-run] Would migrate legacy teamai config'); + it.each(LOAD_ONLY_COMMANDS)('%s migrates nothing it loads (#850)', async (_command, run) => { const { root, configPath } = legacyRoot(); + const info = vi.spyOn(log, 'info').mockImplementation(() => {}); const before = snapshotTree(root); const error = await run().then(() => null, (e: unknown) => e); expect(error).toBeNull(); + expect(info).toHaveBeenCalledWith(PREVIEWED); expect(snapshotTree(root)).toEqual(before); expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('primaryRole'); // A dry run may parse the remote, but nothing else may reach a provider. @@ -319,6 +337,9 @@ describe('--dry-run through the loaders the commands share (#850)', () => { ['import --from-iwiki', (dryRun) => importCmd({ fromIwiki: 'x', dryRun })], ['import --from-mr', (dryRun) => importCmd({ fromMr: 'https://github.com/acme/app/pull/1', dryRun })], ['codebase --reconcile', (dryRun) => codebaseCmd({ reconcile: true, dryRun })], + ['projects update', (dryRun) => projectsUpdate('checkout', { dryRun, description: 'x' })], + ['projects remove', (dryRun) => projectsRemove('checkout', { dryRun })], + ['import --from-claude', (dryRun) => importCmd({ fromClaude: true, all: true, dryRun })], ['codebase --deep-enrich', (dryRun) => codebaseCmd({ deepEnrich: true, project: 'x', dryRun })], ['models switch', (dryRun) => modelsSwitch('p', { dryRun })], ]; @@ -339,8 +360,10 @@ describe('--dry-run through the loaders the commands share (#850)', () => { it('--dry-run leaves a config pending the role migration as it was (#893)', async () => { const { configPath } = legacyRoot(); + const info = vi.spyOn(log, 'info').mockImplementation(() => {}); const before = fs.readFileSync(configPath, 'utf-8'); await run(true).catch(() => {}); + expect(info).toHaveBeenCalledWith(PREVIEWED); expect(fs.readFileSync(configPath, 'utf-8')).toBe(before); }); @@ -351,6 +374,26 @@ describe('--dry-run through the loaders the commands share (#850)', () => { }); }); + // Read-only commands that, past the load, fail on this fixture (there is no + // reports branch to read members from) or keep state of their own (`stats` + // indexes session owners for the dashboard). Only the load is asserted. + const READ_ONLY_PAST_THE_LOAD: Array<[string, () => Promise]> = [ + ['members', () => listMembers({})], + ['projects members', () => projectsMembers('checkout', {})], + ['stats', () => showStats()], + ]; + + it.each(READ_ONLY_PAST_THE_LOAD)('%s migrates nothing it loads (#893)', async (_command, run) => { + const { configPath } = legacyRoot(); + const info = vi.spyOn(log, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const before = fs.readFileSync(configPath, 'utf-8'); + await run().catch(() => {}); + providerCalls.length = 0; + expect(info).toHaveBeenCalledWith(PREVIEWED); + expect(fs.readFileSync(configPath, 'utf-8')).toBe(before); + }); + /** A git project whose partition still carries its pre-#546 name, i.e. project scope. */ function projectRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dry-run-project-')); From 8b32fa8fc35493b3497fa4c72e5f294713b798ef Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:48:26 +0200 Subject: [PATCH 19/26] fix(dry-run): keep the pre-command migration from adopting a partition on a dry run (#893) The preAction hook passes dryRun to maybeMigrate, but planMigration and queueKeptInCheckout resolved the partition bare, so pull/push --dry-run still renamed a pre-#546 partition. Both now take the option. --- src/index.ts | 2 +- src/migrate.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index c3d07a564..5e5f3b954 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,7 @@ program // A learning queued in this checkout would go with it when the worktree is // removed (#808). if (needsQueueOutOfCheckout(actionCommand)) { - const kept = await queueKeptInCheckout(migration); + const kept = await queueKeptInCheckout(migration, { dryRun: !!opts.dryRun }); if (kept) { log.error(kept); process.exit(1); diff --git a/src/migrate.ts b/src/migrate.ts index c8aa01c43..e55934530 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -189,7 +189,7 @@ const SUPERSEDED_ENTRIES = [ * and the legacy dir holds its knowledge. A partition config that exists but * cannot be read skips. */ -export async function planMigration(cwd?: string): Promise { +export async function planMigration(cwd?: string, options: { dryRun?: boolean } = {}): Promise { const anchors = await resolveAnchors(cwd ?? process.cwd()); if (!anchors) return null; @@ -199,7 +199,8 @@ export async function planMigration(cwd?: string): Promise // adopting (renaming) it FIRST is what keeps the "partition already built" // checks below honest — otherwise an upgraded CLI would see "no partition" // and re-copy a retired workspace's data into a second, empty partition. - const partitionDir = await resolvePartitionDir(anchors.projectAnchor); + // A dry run adopts nothing: the preview reads the partition where it is (#893). + const partitionDir = await resolvePartitionDir(anchors.projectAnchor, options); const legacyConfig = path.join(legacyDir, 'config.yaml'); // Gate on scope/kind read from config.yaml. It is normally in the repo, but a @@ -528,7 +529,7 @@ export async function runMigration( * not silently proceed on stale legacy data), but never crash a dry-run preview. */ export async function maybeMigrate(opts: { dryRun?: boolean } = {}): Promise { - const plan = await planMigration(); + const plan = await planMigration(undefined, opts); if (plan) return runMigration(plan, opts); if (!opts.dryRun) await settleOrphanQueue(); return undefined; @@ -560,7 +561,10 @@ async function settleOrphanQueue(): Promise { * its data home is still that directory, or an old queue there could not move. * Outside a git repo `.teamai/` is where the data belongs. */ -export async function queueKeptInCheckout(migration: MigrationResult | undefined): Promise { +export async function queueKeptInCheckout( + migration: MigrationResult | undefined, + options: { dryRun?: boolean } = {}, +): Promise { switch (migration) { case 'dry-run': return null; @@ -582,10 +586,10 @@ export async function queueKeptInCheckout(migration: MigrationResult | undefined // home, and the queue there is the user scope's. const home = getUserHome(); if (anchors.workspaceRoot === (await realpath(home).catch(() => home))) return null; - const config = await detectProjectConfig(anchors.workspaceRoot); + const config = await detectProjectConfig(anchors.workspaceRoot, undefined, options); const queueDir = config ? pendingLearningsDir(config) : null; if (queueDir && isInside(queueDir, legacyDir)) { - const partitionDir = await resolvePartitionDir(anchors.projectAnchor); + const partitionDir = await resolvePartitionDir(anchors.projectAnchor, options); const partition = await readPartitionState(partitionDir, anchors.workspaceRoot); switch (partition.state) { case 'unreadable': From b227f3a77046bc2df48dc60d44d35ca3d9706c8e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:48:26 +0200 Subject: [PATCH 20/26] fix(dry-run): load skill get/path read-only through the share gate (#893) blockReason fell back to shareGate(), a bare load, when no team was passed; only skill get, skill path and the catalog reach that fallback. The Stop hook still asks shareGate directly and is unchanged. --- src/skill-content.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/skill-content.ts b/src/skill-content.ts index 148abe330..492132cd4 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -196,7 +196,8 @@ export async function contributeHintAllowed(cwd?: string): Promise { /** What makes this skill unusable right now, or null. */ async function blockReason(name: string, team?: TeamDetection): Promise { if (!RECALL_DEPENDENT_SKILLS.has(name)) return null; - return (team ? await gateFor(team) : await shareGate()).block; + // `skill get` / `skill path` only read; the Stop hook asks shareGate itself (#893). + return (await gateFor(team ?? await detectTeam(undefined, { dryRun: true }))).block; } /** A skill directory that ships inside the npm package. */ From 37f94a27e992df66b769acbb258cb7d3e7e53546 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:48:26 +0200 Subject: [PATCH 21/26] refactor(dry-run): give loadWebhookConfig the option instead of copying its branch (#893) Also note in loadLocalAgentConfig that dryRun covers only its config.yaml load. --- src/local-agent.ts | 1 + src/webhook.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/local-agent.ts b/src/local-agent.ts index 2ab9c332e..aeb076ca4 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -629,6 +629,7 @@ export async function loadLocalAgentConfig(options: { dryRun?: boolean } = {}): // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. const { loadLocalConfig } = await import('./config.js'); const { resolveApiKey } = await import('./api-key.js'); + // `dryRun` covers this config.yaml load only; config.json writes above and below are unchanged. const legacy = await loadLocalConfig(options); if (legacy?.repo?.kind === 'http' && legacy.repo.url) { const endpoint = normalizeEndpoint(legacy.repo.url); diff --git a/src/webhook.ts b/src/webhook.ts index 73535ecff..4bd0712a0 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -136,8 +136,11 @@ function formatMessage( * Load webhook config from the team config of `localConfig`'s scope, or of the * scope detected from the process cwd when none is given. */ -export async function loadWebhookConfig(localConfig?: LocalConfig): Promise { - if (!localConfig) return getWebhookSharing((await autoDetectInit()).teamConfig); +export async function loadWebhookConfig( + localConfig?: LocalConfig, + options: { dryRun?: boolean } = {}, +): Promise { + if (!localConfig) return getWebhookSharing((await autoDetectInit(undefined, options)).teamConfig); const teamConfig = await loadTeamConfig(localConfig.repo.localPath); if (!teamConfig) throw new Error(`No usable team config (teamai.yaml) in ${localConfig.repo.localPath}.`); return getWebhookSharing(teamConfig); @@ -148,7 +151,7 @@ export async function loadWebhookConfig(localConfig?: LocalConfig): Promise { // Read-only: the load never persists a migration (#893). - return getWebhookSharing((await autoDetectInit(undefined, { dryRun: true })).teamConfig).endpoints; + return (await loadWebhookConfig(undefined, { dryRun: true })).endpoints; } /** From 7e412e5cd4fab6a65fca4464c015408deb7aa658 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:48:26 +0200 Subject: [PATCH 22/26] revert(dry-run): leave stats out of this change (#893) stats also loads bare per session through the dashboard scope helpers on the pull/report path; a partial fix would claim more than it does. Tracked in the follow-up issue with the other dry-run gaps. --- src/config.ts | 8 ++++---- src/stats.ts | 7 +++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index c36062e31..bd9ead2e1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -373,13 +373,13 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string * resolveHookConfig (dashboard-collector.ts), which gives such a payload the * scope its session last recorded (#810). */ -export async function resolveConfigForDir(dir?: string, options: LoadOptions = {}): Promise { +export async function resolveConfigForDir(dir?: string): Promise { const target = dir ?? process.cwd(); - if (!(await pathExists(target))) return loadLocalConfig(options); + if (!(await pathExists(target))) return loadLocalConfig(); let unreadable = false; - const project = await detectProjectConfig(target, () => { unreadable = true; }, options); + const project = await detectProjectConfig(target, () => { unreadable = true; }); if (unreadable) return null; - return project ?? loadLocalConfig(options); + return project ?? loadLocalConfig(); } /** diff --git a/src/stats.ts b/src/stats.ts index 465f99fc9..f427fd8f0 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -49,8 +49,7 @@ export function aggregateUsage(events: UsageEvent[]): SkillStats[] { */ async function loadReportedStats(): Promise { try { - // Read-only: the load never persists a migration (#893). - const config = await resolveConfigForDir(undefined, { dryRun: true }); + const config = await resolveConfigForDir(); if (!config) return null; // Non-HTTP: stats live on the teamai-reports orphan branch worktree. // Leftover stats/ on the default-branch clone is ignored. Read-only: never @@ -220,8 +219,8 @@ export interface ShowStatsOptions { */ export async function showStats(options: ShowStatsOptions = {}): Promise { // The same scope loadReportedStats reads, so local and reported totals match; - // a directory without teamai has no usage of its own (#748). Read-only, as there (#893). - const config = await resolveConfigForDir(undefined, { dryRun: true }); + // a directory without teamai has no usage of its own (#748). + const config = await resolveConfigForDir(); const events = config ? await readUsageEvents(config) : []; const localStats = aggregateUsage(events); const reported = await loadReportedStats(); From a78b44cdcc64404a784b06235d302149103eb44e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:48:26 +0200 Subject: [PATCH 23/26] test(dry-run): cover the pre-command migration and skill get/path share (#893) --- src/__tests__/dry-run-load-path.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/__tests__/dry-run-load-path.test.ts b/src/__tests__/dry-run-load-path.test.ts index e4e996beb..ea175266c 100644 --- a/src/__tests__/dry-run-load-path.test.ts +++ b/src/__tests__/dry-run-load-path.test.ts @@ -38,6 +38,7 @@ import { excludeList } from '../exclude.js'; import { hooksList } from '../hooks-cmd.js'; import { importCmd } from '../import.js'; import { mcpInject, mcpList } from '../mcp-cmd.js'; +import { maybeMigrate, queueKeptInCheckout } from '../migrate.js'; import { modelsList, modelsSwitch } from '../models-cmd.js'; import { pkgInstall } from '../pkg/commands.js'; import { listMembers } from '../members.js'; @@ -49,8 +50,8 @@ import { recallStatus } from '../recall-toggle.js'; import { remove } from '../remove.js'; import { rolesAdd, rolesInit, rolesList, rolesRemove, rolesSet, rolesUpdate } from '../roles-cmd.js'; import { skillList, skillShow } from '../skill-cmd.js'; +import { skillGet, skillPath } from '../skill-content.js'; import { sourceAdd, sourceAddHttp, sourceBrowse, sourceList, sourceRemove } from '../source.js'; -import { showStats } from '../stats.js'; import { list, status } from '../status.js'; import { tagsAdd, tagsList, tagsRemove, tagsSubscribe, tagsUnsubscribe } from '../tags.js'; import { uninstall } from '../uninstall.js'; @@ -282,7 +283,9 @@ describe('--dry-run through the loaders the commands share (#850)', () => { ['hooks list', () => hooksList({})], ['exclude list', () => excludeList({})], ['recall status', () => recallStatus({})], - ['doctor', async () => { await resolveDoctorContext(); }], + ['doctor (its loader)', async () => { await resolveDoctorContext(); }], + ['skill get share', () => skillGet(['share'])], + ['skill path share', () => skillPath('share')], ['models list', () => modelsList()], ['codebase --status', () => codebaseCmd({ status: true })], ['uninstall --dry-run', () => uninstall({ dryRun: true, force: true })], @@ -339,6 +342,7 @@ describe('--dry-run through the loaders the commands share (#850)', () => { ['codebase --reconcile', (dryRun) => codebaseCmd({ reconcile: true, dryRun })], ['projects update', (dryRun) => projectsUpdate('checkout', { dryRun, description: 'x' })], ['projects remove', (dryRun) => projectsRemove('checkout', { dryRun })], + // No candidates on this fixture: covers the scan's load, not the one after review. ['import --from-claude', (dryRun) => importCmd({ fromClaude: true, all: true, dryRun })], ['codebase --deep-enrich', (dryRun) => codebaseCmd({ deepEnrich: true, project: 'x', dryRun })], ['models switch', (dryRun) => modelsSwitch('p', { dryRun })], @@ -374,13 +378,11 @@ describe('--dry-run through the loaders the commands share (#850)', () => { }); }); - // Read-only commands that, past the load, fail on this fixture (there is no - // reports branch to read members from) or keep state of their own (`stats` - // indexes session owners for the dashboard). Only the load is asserted. + // Read-only commands that, past the load, fail on this fixture: there is no + // reports branch to read members from. Only the load is asserted. const READ_ONLY_PAST_THE_LOAD: Array<[string, () => Promise]> = [ ['members', () => listMembers({})], ['projects members', () => projectsMembers('checkout', {})], - ['stats', () => showStats()], ]; it.each(READ_ONLY_PAST_THE_LOAD)('%s migrates nothing it loads (#893)', async (_command, run) => { @@ -415,6 +417,11 @@ describe('--dry-run through the loaders the commands share (#850)', () => { ['pull --dry-run', () => pull({ dryRun: true })], ['status', () => status({})], ['list', () => list(undefined, {})], + // What the CLI's preAction hook runs before `pull`/`push --dry-run`, which + // calling `pull()` directly skips. + ['the pre-command migration under --dry-run', async () => { + await queueKeptInCheckout(await maybeMigrate({ dryRun: true }), { dryRun: true }); + }], ]; it.each(PROJECT_SCOPE_COMMANDS)('%s adopts no legacy partition on a git project (#850)', async (_command, run) => { From c19a8b3953bdd91d760b109662171c872356e969 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 11:56:54 +0200 Subject: [PATCH 24/26] refactor(dry-run): give shareGate the option instead of repeating it in blockReason (#893) Also correct the test comment on when the pre-command migration runs. --- src/__tests__/dry-run-load-path.test.ts | 6 ++++-- src/skill-content.ts | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/__tests__/dry-run-load-path.test.ts b/src/__tests__/dry-run-load-path.test.ts index ea175266c..c2c04bb63 100644 --- a/src/__tests__/dry-run-load-path.test.ts +++ b/src/__tests__/dry-run-load-path.test.ts @@ -417,8 +417,10 @@ describe('--dry-run through the loaders the commands share (#850)', () => { ['pull --dry-run', () => pull({ dryRun: true })], ['status', () => status({})], ['list', () => list(undefined, {})], - // What the CLI's preAction hook runs before `pull`/`push --dry-run`, which - // calling `pull()` directly skips. + // What the CLI's preAction hook runs before a command under --dry-run: + // `maybeMigrate` before every write command (`pull`, `push`, ...), and + // `queueKeptInCheckout` before one that queues a learning. Calling + // `pull()` directly, as the rows above do, skips both. ['the pre-command migration under --dry-run', async () => { await queueKeptInCheckout(await maybeMigrate({ dryRun: true }), { dryRun: true }); }], diff --git a/src/skill-content.ts b/src/skill-content.ts index 492132cd4..be505658e 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -141,8 +141,8 @@ export async function detectTeam(cwd?: string, options: { dryRun?: boolean } = { * writable, is then unknown, and the workflow would fail at `teamai contribute`. * Any failure past loading the config is a fault here and propagates. */ -export async function shareGate(cwd?: string): Promise { - return gateFor(await detectTeam(cwd)); +export async function shareGate(cwd?: string, options: { dryRun?: boolean } = {}): Promise { + return gateFor(await detectTeam(cwd, options)); } /** The share gate on a team already detected, for a command that needs the team too. */ @@ -196,8 +196,8 @@ export async function contributeHintAllowed(cwd?: string): Promise { /** What makes this skill unusable right now, or null. */ async function blockReason(name: string, team?: TeamDetection): Promise { if (!RECALL_DEPENDENT_SKILLS.has(name)) return null; - // `skill get` / `skill path` only read; the Stop hook asks shareGate itself (#893). - return (await gateFor(team ?? await detectTeam(undefined, { dryRun: true }))).block; + // `skill get` / `skill path` only read; the Stop hook asks shareGate without options (#893). + return (team ? await gateFor(team) : await shareGate(undefined, { dryRun: true })).block; } /** A skill directory that ships inside the npm package. */ From 3da27052f9d22c394b2d2f46a246f3d32bd52fc7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 13:07:18 +0200 Subject: [PATCH 25/26] fix(dry-run): keep loadLocalAgentConfig from writing config.json under dryRun (#893) The option reached only the config.yaml load. The legacy group-binding cleanup, the binding-key canonicalization and the HTTP backfill still saved config.json, so `teamai source list` could rewrite or create it. Under dryRun they now stay in memory, and the cleanup prints a `[dry-run] Would remove` preview instead of `Removed`. --- src/__tests__/local-agent.test.ts | 58 +++++++++++++++++++++++++++++++ src/local-agent.ts | 9 +++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index fd6178332..59d9bdcf6 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2021,3 +2021,61 @@ describe('local-agent: per-worktree claudemd isolation (issue #374 P1-2C)', () = await fse.remove(wtBReal).catch(() => {}); }); }); + +describe('local-agent: loadLocalAgentConfig({ dryRun: true }) writes nothing (#893)', () => { + const configPath = () => path.join(tmpDir, '.teamai', 'local-agent', 'config.json'); + + it('drops a legacy group binding in memory and leaves config.json as it was', async () => { + await setupConfig({ '/ws/legacy': { groupId: 7, boundAt: 'x' } }); + const before = await fse.readFile(configPath(), 'utf8'); + + const { loadLocalAgentConfig } = await import('../local-agent.js'); + const config = await loadLocalAgentConfig({ dryRun: true }); + + expect(config!.workspaceBindings).toEqual({}); + expect(await fse.readFile(configPath(), 'utf8')).toBe(before); + }); + + it('collapses path aliases in memory and leaves config.json as it was', async () => { + const realWs = path.join(tmpDir, 'ws-real'); + const aliasWs = path.join(tmpDir, 'ws-alias'); + await fse.ensureDir(realWs); + await fse.symlink(realWs, aliasWs, 'dir'); + await setupConfig({ + [realWs]: { projectId: 11, projectName: 'real', boundAt: 'x', ideType: 'codebuddy' }, + [aliasWs]: { projectId: 11, projectName: 'alias', boundAt: 'x', ideType: 'codebuddy' }, + }); + const before = await fse.readFile(configPath(), 'utf8'); + + const { loadLocalAgentConfig } = await import('../local-agent.js'); + const config = await loadLocalAgentConfig({ dryRun: true }); + + expect(Object.keys(config!.workspaceBindings)).toEqual([fse.realpathSync(realWs)]); + expect(await fse.readFile(configPath(), 'utf8')).toBe(before); + }); + + it('backfills from an http config.yaml in memory without creating config.json', async () => { + await fse.ensureDir(path.join(tmpDir, '.teamai')); + await fse.writeFile( + path.join(tmpDir, '.teamai', 'config.yaml'), + [ + 'username: tester', + 'repo:', + ' kind: http', + ' url: https://team.example/api', + ` localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}`, + ' remote: https://team.example/api', + '', + ].join('\n'), + ); + + const { loadLocalAgentConfig } = await import('../local-agent.js'); + const dry = await loadLocalAgentConfig({ dryRun: true }); + expect(dry?.endpoint).toBe('https://team.example/api'); + expect(await fse.pathExists(configPath())).toBe(false); + + // Positive control: the same load without the flag persists the backfill. + await loadLocalAgentConfig(); + expect(await fse.pathExists(configPath())).toBe(true); + }); +}); diff --git a/src/local-agent.ts b/src/local-agent.ts index aeb076ca4..9cd35fe2d 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -597,7 +597,9 @@ export async function loadLocalAgentConfig(options: { dryRun?: boolean } = {}): removedLegacyPaths.push(wsPath); } } - if (removedLegacyPaths.length > 0) { + if (removedLegacyPaths.length > 0 && options.dryRun) { + log.info(`[dry-run] Would remove ${removedLegacyPaths.length} legacy group-based workspace binding(s).`); + } else if (removedLegacyPaths.length > 0) { log.warn( `Removed ${removedLegacyPaths.length} legacy group-based workspace binding(s); ` + `you will be prompted to re-bind on the next session.`, @@ -619,7 +621,7 @@ export async function loadLocalAgentConfig(options: { dryRun?: boolean } = {}): migrated[canonicalKey] = mergeWorkspaceBindings(migrated[canonicalKey], binding, canonicalKey); } config.workspaceBindings = migrated; - if (migrationChanged) { + if (migrationChanged && !options.dryRun) { await saveLocalAgentConfig(config); } return config; @@ -629,7 +631,7 @@ export async function loadLocalAgentConfig(options: { dryRun?: boolean } = {}): // an HTTP team repo, auto-create config.json so v0.17.x upgraders keep capability. const { loadLocalConfig } = await import('./config.js'); const { resolveApiKey } = await import('./api-key.js'); - // `dryRun` covers this config.yaml load only; config.json writes above and below are unchanged. + // Under `dryRun` the migrations above and this backfill stay in memory: nothing is written. const legacy = await loadLocalConfig(options); if (legacy?.repo?.kind === 'http' && legacy.repo.url) { const endpoint = normalizeEndpoint(legacy.repo.url); @@ -640,6 +642,7 @@ export async function loadLocalAgentConfig(options: { dryRun?: boolean } = {}): createdAt: new Date().toISOString(), workspaceBindings: {}, }; + if (options.dryRun) return backfilled; try { await saveLocalAgentConfig(backfilled); log.debug('local-agent: backfilled config.json from legacy ~/.teamai/config.yaml (http repo)'); From 277b4ae5c270220318a71ee4320da468b77b044a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 29 Sep 2026 13:17:14 +0200 Subject: [PATCH 26/26] fix(dry-run): keep models list from saving a re-bound beta key (#893) models list loaded the scope read-only but read the team keys through loadTeamValues without the option, so a key a 0.26.0 beta stored under the profile id alone was bound to its gateway and the values file rewritten. It now binds the key in memory only; the next write command saves it. --- src/__tests__/pull-model-namespaces.test.ts | 11 +++++++++++ src/models-cmd.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/__tests__/pull-model-namespaces.test.ts b/src/__tests__/pull-model-namespaces.test.ts index 10f6db3fd..d6764c2ef 100644 --- a/src/__tests__/pull-model-namespaces.test.ts +++ b/src/__tests__/pull-model-namespaces.test.ts @@ -332,6 +332,17 @@ describe('pull: team model profiles by namespace', () => { expect(await claude()).toEqual({ url: `${COMPANY}/checkout`, token: 'company-secret', model: 'checkout-model' }); }); + it('models list reads a key stored before namespaces existed bound, and leaves the file as it was (#893)', async () => { + const file = getTeamValuesPath(configFor([])); + await saveModelInputs(file, { 'team:gw': { API_KEY: { env: 'COMPANY_KEY' } } }); + const before = await fse.readFile(file, 'utf8'); + + const output = await captureOutput(() => modelsList('team:gw')); + + expect(output).toContain(' API key: environment COMPANY_KEY'); + expect(await fse.readFile(file, 'utf8')).toBe(before); + }); + it('binds a beta key to the gateway its agent was switched to, so a root profile that moved since never gets it', async () => { const beta = { 'team:gw': { API_KEY: { env: 'COMPANY_KEY' } } }; await saveModelInputs(getTeamValuesPath(configFor([])), beta); diff --git a/src/models-cmd.ts b/src/models-cmd.ts index 4b107ddda..7331f25e1 100644 --- a/src/models-cmd.ts +++ b/src/models-cmd.ts @@ -203,7 +203,7 @@ export async function modelsList(reference?: string): Promise { return; } const values: Record = { - team: context.localConfig && refs.some((ref) => ref.source === 'team') ? await loadTeamValues(context.localConfig, context.team) : {}, + team: context.localConfig && refs.some((ref) => ref.source === 'team') ? await loadTeamValues(context.localConfig, context.team, { dryRun: true }) : {}, local: refs.some((ref) => ref.source === 'local') ? await loadModelInputs(getLocalValuesPath()) : {}, }; refs.forEach((ref, index) => {