Skip to content

Commit d79a974

Browse files
committed
PureStack now manages purestack dev skills for agents.
1 parent 7160107 commit d79a974

39 files changed

Lines changed: 326 additions & 53 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ tsconfig.bundle.generated.json
1010
/frontend/out/
1111
/frontend/purestack.studio/legal/_private/
1212
/private-docs
13+
14+
# Copied from /skills while packing purestack; removed after packing
15+
/packages/purestack/skills/

‎docs/template-scaffolding.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ minimum version, such as `"1.1.19"`. The CLI must meet that minimum and have the
4040
same major version. Versions use plain `major.minor.patch`; ranges, prereleases,
4141
and build metadata are not supported. `template/` contains a complete standalone
4242
project, including `package.json`, `README.md`, `AGENTS.md`, and
43-
`content/siteConfig.json`.
43+
`content/siteConfig.json`. PureStack adds its development skill to every
44+
generated site at `skills/purestack-development/SKILL.md`, copied from the
45+
installed `purestack` package; templates must not include that file. Point the
46+
template's `AGENTS.md` to it and keep `AGENTS.md` for site-specific guidance.
47+
`purestack skills update` refreshes the copy after an upgrade.
4448
Project dependencies must use registry versions, ranges, tags, or `npm:` aliases.
4549
Workspace references, local paths, Git sources, and tarball URLs are rejected.
4650

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"private": true,
33
"name": "purestack-repo",
4-
"version": "1.1.21",
4+
"version": "1.1.22",
55
"packageManager": "yarn@4.18.1",
66
"type": "module",
77
"sideEffects": false,

