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
1 change: 1 addition & 0 deletions .dex/tasks.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
{"id":"k746m3ok","parent_id":"bpb4f298","name":"Bun compile and CI","description":"Set up Bun binary compilation and GitHub Actions CI.\n\nBuild:\n- bun build src/cli/index.ts --compile --outfile dist/dotagents\n- Per-platform binaries: darwin-arm64, darwin-x64, linux-x64, linux-arm64\n\nGitHub Actions:\n- CI workflow: lint, typecheck, test on push/PR\n- Release workflow: build per-platform binaries, attach to GitHub Release\n- Install script (optional): curl-pipe-sh that detects platform and downloads binary\n\nnpm:\n- Package published with bin field pointing to dist/cli/index.js (JS fallback)\n- npx dotagents works for Node.js users\n\nAcceptance criteria:\n- bun build produces a working standalone binary\n- Binary works without Bun/Node.js installed\n- CI runs lint + typecheck + test on every push","priority":1,"completed":false,"result":null,"metadata":null,"created_at":"2026-02-07T18:00:02.720Z","updated_at":"2026-02-07T18:00:14.303Z","started_at":null,"completed_at":null,"blockedBy":["srnjavhs"],"blocks":[],"children":[]}
{"id":"qze9byy6","parent_id":"bpb4f298","name":"Skill resolution and git source","description":"Implement skill specifier parsing and git-based skill fetching.\n\nFiles to create:\n- src/skills/resolver.ts -- Parse source specifiers into ResolvedSource objects\n- src/sources/git.ts -- Clone/fetch git repos via git CLI\n- src/sources/local.ts -- Resolve path: sources relative to project root\n- src/sources/cache.ts -- Global cache at ~/.local/dotagents/cache/\n- src/utils/exec.ts -- Child process execution helper for non-interactive git commands\n- src/skills/loader.ts -- Parse SKILL.md files (YAML frontmatter + markdown body)\n\nResolution rules:\n- github:owner/repo/path -> https://github.com/owner/repo.git, subdirectory path/\n- git:url + path field -> full URL clone, use path for subdirectory\n- path:relative -> resolve relative to project root\n- @scope/name -> try github:scope/skills/name, fallback to github:scope/name\n\nCache structure: ~/.local/dotagents/cache/github.com/owner/repo/ (bare git repos)\n\nAcceptance criteria:\n- All 4 source types resolve correctly\n- Git clone works with --depth=1 for fresh clones\n- Cache is checked before network fetch\n- SKILL.md is validated (name + description required in frontmatter)","priority":1,"completed":false,"result":null,"metadata":null,"created_at":"2026-02-07T17:59:17.801Z","updated_at":"2026-02-07T18:00:08.470Z","started_at":null,"completed_at":null,"blockedBy":["iashsswc"],"blocks":["g6gl9kkc"],"children":[]}
{"id":"srnjavhs","parent_id":"bpb4f298","name":"Update, sync, and list commands","description":"Implement the remaining CLI commands: update, sync, list.\n\nFiles: src/cli/commands/update.ts, src/cli/commands/sync.ts, src/cli/commands/list.ts\n\ndotagents update [<name>]:\n- Branch refs: fetch latest commit\n- Tag refs: check for newer semver tags\n- Commit SHA refs: skip (immutable)\n- Update lockfile, re-install, print changelog\n\ndotagents sync:\n- Regenerate .agents/.gitignore\n- Verify/repair symlinks\n- Warn on managed skills accidentally in git\n- Warn on orphaned skills (installed but not in agents.toml)\n- Verify integrity hashes\n\ndotagents list [--json]:\n- Show each skill: name, source, installed commit (short SHA), status\n- Status: up to date, outdated, modified (integrity mismatch), custom (unmanaged)\n- --json for machine consumption\n\nAcceptance criteria:\n- update fetches new commits for branch-pinned skills\n- sync detects and reports drift\n- list shows accurate status for all skills","priority":1,"completed":false,"result":null,"metadata":null,"created_at":"2026-02-07T17:59:57.259Z","updated_at":"2026-02-07T18:00:13.465Z","started_at":null,"completed_at":null,"blockedBy":["ib51q4f3"],"blocks":["k746m3ok"],"children":[]}
{"id":"z7bt1ifx","parent_id":null,"name":"Install anti-slop Oxlint rules","description":"Vendor Dillon Mulroy's dmmulroy/anti-slop generic Oxlint plugin into tools/oxlint/anti-slop, upgrade and align oxlint plus @oxlint/plugins to the current matching versions, register every generic rule at error severity, and merge repository-local agent/tooling ignores into the existing Oxlint configuration. Bring all owned TypeScript/JavaScript source into compliance without weakening rules, suppressing findings, or changing intended behavior. Effect rules are out of scope because no package manifest directly depends on Effect. Done when the normal pnpm lint/typecheck/test checks pass, the docs workspace remains valid as applicable, the vendored plugin is excluded from self-linting, and no anti-slop findings remain.","priority":1,"completed":true,"result":"Vendored dmmulroy/anti-slop generic Oxlint plugin sources at upstream commit 6d538555cb151d4121ed51a27db81890eacf8ae9 with MIT provenance, pinned oxlint and @oxlint/plugins 1.79.0, registered all 15 generic rules at error severity, expanded root lint to all owned files, and resolved all 274 initial findings without rule suppressions or unsafe casts. Replaced module mocks with typed dependency seams and boundary parsing, including deterministic CLI scope resolution after clean-container QA exposed a checkout-dependent test. Exported the new dotagents-lib public signature types. Verification: local frozen install and pnpm check pass; 0 Oxlint findings across 154 files, 6 AST policy tests pass, TypeScript declarations pass, and 1,138 Vitest tests pass. Docker dotagents-qa:local sha256:19e019f ran as non-root Node v24.19.0/pnpm 10.28.2 with a read-only host checkout and isolated state; frozen install, pnpm check, pnpm qa:example install/sync repair, and pnpm qa:plugins Claude validation/marketplace install plus Codex discovery all passed. Evidence retained at /var/folders/ml/rhcrtjss3glfydv5v9rryhlm0000gn/T/dotagents-qa.tpSADp.","metadata":null,"created_at":"2026-08-22T17:14:58.639Z","updated_at":"2026-08-22T17:47:17.511Z","started_at":"2026-08-22T17:47:17.509Z","completed_at":"2026-08-22T17:47:17.511Z","blockedBy":[],"blocks":[],"children":[]}
48 changes: 47 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"ignorePatterns": [
".agent/**",
".agents/**",
".claude/**",
".claude-plugin/**",
".codex/**",
".continue/**",
".cursor/**",
".cursor-plugin/**",
".dex/**",
".gemini/**",
".grok/**",
".opencode/**",
".pi/**",
".roo/**",
".windsurf/**",
"docs/src/env.d.ts",
"tools/oxlint/anti-slop/**"
],
"jsPlugins": [
{
"name": "anti-slop",
"specifier": "./tools/oxlint/anti-slop/index.ts"
}
],
"categories": {
"suspicious": "error",
"perf": "error"
Expand All @@ -21,6 +46,27 @@

"no-duplicate-imports": "error",
"prefer-template": "error",
"curly": "error"
"curly": "error",

"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-conditional-empty-object-spread": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-module-mocking": "error",
"anti-slop/no-object-parameters": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": [
"error",
{
"allowInTypeGuards": true
}
],
"anti-slop/no-shape-in-symbol-names": "error",
"anti-slop/no-unknown-parameters": "error",
"anti-slop/no-unknown-returns": "error",
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/require-safety-comment-for-type-assertion": "error"
}
}
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint && pnpm lint:ast-grep",
"lint": "node --import tsx ./node_modules/oxlint/bin/oxlint . --deny-warnings && pnpm lint:ast-grep",
"lint:ast-grep": "ast-grep scan && ast-grep test --skip-snapshot-tests",
"typecheck": "pnpm -r typecheck",
"check": "pnpm lint && pnpm typecheck && pnpm test",
Expand All @@ -21,7 +21,7 @@
},
"lint-staged": {
"packages/*/src/**/*.ts": [
"oxlint --deny-warnings"
"node --import tsx ./node_modules/oxlint/bin/oxlint --deny-warnings"
]
},
"packageManager": "pnpm@10.28.2",
Expand All @@ -31,9 +31,10 @@
},
"devDependencies": {
"@ast-grep/cli": "^0.44.1",
"@oxlint/plugins": "1.79.0",
"@types/node": "^25.2.1",
"lint-staged": "^16.2.7",
"oxlint": "^1.43.0",
"oxlint": "1.79.0",
"simple-git-hooks": "^2.13.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/dotagents-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"build": "tsc -b",
"test": "vitest run",
"test:watch": "vitest",
"lint": "oxlint src --deny-warnings",
"lint": "node --import tsx ../../node_modules/oxlint/bin/oxlint src --deny-warnings",
"typecheck": "tsc -b --emitDeclarationOnly"
},
"files": [
Expand Down
4 changes: 3 additions & 1 deletion packages/dotagents-lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ export {
} from "./skills/resolver.js";
export type { ParseSourceErrorKind } from "./skills/resolver.js";
export type {
ParsedSource,
ResolvedSkill,
ResolvedGitSkill,
ResolvedLocalSkill,
ResolvedWellKnownSkill,
NamedResolvedSkill,
WildcardDependencyInput,
ResolveOpts,
ResolverServices,
} from "./skills/resolver.js";

// Sources / cache
Expand All @@ -56,7 +58,7 @@ export {
isGitRepo,
GitError,
} from "./sources/git.js";
export type { GitErrorDetails } from "./sources/git.js";
export type { GitErrorDetails, GitExecutor } from "./sources/git.js";
export {
ensureCached,
CacheError,
Expand Down
22 changes: 16 additions & 6 deletions packages/dotagents-lib/src/skills/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ export async function loadSkillMd(
fileDescription: "SKILL.md",
});

if (typeof meta["name"] !== "string" || !meta["name"]) {
if (!isNonEmptyString(meta["name"])) {
throw new SkillLoadError(`Missing 'name' in SKILL.md frontmatter: ${filePath}`);
}
if (typeof meta["description"] !== "string" || !meta["description"]) {
if (!isNonEmptyString(meta["description"])) {
throw new SkillLoadError(`Missing 'description' in SKILL.md frontmatter: ${filePath}`);
}

Expand All @@ -59,6 +59,8 @@ export async function loadSkillMd(
meta["allowedTools"] = allowedTools;
}

// SAFETY: name and description were validated above; the remaining fields
// retain the SerializedObject contract established by the frontmatter parser.
return meta as SkillMeta;
}

Expand Down Expand Up @@ -152,7 +154,7 @@ function extractFrontmatter(content: string): { yaml: string; end: number } | nu
* a way the lib can't interpret (e.g. an object).
*/
function parseAllowedTools(
raw: unknown,
raw: SerializedObject[string],
onWarning?: (message: string) => void,
): ToolName[] | undefined {
if (raw === undefined || raw === null) {return undefined;}
Expand All @@ -162,20 +164,20 @@ function parseAllowedTools(
// likely a typo and we treat it as absent.
let tokens: string[];
let isArrayForm = false;
if (typeof raw === "string") {
if (isString(raw)) {
tokens = raw.split(/\s+/).filter(Boolean);
} else if (Array.isArray(raw)) {
isArrayForm = true;
tokens = [];
for (const entry of raw) {
if (typeof entry === "string" && entry.trim().length > 0) {
if (isNonEmptyString(entry)) {
tokens.push(entry.trim());
} else {
onWarning?.(`allowed-tools: skipping non-string entry ${JSON.stringify(entry)}`);
}
}
} else {
onWarning?.(`allowed-tools must be a string or array, got ${typeof raw}; ignoring`);
onWarning?.(`allowed-tools must be a string or array; ignoring ${JSON.stringify(raw)}`);
return undefined;
}

Expand All @@ -196,3 +198,11 @@ function parseAllowedTools(
// Declared-but-no-recognized-tokens stays distinct from field-absent.
return accepted;
}

function isString<Value>(value: Value): value is Value & string {
return typeof value === "string";
}

function isNonEmptyString<Value>(value: Value): value is Value & string {
return typeof value === "string" && value.length > 0;
}
36 changes: 16 additions & 20 deletions packages/dotagents-lib/src/skills/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import {
ParseSourceError,
} from "./resolver.js";

function captureParseSourceError(source: string): ParseSourceError {
try {
parseSource(source);
} catch (err) {
if (err instanceof ParseSourceError) {return err;}
throw err;
}
throw new Error("expected parseSource to throw");
}

describe("parseOwnerRepoShorthand", () => {
it("parses owner/repo", () => {
expect(parseOwnerRepoShorthand("getsentry/skills")).toEqual({
Expand Down Expand Up @@ -472,32 +482,18 @@ describe("isSourceExcluded", () => {
describe("parseSource strict shorthand", () => {
it("throws ParseSourceError on empty SHA after @", () => {
expect(() => parseSource("owner/repo@")).toThrow(ParseSourceError);
try {
parseSource("owner/repo@");
} catch (err) {
expect(err).toBeInstanceOf(ParseSourceError);
expect((err as ParseSourceError).kind).toBe("empty-sha");
}
const err = captureParseSourceError("owner/repo@");
expect(err.kind).toBe("empty-sha");
});

it("throws ParseSourceError on empty SHA in scoped shorthand", () => {
try {
parseSource("@owner/repo@");
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(ParseSourceError);
expect((err as ParseSourceError).kind).toBe("empty-sha");
}
expect(captureParseSourceError("@owner/repo@").kind).toBe("empty-sha");
});

it("throws ParseSourceError on multi-segment shorthand", () => {
try {
parseSource("owner/repo/nested");
throw new Error("expected to throw");
} catch (err) {
expect(err).toBeInstanceOf(ParseSourceError);
expect((err as ParseSourceError).kind).toBe("multi-segment-shorthand");
}
expect(captureParseSourceError("owner/repo/nested").kind).toBe(
"multi-segment-shorthand",
);
});

it("still parses valid github shorthand without throwing", () => {
Expand Down
46 changes: 18 additions & 28 deletions packages/dotagents-lib/src/skills/resolver.trust.test.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { resolveSkill, resolveWildcardSkills } from "./resolver.js";
import { resolveSkill, resolveWildcardSkills, type ResolverServices } from "./resolver.js";
import { TrustError } from "../trust/validator.js";
import type { TrustPolicy } from "../trust/policy.js";

vi.mock("../sources/cache.js", () => ({
const services = {
ensureCached: vi.fn(async () => {
throw new Error("ensureCached should not be called when trust rejects");
}),
}));

vi.mock("../sources/wellknown.js", () => ({
ensureWellKnownCached: vi.fn(async () => {
throw new Error("ensureWellKnownCached should not be called when trust rejects");
}),
}));

vi.mock("../sources/local.js", () => ({
resolveLocalSource: vi.fn(async () => {
throw new Error("resolveLocalSource should not be called when trust rejects");
}),
LocalSourceError: class extends Error {},
}));

import { ensureCached } from "../sources/cache.js";
import { ensureWellKnownCached } from "../sources/wellknown.js";
} satisfies ResolverServices;

const allowOnlyAnthropics: TrustPolicy = {
allow_all: false,
Expand All @@ -44,25 +34,25 @@ describe("resolveSkill with trust opt", () => {
resolveSkill(
"foo",
{ source: "evil-org/evil-skills" },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics, services },
),
).rejects.toBeInstanceOf(TrustError);

expect(ensureCached).not.toHaveBeenCalled();
expect(ensureWellKnownCached).not.toHaveBeenCalled();
expect(services.ensureCached).not.toHaveBeenCalled();
expect(services.ensureWellKnownCached).not.toHaveBeenCalled();
});

it("blocks a disallowed well-known source BEFORE any network access", async () => {
await expect(
resolveSkill(
"foo",
{ source: "https://untrusted.example.com/skills" },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics, services },
),
).rejects.toBeInstanceOf(TrustError);

expect(ensureCached).not.toHaveBeenCalled();
expect(ensureWellKnownCached).not.toHaveBeenCalled();
expect(services.ensureCached).not.toHaveBeenCalled();
expect(services.ensureWellKnownCached).not.toHaveBeenCalled();
});

it("validates the expanded source under a non-default host (regression: shorthand+gitlab bypass)", async () => {
Expand All @@ -73,21 +63,21 @@ describe("resolveSkill with trust opt", () => {
resolveSkill(
"foo",
{ source: "anthropics/skills" },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics, defaultRepositorySource: "gitlab" },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics, defaultRepositorySource: "gitlab", services },
),
).rejects.toBeInstanceOf(TrustError);

expect(ensureCached).not.toHaveBeenCalled();
expect(services.ensureCached).not.toHaveBeenCalled();
});

it("does NOT enforce trust when the opt is omitted (today's behavior)", async () => {
// No trust opt → resolver should proceed to network. Mock throws a sentinel
// so we can verify network was attempted (rather than trust short-circuiting).
await expect(
resolveSkill("foo", { source: "evil-org/evil-skills" }, { stateDir: STATE_DIR }),
resolveSkill("foo", { source: "evil-org/evil-skills" }, { stateDir: STATE_DIR, services }),
).rejects.toThrowError(/ensureCached should not be called/);

expect(ensureCached).toHaveBeenCalledTimes(1);
expect(services.ensureCached).toHaveBeenCalledTimes(1);
});
});

Expand All @@ -100,19 +90,19 @@ describe("resolveWildcardSkills with trust opt", () => {
await expect(
resolveWildcardSkills(
{ source: "evil-org/evil-skills" },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics },
{ stateDir: STATE_DIR, trust: allowOnlyAnthropics, services },
),
).rejects.toBeInstanceOf(TrustError);

expect(ensureCached).not.toHaveBeenCalled();
expect(ensureWellKnownCached).not.toHaveBeenCalled();
expect(services.ensureCached).not.toHaveBeenCalled();
expect(services.ensureWellKnownCached).not.toHaveBeenCalled();
});

it("does NOT enforce trust when the opt is omitted", async () => {
await expect(
resolveWildcardSkills({ source: "evil-org/evil-skills" }, { stateDir: STATE_DIR }),
resolveWildcardSkills({ source: "evil-org/evil-skills" }, { stateDir: STATE_DIR, services }),
).rejects.toThrowError(/ensureCached should not be called/);

expect(ensureCached).toHaveBeenCalledTimes(1);
expect(services.ensureCached).toHaveBeenCalledTimes(1);
});
});
Loading
Loading