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: 1 addition & 1 deletion .claude/rules/planbridge-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Edit `packages/skills/sources/<name>/SKILL.md`. Files under `harnessIntegrations

## Steps

1. Write `packages/skills/sources/<name>/SKILL.md`. Frontmatter: required `name` (kebab-case logical name — `open`, not `planbridge-open`) and `description`. The body is harness-neutral prose; the harness routes invocation before the SKILL.md loads, so the body rarely needs to name the invocation prefix.
1. Write `packages/skills/sources/<name>/SKILL.md`. Frontmatter: required `name` (kebab-case logical name — `open`, not `planbridge-open`) and `description`. The body is a Handlebars template evaluated with `{ harness }` as context; wrap any harness-specific content (e.g. Codex sandbox guidance) in `{{#if (eq harness.id "…")}}…{{/if}}` and pull in reusable partials from `sources/_partials/<harnessId>/<topic>.md` via `{{> …}}` inside the conditional.
2. Add an import + entry in `packages/skills/src/codex.ts`:

```typescript
Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions harnessIntegrations/codex/skills/planbridge-open/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ The CLI takes a file path or stdin content:
# Piped content (when the content lives in conversation, not on disk)
printf %s "<content>" | contextbridge open

## Running this from Codex

This skill runs commands that require resources outside Codex's default sandbox. When invoking them, set `sandbox_permissions: "require_escalated"` and supply a brief `justification` describing what the command does. If the user runs the command regularly, suggest a matching `prefix_rule` (e.g., `["contextbridge", "<subcommand>"]`) so future invocations don't require re-approval.

### Resolving the argument

The user's argument may be a literal path or a human-language description. Resolve it first:
Expand Down
18 changes: 17 additions & 1 deletion packages/skills/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,25 @@ A skill is `sources/<name>/SKILL.md` following the [agentskills.io specification

All files are committed. `bun run skills:check` (wired into `just verify` and the lint CI job) regenerates in-memory and fails on byte-diff.

## Templating
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I figure we'll have this with other harnesses eventually


SKILL.md bodies are Handlebars templates. `render()` compiles the body through an isolated Handlebars instance and evaluates it with `{ harness }` as the data context, so templates can branch on the active harness:

{{#if (eq harness.id "codex")}}
{{> codex/sandbox-escalation}}
{{/if}}

Only one helper is registered, `eq` (strict equality). The harness conditional is always written at the call site so a reader of the SKILL.md can see what content is harness-specific without opening each referenced partial.

### Partials

Reusable per-harness snippets live at `packages/skills/sources/_partials/<harnessId>/<topic>.md`. Their on-disk path under `_partials/` minus the `.md` extension is the partial name — `_partials/codex/sandbox-escalation.md` is callable as `{{> codex/sandbox-escalation}}`. `loadAllFrom` skips `_`-prefixed directories so the partials tree is invisible to the skill loader and to the orphan-detection pass in `skills:check`.

Partials emit content directly. Wrap them in a `{{#if (eq harness.id "…")}}` block at the call site. Group multiple partials under a single conditional when they're adjacent.

## Rendering

`render(skill, harness)` in `src/render.ts` takes a parsed `Skill` and a `HarnessDescriptor` from `@contextbridge/harness`. The descriptor's `skillRendering` rules supply `installName(name)` — the frontmatter `name` field in the rendered output. The body is emitted verbatim.
`render(skill, harness)` in `src/render.ts` takes a parsed `Skill` and a `HarnessDescriptor` from `@contextbridge/harness`. The descriptor's `skillRendering` rules supply `installName(name)` — the frontmatter `name` field in the rendered output. The body is compiled as a Handlebars template (see `## Templating`).

## Adding a skill

Expand Down
4 changes: 4 additions & 0 deletions packages/skills/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@
"skills:check": "bun run scripts/check.ts"
},
"dependencies": {
"@contextbridge/context": "workspace:*",
"@contextbridge/harness": "workspace:*",
"@contextbridge/shared": "workspace:*",
"front-matter": "^4.0.2",
"handlebars": "^4.7.8",
"neverthrow": "^8.2.0",
"prettier": "^3.8.2",
"yaml": "^2.9.0",
"zod": "^4.3.6"
}
Expand Down
22 changes: 15 additions & 7 deletions packages/skills/scripts/check.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import type { BaseContext } from '@contextbridge/context';
import { SKILL_RENDERABLE_HARNESSES } from '@contextbridge/harness';
import { Result, err, fromThrowable, ok } from 'neverthrow';
import { loadAllFrom } from '#src/skills.ts';
import { REPO_ROOT, type RenderTarget, SOURCES_DIR, outDirFor, targetsFor } from './renderTargets.ts';
import { createScriptContext } from './context.ts';
import { REPO_ROOT, type RenderTarget, SOURCES_DIR, outDirFor, targetsForAll } from './renderTargets.ts';

const safeReadFile = fromThrowable((path: string) => readFileSync(path, 'utf8'));
const safeReaddir = fromThrowable((dir: string) => readdirSync(dir, { withFileTypes: true }));

function main(): void {
async function main(ctx: BaseContext): Promise<void> {
const { logger } = ctx;
const skills = loadAllFrom(SOURCES_DIR);
const targets = skills.flatMap(targetsFor);
const targetResult = await targetsForAll(skills);
if (targetResult.isErr()) {
logger.error(targetResult.error.message);
process.exit(1);
}
const targets = targetResult.value;
const expectedDirs = new Set(targets.map((t) => dirname(t.path)));

const driftErrors = Result.combineWithAllErrors(targets.map(checkDrift)).match(
Expand All @@ -25,11 +33,11 @@ function main(): void {

const errors = [...driftErrors, ...orphanErrors];
if (errors.length > 0) {
errors.forEach((e) => console.error(e));
console.error('\nRun `bun run skills:generate` to regenerate harness outputs from sources.');
errors.forEach((e) => logger.error(e));
logger.error('Run `bun run skills:generate` to regenerate harness outputs from sources.');
process.exit(1);
}
console.log(`✓ ${skills.length} skill(s) in sync with committed outputs.`);
logger.info(`✓ ${skills.length} skill(s) in sync with committed outputs.`);
}

function checkDrift({ path, body, harness }: RenderTarget): Result<void, string> {
Expand All @@ -50,4 +58,4 @@ function findOrphans(parent: string, expectedDirs: Set<string>): string[] {
.filter((dir) => !expectedDirs.has(dir));
}

main();
await main(createScriptContext());
10 changes: 10 additions & 0 deletions packages/skills/scripts/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type BaseContext, createBaseContext, createLogger } from '@contextbridge/context';

export function createScriptContext(): BaseContext {
const logger = createLogger({ level: 'info', destination: process.stderr });
return createBaseContext({
logger,
distinctId: 'skills-script',
telemetryDisabled: true,
});
}
21 changes: 15 additions & 6 deletions packages/skills/scripts/generate.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import type { BaseContext, Logger } from '@contextbridge/context';
import { SKILL_RENDERABLE_HARNESSES } from '@contextbridge/harness';
import { loadAllFrom } from '#src/skills.ts';
import { type RenderTarget, SOURCES_DIR, outDirFor, targetsFor } from './renderTargets.ts';
import { createScriptContext } from './context.ts';
import { type RenderTarget, SOURCES_DIR, outDirFor, targetsForAll } from './renderTargets.ts';

function main(): void {
async function main(ctx: BaseContext): Promise<void> {
const { logger } = ctx;
SKILL_RENDERABLE_HARNESSES.forEach((harness) => rmSync(outDirFor(harness), { recursive: true, force: true }));
loadAllFrom(SOURCES_DIR).flatMap(targetsFor).forEach(writeTarget);
const skills = loadAllFrom(SOURCES_DIR);
const targetResult = await targetsForAll(skills);
if (targetResult.isErr()) {
logger.error(targetResult.error.message);
process.exit(1);
}
targetResult.value.forEach((target) => writeTarget(logger, target));
}

function writeTarget({ path, body }: RenderTarget): void {
function writeTarget(logger: Logger, { path, body }: RenderTarget): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, body);
console.log(`wrote ${path}`);
logger.info(`wrote ${path}`);
}

main();
await main(createScriptContext());
31 changes: 25 additions & 6 deletions packages/skills/scripts/renderTargets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { join } from 'node:path';
import { SKILL_RENDERABLE_HARNESSES, type SkillRenderableHarness } from '@contextbridge/harness';
import { toError } from '@contextbridge/shared/errors';
import { Result, ResultAsync, errAsync } from 'neverthrow';
import prettier from 'prettier';
import { render } from '#src/render.ts';
import type { Skill } from '#src/skills.ts';

Expand All @@ -13,14 +16,30 @@ export interface RenderTarget {
readonly body: string;
}

export function targetsFor(skill: Skill): RenderTarget[] {
return SKILL_RENDERABLE_HARNESSES.map((harness) => ({
harness,
path: join(outDirFor(harness), harness.skillRendering.installName(skill.frontmatter.name), 'SKILL.md'),
body: render(skill, harness),
}));
export function targetsForAll(skills: readonly Skill[]): ResultAsync<RenderTarget[], Error> {
return ResultAsync.fromSafePromise(Promise.all(skills.map(targetsFor)))
.andThen((results) => Result.combine(results))
.map((targets) => targets.flat());
}

export function targetsFor(skill: Skill): ResultAsync<RenderTarget[], Error> {
return ResultAsync.fromSafePromise(
Promise.all(SKILL_RENDERABLE_HARNESSES.map((harness) => targetFor(skill, harness))),
).andThen((results) => Result.combine(results));
}

export function outDirFor(harness: SkillRenderableHarness): string {
return join(REPO_ROOT, harness.skillRendering.destDir);
}

function targetFor(skill: Skill, harness: SkillRenderableHarness): ResultAsync<RenderTarget, Error> {
const path = join(outDirFor(harness), harness.skillRendering.installName(skill.frontmatter.name), 'SKILL.md');
const rendered = render(skill, harness);
if (rendered.isErr()) return errAsync(rendered.error);

return ResultAsync.fromPromise(prettier.resolveConfig(path), toError)
.andThen((config) =>
ResultAsync.fromPromise(prettier.format(rendered.value, { ...config, filepath: path }), toError),
)
.map((body) => ({ harness, path, body }));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Running this from Codex
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Many skills will need this, so introducing the idea of a partial


This skill runs commands that require resources outside Codex's default sandbox. When invoking them, set `sandbox_permissions: "require_escalated"` and supply a brief `justification` describing what the command does. If the user runs the command regularly, suggest a matching `prefix_rule` (e.g., `["contextbridge", "<subcommand>"]`) so future invocations don't require re-approval.
4 changes: 4 additions & 0 deletions packages/skills/sources/open/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ The CLI takes a file path or stdin content:
# Piped content (when the content lives in conversation, not on disk)
printf %s "<content>" | contextbridge open

{{#if (eq harness.id "codex")}}
{{> codex/sandbox-escalation}}
{{/if}}

### Resolving the argument

The user's argument may be a literal path or a human-language description. Resolve it first:
Expand Down
29 changes: 29 additions & 0 deletions packages/skills/src/handlebars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import Handlebars from 'handlebars';

export type HandlebarsEnv = ReturnType<typeof Handlebars.create>;

export function createHandlebars(partialsDir: string): HandlebarsEnv {
const env = Handlebars.create();
env.registerHelper('eq', (a: unknown, b: unknown) => a === b);
for (const { name, source } of loadPartials(partialsDir)) {
env.registerPartial(name, source);
}
return env;
}

interface LoadedPartial {
readonly name: string;
readonly source: string;
}

function loadPartials(rootDir: string): LoadedPartial[] {
if (!existsSync(rootDir)) return [];
return Array.from(new Bun.Glob('**/*.md').scanSync(rootDir))
.sort((a, b) => a.localeCompare(b))
.map((relPath) => ({
name: relPath.replace(/\.md$/, ''),
source: readFileSync(join(rootDir, relPath), 'utf8'),
}));
}
Loading