‎packages/purestack/package.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "purestack",
3-
"version": "1.1.21",
3+
"version": "1.1.22",
44
"packageManager": "yarn@4.9.2",
55
"module": "./dist/purestack.mjs",
66
"types": "./dist/purestack.d.mts",
@@ -32,13 +32,16 @@
3232
"dist/*.d.mts",
3333
"dist/*.d.mts.map",
3434
"wizard/**/*",
35+
"skills/purestack-development/SKILL.md",
3536
"README.md",
3637
"LICENSE"
3738
],
3839
"scripts": {
3940
"build": "tsc",
4041
"lint": "biome check ./src --formatter-enabled=false --write",
41-
"test": "vitest"
42+
"test": "vitest",
43+
"prepack": "node -e \"const fs=require('node:fs');fs.mkdirSync('skills/purestack-development',{recursive:true});fs.copyFileSync('../../skills/purestack-development/SKILL.md','skills/purestack-development/SKILL.md')\"",
44+
"postpack": "node -e \"require('node:fs').rmSync('skills',{recursive:true,force:true})\""
4245
},
4346
"dependencies": {
4447
"@purestack/ts-components": "workspace:^",

‎packages/purestack/src/cli/application.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'node:path'
2+
import { PURESTACK_SKILL_PATH, updatePureStackSkill } from '../init/agent-skill'
23
import { type TemplateEnvironment, TemplateStore } from '../init/template-store'
34
import { resolveTemplateValues } from '../init/template-ui'
45
import {
@@ -15,6 +16,7 @@ const help = `PureStack
1516
purestack serve --content <directory> Preview with live reload
1617
purestack build --content <directory> Build static files
1718
purestack publish --content <dir> Prepare a local release artifact
19+
purestack skills update Refresh this site's AI agent skill
1820
purestack --version Print the installed version
1921
2022
Use purestack init --help to explore creation options.`
@@ -32,6 +34,19 @@ export async function runApplication(
3234
console.log(args[0] === 'help' && args[1] === 'init' ? initHelp : help)
3335
return 0
3436
}
37+
if (args[0] === 'skills') {
38+
if (args[1] !== 'update' || args.length > 2)
39+
throw new Error('Usage: purestack skills update')
40+
const updated = await updatePureStackSkill(
41+
environment.cwd,
42+
environment.packageRoot,
43+
environment.version,
44+
)
45+
console.log(
46+
`${updated ? 'Updated' : 'Already up to date:'} ${PURESTACK_SKILL_PATH.replaceAll(path.sep, '/')} for PureStack ${environment.version}.`,
47+
)
48+
return 0
49+
}
3550
if (args[0] !== 'init') {
3651
if (!['serve', 'build', 'publish'].includes(args[0]))
3752
throw new Error(`Unknown command: ${args[0]}. Run purestack --help.`)

‎packages/purestack/src/cli/creation.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ export async function createSiteWithProgress(
3939
environment.version,
4040
environment.cwd,
4141
progressTasks(output),
42-
{ templateRoot: request.templateRoot, signal: abort.signal },
42+
{
43+
templateRoot: request.templateRoot,
44+
signal: abort.signal,
45+
packageRoot: environment.packageRoot,
46+
},
4347
)
4448
output.write(view.complete(result, nextCommands(result, environment.cwd)))
4549
return result.failures.length ? 1 : 0

‎packages/purestack/src/cli/wizard/session.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,11 @@ export class StudioSession {
214214
this.environment.version,
215215
this.environment.cwd,
216216
this.tasks(),
217-
{ templateRoot: template.root, signal: this.abort.signal },
217+
{
218+
templateRoot: template.root,
219+
signal: this.abort.signal,
220+
packageRoot: this.environment.packageRoot,
221+
},
218222
)
219223
this.snapshot.result = {
220224
...result,

‎packages/purestack/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export const version: string = '1.1.21'
1+
export const version: string = '1.1.22'
22

33
export * from '@purestack/ts-ssg'
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import path from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import {
6+
PURESTACK_SKILL_PATH,
7+
readPureStackSkill,
8+
updatePureStackSkill,
9+
} from './agent-skill'
10+
11+
const packageRoot = path.resolve(import.meta.dirname, '../..')
12+
let temporary: string
13+
14+
beforeEach(async () => {
15+
temporary = await mkdtemp(path.join(tmpdir(), 'purestack-skill-test-'))
16+
})
17+
afterEach(async () => {
18+
await rm(temporary, { recursive: true, force: true })
19+
})
20+
21+
async function site(dependencies: Record<string, string> = { purestack: '1' }) {
22+
await writeFile(
23+
path.join(temporary, 'package.json'),
24+
JSON.stringify({ name: 'site', dependencies }),
25+
)
26+
return path.join(temporary, PURESTACK_SKILL_PATH)
27+
}
28+
29+
describe('PureStack agent skill', () => {
30+
it('marks the shipped skill as managed without breaking its frontmatter', async () => {
31+
const skill = await readPureStackSkill(packageRoot, '9.8.7')
32+
const [frontmatter, rest] = skill.split(/(?<=\n---\n)/, 2)
33+
expect(frontmatter).toMatch(/^---\nname: purestack-development\n/)
34+
expect(rest).toMatch(
35+
/^\n<!-- Managed by PureStack 9\.8\.7\. Run `purestack skills update`/,
36+
)
37+
})
38+
39+
it('rejects a shipped skill without frontmatter', async () => {
40+
const file = path.join(temporary, PURESTACK_SKILL_PATH)
41+
await mkdir(path.dirname(file), { recursive: true })
42+
await writeFile(file, '# No frontmatter\n')
43+
await expect(readPureStackSkill(temporary, '1.0.0')).rejects.toThrow(
44+
'skills/purestack-development/SKILL.md must start with frontmatter.',
45+
)
46+
})
47+
48+
it('replaces a stale or edited copy and reports when nothing changed', async () => {
49+
const target = await site()
50+
await mkdir(path.dirname(target), { recursive: true })
51+
await writeFile(target, 'locally edited')
52+
53+
expect(await updatePureStackSkill(temporary, packageRoot, '2.0.0')).toBe(
54+
true,
55+
)
56+
expect(await readFile(target, 'utf8')).toBe(
57+
await readPureStackSkill(packageRoot, '2.0.0'),
58+
)
59+
expect(await updatePureStackSkill(temporary, packageRoot, '2.0.0')).toBe(
60+
false,
61+
)
62+
})
63+
64+
it('also recognizes PureStack as a development dependency', async () => {
65+
await writeFile(
66+
path.join(temporary, 'package.json'),
67+
JSON.stringify({ devDependencies: { purestack: '1' } }),
68+
)
69+
expect(await updatePureStackSkill(temporary, packageRoot, '2.0.0')).toBe(
70+
true,
71+
)
72+
})
73+
74+
it.each([
75+
['without a package.json', undefined],
76+
['without a PureStack dependency', { regor: '1' }],
77+
])('refuses folders %s', async (_case, dependencies) => {
78+
if (dependencies) await site(dependencies)
79+
await expect(
80+
updatePureStackSkill(temporary, packageRoot, '2.0.0'),
81+
).rejects.toThrow(
82+
'Run purestack skills update in the root folder of a PureStack site.',
83+
)
84+
})
85+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { repositoryCheckout } from './checkout'
4+
5+
/**
6+
* The skill's place in the PureStack repository, in the published package
7+
* (copied there while packing), and in every generated site.
8+
*/
9+
export const PURESTACK_SKILL_PATH = path.join(
10+
'skills',
11+
'purestack-development',
12+
'SKILL.md',
13+
)
14+
15+
/** The skill, marked as managed so local edits are not expected to last. */
16+
export async function readPureStackSkill(packageRoot: string, version: string) {
17+
// The repository's own skill is the source; packages carry a packed copy.
18+
const source = (await repositoryCheckout(packageRoot)) ?? packageRoot
19+
const skill = await readFile(path.join(source, PURESTACK_SKILL_PATH), 'utf8')
20+
// Agents read the frontmatter first, so the notice follows it.
21+
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(skill)
22+
if (!frontmatter)
23+
throw new Error(
24+
`${PURESTACK_SKILL_PATH.replaceAll(path.sep, '/')} must start with frontmatter.`,
25+
)
26+
const notice = `\n<!-- Managed by PureStack ${version}. Run \`purestack skills update\` after upgrading PureStack; local edits are replaced. -->\n`
27+
return `${frontmatter[0]}${notice}${skill.slice(frontmatter[0].length)}`
28+
}
29+
30+
/** Refreshes an existing site's copy from the installed PureStack package. */
31+
export async function updatePureStackSkill(
32+
projectRoot: string,
33+
packageRoot: string,
34+
version: string,
35+
) {
36+
const manifest: unknown = JSON.parse(
37+
await readFile(path.join(projectRoot, 'package.json'), 'utf8').catch(
38+
() => '{}',
39+
),
40+
)
41+
const dependencies = isRecord(manifest)
42+
? [manifest.dependencies, manifest.devDependencies].flatMap((entries) =>
43+
isRecord(entries) ? Object.keys(entries) : [],
44+
)
45+
: []
46+
if (!dependencies.includes('purestack'))
47+
throw new Error(
48+
'Run purestack skills update in the root folder of a PureStack site.',
49+
)
50+
const target = path.join(projectRoot, PURESTACK_SKILL_PATH)
51+
const skill = await readPureStackSkill(packageRoot, version)
52+
const current = await readFile(target, 'utf8').catch(() => undefined)
53+
if (current === skill) return false
54+
await mkdir(path.dirname(target), { recursive: true })
55+
await writeFile(target, skill)
56+
return true
57+
}
58+
59+
function isRecord(value: unknown): value is Record<string, unknown> {
60+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
61+
}

0 commit comments

Comments
 (0)