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
6 changes: 3 additions & 3 deletions src/cli/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { discoverAllSkills } from "../../skills/discovery.js";
import { ensureCached } from "../../sources/cache.js";
import { validateTrustedSource, TrustError } from "../../trust/index.js";
import { runInstall } from "./install.js";
import { resolveScope } from "../../scope.js";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";

export class AddError extends Error {
Expand Down Expand Up @@ -144,7 +144,7 @@ export default async function add(args: string[], flags?: { user?: boolean }): P
}

try {
const scope = resolveScope(flags?.user ? "user" : "project", resolve("."));
const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
const name = await runAdd({
scope,
specifier,
Expand All @@ -153,7 +153,7 @@ export default async function add(args: string[], flags?: { user?: boolean }): P
});
console.log(chalk.green(`Added skill: ${name}`));
} catch (err) {
if (err instanceof AddError || err instanceof TrustError) {
if (err instanceof ScopeError || err instanceof AddError || err instanceof TrustError) {
console.error(chalk.red(err.message));
process.exitCode = 1;
return;
Expand Down
12 changes: 10 additions & 2 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ensureSkillsSymlink } from "../../symlinks/manager.js";
import { loadConfig } from "../../config/loader.js";
import { getAgent, allAgentIds, allAgents } from "../../agents/registry.js";
import { parseArgs } from "node:util";
import { resolveScope } from "../../scope.js";
import { resolveScope, isInsideGitRepo } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";
import type { TrustConfig } from "../../config/schema.js";

Expand Down Expand Up @@ -226,7 +226,15 @@ export default async function init(args: string[], flags?: { user?: boolean }):
strict: true,
});

