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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD
- name: Validate provider catalogs
run: node scripts/validate-provider-catalogs.mjs
- name: Verify generated agent skill against its canonical source
run: |
node --test scripts/sync-agent-skill.test.mjs
npm run skills:check
- name: Install JSON Schema validation dependencies
run: npm ci --ignore-scripts --no-audit
- name: Validate machine-readable output schema
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ Write repository content, code comments, commit messages, issues, and pull reque

## Delivery

- Agent guidance is generated from the pinned `stack-sh/docs` source. Edit shared instructions there, then update `skills/docs-source.json` and run `npm run skills:sync`. Never hand-edit the generated skill; verify with `npm run skills:check` and the CLI integration tests.

- Use a topic branch and pull request; squash merge after approval.
- Work in small increments and add repository-specific formatting, linting, tests, and release checks with the code that needs them.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,13 @@ for usage and the [skill source](./skills/stack-diagrams/SKILL.md) for review.
To pin reviewed instructions, check out a specific commit of this repository and
run `npx skills add /absolute/path/to/cli --skill stack-diagrams`.

The CLI repository owns these instructions; the website owns the usage guides.
Process-level tests execute the skill's command examples against the built CLI.
The shared instruction source lives in [stack-sh/docs](https://github.com/stack-sh/docs).
This repository distributes its generated skill; do not edit `SKILL.md` directly.
`skills/docs-source.json` pins the reviewed Docs commit and manifest SHA-256.
After merging a Docs change, update that lock and run `npm run skills:sync`.
`npm run skills:check` verifies the manifest, artifact hash, and exact local bytes;
CI rejects drift. Process-level tests also execute the generated commands against
the built CLI. Updating the skill does not require a new CLI binary release.

## Development

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"private": true,
"type": "module",
"scripts": {
"skills:check": "node scripts/sync-agent-skill.mjs --check",
"skills:sync": "node scripts/sync-agent-skill.mjs --sync",
"test:cli-output-schema": "node scripts/validate-cli-output-schema.mjs && node --test scripts/cli-output-schema.test.mjs",
"test:consumer-prototype": "node examples/consume-cli-json.mjs check tests/fixtures/render.stack"
},
Expand Down
48 changes: 48 additions & 0 deletions scripts/sync-agent-skill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const root = fileURLToPath(new URL('../', import.meta.url));
const skillPath = 'skills/stack-diagrams/SKILL.md';
const digest = bytes => createHash('sha256').update(bytes).digest('hex');

export async function fetchSkill(lock, fetchResource = fetch) {
assert.equal(lock.repository, 'stack-sh/docs');
assert.match(lock.revision, /^[a-f0-9]{40}$/);
assert.match(lock.manifestSha256, /^[a-f0-9]{64}$/);
const base = `https://raw.githubusercontent.com/${lock.repository}/${lock.revision}/generated/`;
const get = async file => {
const response = await fetchResource(base + file, { signal: AbortSignal.timeout(15_000) });
assert.ok(response.ok, `Docs resource unavailable: ${file} (HTTP ${response.status})`);
const bytes = Buffer.from(await response.arrayBuffer());
assert.ok(bytes.length <= 1_048_576, 'Docs resource exceeds size limit');
return bytes;
};
const manifestBytes = await get('manifest.json');
assert.equal(digest(manifestBytes), lock.manifestSha256, 'Docs manifest integrity mismatch');
const manifest = JSON.parse(manifestBytes.toString('utf8'));
assert.equal(manifest.schemaVersion, '1.0', 'Unsupported Docs manifest version');
assert.ok(Array.isArray(manifest.files));
const entries = manifest.files.filter(entry => entry.path === skillPath);
assert.equal(entries.length, 1, 'Expected exactly one skill artifact');
assert.match(entries[0].sha256, /^[a-f0-9]{64}$/);
const bytes = await get(skillPath);
assert.equal(digest(bytes), entries[0].sha256, 'Docs skill integrity mismatch');
return bytes;
}

export async function syncSkill(directory = root, write = false, fetchResource = fetch) {
const lock = JSON.parse(await readFile(path.join(directory, 'skills/docs-source.json'), 'utf8'));
const bytes = await fetchSkill(lock, fetchResource);
const target = path.join(directory, skillPath);
if (write) await writeFile(target, bytes);
else assert.deepEqual(await readFile(target), bytes, 'Generated skill drift: update Docs source, then run npm run skills:sync');
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
assert.ok(process.argv.slice(2).every(arg => ['--check', '--sync'].includes(arg)));
assert.ok(!(process.argv.includes('--check') && process.argv.includes('--sync')));
await syncSkill(root, process.argv.includes('--sync'));
}
49 changes: 49 additions & 0 deletions scripts/sync-agent-skill.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fetchSkill, syncSkill } from './sync-agent-skill.mjs';

