diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx
index 2655381..e73e838 100644
--- a/apps/web/src/app/docs/page.tsx
+++ b/apps/web/src/app/docs/page.tsx
@@ -153,6 +153,13 @@ export default function DocsPage() {
provider configs, migrates them into the canonical format, and
syncs everything back out.
+
+ After initialization, agentloom sync is
+ one-way: it reads from .agents/ and
+ writes provider-native outputs. Rerun{" "}
+ agentloom init only when you want to
+ re-import provider state into canonical config.
+
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 14a6ae4..7f1fda8 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -12,6 +12,8 @@ npx agentloom init
That's all you need. Agentloom picks up your existing provider configs, migrates them into a unified `.agents/` directory, and syncs everything back out to all your tools. From here on, manage your agents, commands, rules, skills, and MCP servers in one place and run `agentloom sync` whenever you make changes.
+`agentloom init` is the provider-to-canonical bootstrap step. After that, `agentloom sync` is one-way: it reads from `.agents/` and writes provider-native outputs. If you intentionally want to pull provider state back into canonical `.agents/`, rerun `agentloom init`.
+
## Install
```bash
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a5ce4cd..68cae7a 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "agentloom",
- "version": "0.1.11",
+ "version": "0.1.12",
"description": "Unified agent and MCP sync CLI for multi-provider AI tooling",
"type": "module",
"bin": {
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 0d96565..1f1bab5 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -16,5 +16,6 @@ export async function runInitCommand(
cwd,
target: "all",
skipSync: Boolean(argv["no-sync"]),
+ migrateProviderState: true,
});
}
diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts
index 8f64eec..8b804d6 100644
--- a/packages/cli/src/commands/sync.ts
+++ b/packages/cli/src/commands/sync.ts
@@ -10,6 +10,10 @@ import {
migrateProviderStateToCanonical,
MigrationConflictError,
} from "../core/migration.js";
+import {
+ hasInitializedCanonicalLayout,
+ resolveScopeForSync,
+} from "../core/scope.js";
import type { EntityType, ScopePaths } from "../types.js";
import {
getNonInteractiveMode,
@@ -42,12 +46,21 @@ export async function runScopedSyncCommand(options: {
cwd: string;
target: EntityType | "all";
skipSync?: boolean;
+ migrateProviderState?: boolean;
}): Promise {
const nonInteractive = getNonInteractiveMode(options.argv);
let cleanupDryRunPaths: (() => void) | undefined;
try {
- const paths = await resolvePathsForCommand(options.argv, options.cwd);
+ const shouldMigrateProviderState = Boolean(options.migrateProviderState);
+ const paths = shouldMigrateProviderState
+ ? await resolvePathsForCommand(options.argv, options.cwd)
+ : await resolveScopeForSync({
+ cwd: options.cwd,
+ global: Boolean(options.argv.global),
+ local: Boolean(options.argv.local),
+ interactive: !nonInteractive,
+ });
const explicitProviders = parseProvidersFlag(options.argv.providers);
const providers = await resolveProvidersForSync({
paths,
@@ -55,6 +68,10 @@ export async function runScopedSyncCommand(options: {
nonInteractive,
});
+ if (!shouldMigrateProviderState) {
+ assertInitializedCanonicalStateExists(paths);
+ }
+
const dryRun = Boolean(options.argv["dry-run"]);
const effectivePaths = dryRun
? createDryRunCanonicalPaths(paths)
@@ -63,17 +80,19 @@ export async function runScopedSyncCommand(options: {
initializeCanonicalLayout(effectivePaths.paths, providers);
- const migrationSummary = await migrateProviderStateToCanonical({
- paths: effectivePaths.paths,
- providers,
- target: options.target,
- yes: Boolean(options.argv.yes),
- nonInteractive,
- dryRun,
- materializeCanonical: dryRun,
- });
+ if (shouldMigrateProviderState) {
+ const migrationSummary = await migrateProviderStateToCanonical({
+ paths: effectivePaths.paths,
+ providers,
+ target: options.target,
+ yes: Boolean(options.argv.yes),
+ nonInteractive,
+ dryRun,
+ materializeCanonical: dryRun,
+ });
- console.log(formatMigrationSummary(migrationSummary));
+ console.log(formatMigrationSummary(migrationSummary));
+ }
if (options.skipSync) {
return;
@@ -101,6 +120,21 @@ export async function runScopedSyncCommand(options: {
}
}
+function assertInitializedCanonicalStateExists(paths: ScopePaths): void {
+ if (hasInitializedCanonicalLayout(paths)) {
+ return;
+ }
+
+ const initCommand =
+ paths.scope === "global"
+ ? "agentloom init --global"
+ : "agentloom init --local";
+
+ throw new Error(
+ `No initialized canonical .agents state found at ${paths.agentsRoot}.\nRun \`${initCommand}\` to bootstrap from provider configs first, or use \`agentloom add\` to create canonical content before syncing.`,
+ );
+}
+
function createDryRunCanonicalPaths(paths: ScopePaths): {
paths: ScopePaths;
cleanup: () => void;
diff --git a/packages/cli/src/core/copy.ts b/packages/cli/src/core/copy.ts
index 5480df3..81084ca 100644
--- a/packages/cli/src/core/copy.ts
+++ b/packages/cli/src/core/copy.ts
@@ -17,11 +17,11 @@ Usage:
Aggregate commands:
add Import agents/commands/mcp/rules/skills from a source
- init Bootstrap canonical files, migrate providers, then sync
+ init Bootstrap canonical files from provider configs, then sync
find Search remote + local entities
update [source] Refresh lockfile-managed imports
upgrade Install the latest CLI release
- sync Migrate provider configs then generate provider outputs
+ sync Generate provider outputs from canonical .agents
delete Delete imported entities by source or name(s)
Entity commands:
@@ -147,7 +147,9 @@ Behavior:
}
export function getSyncHelpText(): string {
- return `Migrate provider configs into canonical .agents data, then generate provider-specific outputs.
+ return `Generate provider-specific outputs from canonical .agents data.
+
+Use \`agentloom init\` when you want to bootstrap or re-import provider configs into canonical state.
Usage:
agentloom sync [options]
@@ -162,7 +164,7 @@ Options:
}
export function getInitHelpText(): string {
- return `Bootstrap canonical .agents files, migrate provider configs into canonical state, and sync providers.
+ return `Bootstrap canonical .agents files from existing provider configs, then sync providers.
Usage:
agentloom init [options]
diff --git a/packages/cli/src/core/scope.ts b/packages/cli/src/core/scope.ts
index 6085b78..8eda11c 100644
--- a/packages/cli/src/core/scope.ts
+++ b/packages/cli/src/core/scope.ts
@@ -12,6 +12,46 @@ export interface ScopeResolutionOptions {
interactive?: boolean;
}
+function directoryHasEntries(dirPath: string): boolean {
+ return fs.existsSync(dirPath) && fs.readdirSync(dirPath).length > 0;
+}
+
+export function hasInitializedCanonicalLayout(
+ paths: Pick<
+ ScopePaths,
+ | "agentsRoot"
+ | "agentsDir"
+ | "commandsDir"
+ | "rulesDir"
+ | "skillsDir"
+ | "mcpPath"
+ | "lockPath"
+ | "manifestPath"
+ >,
+): boolean {
+ if (
+ !fs.existsSync(paths.agentsRoot) ||
+ !fs.statSync(paths.agentsRoot).isDirectory()
+ ) {
+ return false;
+ }
+
+ if (
+ fs.existsSync(paths.mcpPath) ||
+ fs.existsSync(paths.lockPath) ||
+ fs.existsSync(paths.manifestPath)
+ ) {
+ return true;
+ }
+
+ return (
+ directoryHasEntries(paths.agentsDir) ||
+ directoryHasEntries(paths.commandsDir) ||
+ directoryHasEntries(paths.rulesDir) ||
+ directoryHasEntries(paths.skillsDir)
+ );
+}
+
export function buildScopePaths(
cwd: string,
scope: Scope,
@@ -59,31 +99,86 @@ export async function resolveScope(
return buildScopePaths(cwd, hasLocalAgents ? "local" : "global");
}
- const globalSettings = readSettings(getGlobalSettingsPath());
- const defaultScope =
- globalSettings.lastScope === "local" ? "local" : "global";
+ const defaultScope = getDefaultScope();
+ const selected = await promptForScopeSelection({
+ hasLocalAgents,
+ defaultScope,
+ });
+
+ return buildScopePaths(cwd, selected);
+}
+
+export async function resolveScopeForSync(
+ options: ScopeResolutionOptions,
+): Promise {
+ const { cwd } = options;
+
+ if (options.global && options.local) {
+ throw new Error("Use either --global or --local, not both.");
+ }
+
+ if (options.global) return buildScopePaths(cwd, "global");
+ if (options.local) return buildScopePaths(cwd, "local");
+
+ const localPaths = buildScopePaths(cwd, "local");
+ const globalPaths = buildScopePaths(cwd, "global");
+ const hasLocalAgents = fs.existsSync(localPaths.agentsRoot);
+ const hasLocalCanonical = hasInitializedCanonicalLayout(localPaths);
+ const hasGlobalCanonical = hasInitializedCanonicalLayout(globalPaths);
+
+ const interactive =
+ options.interactive ?? (process.stdin.isTTY && process.stdout.isTTY);
+ if (!interactive) {
+ return hasLocalAgents ? localPaths : globalPaths;
+ }
+
+ if (hasLocalAgents && hasGlobalCanonical) {
+ const selected = await promptForScopeSelection({
+ hasLocalAgents: true,
+ defaultScope: getDefaultScope(globalPaths.homeDir),
+ });
+ return buildScopePaths(cwd, selected, globalPaths.homeDir);
+ }
+
+ if (hasLocalCanonical) return localPaths;
+ if (hasGlobalCanonical) return globalPaths;
+ if (hasLocalAgents) return localPaths;
+
+ throw new Error(
+ `No initialized canonical .agents state found at ${localPaths.agentsRoot} or ${globalPaths.agentsRoot}.\nRun \`agentloom init --local\` or \`agentloom init --global\` to bootstrap from provider configs first, or use \`agentloom add\` to create canonical content before syncing.`,
+ );
+}
+
+function getDefaultScope(homeDir = os.homedir()): Scope {
+ const globalSettings = readSettings(getGlobalSettingsPath(homeDir));
+ return globalSettings.lastScope === "local" ? "local" : "global";
+}
+async function promptForScopeSelection(options: {
+ hasLocalAgents: boolean;
+ defaultScope: Scope;
+}): Promise {
const selected = await select({
message: "Choose scope for this command",
options: [
{
value: "local",
label: ".agents in this repository",
- hint: hasLocalAgents
- ? defaultScope === "local"
+ hint: options.hasLocalAgents
+ ? options.defaultScope === "local"
? "default"
: undefined
- : defaultScope === "local"
+ : options.defaultScope === "local"
? "default (creates .agents)"
: "creates .agents",
},
{
value: "global",
label: "~/.agents shared config",
- hint: defaultScope === "global" ? "default" : undefined,
+ hint: options.defaultScope === "global" ? "default" : undefined,
},
],
- initialValue: defaultScope,
+ initialValue: options.defaultScope,
});
if (isCancel(selected)) {
@@ -91,5 +186,5 @@ export async function resolveScope(
process.exit(1);
}
- return buildScopePaths(cwd, selected as Scope);
+ return selected as Scope;
}
diff --git a/packages/cli/tests/integration/sync-migration.test.ts b/packages/cli/tests/integration/sync-migration.test.ts
index 852f1da..f4f102b 100644
--- a/packages/cli/tests/integration/sync-migration.test.ts
+++ b/packages/cli/tests/integration/sync-migration.test.ts
@@ -12,15 +12,160 @@ afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
+ vi.restoreAllMocks();
});
-describe("sync migration pre-step", () => {
- it("migrates provider commands into canonical before sync generation", async () => {
+describe("sync canonical output flow", () => {
+ it("rejects settings-only global preference state", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ vi.spyOn(os, "homedir").mockReturnValue(homeRoot);
+
+ writeTextAtomic(
+ path.join(homeRoot, ".agents", "settings.local.json"),
+ JSON.stringify({ version: 1, lastScope: "global" }, null, 2),
+ );
+
+ await expect(
+ runSyncCommand(
+ parseArgs(["sync", "--global", "--providers", "cursor", "--yes"]),
+ workspaceRoot,
+ ),
+ ).rejects.toThrow(
+ `No initialized canonical .agents state found at ${path.join(homeRoot, ".agents")}.`,
+ );
+
+ expect(fs.existsSync(path.join(homeRoot, ".agents", "mcp.json"))).toBe(
+ false,
+ );
+ });
+
+ it("requires canonical .agents before syncing", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ tempDirs.push(workspaceRoot);
+
+ ensureDir(path.join(workspaceRoot, ".cursor", "commands"));
+ writeTextAtomic(
+ path.join(workspaceRoot, ".cursor", "commands", "review.prompt.md"),
+ "# /review\n\nReview this pull request.\n",
+ );
+
+ await expect(
+ runSyncCommand(
+ parseArgs(["sync", "--local", "--providers", "cursor", "--yes"]),
+ workspaceRoot,
+ ),
+ ).rejects.toThrow(
+ `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")}.`,
+ );
+ await expect(
+ runSyncCommand(
+ parseArgs(["sync", "--local", "--providers", "cursor", "--yes"]),
+ workspaceRoot,
+ ),
+ ).rejects.toThrow("agentloom init --local");
+
+ expect(
+ fs.existsSync(
+ path.join(workspaceRoot, ".agents", "commands", "review.md"),
+ ),
+ ).toBe(false);
+ });
+
+ it("rejects empty placeholder .agents directories", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ tempDirs.push(workspaceRoot);
+
+ ensureDir(path.join(workspaceRoot, ".agents"));
+ ensureDir(path.join(workspaceRoot, ".cursor"));
+ writeTextAtomic(
+ path.join(workspaceRoot, ".cursor", "mcp.json"),
+ JSON.stringify(
+ {
+ mcpServers: {
+ browser: {
+ command: "npx",
+ args: ["browser-tools-mcp"],
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+
+ await expect(
+ runSyncCommand(
+ parseArgs(["sync", "--local", "--providers", "cursor", "--yes"]),
+ workspaceRoot,
+ ),
+ ).rejects.toThrow(
+ `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")}.`,
+ );
+
+ const cursorMcp = JSON.parse(
+ fs.readFileSync(path.join(workspaceRoot, ".cursor", "mcp.json"), "utf8"),
+ );
+ expect(cursorMcp.mcpServers).toEqual({
+ browser: {
+ command: "npx",
+ args: ["browser-tools-mcp"],
+ },
+ });
+ });
+
+ it("does not fall back to global sync when the repo already has .agents", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ vi.spyOn(os, "homedir").mockReturnValue(homeRoot);
+
+ ensureDir(path.join(workspaceRoot, ".agents"));
+
+ writeTextAtomic(
+ path.join(homeRoot, ".agents", "mcp.json"),
+ JSON.stringify({ version: 1, mcpServers: {} }, null, 2),
+ );
+ writeTextAtomic(
+ path.join(homeRoot, ".agents", "commands", "write.md"),
+ "# /write\n\nWrite the change.\n",
+ );
+
+ await expect(
+ runSyncCommand(
+ parseArgs(["sync", "--providers", "cursor", "--yes"]),
+ workspaceRoot,
+ ),
+ ).rejects.toThrow(
+ `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")}.`,
+ );
+
+ expect(
+ fs.existsSync(path.join(homeRoot, ".cursor", "commands", "write.md")),
+ ).toBe(false);
+ });
+
+ it("syncs from canonical state without importing provider-only files", async () => {
const workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "agentloom-workspace-"),
);
tempDirs.push(workspaceRoot);
+ ensureDir(path.join(workspaceRoot, ".agents", "commands"));
+ writeTextAtomic(
+ path.join(workspaceRoot, ".agents", "commands", "write.md"),
+ "# /write\n\nWrite the change.\n",
+ );
+
ensureDir(path.join(workspaceRoot, ".cursor", "commands"));
writeTextAtomic(
path.join(workspaceRoot, ".cursor", "commands", "review.prompt.md"),
@@ -34,22 +179,28 @@ describe("sync migration pre-step", () => {
expect(
fs.existsSync(
- path.join(workspaceRoot, ".agents", "commands", "review.md"),
+ path.join(workspaceRoot, ".cursor", "commands", "write.md"),
),
).toBe(true);
expect(
fs.existsSync(
- path.join(workspaceRoot, ".cursor", "commands", "review.md"),
+ path.join(workspaceRoot, ".agents", "commands", "review.md"),
),
- ).toBe(true);
+ ).toBe(false);
});
- it("includes migrated commands in dry-run sync preview", async () => {
+ it("keeps dry-run previews one-way from canonical state", async () => {
const workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "agentloom-workspace-"),
);
tempDirs.push(workspaceRoot);
+ ensureDir(path.join(workspaceRoot, ".agents", "commands"));
+ writeTextAtomic(
+ path.join(workspaceRoot, ".agents", "commands", "write.md"),
+ "# /write\n\nWrite the change.\n",
+ );
+
ensureDir(path.join(workspaceRoot, ".cursor", "commands"));
writeTextAtomic(
path.join(workspaceRoot, ".cursor", "commands", "review.prompt.md"),
@@ -71,17 +222,19 @@ describe("sync migration pre-step", () => {
);
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
- logSpy.mockRestore();
expect(output).toContain("Generated/updated files: 2");
expect(
fs.existsSync(
- path.join(workspaceRoot, ".agents", "commands", "review.md"),
+ path.join(workspaceRoot, ".cursor", "commands", "write.md"),
),
).toBe(false);
+ expect(fs.existsSync(path.join(workspaceRoot, ".cursor", "mcp.json"))).toBe(
+ false,
+ );
expect(
fs.existsSync(
- path.join(workspaceRoot, ".cursor", "commands", "review.md"),
+ path.join(workspaceRoot, ".agents", "commands", "review.md"),
),
).toBe(false);
});
diff --git a/packages/cli/tests/unit/cli-help.test.ts b/packages/cli/tests/unit/cli-help.test.ts
index 5e90528..91dbff4 100644
--- a/packages/cli/tests/unit/cli-help.test.ts
+++ b/packages/cli/tests/unit/cli-help.test.ts
@@ -26,9 +26,21 @@ describe("cli help routing", () => {
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("agentloom init [options]");
+ expect(output).toContain("existing provider configs");
expect(output).toContain("--no-sync");
});
+ it("prints sync help", async () => {
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+
+ await runCli(["sync", "--help"]);
+
+ const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
+ expect(output).toContain("agentloom sync [options]");
+ expect(output).toContain("canonical .agents data");
+ expect(output).toContain("Use `agentloom init`");
+ });
+
it("prints upgrade help", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
diff --git a/packages/cli/tests/unit/copy.test.ts b/packages/cli/tests/unit/copy.test.ts
index ccf3382..2ed8245 100644
--- a/packages/cli/tests/unit/copy.test.ts
+++ b/packages/cli/tests/unit/copy.test.ts
@@ -6,9 +6,11 @@ import {
formatUnknownCommandError,
formatUsageError,
getAddHelpText,
+ getInitHelpText,
getMcpAddHelpText,
getMcpHelpText,
getRootHelpText,
+ getSyncHelpText,
} from "../../src/core/copy.js";
describe("copy helpers", () => {
@@ -18,7 +20,9 @@ describe("copy helpers", () => {
expect(help).toContain("command ");
expect(help).toContain("find ");
expect(help).toContain("init");
+ expect(help).toContain("Bootstrap canonical files from provider configs");
expect(help).toContain("upgrade");
+ expect(help).toContain("Generate provider outputs from canonical .agents");
expect(help).toContain("delete ");
expect(help).toContain("mcp ");
expect(help).toContain("rule ");
@@ -79,4 +83,14 @@ describe("copy helpers", () => {
expect(help).toContain("--selection-mode ");
expect(help).toContain("agentloom add farnoodma/agents --providers");
});
+
+ it("separates init migration from sync generation help", () => {
+ expect(getSyncHelpText()).toContain(
+ "Generate provider-specific outputs from canonical .agents data.",
+ );
+ expect(getSyncHelpText()).toContain("Use `agentloom init`");
+ expect(getInitHelpText()).toContain(
+ "Bootstrap canonical .agents files from existing provider configs",
+ );
+ });
});
diff --git a/packages/cli/tests/unit/init-command.test.ts b/packages/cli/tests/unit/init-command.test.ts
index a25dc13..f9a7c9e 100644
--- a/packages/cli/tests/unit/init-command.test.ts
+++ b/packages/cli/tests/unit/init-command.test.ts
@@ -41,6 +41,7 @@ describe("runInitCommand", () => {
cwd: "/workspace",
target: "all",
skipSync: true,
+ migrateProviderState: true,
});
});
});
diff --git a/packages/cli/tests/unit/scope.test.ts b/packages/cli/tests/unit/scope.test.ts
index e710e5e..3e8ab0c 100644
--- a/packages/cli/tests/unit/scope.test.ts
+++ b/packages/cli/tests/unit/scope.test.ts
@@ -27,8 +27,30 @@ vi.mock("../../src/core/settings.js", () => ({
}));
import { resolveScope } from "../../src/core/scope.js";
+import { resolveScopeForSync } from "../../src/core/scope.js";
const tempDirs: string[] = [];
+let homedirSpy: ReturnType;
+
+function writeInitializedCanonicalMarker(root: string): void {
+ const agentsRoot = path.join(root, ".agents");
+ fs.mkdirSync(agentsRoot, { recursive: true });
+ fs.writeFileSync(
+ path.join(agentsRoot, "mcp.json"),
+ JSON.stringify({ version: 1, mcpServers: {} }, null, 2),
+ "utf8",
+ );
+}
+
+function writeSettingsOnlyMarker(root: string): void {
+ const agentsRoot = path.join(root, ".agents");
+ fs.mkdirSync(agentsRoot, { recursive: true });
+ fs.writeFileSync(
+ path.join(agentsRoot, "settings.local.json"),
+ JSON.stringify({ version: 1, lastScope: "global" }, null, 2),
+ "utf8",
+ );
+}
beforeEach(() => {
promptMocks.cancel.mockReset();
@@ -54,12 +76,15 @@ beforeEach(() => {
],
telemetry: { enabled: true },
} satisfies AgentloomSettings);
+
+ homedirSpy = vi.spyOn(os, "homedir");
});
afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
+ homedirSpy.mockRestore();
});
describe("resolveScope", () => {
@@ -152,3 +177,125 @@ describe("resolveScope", () => {
);
});
});
+
+describe("resolveScopeForSync", () => {
+ it("fails before prompting when no canonical scope exists", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ homedirSpy.mockReturnValue(homeRoot);
+
+ await expect(
+ resolveScopeForSync({
+ cwd: workspaceRoot,
+ interactive: true,
+ }),
+ ).rejects.toThrow(
+ `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")} or ${path.join(homeRoot, ".agents")}.`,
+ );
+
+ expect(promptMocks.select).not.toHaveBeenCalled();
+ });
+
+ it("skips the scope prompt when only one canonical scope exists", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ homedirSpy.mockReturnValue(homeRoot);
+
+ writeInitializedCanonicalMarker(homeRoot);
+
+ const paths = await resolveScopeForSync({
+ cwd: workspaceRoot,
+ interactive: true,
+ });
+
+ expect(paths.scope).toBe("global");
+ expect(promptMocks.select).not.toHaveBeenCalled();
+ });
+
+ it("ignores settings-only scope markers", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ homedirSpy.mockReturnValue(homeRoot);
+
+ writeSettingsOnlyMarker(homeRoot);
+
+ await expect(
+ resolveScopeForSync({
+ cwd: workspaceRoot,
+ interactive: true,
+ }),
+ ).rejects.toThrow(
+ `No initialized canonical .agents state found at ${path.join(workspaceRoot, ".agents")} or ${path.join(homeRoot, ".agents")}.`,
+ );
+ });
+
+ it("keeps placeholder .agents directories on local scope in non-interactive mode", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ homedirSpy.mockReturnValue(homeRoot);
+
+ fs.mkdirSync(path.join(workspaceRoot, ".agents"), { recursive: true });
+ writeInitializedCanonicalMarker(homeRoot);
+
+ const paths = await resolveScopeForSync({
+ cwd: workspaceRoot,
+ interactive: false,
+ });
+
+ expect(paths.scope).toBe("local");
+ });
+
+ it("prompts when a placeholder local .agents exists alongside global canonical state", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ homedirSpy.mockReturnValue(homeRoot);
+
+ fs.mkdirSync(path.join(workspaceRoot, ".agents"), { recursive: true });
+ writeInitializedCanonicalMarker(homeRoot);
+ promptMocks.select.mockResolvedValueOnce("global");
+
+ const paths = await resolveScopeForSync({
+ cwd: workspaceRoot,
+ interactive: true,
+ });
+
+ expect(paths.scope).toBe("global");
+ expect(promptMocks.select).toHaveBeenCalledTimes(1);
+ });
+
+ it("prompts when both canonical scopes exist", async () => {
+ const workspaceRoot = fs.mkdtempSync(
+ path.join(os.tmpdir(), "agentloom-workspace-"),
+ );
+ const homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
+ tempDirs.push(workspaceRoot, homeRoot);
+ homedirSpy.mockReturnValue(homeRoot);
+
+ writeInitializedCanonicalMarker(workspaceRoot);
+ writeInitializedCanonicalMarker(homeRoot);
+ promptMocks.select.mockResolvedValueOnce("local");
+
+ const paths = await resolveScopeForSync({
+ cwd: workspaceRoot,
+ interactive: true,
+ });
+
+ expect(paths.scope).toBe("local");
+ expect(promptMocks.select).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/cli/tests/unit/sync-migration.test.ts b/packages/cli/tests/unit/sync-migration.test.ts
index 224465e..0afbcc8 100644
--- a/packages/cli/tests/unit/sync-migration.test.ts
+++ b/packages/cli/tests/unit/sync-migration.test.ts
@@ -1,9 +1,14 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ParsedArgs } from "minimist";
import type { ScopePaths } from "../../src/types.js";
const commandMocks = vi.hoisted(() => ({
resolvePathsForCommand: vi.fn(),
+ resolveScopeForSync: vi.fn(),
+ hasInitializedCanonicalLayout: vi.fn(),
resolveProvidersForSync: vi.fn(),
initializeCanonicalLayout: vi.fn(),
migrateProviderStateToCanonical: vi.fn(),
@@ -17,6 +22,11 @@ vi.mock("../../src/commands/entity-utils.js", () => ({
getNonInteractiveMode: vi.fn(() => true),
}));
+vi.mock("../../src/core/scope.js", () => ({
+ resolveScopeForSync: commandMocks.resolveScopeForSync,
+ hasInitializedCanonicalLayout: commandMocks.hasInitializedCanonicalLayout,
+}));
+
vi.mock("../../src/core/migration.js", () => ({
initializeCanonicalLayout: commandMocks.initializeCanonicalLayout,
migrateProviderStateToCanonical: commandMocks.migrateProviderStateToCanonical,
@@ -32,7 +42,7 @@ vi.mock("../../src/sync/index.js", () => ({
const { runScopedSyncCommand } = await import("../../src/commands/sync.js");
-function createScopePaths(root = "/tmp/agentloom"): ScopePaths {
+function createScopePaths(root: string): ScopePaths {
return {
scope: "local",
workspaceRoot: root,
@@ -49,10 +59,17 @@ function createScopePaths(root = "/tmp/agentloom"): ScopePaths {
};
}
-const paths = createScopePaths();
+let tempRoot = "";
+let paths: ScopePaths;
beforeEach(() => {
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-test-"));
+ paths = createScopePaths(tempRoot);
+ fs.mkdirSync(paths.agentsRoot, { recursive: true });
+
commandMocks.resolvePathsForCommand.mockReset();
+ commandMocks.resolveScopeForSync.mockReset();
+ commandMocks.hasInitializedCanonicalLayout.mockReset();
commandMocks.resolveProvidersForSync.mockReset();
commandMocks.initializeCanonicalLayout.mockReset();
commandMocks.migrateProviderStateToCanonical.mockReset();
@@ -61,6 +78,8 @@ beforeEach(() => {
commandMocks.formatSyncSummary.mockReset();
commandMocks.resolvePathsForCommand.mockResolvedValue(paths);
+ commandMocks.resolveScopeForSync.mockResolvedValue(paths);
+ commandMocks.hasInitializedCanonicalLayout.mockReturnValue(true);
commandMocks.resolveProvidersForSync.mockResolvedValue(["cursor"]);
commandMocks.migrateProviderStateToCanonical.mockResolvedValue({
providers: ["cursor"],
@@ -82,8 +101,13 @@ beforeEach(() => {
commandMocks.formatSyncSummary.mockReturnValue("sync summary");
});
-describe("sync migration pipeline", () => {
- it("runs migration before sync", async () => {
+afterEach(() => {
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+ vi.restoreAllMocks();
+});
+
+describe("sync command pipeline", () => {
+ it("runs provider sync without migration when canonical config exists", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
await runScopedSyncCommand({
@@ -92,33 +116,50 @@ describe("sync migration pipeline", () => {
target: "all",
});
+ expect(commandMocks.resolveScopeForSync).toHaveBeenCalledWith({
+ cwd: "/workspace",
+ global: false,
+ local: false,
+ interactive: false,
+ });
expect(commandMocks.initializeCanonicalLayout).toHaveBeenCalledWith(paths, [
"cursor",
]);
- expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalledWith({
+ expect(commandMocks.migrateProviderStateToCanonical).not.toHaveBeenCalled();
+ expect(commandMocks.syncFromCanonical).toHaveBeenCalledWith({
paths,
providers: ["cursor"],
- target: "all",
yes: true,
nonInteractive: true,
dryRun: false,
- materializeCanonical: false,
+ target: "all",
});
- expect(commandMocks.syncFromCanonical).toHaveBeenCalled();
- expect(logSpy).toHaveBeenCalledWith("migration summary");
expect(logSpy).toHaveBeenCalledWith("sync summary");
+ expect(logSpy).not.toHaveBeenCalledWith("migration summary");
});
- it("supports skipSync for init --no-sync", async () => {
+ it("supports migration for init --no-sync", async () => {
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+
await runScopedSyncCommand({
argv: { _: ["init"], yes: true } as ParsedArgs,
cwd: "/workspace",
target: "all",
skipSync: true,
+ migrateProviderState: true,
});
- expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalled();
+ expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalledWith({
+ paths,
+ providers: ["cursor"],
+ target: "all",
+ yes: true,
+ nonInteractive: true,
+ dryRun: false,
+ materializeCanonical: false,
+ });
expect(commandMocks.syncFromCanonical).not.toHaveBeenCalled();
+ expect(logSpy).toHaveBeenCalledWith("migration summary");
});
it("uses an ephemeral canonical path for dry-run previews", async () => {
@@ -128,20 +169,53 @@ describe("sync migration pipeline", () => {
target: "all",
});
- expect(commandMocks.migrateProviderStateToCanonical).toHaveBeenCalledWith(
- expect.objectContaining({
- providers: ["cursor"],
- dryRun: true,
- materializeCanonical: true,
- }),
- );
-
- const migrationCall =
- commandMocks.migrateProviderStateToCanonical.mock.calls.at(-1)?.[0];
+ const initializeCall =
+ commandMocks.initializeCanonicalLayout.mock.calls.at(-1)?.[0];
const syncCall = commandMocks.syncFromCanonical.mock.calls.at(-1)?.[0];
- expect(migrationCall?.paths.agentsRoot).not.toBe(paths.agentsRoot);
- expect(syncCall?.paths.agentsRoot).toBe(migrationCall?.paths.agentsRoot);
+ expect(commandMocks.migrateProviderStateToCanonical).not.toHaveBeenCalled();
+ expect(initializeCall?.agentsRoot).not.toBe(paths.agentsRoot);
+ expect(syncCall?.paths.agentsRoot).toBe(initializeCall?.agentsRoot);
expect(syncCall?.dryRun).toBe(true);
});
+
+ it("fails before provider resolution when sync scope cannot be resolved", async () => {
+ commandMocks.resolveScopeForSync.mockRejectedValueOnce(
+ new Error("No initialized canonical .agents state found."),
+ );
+
+ await expect(
+ runScopedSyncCommand({
+ argv: { _: ["sync"] } as ParsedArgs,
+ cwd: "/workspace",
+ target: "all",
+ }),
+ ).rejects.toThrow("No initialized canonical .agents state found.");
+
+ expect(commandMocks.resolveProvidersForSync).not.toHaveBeenCalled();
+ });
+
+ it("fails sync when canonical .agents does not exist yet", async () => {
+ commandMocks.hasInitializedCanonicalLayout.mockReturnValue(false);
+
+ await expect(
+ runScopedSyncCommand({
+ argv: { _: ["sync"], yes: true } as ParsedArgs,
+ cwd: "/workspace",
+ target: "all",
+ }),
+ ).rejects.toThrow(
+ `No initialized canonical .agents state found at ${paths.agentsRoot}.`,
+ );
+ await expect(
+ runScopedSyncCommand({
+ argv: { _: ["sync"], yes: true } as ParsedArgs,
+ cwd: "/workspace",
+ target: "all",
+ }),
+ ).rejects.toThrow("agentloom init --local");
+
+ expect(commandMocks.initializeCanonicalLayout).not.toHaveBeenCalled();
+ expect(commandMocks.syncFromCanonical).not.toHaveBeenCalled();
+ });
});