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
45 changes: 45 additions & 0 deletions docs/src/content/docs/features/built-in-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,51 @@ $ squad roles --search "security" # search by keyword
- Add extra ownership or approach items with `extraOwnership`/`extraApproach`.
- Base roles are starting points — the coordinator refines them for your project context during casting.

## Plugin-contributed roles

Marketplace plugins can extend the catalog with their own roles. A plugin ships a `roles/` directory containing JSON role definitions; Squad loads `.squad/plugins/<plugin>/roles/*.json` automatically when you run `squad`, `squad roles`, or `squad init`.

Plugin roles are **additive** — they cannot override a built-in role id. Use a namespaced id (for example `@acme/react-frontend`) so there's no collision:

```json
// .squad/plugins/@acme-frontend/roles/react.json
{
"id": "@acme/react-frontend",
"title": "Acme React Frontend",
"category": "engineering",
"emoji": "⚛️",
"vibe": "Acme-flavored React specialist.",
"expertise": ["React", "Testing Library", "State management"],
"style": "Direct. Tested.",
"ownership": ["Acme UI layer"],
"approach": ["Measure-first"],
"boundaries": { "handles": "React UI", "doesNotHandle": "Backend APIs" },
"voice": "Crisp and opinionated.",
"routingPatterns": ["react", "frontend", "acme"],
"attribution": "Contributed by the @acme marketplace plugin."
}
```

Once the plugin is installed, the role resolves identically to a built-in:

```typescript
useRole('@acme/react-frontend', { name: 'ada' });
```

It also appears in `squad roles` under a **🔌 Plugin Roles** section grouped by plugin, and in `searchRoles()` / `listRoles()` / `getCategories()`.

For programmatic registration (tests, hot-reload, or a custom loader), import:

```typescript
import {
registerPluginRoles,
getPluginRoleRegistrations,
loadPluginRolesFromDir,
} from '@bradygaster/squad-sdk';
```

`registerPluginRoles(plugin, roles)` throws if any `role.id` collides with a built-in and skips ids already registered by another plugin (reported via the `skipped` list).

## Attribution

