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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/web/src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ export default function DocsPage() {
provider configs, migrates them into the canonical format, and
syncs everything back out.
</Prose>
<Prose>
After initialization, <InlineCode>agentloom sync</InlineCode> is
one-way: it reads from <InlineCode>.agents/</InlineCode> and
writes provider-native outputs. Rerun{" "}
<InlineCode>agentloom init</InlineCode> only when you want to
re-import provider state into canonical config.
</Prose>

<div className="space-y-3">
<p className="text-xs font-medium uppercase tracking-wide text-ink/60 dark:text-white/60">
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export async function runInitCommand(
cwd,
target: "all",
skipSync: Boolean(argv["no-sync"]),
migrateProviderState: true,
});
}
56 changes: 45 additions & 11 deletions packages/cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,19 +46,32 @@ export async function runScopedSyncCommand(options: {
cwd: string;
target: EntityType | "all";
skipSync?: boolean;
migrateProviderState?: boolean;
}): Promise<void> {
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,
explicitProviders,
nonInteractive,
});

if (!shouldMigrateProviderState) {
assertInitializedCanonicalStateExists(paths);
}

const dryRun = Boolean(options.argv["dry-run"]);
const effectivePaths = dryRun
? createDryRunCanonicalPaths(paths)
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/core/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ Usage:

Aggregate commands:
add <source> 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 <query> 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 <source|name...> Delete imported entities by source or name(s)

Entity commands:
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down
113 changes: 104 additions & 9 deletions packages/cli/src/core/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,37 +99,92 @@ 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<ScopePaths> {
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<Scope> {
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)) {
cancel("Operation cancelled.");
process.exit(1);
}

return buildScopePaths(cwd, selected as Scope);
return selected as Scope;
}
Loading
Loading