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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ npx @sentry/dotagents install

All commands accept `--user` to operate on user scope (`~/.agents/`) instead of the current project.

`install --frozen` is a deprecated compatibility no-op. It warns, performs a normal install, and will be removed in the next major release. Use explicit `ref` values in `agents.toml` to pin sources.

## Source Formats

Skills can come from GitHub, GitLab, any git server, well-known HTTPS skill sources, or local directories:
Expand Down
4 changes: 2 additions & 2 deletions docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ Generated subagent files:
- Codex: `.codex/agents/<name>.toml`, or `~/.codex/agents/<name>.toml` for user scope
- OpenCode: `.opencode/agents/<name>.md`, or `~/.config/opencode/agents/<name>.md` for user scope

Generated files include a dotagents header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent.
Generated files include a dotagents header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. The deprecated `--frozen` option is a warned compatibility no-op and does not change this behavior. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent.

### Trust

Expand Down Expand Up @@ -314,7 +314,7 @@ Create `agents.toml` and `.agents/skills/` directory. Automatically includes the
npx @sentry/dotagents install
```

Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, subagents are loaded from existing installed files without resolving sources, the lockfile is not updated, and existing managed subagent files are not pruned.
Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. The deprecated `--frozen` option prints a warning and performs the same normal install. It is ignored and will be removed in the next major release; use explicit `ref` values to pin sources.

### add

Expand Down
8 changes: 4 additions & 4 deletions docs/src/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ dotagents install
Install and refresh skill and subagent dependencies from `agents.toml`. Resolves
sources, copies skills, writes the lockfile, creates symlinks, and generates MCP,
hook, and subagent configs. There is no separate update command. With
`--frozen`, declared skills and subagents must already exist in `agents.lock`,
subagents are loaded from existing installed files without resolving sources,
the lockfile is not updated, and existing managed subagent files are not pruned.
`--frozen`, dotagents prints a compatibility warning and performs the same normal
install. The flag is ignored and will be removed in the next major release; use
explicit `ref` values in `agents.toml` to pin sources.

Example:

Expand Down Expand Up @@ -405,7 +405,7 @@ Generated files:
- Codex: `.codex/agents/<name>.toml`
- OpenCode: `.opencode/agents/<name>.md`

Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent.
Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. The deprecated `--frozen` option does not change this behavior.

## Scopes

Expand Down
187 changes: 20 additions & 167 deletions packages/dotagents/src/cli/commands/install.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access, chmod } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runInstall as runInstallCommand, InstallError, type InstallOptions, type InstallResult } from "./install.js";
import install, { runInstall as runInstallCommand, InstallError, type InstallOptions, type InstallResult } from "./install.js";
import { runSync } from "./sync.js";
import { exec } from "@sentry/dotagents-lib";
import { loadLockfile } from "../../lockfile/loader.js";
Expand Down Expand Up @@ -182,32 +182,31 @@ describe("runInstall", () => {
expect(await readFile(mcpPath, "utf-8")).toBe(content);
});

it("fails with --frozen when no lockfile exists", async () => {
it("accepts --frozen as a warned normal install", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`,
);
await ensureGitRepo();

const scope = resolveScope("project", projectRoot);
await expect(
runInstall({ scope, frozen: true }),
).rejects.toThrow(InstallError);
});
const previousCwd = process.cwd();
const log = vi.spyOn(console, "log").mockImplementation(() => {});
let output = "";
try {
process.chdir(projectRoot);
await install(["--frozen"]);
} finally {
output = log.mock.calls.flat().join("\n");
process.chdir(previousCwd);
log.mockRestore();
}