Built-in role content is adapted from [agency-agents](https://github.com/msitarzewski/agency-agents) by AgentLand Contributors, released under the MIT License.
Expand Down
4 changes: 4 additions & 0 deletions docs/src/content/docs/features/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ my-team-plugins/
│ ├── charter.md # Agent template
│ ├── skills/
│ │ └── awesome-skill.md
│ ├── roles/ # Optional: additional base roles
│ │ └── react.json # Resolves via useRole('@your-plugin/react')
│ └── decisions.md # Conventions
├── microservices-template/
│ ├── charter.md
Expand All @@ -294,6 +296,8 @@ my-team-plugins/
└── README.md # Plugin descriptions
```

Role files in `roles/` are loaded into the SDK role catalog. See [Built-in Roles → Plugin-contributed roles](./built-in-roles.md#plugin-contributed-roles) for the file format and collision rules.

### Step 1: Create the repository

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/guide/building-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ my-extension/
│ └── CEREMONY.md
├── directives/
│ └── DIRECTIVE.md
├── roles/
│ └── ROLE.json # Optional: additional base roles (see built-in-roles doc)
└── README.md
```

Expand Down
7 changes: 7 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ async function main(): Promise<void> {
if (rawCmd === undefined) {
// Fire-and-forget update check — non-blocking, never delays shell startup
import('./cli/self-update.js').then(m => m.notifyIfUpdateAvailable(VERSION)).catch(() => {});
const { loadPluginRolesForDest } = await import('./cli/core/plugin-roles.js');
loadPluginRolesForDest(getSquadStartDir());
const { runShell } = await lazyRunShell();
await runShell();
return;
Expand Down Expand Up @@ -309,6 +311,9 @@ async function main(): Promise<void> {
const noWorkflows = args.includes('--no-workflows');
const sdk = args.includes('--sdk');
const roles = args.includes('--roles');
// Load plugin-contributed roles so init scaffolding can discover them.
const { loadPluginRolesForDest } = await import('./cli/core/plugin-roles.js');
loadPluginRolesForDest(dest);
// Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad
runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).catch(err => {
fatal(err.message);
Expand Down Expand Up @@ -714,6 +719,8 @@ async function main(): Promise<void> {
}

if (cmd === 'roles') {
const { loadPluginRolesForDest } = await import('./cli/core/plugin-roles.js');
loadPluginRolesForDest(getSquadStartDir());
const { runRoles } = await import('./cli/commands/roles.js');
await runRoles(args.slice(1));
return;
Expand Down
36 changes: 32 additions & 4 deletions packages/squad-cli/src/cli/commands/roles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { listRoles, searchRoles, getCategories } from '@bradygaster/squad-sdk';
import {
listRoles,
searchRoles,
getCategories,
getPluginRoleRegistrations,
BASE_ROLES,
} from '@bradygaster/squad-sdk';

type RoleRecord = ReturnType<typeof listRoles>[number];

Expand Down Expand Up @@ -51,10 +57,14 @@ export async function runRoles(args: string[]): Promise<void> {
return;
}

const softwareRoles = roles.filter(r => SOFTWARE_DEVELOPMENT_CATEGORIES.has(r.category));
const businessRoles = roles.filter(r => !SOFTWARE_DEVELOPMENT_CATEGORIES.has(r.category));
const builtinIds = new Set(BASE_ROLES.map(r => r.id));
const builtinRoles = roles.filter(r => builtinIds.has(r.id));
const pluginRoles = roles.filter(r => !builtinIds.has(r.id));

console.log(`\n📦 Built-in Roles (${listRoles().length} base roles)`);
const softwareRoles = builtinRoles.filter(r => SOFTWARE_DEVELOPMENT_CATEGORIES.has(r.category));
const businessRoles = builtinRoles.filter(r => !SOFTWARE_DEVELOPMENT_CATEGORIES.has(r.category));

console.log(`\n📦 Built-in Roles (${BASE_ROLES.length} base roles)`);
console.log(' Adapted from agency-agents by AgentLand Contributors (MIT)\n');

if (softwareRoles.length > 0) {
Expand All @@ -68,4 +78,22 @@ export async function runRoles(args: string[]): Promise<void> {
printRoleRows(businessRoles);
console.log();
}

if (pluginRoles.length > 0) {
const registrations = getPluginRoleRegistrations();
const byPlugin = new Map<string, typeof pluginRoles>();
for (const reg of registrations) {
if (!pluginRoles.some(r => r.id === reg.role.id)) continue;
const bucket = byPlugin.get(reg.plugin) ?? [];
bucket.push(reg.role);
byPlugin.set(reg.plugin, bucket);
}

console.log(`🔌 Plugin Roles (${pluginRoles.length} from ${byPlugin.size} plugin${byPlugin.size === 1 ? '' : 's'})\n`);
for (const [plugin, pluginRoleList] of byPlugin) {
console.log(` ${plugin}:`);
printRoleRows(pluginRoleList);
console.log();
}
}
}
29 changes: 29 additions & 0 deletions packages/squad-cli/src/cli/core/plugin-roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Plugin role discovery for the CLI — loads `<squadDir>/plugins/*\/roles/*.json`
* into the SDK role registry so `squad roles`, `squad hire`, and `squad init`
* see plugin-contributed roles alongside the built-ins.
*/

import { loadPluginRolesFromDir, type PluginRoleLoadSummary } from '@bradygaster/squad-sdk';
import { join } from 'node:path';
import { detectSquadDir } from './detect-squad-dir.js';

let loadedForDir: string | null = null;

/**
* Load plugin roles for the squad directory derived from `dest`.
*
* Idempotent per-process: once a directory has been scanned, subsequent
* calls for the same directory are no-ops. Pass `{ force: true }` to
* reload (e.g. after installing a new plugin in the same shell session).
*/
export function loadPluginRolesForDest(
dest: string,
opts: { force?: boolean } = {},
): PluginRoleLoadSummary[] {
const info = detectSquadDir(dest);
const pluginsDir = join(info.path, 'plugins');
if (!opts.force && loadedForDir === pluginsDir) return [];
loadedForDir = pluginsDir;
return loadPluginRolesFromDir(pluginsDir);
}
40 changes: 29 additions & 11 deletions packages/squad-sdk/src/roles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,55 @@

export type { BaseRole, RoleCategory, UseRoleOptions } from './types.js';
export { BASE_ROLES, ENGINEERING_ROLE_IDS, CATEGORY_ROLE_IDS } from './catalog.js';
export {
registerPluginRoles,
unregisterPluginRole,
clearPluginRoles,
getPluginRoles,
getPluginRoleRegistrations,
getAllRoles,
} from './registry.js';
export type { PluginRoleRegistration, RegisterPluginRolesResult } from './registry.js';
export { loadPluginRolesFromDir } from './loader.js';
export type { PluginRoleLoadSummary } from './loader.js';

import type { BaseRole, RoleCategory, UseRoleOptions } from './types.js';
import type { AgentDefinition } from '../builders/types.js';
import { BASE_ROLES } from './catalog.js';
import { getAllRoles } from './registry.js';

/**
* Get all available base roles, optionally filtered by category.
* Get all available roles (built-in + plugin), optionally filtered by category.
*
* Plugin roles registered via {@link registerPluginRoles} are included
* after the built-in base roles.
*/
export function listRoles(category?: RoleCategory): readonly BaseRole[] {
if (!category) return BASE_ROLES;
return BASE_ROLES.filter(r => r.category === category);
const all = getAllRoles();
if (!category) return all;
return all.filter(r => r.category === category);
}

/**
* Look up a base role by ID.
* Look up a role by ID. Searches built-in base roles and plugin-registered
* roles. Built-ins are checked first; plugin roles cannot override a built-in.
*
* @param id - Role ID (e.g., 'backend', 'marketing')
* @param id - Role ID (e.g., 'backend', '@acme/frontend')
* @returns The role definition, or undefined if not found
*/
export function getRoleById(id: string): BaseRole | undefined {
return BASE_ROLES.find(r => r.id === id);
return getAllRoles().find(r => r.id === id);
}

/**
* Search roles by keyword across title, vibe, expertise, and routing patterns.
* Includes plugin-registered roles.
*
* @param query - Search query (case-insensitive)
* @returns Matching roles sorted by relevance
*/
export function searchRoles(query: string): readonly BaseRole[] {
const q = query.toLowerCase();
return BASE_ROLES.filter(r => {
return getAllRoles().filter(r => {
return (
r.title.toLowerCase().includes(q) ||
r.vibe.toLowerCase().includes(q) ||
Expand All @@ -55,11 +72,12 @@ export function searchRoles(query: string): readonly BaseRole[] {
}

/**
* Get all unique categories in the catalog.
* Get all unique categories in the catalog, including categories
* contributed by plugin roles.
*/
export function getCategories(): readonly RoleCategory[] {
const cats = new Set<RoleCategory>();
for (const r of BASE_ROLES) cats.add(r.category);
for (const r of getAllRoles()) cats.add(r.category);
return [...cats];
}

Expand Down Expand Up @@ -87,7 +105,7 @@ export function getCategories(): readonly RoleCategory[] {
export function useRole(roleId: string, options: UseRoleOptions): AgentDefinition {
const role = getRoleById(roleId);
if (!role) {
const available = BASE_ROLES.map(r => r.id).join(', ');
const available = getAllRoles().map(r => r.id).join(', ');
throw new Error(
`Unknown base role '${roleId}'. Available roles: ${available}`
);
Expand Down
107 changes: 107 additions & 0 deletions packages/squad-sdk/src/roles/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Plugin Role Loader — discovers role definitions in a plugins directory
* and registers them with the {@link registerPluginRoles} registry.
*
* Convention:
* `<pluginsDir>/<plugin-name>/roles/*.json`
*
* Each JSON file may contain either a single {@link BaseRole} object or
* an array of `BaseRole` objects. The plugin directory name is used as
* the `plugin` attribution in the registry.
*
* @module roles/loader
*/

import { join } from 'node:path';
import type { BaseRole } from './types.js';
import type { StorageProvider } from '../storage/index.js';
import { FSStorageProvider } from '../storage/index.js';
import { registerPluginRoles, type RegisterPluginRolesResult } from './registry.js';

/** Per-file load summary. */
export interface PluginRoleLoadSummary {
/** Plugin directory name. */
readonly plugin: string;
/** Absolute path of the JSON file loaded. */
readonly source: string;
/** Registration result for the roles in this file. */
readonly result: RegisterPluginRolesResult;
/** Error message, if the file was malformed or registration failed. */
readonly error?: string;
}

/**
* Scan `pluginsDir` for plugin role definitions and register them.
*
* Safe to call even when `pluginsDir` does not exist — returns an empty
* summary list.
*
* @param pluginsDir - Absolute path to the plugins directory
* (typically `<squadDir>/plugins`).
* @param storage - Optional storage provider (defaults to filesystem).
*/
export function loadPluginRolesFromDir(
pluginsDir: string,
storage: StorageProvider = new FSStorageProvider(),
): PluginRoleLoadSummary[] {
const summaries: PluginRoleLoadSummary[] = [];

if (!storage.existsSync(pluginsDir) || !storage.isDirectorySync(pluginsDir)) {
return summaries;
}

for (const plugin of storage.listSync(pluginsDir)) {
const pluginPath = join(pluginsDir, plugin);
if (!storage.isDirectorySync(pluginPath)) continue;

const rolesDir = join(pluginPath, 'roles');
if (!storage.existsSync(rolesDir) || !storage.isDirectorySync(rolesDir)) continue;

for (const entry of storage.listSync(rolesDir)) {
if (!entry.endsWith('.json')) continue;
const source = join(rolesDir, entry);

let raw: string | undefined;
try {
raw = storage.readSync(source);
} catch (err) {
summaries.push({
plugin,
source,
result: { registered: [], skipped: [] },
error: err instanceof Error ? err.message : String(err),
});
continue;
}
if (!raw) continue;

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
summaries.push({
plugin,
source,
result: { registered: [], skipped: [] },
error: `invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
});
continue;
}

const roles = (Array.isArray(parsed) ? parsed : [parsed]) as BaseRole[];
try {
const result = registerPluginRoles(plugin, roles);
summaries.push({ plugin, source, result });
} catch (err) {
summaries.push({
plugin,
source,
result: { registered: [], skipped: [] },
error: err instanceof Error ? err.message : String(err),
});
}
}
}

return summaries;
}
Loading