const scope = resolveScope(flags?.user ? "user" : "project", resolve("."));
let scope: ScopeRoot;
if (flags?.user) {
scope = resolveScope("user");
} else if (!isInsideGitRepo(resolve("."))) {
console.error("No project found, using user scope (~/.agents/)");
scope = resolveScope("user");
} else {
scope = resolveScope("project", resolve("."));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Init scope resolution skips agents.toml existence check

Medium Severity

The init command's scope resolution logic doesn't check for agents.toml existence before falling back based on git repo detection, unlike resolveDefaultScope used by every other command. In a non-git directory that already has agents.toml (e.g., a Mercurial project, or a copied project), other commands correctly resolve to project scope, but dotagents init --force would silently target user scope instead, potentially overwriting ~/.agents/agents.toml while leaving the project config untouched.

Additional Locations (1)

Fix in Cursor Fix in Web


try {
// Interactive mode: TTY with no --agents flag
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { getAgent } from "../../agents/registry.js";
import { writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js";
import { writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js";
import { userMcpResolver } from "../../agents/paths.js";
import { resolveScope } from "../../scope.js";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";

/** A skill whose source points to its own install location (adopted orphan). */
Expand Down Expand Up @@ -204,7 +204,7 @@ export default async function install(args: string[], flags?: { user?: boolean }
});

try {
const scope = resolveScope(flags?.user ? "user" : "project", resolve("."));
const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
const result = await runInstall({
scope,
frozen: values["frozen"],
Expand All @@ -220,7 +220,7 @@ export default async function install(args: string[], flags?: { user?: boolean }
console.log(chalk.yellow(` warn: ${w.message}`));
}
} catch (err) {
if (err instanceof InstallError || err instanceof TrustError) {
if (err instanceof ScopeError || err instanceof InstallError || err instanceof TrustError) {
console.error(chalk.red(err.message));
process.exitCode = 1;
return;
Expand Down
14 changes: 12 additions & 2 deletions src/cli/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { loadLockfile } from "../../lockfile/loader.js";
import { isGitLocked } from "../../lockfile/schema.js";
import { hashDirectory } from "../../utils/hash.js";
import { existsSync } from "node:fs";
import { resolveScope } from "../../scope.js";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";

export interface SkillStatus {
Expand Down Expand Up @@ -85,7 +85,17 @@ export default async function list(args: string[], flags?: { user?: boolean }):
strict: true,
});

const scope = resolveScope(flags?.user ? "user" : "project", resolve("."));
let scope: ScopeRoot;
try {
scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
} catch (err) {
if (err instanceof ScopeError) {
console.error(chalk.red(err.message));
process.exitCode = 1;
return;
}
throw err;
}
const results = await runList({
scope,
json: values["json"],
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { removeSkillFromConfig } from "../../config/writer.js";
import { loadLockfile } from "../../lockfile/loader.js";
import { writeLockfile } from "../../lockfile/writer.js";
import { updateAgentsGitignore } from "../../gitignore/writer.js";
import { resolveScope } from "../../scope.js";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";

export class RemoveError extends Error {
Expand Down Expand Up @@ -69,11 +69,11 @@ export default async function remove(args: string[], flags?: { user?: boolean })
}

try {
const scope = resolveScope(flags?.user ? "user" : "project", resolve("."));
const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
await runRemove({ scope, skillName });
console.log(chalk.green(`Removed skill: ${skillName}`));
} catch (err) {
if (err instanceof RemoveError) {
if (err instanceof ScopeError || err instanceof RemoveError) {
console.error(chalk.red(err.message));
process.exitCode = 1;
return;
Expand Down
14 changes: 12 additions & 2 deletions src/cli/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getAgent } from "../../agents/registry.js";
import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js";
import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js";
import { userMcpResolver } from "../../agents/paths.js";
import { resolveScope } from "../../scope.js";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";

/** A skill whose source points to its own install location (adopted orphan). */
Expand Down Expand Up @@ -211,7 +211,17 @@ export async function runSync(opts: SyncOptions): Promise<SyncResult> {
}

export default async function sync(_args: string[], flags?: { user?: boolean }): Promise<void> {
const scope = resolveScope(flags?.user ? "user" : "project", resolve("."));
let scope: ScopeRoot;
try {
scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
} catch (err) {
if (err instanceof ScopeError) {
console.error(chalk.red(err.message));
process.exitCode = 1;
return;
}
throw err;
}
const result = await runSync({ scope });

if (result.adopted.length > 0) {
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { copyDir } from "../../utils/fs.js";
import { writeLockfile } from "../../lockfile/writer.js";
import { updateAgentsGitignore } from "../../gitignore/writer.js";
import type { Lockfile, LockedSkill } from "../../lockfile/schema.js";
import { resolveScope } from "../../scope.js";
import { resolveScope, resolveDefaultScope, ScopeError } from "../../scope.js";
import type { ScopeRoot } from "../../scope.js";

/** A skill whose source points to its own install location (adopted orphan). */
Expand Down Expand Up @@ -128,7 +128,7 @@ export default async function update(args: string[], flags?: { user?: boolean })
});

try {
const scope = resolveScope(flags?.user ? "user" : "project", resolve("."));
const scope = flags?.user ? resolveScope("user") : resolveDefaultScope(resolve("."));
const updated = await runUpdate({
scope,
skillName: positionals[0],
Expand All @@ -146,7 +146,7 @@ export default async function update(args: string[], flags?: { user?: boolean })
}
console.log(chalk.green(`Updated ${updated.length} skill(s).`));
} catch (err) {
if (err instanceof UpdateError || err instanceof TrustError) {
if (err instanceof ScopeError || err instanceof UpdateError || err instanceof TrustError) {
console.error(chalk.red(err.message));
process.exitCode = 1;
return;
Expand Down
68 changes: 66 additions & 2 deletions src/scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { join } from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { homedir } from "node:os";
import { resolveScope } from "./scope.js";
import { resolveScope, isInsideGitRepo, resolveDefaultScope, ScopeError } from "./scope.js";

describe("resolveScope", () => {
afterEach(() => {
Expand Down Expand Up @@ -48,3 +50,65 @@ describe("resolveScope", () => {
expect(s.root).toBe(process.cwd());
});
});

describe("isInsideGitRepo", () => {
let tempDir: string;

afterEach(() => {
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
});

it("returns true when .git exists in dir", () => {
tempDir = mkdtempSync(join(tmpdir(), "scope-test-"));
mkdirSync(join(tempDir, ".git"));
expect(isInsideGitRepo(tempDir)).toBe(true);
});

it("returns true when .git exists in a parent", () => {
tempDir = mkdtempSync(join(tmpdir(), "scope-test-"));
mkdirSync(join(tempDir, ".git"));
const child = join(tempDir, "sub", "deep");
mkdirSync(child, { recursive: true });
expect(isInsideGitRepo(child)).toBe(true);
});

it("returns false when no .git in any parent", () => {
tempDir = mkdtempSync(join(tmpdir(), "scope-test-"));
// No .git directory created
expect(isInsideGitRepo(tempDir)).toBe(false);
});
});

describe("resolveDefaultScope", () => {
let tempDir: string;

afterEach(() => {
delete process.env["DOTAGENTS_HOME"];
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
});

it("returns project scope when agents.toml exists", () => {
tempDir = mkdtempSync(join(tmpdir(), "scope-test-"));
writeFileSync(join(tempDir, "agents.toml"), "");
const s = resolveDefaultScope(tempDir);
expect(s.scope).toBe("project");
expect(s.root).toBe(tempDir);
});

it("falls back to user scope when not in a git repo", () => {
tempDir = mkdtempSync(join(tmpdir(), "scope-test-"));
process.env["DOTAGENTS_HOME"] = join(tempDir, "user-home");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const s = resolveDefaultScope(tempDir);
expect(s.scope).toBe("user");
expect(spy).toHaveBeenCalledWith(expect.stringContaining("user scope"));
spy.mockRestore();
});

it("throws ScopeError when in a git repo but no agents.toml", () => {
tempDir = mkdtempSync(join(tmpdir(), "scope-test-"));
mkdirSync(join(tempDir, ".git"));
expect(() => resolveDefaultScope(tempDir)).toThrow(ScopeError);
expect(() => resolveDefaultScope(tempDir)).toThrow(/dotagents init/);
});
});
46 changes: 45 additions & 1 deletion src/scope.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from "node:path";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { homedir } from "node:os";

export type Scope = "project" | "user";
Expand Down Expand Up @@ -47,3 +48,46 @@ export function resolveScope(scope: Scope, projectRoot?: string): ScopeRoot {
skillsDir: join(agentsDir, "skills"),
};
}

/** Walk up from `dir` looking for a `.git` directory. */
export function isInsideGitRepo(dir: string): boolean {
let current = resolve(dir);
const root = dirname(current) === current ? current : undefined;

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
if (existsSync(join(current, ".git"))) return true;
const parent = dirname(current);
if (parent === current || parent === root) return false;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead root variable in isInsideGitRepo loop termination

Low Severity

The root variable and the parent === root check in isInsideGitRepo are dead code. When root is defined (i.e., dir is the filesystem root itself), parent === current is already true on the very first iteration, so parent === root is never the deciding condition. When root is undefined (the normal case), parent === root is always false. The loop terminates correctly via parent === current alone in all cases, making the root variable misleading — it suggests a special case is being handled when it isn't.

Fix in Cursor Fix in Web

current = parent;
}
}

export class ScopeError extends Error {
constructor(message: string) {
super(message);
this.name = "ScopeError";
}
}

/**
* Resolve scope when the user did NOT pass `--user`.
*
* - If `agents.toml` exists at `projectRoot` → project scope.
* - If we're not inside a git repo → user scope (with a notice).
* - Otherwise (in a repo, no agents.toml) → throw with a helpful message.
*/
export function resolveDefaultScope(projectRoot: string): ScopeRoot {
if (existsSync(join(projectRoot, "agents.toml"))) {
return resolveScope("project", projectRoot);
}

if (!isInsideGitRepo(projectRoot)) {
console.error("No project found, using user scope (~/.agents/)");
return resolveScope("user");
}

throw new ScopeError(
"No agents.toml found. Run 'dotagents init' to set up this project, or use --user for user scope.",
);
}