it("frozen mode passes when lockfile matches", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`,
expect(output).toContain(
"--frozen is ignored and will be removed in the next major release",
);

const scope = resolveScope("project", projectRoot);

// First install to create lockfile
await runInstall({ scope });

// Second install with --frozen
const result = await runInstall({ scope, frozen: true });
expect(result.installed).toContain("pdf");
expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true);
const lockfile = await loadLockfile(join(projectRoot, "agents.lock"));
expect(lockfile?.skills["pdf"]).toBeDefined();
});

it("creates agent-specific symlinks (cursor shares .claude)", async () => {
Expand Down Expand Up @@ -374,57 +373,6 @@ path = "code-reviewer.md"
expect(await readFile(installedPath, "utf-8")).toBe("hand-written subagent\n");
});

it("frozen mode fails when a subagent is missing from the lockfile", async () => {
const scope = resolveScope("project", projectRoot);
await writeLockfile(join(projectRoot, "agents.lock"), {
version: 1,
skills: {},
subagents: {},
});

const sourceDir = join(projectRoot, "agents");
await mkdir(sourceDir, { recursive: true });
await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer"));

await writeFile(
join(projectRoot, "agents.toml"),
`version = 1
[[subagents]]
name = "code-reviewer"
source = "path:agents"
path = "code-reviewer.md"
`,
);

await expect(runInstall({ scope, frozen: true })).rejects.toThrow(
'--frozen: subagent "code-reviewer" is in agents.toml but missing from agents.lock.',
);
});

it("frozen mode passes when subagent lockfile entries match", async () => {
const sourceDir = join(projectRoot, "agents");
await mkdir(sourceDir, { recursive: true });
await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer"));

await writeFile(
join(projectRoot, "agents.toml"),
`version = 1
[[subagents]]
name = "code-reviewer"
source = "path:agents"
path = "code-reviewer.md"
`,
);

const scope = resolveScope("project", projectRoot);
await runInstall({ scope });

await rm(sourceDir, { recursive: true });

const result = await runInstall({ scope, frozen: true });
expect(result.subagentWarnings).toEqual([]);
});

it("clears removed skills from the lockfile when installing subagents", async () => {
const scope = resolveScope("project", projectRoot);
const skillDir = join(projectRoot, ".agents", "skills", "pdf");
Expand Down Expand Up @@ -966,22 +914,6 @@ path = "reviewer.md"
expect(lockfile!.skills["review"]).toBeDefined();
});

it("frozen mode works with wildcard lockfile", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1\n\n[[skills]]\nname = "*"\nsource = "git:${repoDir}"\n`,
);

const scope = resolveScope("project", projectRoot);
// First install to create lockfile
await runInstall({ scope });

// Second install with --frozen
const result = await runInstall({ scope, frozen: true });
expect(result.installed).toContain("pdf");
expect(result.installed).toContain("review");
});

it("wildcard-expanded skills are gitignored", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
Expand Down Expand Up @@ -1117,68 +1049,6 @@ path = "reviewer.md"
expect(existsSync(join(projectRoot, ".agents", "skills", "review"))).toBe(false);
});

it("does not prune in frozen mode", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1\n\n[[skills]]\nname = "*"\nsource = "git:${repoDir}"\n`,
);

const scope = resolveScope("project", projectRoot);

// First install — gets both "pdf" and "review"
await runInstall({ scope });

// Add "review" to the exclude list
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1\n\n[[skills]]\nname = "*"\nsource = "git:${repoDir}"\nexclude = ["review"]\n`,
);

// Frozen install should NOT prune (would create disk/lockfile inconsistency)
const result = await runInstall({ scope, frozen: true });
expect(result.pruned).toHaveLength(0);
// Directory should still exist
expect(existsSync(join(projectRoot, ".agents", "skills", "review", "SKILL.md"))).toBe(true);
});

it("does not prune installed subagents in frozen mode", async () => {
const sourceDir = join(projectRoot, "agents");
await mkdir(sourceDir, { recursive: true });
await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer"));

await writeFile(
join(projectRoot, "agents.toml"),
`version = 1
agents = ["claude"]

[[subagents]]
name = "code-reviewer"
source = "path:agents"
path = "code-reviewer.md"
`,
);

const scope = resolveScope("project", projectRoot);
await runInstall({ scope });

await writeFile(
join(projectRoot, "agents.toml"),
`version = 1
agents = ["claude"]
`,
);

const result = await runInstall({ scope, frozen: true });

expect(result.pruned).toEqual([]);
expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(true);
expect(existsSync(join(projectRoot, ".claude", "agents", "code-reviewer.md"))).toBe(true);
const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8");
expect(gitignore).toContain("/agents/code-reviewer.md");
const lockfile = await loadLockfile(join(projectRoot, "agents.lock"));
expect(lockfile!.subagents["code-reviewer"]).toBeDefined();
});