const digest = text => createHash('sha256').update(text).digest('hex');
const content = 'canonical skill\n';
function fixture(schemaVersion = '1.0') {
const manifest = JSON.stringify({ schemaVersion, files: [{ path: 'skills/stack-diagrams/SKILL.md', sha256: digest(content) }] });
const lock = { repository: 'stack-sh/docs', revision: 'a'.repeat(40), manifestSha256: digest(manifest) };
const fetchResource = async url => {
assert.ok(url.startsWith(`https://raw.githubusercontent.com/stack-sh/docs/${lock.revision}/generated/`));
return new Response(url.endsWith('manifest.json') ? manifest : content);
};
return { lock, manifest, fetchResource };
}

test('fetches only immutable provider artifacts with verified hashes', async () => {
const { lock, fetchResource } = fixture();
assert.equal((await fetchSkill(lock, fetchResource)).toString(), content);
});

test('rejects mutable refs, unsupported schemas, missing resources, and tampering', async () => {
const { lock, manifest, fetchResource } = fixture();
await assert.rejects(fetchSkill({ ...lock, revision: 'main' }, fetchResource));
await assert.rejects(fetchSkill(lock, async () => new Response('', { status: 404 })), /HTTP 404/);
await assert.rejects(fetchSkill(lock, async () => new Response('altered')), /manifest integrity/);
await assert.rejects(fetchSkill(lock, async url => new Response(url.endsWith('manifest.json') ? manifest : 'altered')), /skill integrity/);
const newer = fixture('2.0');
await assert.rejects(fetchSkill(newer.lock, newer.fetchResource), /Unsupported Docs manifest/);
});

test('check is read-only, rejects drift, and explicit sync restores canonical bytes', async t => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'stack-cli-docs-source-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const { lock, fetchResource } = fixture();
await mkdir(path.join(directory, 'skills/stack-diagrams'), { recursive: true });
await writeFile(path.join(directory, 'skills/docs-source.json'), JSON.stringify(lock));
const target = path.join(directory, 'skills/stack-diagrams/SKILL.md');
await writeFile(target, 'manual change');
await assert.rejects(syncSkill(directory, false, fetchResource), /Generated skill drift/);
assert.equal(await readFile(target, 'utf8'), 'manual change');
await syncSkill(directory, true, fetchResource);
await syncSkill(directory, false, fetchResource);
assert.equal(await readFile(target, 'utf8'), content);
});
5 changes: 5 additions & 0 deletions skills/docs-source.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"repository": "stack-sh/docs",
"revision": "69e4615c5356b44517fa557bea248ea2fe008631",
"manifestSha256": "3f84b30a4fb6216c96b13b53035bf62003504eee686fd7e44867c711da0fcfea"
}
8 changes: 5 additions & 3 deletions skills/stack-diagrams/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
---
name: stack-diagrams
description: Create or edit Stack (.stack) software architecture diagrams, validate them with the Stack CLI, and render SVG. Use for Stack diagrams or when a user chooses Stack for architecture documentation; not for infrastructure provisioning or unrelated programming stacks.
license: Apache-2.0
name: "stack-diagrams"
description: "Create or edit Stack (.stack) software architecture diagrams, validate them with the Stack CLI, and render SVG. Use for Stack diagrams or when a user chooses Stack for architecture documentation; not for infrastructure provisioning or unrelated programming stacks."
license: "Apache-2.0"
---

<!-- Generated from stack-sh/docs/content/agent-workflow.md. Do not edit. -->

# Stack diagrams

Deliver editable `.stack` source and, when rendering is available, an SVG. Preserve the requested architecture and existing unrelated content. Stack describes architecture; it does not provision resources or execute application code.
Expand Down