Skip to content
Open
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
30 changes: 30 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "agency-agents",
"version": "1.0.0",
"description": "> **A complete AI agency at your fingertips** - From frontend wizards to Reddit community ninjas, from whimsy injectors to reality checkers. Each agent is a specialized expert with personality, processes, and proven deliverables.",
"main": "index.js",
"directories": {
"example": "examples"
},
"scripts": {
"test": "vitest run"
},
"repository": {
"type": "git",
"url": "git+https://github.com/msitarzewski/agency-agents.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/msitarzewski/agency-agents/issues"
},
"homepage": "https://github.com/msitarzewski/agency-agents#readme",
"devDependencies": {
"@types/node": "^25.5.0",
"gray-matter": "^4.0.3",
"typescript": "^6.0.2",
"vitest": "^4.1.1"
}
}
177 changes: 177 additions & 0 deletions tests/agent-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import matter from "gray-matter";

const ROOT = path.resolve(__dirname, "..");

/**
* Agent category directories containing agent markdown files.
* Aligned with scripts/lint-agents.sh AGENT_DIRS plus additional agent
* categories (academic, sales) discovered in the repository.
*
* Excludes strategy/ (orchestration docs, not individual agents),
* examples/, integrations/, and scripts/.
*/
const AGENT_CATEGORIES = [
"academic",
"design",
"engineering",
"game-development",
"marketing",
"paid-media",
"product",
"project-management",
"sales",
"spatial-computing",
"specialized",
"support",
"testing",
];

/**
* Recursively collect agent .md files under a directory.
* Filters out README.md and files without frontmatter delimiters.
*/
function collectAgentFiles(dir: string): string[] {
const results: string[] = [];
if (!fs.existsSync(dir)) return results;

const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...collectAgentFiles(full));
} else if (
entry.isFile() &&
entry.name.endsWith(".md") &&
entry.name !== "README.md"
) {
// Only include files that start with YAML frontmatter delimiter
const content = fs.readFileSync(full, "utf-8");
if (content.startsWith("---\n") || content.startsWith("---\r\n")) {
results.push(full);
}
}
}
return results;
}

/** Collect all agent markdown files across all category directories */
function getAllAgentFiles(): string[] {
const files: string[] = [];
for (const category of AGENT_CATEGORIES) {
files.push(...collectAgentFiles(path.join(ROOT, category)));
}
return files;
}

/**
* Safely parse YAML frontmatter. Returns parsed data or null on error.
* When gray-matter fails (e.g. unquoted colons in values), falls back
* to a simple line-by-line key: value parser for basic field extraction.
*/
function safeParseFrontmatter(
content: string
): { data: Record<string, unknown> } | null {
try {
const { data } = matter(content);
return { data };
} catch {
// Fallback: extract frontmatter block and parse key: value lines
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;

const data: Record<string, unknown> = {};
for (const line of match[1].split(/\r?\n/)) {
const kv = line.match(/^(\w+):\s*(.+)$/);
if (kv) {
data[kv[1]] = kv[2].trim();
}
}
return Object.keys(data).length > 0 ? { data } : null;
}
}

const KEBAB_CASE_RE = /^[a-z0-9]+(-[a-z0-9]+)*\.md$/;

describe("Agent validation", () => {
const agentFiles = getAllAgentFiles();

it("should find agent files in the repository", () => {
expect(agentFiles.length).toBeGreaterThan(0);
});

describe.each(AGENT_CATEGORIES)("category: %s", (category) => {
it("should contain at least one agent file", () => {
const files = collectAgentFiles(path.join(ROOT, category));
expect(
files.length,
`${category}/ should contain at least one agent markdown file`
).toBeGreaterThan(0);
});
});

// Pre-parse all agent files to avoid repeated I/O and parsing in each test
const agentData = agentFiles.map((filePath) => {
const relativePath = path.relative(ROOT, filePath);
const fileName = path.basename(filePath);
const content = fs.readFileSync(filePath, "utf-8");
const parsed = safeParseFrontmatter(content);
return { filePath, relativePath, fileName, parsed };
});

for (const { filePath, relativePath, fileName, parsed } of agentData) {
describe(relativePath, () => {
it("file name should be kebab-case", () => {
expect(
KEBAB_CASE_RE.test(fileName),
`${relativePath}: file name "${fileName}" is not kebab-case`
).toBe(true);
});

it("should have valid YAML frontmatter", () => {
expect(
parsed,
`${relativePath}: YAML frontmatter failed to parse`
).not.toBeNull();
expect(
Object.keys(parsed!.data).length,
`${relativePath}: frontmatter is empty`
).toBeGreaterThan(0);
});

it("should have a non-empty 'name' in frontmatter", () => {
expect(
parsed,
`${relativePath}: cannot check 'name' — frontmatter parse failed`
).not.toBeNull();
expect(
parsed!.data.name,
`${relativePath}: frontmatter missing 'name'`
).toBeDefined();
expect(
typeof parsed!.data.name === "string" &&
(parsed!.data.name as string).trim().length > 0,
`${relativePath}: frontmatter 'name' is empty`
).toBe(true);
});

it("should have a non-empty 'description' in frontmatter", () => {
expect(
parsed,
`${relativePath}: cannot check 'description' — frontmatter parse failed`
).not.toBeNull();
expect(
parsed!.data.description,
`${relativePath}: frontmatter missing 'description'`
).toBeDefined();
expect(
typeof parsed!.data.description === "string" &&
(parsed!.data.description as string).trim().length > 0,
`${relativePath}: frontmatter 'description' is empty`
).toBe(true);
});
});
}
});
50 changes: 50 additions & 0 deletions tests/install-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";

const ROOT = path.resolve(__dirname, "..");
const SCRIPTS_DIR = path.join(ROOT, "scripts");

describe("Install validation", () => {
describe("scripts/install.sh", () => {
const installSh = path.join(SCRIPTS_DIR, "install.sh");

it("should exist", () => {
expect(fs.existsSync(installSh)).toBe(true);
});

it("should pass bash syntax check (bash -n)", () => {
const result = execSync(`bash -n "${installSh}" 2>&1`, {
encoding: "utf-8",
timeout: 10_000,
});
// bash -n produces no output on success
expect(result.trim()).toBe("");
});
});

describe("scripts/install.ps1", () => {
const installPs1 = path.join(SCRIPTS_DIR, "install.ps1");

it.todo(
"should exist (PowerShell install script not yet available)"
);
});

describe("scripts/convert.sh", () => {
const convertSh = path.join(SCRIPTS_DIR, "convert.sh");

it("should exist", () => {
expect(fs.existsSync(convertSh)).toBe(true);
});

it("should pass bash syntax check (bash -n)", () => {
const result = execSync(`bash -n "${convertSh}" 2>&1`, {
encoding: "utf-8",
timeout: 10_000,
});
expect(result.trim()).toBe("");
});
});
});
13 changes: 13 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["tests/**/*.ts", "vitest.config.ts"]
}
8 changes: 8 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 30_000,
},
});