it("wildcard with all skills excluded installs nothing from that source", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
Expand All @@ -1190,23 +1060,6 @@ agents = ["claude"]
expect(result.installed).toHaveLength(0);
});

it("frozen mode fails when skill missing from lockfile", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`,
);
const scope = resolveScope("project", projectRoot);
await runInstall({ scope });

// Add review to config but lockfile still has only pdf
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n\n[[skills]]\nname = "review"\nsource = "git:${repoDir}"\n`,
);

await expect(runInstall({ scope, frozen: true })).rejects.toThrow(InstallError);
});

it("does not auto-create root .gitignore", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
Expand Down
24 changes: 10 additions & 14 deletions packages/dotagents/src/cli/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export { InstallError };
// writes have succeeded, then writes runtime projections and CLI output.
export interface InstallOptions {
scope: ScopeRoot;
frozen?: boolean;
}

export interface InstallResult {
Expand All @@ -40,19 +39,19 @@ export interface InstallResult {
}

export async function runInstall(opts: InstallOptions): Promise<InstallResult> {
const { scope, frozen } = opts;
const { scope } = opts;
const config = await loadConfig(scope.configPath);
const lockfile = await loadLockfile(scope.lockPath);
const skills = await installSkills(config, lockfile, scope, frozen);
const subagents = await installSubagents(config, lockfile, scope, frozen);
const skills = await installSkills(config, lockfile, scope);
const subagents = await installSubagents(config, scope);
const newLock: Lockfile = {
version: 1,
skills: skills.lockEntries,
subagents: subagents.lockEntries,
};

await writeCanonicalSubagents(config, scope, subagents.subagents, frozen);
const writeLock = !frozen && (
await writeCanonicalSubagents(config, scope, subagents.subagents);
const writeLock = (
!!lockfile ||
config.skills.length > 0 ||
config.subagents.length > 0
Expand All @@ -61,14 +60,14 @@ export async function runInstall(opts: InstallOptions): Promise<InstallResult> {
await writeLockfile(scope.lockPath, newLock);
}

await writeInstallGitignore(config, lockfile, scope, {
await writeInstallGitignore(config, scope, {
installedSkillNames: skills.installed,
subagents: subagents.subagents,
}, frozen);
});
await writeSkillSymlinks(config, scope);
const mcpWarnings = await writeMcpRuntime(config, scope);
const hookWarnings = await writeHookRuntime(config, scope);
const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents, frozen);
const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents);

return {
installed: skills.installed,
Expand All @@ -93,13 +92,10 @@ export default async function install(args: string[], flags?: { user?: boolean }
const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
await ensureUserScopeBootstrapped(scope);
if (values["frozen"]) {
console.log(chalk.yellow("Warning: --frozen is deprecated and will be removed in a future release. Pinning is now managed via agents.toml refs."));
console.log(chalk.yellow("Warning: --frozen is ignored and will be removed in the next major release. Install now follows normal agents.toml resolution; use explicit refs to pin sources."));
}

const result = await runInstall({
scope,
frozen: values["frozen"],
});
const result = await runInstall({ scope });

if (result.installed.length > 0) {
console.log(
Expand Down
2 changes: 0 additions & 2 deletions packages/dotagents/src/cli/commands/install/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,12 @@ export async function writeSubagentRuntime(
config: AgentsConfig,
scope: ScopeRoot,
subagents: SubagentDeclaration[],
frozen?: boolean,
): Promise<{ agent: string; name: string; message: string }[]> {
const resolver = scope.scope === "user"
? userSubagentResolver()
: projectSubagentResolver(scope.root);
const result = await reconcileSubagentConfigs(config.agents, subagents, resolver, {
mode: "apply",
prune: !frozen,
});
return result.warnings;
}
Loading
Loading