@erclx/aitk 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/claude/.claude-plugin/plugin.json +19 -0
- package/claude/skills/bash-script/SKILL.md +96 -0
- package/claude/skills/bash-script/references/patterns.md +349 -0
- package/claude/skills/ci-workflow/SKILL.md +52 -0
- package/claude/skills/ci-workflow/references/workflows.md +74 -0
- package/claude/skills/claude-address-review/SKILL.md +107 -0
- package/claude/skills/claude-autoship/SKILL.md +137 -0
- package/claude/skills/claude-design-extract/SKILL.md +69 -0
- package/claude/skills/claude-design-propose/SKILL.md +75 -0
- package/claude/skills/claude-diagram/SKILL.md +126 -0
- package/claude/skills/claude-docs/SKILL.md +204 -0
- package/claude/skills/claude-feature/REQUIREMENT.md +35 -0
- package/claude/skills/claude-feature/SKILL.md +148 -0
- package/claude/skills/claude-groundwork/SKILL.md +148 -0
- package/claude/skills/claude-groundwork/references/folder-format.md +107 -0
- package/claude/skills/claude-memory-capture/SKILL.md +80 -0
- package/claude/skills/claude-memory-review/REQUIREMENT.md +37 -0
- package/claude/skills/claude-memory-review/SKILL.md +206 -0
- package/claude/skills/claude-orchestrate/SKILL.md +125 -0
- package/claude/skills/claude-pr-review/SKILL.md +150 -0
- package/claude/skills/claude-review/SKILL.md +136 -0
- package/claude/skills/claude-roadmap/SKILL.md +61 -0
- package/claude/skills/claude-roadmap/references/roadmap.md +52 -0
- package/claude/skills/claude-screencast/SKILL.md +160 -0
- package/claude/skills/claude-seed-sync/SKILL.md +141 -0
- package/claude/skills/claude-slides-draft/SKILL.md +62 -0
- package/claude/skills/claude-standards-audit/REQUIREMENT.md +33 -0
- package/claude/skills/claude-standards-audit/SKILL.md +79 -0
- package/claude/skills/claude-standards-audit/references/branch.md +49 -0
- package/claude/skills/claude-standards-audit/references/pr.md +124 -0
- package/claude/skills/claude-tasks/SKILL.md +123 -0
- package/claude/skills/claude-ui-test/SKILL.md +75 -0
- package/claude/skills/claude-ux-audit/SKILL.md +78 -0
- package/claude/skills/claude-worktree/SKILL.md +79 -0
- package/claude/skills/cli-script/SKILL.md +47 -0
- package/claude/skills/cli-script/references/template.md +43 -0
- package/claude/skills/create-rule/SKILL.md +68 -0
- package/claude/skills/create-skill/REQUIREMENT.md +30 -0
- package/claude/skills/create-skill/SKILL.md +24 -0
- package/claude/skills/create-snippet/SKILL.md +28 -0
- package/claude/skills/create-snippet/references/snippets.md +57 -0
- package/claude/skills/create-standard/SKILL.md +31 -0
- package/claude/skills/create-standard/references/standard.md +52 -0
- package/claude/skills/docs-sync/SKILL.md +87 -0
- package/claude/skills/git-branch/REQUIREMENT.md +38 -0
- package/claude/skills/git-branch/SKILL.md +60 -0
- package/claude/skills/git-branch/references/branch.md +49 -0
- package/claude/skills/git-commit/REQUIREMENT.md +33 -0
- package/claude/skills/git-commit/SKILL.md +51 -0
- package/claude/skills/git-commit/references/commit.md +63 -0
- package/claude/skills/git-followup/REQUIREMENT.md +38 -0
- package/claude/skills/git-followup/SKILL.md +35 -0
- package/claude/skills/git-issue/REQUIREMENT.md +38 -0
- package/claude/skills/git-issue/SKILL.md +69 -0
- package/claude/skills/git-issue/references/issue.md +84 -0
- package/claude/skills/git-pr/REQUIREMENT.md +37 -0
- package/claude/skills/git-pr/SKILL.md +119 -0
- package/claude/skills/git-pr/references/branch.md +49 -0
- package/claude/skills/git-pr/references/pr.md +124 -0
- package/claude/skills/git-ship/REQUIREMENT.md +34 -0
- package/claude/skills/git-ship/SKILL.md +39 -0
- package/claude/skills/git-split/REQUIREMENT.md +38 -0
- package/claude/skills/git-split/SKILL.md +160 -0
- package/claude/skills/git-split/references/branch.md +49 -0
- package/claude/skills/git-split/references/pr.md +124 -0
- package/claude/skills/git-stage/REQUIREMENT.md +36 -0
- package/claude/skills/git-stage/SKILL.md +74 -0
- package/claude/skills/git-stage/references/commit.md +63 -0
- package/claude/skills/git-worktree/REQUIREMENT.md +37 -0
- package/claude/skills/git-worktree/SKILL.md +120 -0
- package/claude/skills/migration-claude-md/SKILL.md +74 -0
- package/claude/skills/migration-context/SKILL.md +93 -0
- package/claude/skills/migration-standards/SKILL.md +75 -0
- package/claude/skills/project-commands/SKILL.md +73 -0
- package/claude/skills/session-resume/SKILL.md +36 -0
- package/claude/skills/setup-gov/SKILL.md +71 -0
- package/claude/skills/setup-indexes/SKILL.md +148 -0
- package/claude/skills/setup-init/SKILL.md +114 -0
- package/claude/skills/setup-plugins/SKILL.md +81 -0
- package/claude/skills/setup-plugins/references/plugin-catalog.md +53 -0
- package/claude/skills/setup-verify/SKILL.md +51 -0
- package/claude/skills/systematic-debugging/SKILL.md +70 -0
- package/claude/skills/toolkit-cli/SKILL.md +38 -0
- package/claude/skills/toolkit-feedback/SKILL.md +80 -0
- package/claude/skills/toolkit-operator/SKILL.md +45 -0
- package/claude/skills/toolkit-triage/SKILL.md +63 -0
- package/claude/skills/youtube-transcripts/SKILL.md +34 -0
- package/docs/agents.md +371 -0
- package/docs/ai-workflow.md +157 -0
- package/docs/index.md +20 -0
- package/docs/operating-model.md +106 -0
- package/docs/target-projects.md +183 -0
- package/docs/visual-design-workflow.md +136 -0
- package/docs/zshrc-aliases.md +65 -0
- package/governance/rules/claude/500-prose.md +12 -0
- package/governance/rules/claude/510-context.md +16 -0
- package/governance/rules/claude/520-wireframes.md +12 -0
- package/governance/rules/claude/530-requirements.md +11 -0
- package/governance/rules/claude/540-architecture.md +11 -0
- package/governance/rules/claude/550-design.md +11 -0
- package/governance/rules/claude/555-tasks.md +12 -0
- package/governance/rules/claude/560-diagrams.md +11 -0
- package/governance/rules/claude/570-skill.md +18 -0
- package/governance/rules/claude/580-readme.md +11 -0
- package/governance/rules/claude/590-rule-authoring.md +12 -0
- package/governance/rules/core/000-constitution.md +30 -0
- package/governance/rules/core/010-testing.md +35 -0
- package/governance/rules/core/020-concurrency.md +22 -0
- package/governance/rules/core/030-error-handling.md +31 -0
- package/governance/rules/core/040-performance.md +20 -0
- package/governance/rules/core/050-logging.md +20 -0
- package/governance/rules/core/060-naming.md +19 -0
- package/governance/rules/core/070-planning.md +13 -0
- package/governance/rules/core/080-config-comments.md +22 -0
- package/governance/rules/framework/200-react.md +51 -0
- package/governance/rules/framework/210-astro.md +41 -0
- package/governance/rules/framework/220-fastapi.md +43 -0
- package/governance/rules/framework/230-nextjs.md +48 -0
- package/governance/rules/framework/250-tailwind.md +32 -0
- package/governance/rules/framework/260-shadcn.md +34 -0
- package/governance/rules/lang/100-typescript.md +40 -0
- package/governance/rules/lang/110-python.md +42 -0
- package/governance/rules/lib/300-testing-ts.md +38 -0
- package/governance/rules/lib/310-zod.md +25 -0
- package/governance/rules/lib/320-tanstack-query.md +32 -0
- package/governance/rules/lib/330-testing-py.md +44 -0
- package/governance/rules/lib/340-pydantic.md +38 -0
- package/governance/rules/lib/350-security-web.md +31 -0
- package/governance/rules/ui/400-ui.md +34 -0
- package/governance/rules/ui/410-a11y.md +44 -0
- package/governance/rules/ui/420-forms.md +34 -0
- package/governance/rules/ui/430-ux-completeness.md +62 -0
- package/governance/stacks/astro.toml +2 -0
- package/governance/stacks/base.toml +2 -0
- package/governance/stacks/node.toml +2 -0
- package/governance/stacks/python-fastapi.toml +2 -0
- package/governance/stacks/python.toml +2 -0
- package/governance/stacks/react.toml +2 -0
- package/package.json +67 -0
- package/scripts/config.sh +11 -0
- package/scripts/core/bootstrap.sh +81 -0
- package/scripts/core/check-plugin-boundary.sh +45 -0
- package/scripts/core/check-skill-paths.sh +25 -0
- package/scripts/core/clean.sh +36 -0
- package/scripts/core/install-check.sh +93 -0
- package/scripts/core/regen-claude-copies.sh +26 -0
- package/scripts/core/regen-indexes.sh +10 -0
- package/scripts/core/regen-skill-references.sh +27 -0
- package/scripts/core/snapshot.sh +132 -0
- package/scripts/core/update.sh +35 -0
- package/scripts/core/verify.sh +201 -0
- package/scripts/docs/list.sh +129 -0
- package/scripts/gov/list.sh +234 -0
- package/scripts/lib/frontmatter.sh +30 -0
- package/scripts/lib/gov.sh +14 -0
- package/scripts/lib/sandbox-fixtures.sh +109 -0
- package/scripts/lib/sandbox-git.sh +73 -0
- package/scripts/lib/tooling.sh +24 -0
- package/scripts/lib/ui.sh +173 -0
- package/scripts/lib/worktree.sh +20 -0
- package/scripts/manage-sandbox.sh +453 -0
- package/scripts/snippets/create.sh +156 -0
- package/scripts/standards/list.sh +91 -0
- package/scripts/tooling/create.sh +109 -0
- package/scripts/tooling/ref.sh +141 -0
- package/scripts/tooling/verify.sh +179 -0
- package/snippets/claude/decision-memo.md +39 -0
- package/snippets/claude/feature-recap.md +19 -0
- package/snippets/claude/figma-steps.md +18 -0
- package/snippets/claude/orchestrator-resume.md +23 -0
- package/snippets/claude/orchestrator-sweep.md +10 -0
- package/snippets/compact-summary.md +5 -0
- package/snippets/create-snippet.md +7 -0
- package/snippets/decision-help.md +6 -0
- package/snippets/meta-prompt.md +14 -0
- package/snippets/research-prompt.md +7 -0
- package/snippets/session-notes.md +11 -0
- package/snippets/snippets.toml +6 -0
- package/snippets/step-by-step.md +10 -0
- package/snippets/web-research.md +21 -0
- package/src/claude/gitignore.ts +51 -0
- package/src/claude/seeds-list.ts +47 -0
- package/src/claude/seeds.ts +124 -0
- package/src/claude/settings.ts +151 -0
- package/src/cli-run.ts +43 -0
- package/src/cli.ts +111 -0
- package/src/commands/claude.ts +349 -0
- package/src/commands/design.ts +39 -0
- package/src/commands/docs.ts +59 -0
- package/src/commands/feedback-format.ts +23 -0
- package/src/commands/feedback.ts +103 -0
- package/src/commands/gov.ts +268 -0
- package/src/commands/indexes.ts +182 -0
- package/src/commands/init.ts +117 -0
- package/src/commands/pass-through.ts +39 -0
- package/src/commands/sandbox.ts +315 -0
- package/src/commands/slides.ts +106 -0
- package/src/commands/snippets.ts +236 -0
- package/src/commands/standards.ts +89 -0
- package/src/commands/sync.ts +211 -0
- package/src/commands/tasks.ts +185 -0
- package/src/commands/tooling.ts +432 -0
- package/src/commands/transcripts.ts +48 -0
- package/src/commands/wiki.ts +101 -0
- package/src/copy.ts +30 -0
- package/src/design/parse.ts +79 -0
- package/src/design/render.ts +170 -0
- package/src/docs/read.ts +59 -0
- package/src/exec.ts +17 -0
- package/src/frontmatter.ts +13 -0
- package/src/github-format.ts +13 -0
- package/src/github.ts +20 -0
- package/src/gov/adapter.ts +73 -0
- package/src/gov/install.ts +103 -0
- package/src/gov/payload.ts +39 -0
- package/src/gov/stacks.ts +124 -0
- package/src/indexes/frontmatter.ts +46 -0
- package/src/indexes/regen.ts +84 -0
- package/src/indexes/render.ts +181 -0
- package/src/indexes/walk.ts +111 -0
- package/src/init/flags.ts +66 -0
- package/src/init/plan.ts +128 -0
- package/src/init/run.ts +46 -0
- package/src/init/steps.ts +91 -0
- package/src/sandbox/coverage.ts +115 -0
- package/src/sandbox/expect.ts +412 -0
- package/src/slides/layouts.ts +391 -0
- package/src/slides/open.ts +18 -0
- package/src/slides/parse.ts +84 -0
- package/src/slides/render.ts +88 -0
- package/src/slides/styles.ts +44 -0
- package/src/snippets/adapter.ts +34 -0
- package/src/snippets/categories.ts +59 -0
- package/src/snippets/install.ts +125 -0
- package/src/snippets/list.ts +32 -0
- package/src/snippets/presets.ts +50 -0
- package/src/standards/adapter.ts +51 -0
- package/src/standards/index-refresh.ts +44 -0
- package/src/standards/install.ts +52 -0
- package/src/sync/check.ts +269 -0
- package/src/sync/engine.ts +416 -0
- package/src/sync/git.ts +204 -0
- package/src/sync/stamp.ts +202 -0
- package/src/sync/target.ts +79 -0
- package/src/sync/workflow.ts +201 -0
- package/src/target.ts +43 -0
- package/src/tasks/archive.ts +304 -0
- package/src/tooling/gitignore.ts +122 -0
- package/src/tooling/inject.ts +214 -0
- package/src/tooling/list.ts +39 -0
- package/src/tooling/manifest.ts +178 -0
- package/src/tooling/package.ts +166 -0
- package/src/tooling/scan.ts +186 -0
- package/src/transcripts/fetch.ts +156 -0
- package/src/transcripts/metadata.ts +54 -0
- package/src/transcripts/vtt.ts +114 -0
- package/src/ui.ts +187 -0
- package/src/wiki/init.ts +85 -0
- package/standards/architecture.md +41 -0
- package/standards/bundled/branch.md +49 -0
- package/standards/bundled/commit.md +63 -0
- package/standards/bundled/issue.md +84 -0
- package/standards/bundled/pr.md +124 -0
- package/standards/bundled/roadmap.md +52 -0
- package/standards/bundled/snippets.md +57 -0
- package/standards/bundled/standard.md +52 -0
- package/standards/context.md +132 -0
- package/standards/design.md +31 -0
- package/standards/diagrams.md +55 -0
- package/standards/index.md +21 -0
- package/standards/prose.md +122 -0
- package/standards/readme.md +163 -0
- package/standards/requirements.md +61 -0
- package/standards/rule.md +79 -0
- package/standards/skill.md +257 -0
- package/standards/tasks.md +140 -0
- package/standards/versioning.md +47 -0
- package/standards/wireframes.md +67 -0
- package/tooling/astro/configs/astro.config.mjs +17 -0
- package/tooling/astro/configs/eslint.config.js +79 -0
- package/tooling/astro/configs/playwright.config.ts +25 -0
- package/tooling/astro/configs/tsconfig.json +12 -0
- package/tooling/astro/configs/vitest.config.ts +22 -0
- package/tooling/astro/manifest.toml +32 -0
- package/tooling/astro/reference.md +60 -0
- package/tooling/base/configs/.editorconfig +5 -0
- package/tooling/base/configs/.github/pull_request_template.md +18 -0
- package/tooling/base/configs/.github/workflows/verify.yml +35 -0
- package/tooling/base/configs/.husky/commit-msg +1 -0
- package/tooling/base/configs/.husky/post-merge +61 -0
- package/tooling/base/configs/.husky/post-rewrite +21 -0
- package/tooling/base/configs/.husky/pre-commit +1 -0
- package/tooling/base/configs/.husky/pre-push +1 -0
- package/tooling/base/configs/.prettierrc +12 -0
- package/tooling/base/configs/.shellcheckrc +1 -0
- package/tooling/base/configs/.vscode/extensions.json +9 -0
- package/tooling/base/configs/.vscode/settings.json +3 -0
- package/tooling/base/configs/commitlint.config.js +11 -0
- package/tooling/base/configs/scripts/clean.sh +45 -0
- package/tooling/base/configs/scripts/update.sh +49 -0
- package/tooling/base/configs/scripts/verify.sh +64 -0
- package/tooling/base/manifest.toml +32 -0
- package/tooling/base/reference.md +114 -0
- package/tooling/base/seeds/.claude/context/ci.md +27 -0
- package/tooling/base/seeds/.claude/context/development.md +32 -0
- package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +19 -0
- package/tooling/base/seeds/.lintstagedrc +8 -0
- package/tooling/base/seeds/.prettierignore +0 -0
- package/tooling/base/seeds/cspell.json +20 -0
- package/tooling/claude/manifest.toml +11 -0
- package/tooling/claude/reference.md +62 -0
- package/tooling/claude/seeds/.claude/ARCHITECTURE.md +13 -0
- package/tooling/claude/seeds/.claude/DESIGN.md +62 -0
- package/tooling/claude/seeds/.claude/REQUIREMENTS.md +18 -0
- package/tooling/claude/seeds/.claude/context/index.md +8 -0
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +43 -0
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +45 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +61 -0
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +60 -0
- package/tooling/claude/seeds/.claude/settings.json +39 -0
- package/tooling/claude/seeds/.claude/tasks/index.md +8 -0
- package/tooling/claude/seeds/.claude/wireframes/index.md +8 -0
- package/tooling/claude/seeds/CLAUDE.md +87 -0
- package/tooling/claude/user/settings.template.json +10 -0
- package/tooling/claude/user/statusline-command.sh +53 -0
- package/tooling/python/configs/.coveragerc +14 -0
- package/tooling/python/configs/.python-version +1 -0
- package/tooling/python/configs/mypy.ini +6 -0
- package/tooling/python/configs/pytest.ini +4 -0
- package/tooling/python/configs/ruff.toml +15 -0
- package/tooling/python/configs/scripts/verify.sh +77 -0
- package/tooling/python/manifest.toml +16 -0
- package/tooling/python/reference.md +66 -0
- package/tooling/python/seeds/.cspell/tech-stack.txt +19 -0
- package/tooling/python/seeds/tests/test_smoke.py +2 -0
- package/tooling/vite-react/configs/playwright.config.ts +25 -0
- package/tooling/vite-react/configs/tsconfig.json +35 -0
- package/tooling/vite-react/configs/vite.config.ts +14 -0
- package/tooling/vite-react/configs/vitest.config.ts +26 -0
- package/tooling/vite-react/manifest.toml +21 -0
- package/tooling/vite-react/reference.md +55 -0
- package/tooling/vite-react/seeds/.cspell/project-terms.txt +1 -0
- package/tooling/vite-react/seeds/.cspell/tech-stack.txt +1 -0
- package/tooling/web/configs/.github/workflows/verify.yml +134 -0
- package/tooling/web/configs/.vscode/extensions.json +13 -0
- package/tooling/web/configs/.vscode/settings.json +10 -0
- package/tooling/web/configs/e2e/home.spec.ts +6 -0
- package/tooling/web/configs/e2e/screenshot.ts +41 -0
- package/tooling/web/configs/eslint.config.js +82 -0
- package/tooling/web/configs/scripts/screenshot.sh +28 -0
- package/tooling/web/configs/scripts/verify.sh +80 -0
- package/tooling/web/configs/src/test/setup.ts +8 -0
- package/tooling/web/manifest.toml +57 -0
- package/tooling/web/reference.md +95 -0
- package/tooling/web/seeds/.cspell/tech-stack.txt +18 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isDirectory } from '@/target'
|
|
4
|
+
|
|
5
|
+
export const BASE_CATEGORY = 'base'
|
|
6
|
+
|
|
7
|
+
export function snippetsSourceDir(root: string): string {
|
|
8
|
+
return join(root, 'snippets')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lists the folder categories. `base` is the synthetic name for snippets
|
|
13
|
+
* sitting directly under `snippets/`, so it is not a directory and callers
|
|
14
|
+
* prepend it themselves.
|
|
15
|
+
*
|
|
16
|
+
* Nothing is filtered: toolkit-internal snippets are authored under `internal/`,
|
|
17
|
+
* which this never reads, so every category found here is publishable.
|
|
18
|
+
*/
|
|
19
|
+
export function listFolderCategories(root: string): string[] {
|
|
20
|
+
const dir = snippetsSourceDir(root)
|
|
21
|
+
if (!isDirectory(dir)) return []
|
|
22
|
+
|
|
23
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isDirectory())
|
|
25
|
+
.map((entry) => entry.name)
|
|
26
|
+
.sort()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listCategories(root: string): string[] {
|
|
30
|
+
return [BASE_CATEGORY, ...listFolderCategories(root)]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function categoryDir(root: string, category: string): string {
|
|
34
|
+
const source = snippetsSourceDir(root)
|
|
35
|
+
return category === BASE_CATEGORY ? source : join(source, category)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tests for a directory rather than mere existence. `snippets/` holds files
|
|
40
|
+
* alongside its category folders, so an argument naming one of them resolves
|
|
41
|
+
* to a real path that cannot be scanned as a category.
|
|
42
|
+
*/
|
|
43
|
+
export function categoryExists(root: string, category: string): boolean {
|
|
44
|
+
return isDirectory(categoryDir(root, category))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Lists one category's entry slugs. Only the immediate level is read, matching
|
|
49
|
+
* the `find -maxdepth 1` the bash used, so a nested folder is its own category
|
|
50
|
+
* rather than part of its parent.
|
|
51
|
+
*/
|
|
52
|
+
export function listEntries(root: string, category: string): string[] {
|
|
53
|
+
const dir = categoryDir(root, category)
|
|
54
|
+
if (!isDirectory(dir)) return []
|
|
55
|
+
|
|
56
|
+
return [...new Bun.Glob('*.md').scanSync({ cwd: dir, onlyFiles: true })]
|
|
57
|
+
.map((name) => name.slice(0, -'.md'.length))
|
|
58
|
+
.sort()
|
|
59
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { copyPreservingMode } from '@/copy'
|
|
4
|
+
import {
|
|
5
|
+
BASE_CATEGORY,
|
|
6
|
+
categoryDir,
|
|
7
|
+
categoryExists,
|
|
8
|
+
listCategories,
|
|
9
|
+
listEntries,
|
|
10
|
+
snippetsSourceDir,
|
|
11
|
+
} from '@/snippets/categories'
|
|
12
|
+
import { findPreset, loadPresets } from '@/snippets/presets'
|
|
13
|
+
|
|
14
|
+
export const ALL_CATEGORY = 'all'
|
|
15
|
+
|
|
16
|
+
export interface SnippetFile {
|
|
17
|
+
readonly src: string
|
|
18
|
+
readonly relPath: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SnippetResolution =
|
|
22
|
+
| {
|
|
23
|
+
readonly ok: true
|
|
24
|
+
readonly step: string
|
|
25
|
+
readonly files: readonly SnippetFile[]
|
|
26
|
+
readonly missing: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
| { readonly ok: false; readonly unknownCategory: string }
|
|
29
|
+
|
|
30
|
+
export function installedSnippetsDir(target: string): string {
|
|
31
|
+
return join(target, '.claude', 'snippets')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derives the destination path. A snippet directly under `snippets/` installs
|
|
36
|
+
* flat and a nested one keeps its immediate parent, so the installed tree
|
|
37
|
+
* mirrors the source one level deep.
|
|
38
|
+
*/
|
|
39
|
+
export function deriveDestRelPath(root: string, src: string): string {
|
|
40
|
+
const parent = dirname(src)
|
|
41
|
+
const filename = basename(src)
|
|
42
|
+
|
|
43
|
+
return parent === snippetsSourceDir(root)
|
|
44
|
+
? filename
|
|
45
|
+
: `${basename(parent)}/${filename}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function filesInCategory(root: string, category: string): SnippetFile[] {
|
|
49
|
+
return listEntries(root, category).map((slug) => {
|
|
50
|
+
const src = join(categoryDir(root, category), `${slug}.md`)
|
|
51
|
+
return { src, relPath: deriveDestRelPath(root, src) }
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves the one argument three ways. `all` wins, then a preset name, then a
|
|
57
|
+
* folder, which is the bash precedence and matters because a folder sharing a
|
|
58
|
+
* preset name resolves to the preset.
|
|
59
|
+
*
|
|
60
|
+
* Preset slugs pass the internal-category filter too. A slug is a path relative
|
|
61
|
+
* to `snippets/`, so a preset naming `aitk/<slug>` would otherwise reach an
|
|
62
|
+
* internal snippet through the one install path the filter did not cover.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveSnippets(
|
|
65
|
+
root: string,
|
|
66
|
+
category: string,
|
|
67
|
+
): SnippetResolution {
|
|
68
|
+
if (category === ALL_CATEGORY) {
|
|
69
|
+
const files = listCategories(root).flatMap((name) =>
|
|
70
|
+
filesInCategory(root, name),
|
|
71
|
+
)
|
|
72
|
+
return { ok: true, step: 'Resolving all categories', files, missing: [] }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const preset = findPreset(root, category)
|
|
76
|
+
if (preset) {
|
|
77
|
+
const files: SnippetFile[] = []
|
|
78
|
+
const missing: string[] = []
|
|
79
|
+
|
|
80
|
+
for (const slug of preset.slugs) {
|
|
81
|
+
const src = join(snippetsSourceDir(root), `${slug}.md`)
|
|
82
|
+
if (existsSync(src)) files.push({ src, relPath: `${slug}.md` })
|
|
83
|
+
else missing.push(slug)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: true, step: `Resolving preset: ${category}`, files, missing }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (category !== BASE_CATEGORY && !categoryExists(root, category)) {
|
|
90
|
+
return { ok: false, unknownCategory: category }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
step: `Resolving category: ${category}`,
|
|
96
|
+
files: filesInCategory(root, category),
|
|
97
|
+
missing: [],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Lists everything the picker offers: presets first, then `base`, then the
|
|
103
|
+
* folder categories, matching the order the bash built the menu in.
|
|
104
|
+
*/
|
|
105
|
+
export function installableCategories(root: string): string[] {
|
|
106
|
+
return [
|
|
107
|
+
...loadPresets(root).map((preset) => preset.name),
|
|
108
|
+
...listCategories(root),
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function installSnippets(
|
|
113
|
+
files: readonly SnippetFile[],
|
|
114
|
+
target: string,
|
|
115
|
+
): Promise<string[]> {
|
|
116
|
+
const destDir = installedSnippetsDir(target)
|
|
117
|
+
const installed: string[] = []
|
|
118
|
+
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
await copyPreservingMode(file.src, join(destDir, file.relPath))
|
|
121
|
+
installed.push(join('.claude', 'snippets', file.relPath))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return installed
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { listCategories, listEntries } from '@/snippets/categories'
|
|
2
|
+
import { loadPresets } from '@/snippets/presets'
|
|
3
|
+
|
|
4
|
+
export interface CategoryCatalogEntry {
|
|
5
|
+
readonly name: string
|
|
6
|
+
readonly entries: readonly string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SnippetsCatalog {
|
|
10
|
+
readonly presets: readonly {
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly slugs: readonly string[]
|
|
13
|
+
}[]
|
|
14
|
+
readonly categories: readonly CategoryCatalogEntry[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds the catalog both output modes read, so the human list and the `--json`
|
|
19
|
+
* one cannot describe different corpora.
|
|
20
|
+
*/
|
|
21
|
+
export function buildSnippetsCatalog(root: string): SnippetsCatalog {
|
|
22
|
+
return {
|
|
23
|
+
presets: loadPresets(root).map((preset) => ({
|
|
24
|
+
name: preset.name,
|
|
25
|
+
slugs: preset.slugs,
|
|
26
|
+
})),
|
|
27
|
+
categories: listCategories(root).map((name) => ({
|
|
28
|
+
name,
|
|
29
|
+
entries: listEntries(root, name),
|
|
30
|
+
})),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { snippetsSourceDir } from '@/snippets/categories'
|
|
4
|
+
|
|
5
|
+
export interface Preset {
|
|
6
|
+
readonly name: string
|
|
7
|
+
readonly slugs: readonly string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function presetsPath(root: string): string {
|
|
11
|
+
return join(snippetsSourceDir(root), 'snippets.toml')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reads every preset from `snippets.toml`. The bash read section headers with
|
|
16
|
+
* `grep '^\['` and then re-walked the file per preset with a `BASH_REMATCH`
|
|
17
|
+
* loop, so a name carrying a bracket or a slug split across lines parsed by
|
|
18
|
+
* accident rather than by rule.
|
|
19
|
+
*/
|
|
20
|
+
export function loadPresets(root: string): Preset[] {
|
|
21
|
+
const path = presetsPath(root)
|
|
22
|
+
if (!existsSync(path)) return []
|
|
23
|
+
|
|
24
|
+
const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
25
|
+
string,
|
|
26
|
+
unknown
|
|
27
|
+
>
|
|
28
|
+
const presets: Preset[] = []
|
|
29
|
+
|
|
30
|
+
for (const [name, table] of Object.entries(parsed)) {
|
|
31
|
+
if (typeof table !== 'object' || table === null || Array.isArray(table)) {
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
const names = (table as Record<string, unknown>).names
|
|
35
|
+
presets.push({
|
|
36
|
+
name,
|
|
37
|
+
slugs: Array.isArray(names)
|
|
38
|
+
? names.filter(
|
|
39
|
+
(slug): slug is string => typeof slug === 'string' && slug !== '',
|
|
40
|
+
)
|
|
41
|
+
: [],
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return presets.sort((left, right) => left.name.localeCompare(right.name))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findPreset(root: string, name: string): Preset | undefined {
|
|
49
|
+
return loadPresets(root).find((preset) => preset.name === name)
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
INDEX_FILE,
|
|
5
|
+
refreshIndex,
|
|
6
|
+
standardsInstallDir,
|
|
7
|
+
} from '@/standards/index-refresh'
|
|
8
|
+
import type { InstalledFile, SyncAdapter } from '@/sync/engine'
|
|
9
|
+
|
|
10
|
+
export function standardsSourceDir(root: string): string {
|
|
11
|
+
return join(root, 'standards')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Matches installed standards to sources by filename against the flat
|
|
16
|
+
* `standards/` root. A standard that lives in a source subfolder, such as
|
|
17
|
+
* `bundled/`, has no flat sibling and so reads as project-authored, which is
|
|
18
|
+
* what keeps `install` and `sync` agreeing on the same set.
|
|
19
|
+
*
|
|
20
|
+
* Standards are seeds a project is expected to edit, unlike gov rules and
|
|
21
|
+
* snippets, so a headless run refuses to overwrite drift rather than applying
|
|
22
|
+
* it. That refusal is the reason the engine grew a per-adapter policy.
|
|
23
|
+
*/
|
|
24
|
+
export function createStandardsAdapter(root: string): SyncAdapter {
|
|
25
|
+
const sourceDir = standardsSourceDir(root)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
banner: 'aitk standards sync',
|
|
29
|
+
label: 'standards',
|
|
30
|
+
missingMessage:
|
|
31
|
+
"No .claude/standards/ found in target. Run 'aitk standards install' first.",
|
|
32
|
+
unit: 'standards',
|
|
33
|
+
installedRoot: standardsInstallDir,
|
|
34
|
+
isExcluded: (file: InstalledFile) => basename(file.path) === INDEX_FILE,
|
|
35
|
+
locateSource: (file: InstalledFile) =>
|
|
36
|
+
locateSource(sourceDir, basename(file.path)),
|
|
37
|
+
nonInteractive: {
|
|
38
|
+
kind: 'refuse',
|
|
39
|
+
message:
|
|
40
|
+
'Drifts detected. Refusing to auto-apply in non-interactive mode.',
|
|
41
|
+
hint: 'Run interactively, or use /claude-seed-sync for per-section audit that preserves customizations.',
|
|
42
|
+
},
|
|
43
|
+
onComplete: (target: string) => refreshIndex(sourceDir, target),
|
|
44
|
+
stamp: { domain: 'standards', toolkitRoot: root },
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function locateSource(sourceDir: string, name: string): string | undefined {
|
|
49
|
+
const source = join(sourceDir, name)
|
|
50
|
+
return existsSync(source) ? source : undefined
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { copyPreservingMode } from '@/copy'
|
|
4
|
+
import { regenOne } from '@/indexes/regen'
|
|
5
|
+
import { logAdd, logWarn } from '@/ui'
|
|
6
|
+
|
|
7
|
+
export const STANDARDS_REL = join('.claude', 'standards')
|
|
8
|
+
export const INDEX_FILE = 'index.md'
|
|
9
|
+
|
|
10
|
+
export function standardsInstallDir(target: string): string {
|
|
11
|
+
return join(target, STANDARDS_REL)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Replaces the target's `index.md` from source and rebuilds it against what
|
|
16
|
+
* actually landed. It runs on every completed install and every completed sync,
|
|
17
|
+
* including one with no changes, because the catalog can go stale from a file
|
|
18
|
+
* the toolkit stopped shipping rather than from drift in a file it still does.
|
|
19
|
+
*
|
|
20
|
+
* `install` and `sync` are peers, so this sits beside both rather than inside
|
|
21
|
+
* the sync adapter, where an install would be importing from the other verb.
|
|
22
|
+
*/
|
|
23
|
+
export async function refreshIndex(
|
|
24
|
+
sourceDir: string,
|
|
25
|
+
target: string,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const installedDir = standardsInstallDir(target)
|
|
28
|
+
const source = join(sourceDir, INDEX_FILE)
|
|
29
|
+
|
|
30
|
+
if (!existsSync(source)) {
|
|
31
|
+
logWarn(`No ${INDEX_FILE} in toolkit standards, leaving the target catalog`)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await copyPreservingMode(source, join(installedDir, INDEX_FILE))
|
|
36
|
+
const result = await regenOne(installedDir, { dryRun: false })
|
|
37
|
+
|
|
38
|
+
if (result.action === 'error') {
|
|
39
|
+
logWarn(`${join(STANDARDS_REL, INDEX_FILE)} regen failed: ${result.reason}`)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
logAdd(join(STANDARDS_REL, INDEX_FILE))
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { copyPreservingMode } from '@/copy'
|
|
5
|
+
import { INDEX_FILE, STANDARDS_REL } from '@/standards/index-refresh'
|
|
6
|
+
|
|
7
|
+
export interface StandardsSource {
|
|
8
|
+
readonly path: string
|
|
9
|
+
readonly name: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lists the flat `standards/` root only, which is the same set the sync adapter
|
|
14
|
+
* matches installed files against. A standard in a source subfolder such as
|
|
15
|
+
* `bundled/` is not installed wholesale, so the two verbs agree on scope.
|
|
16
|
+
*
|
|
17
|
+
* `index.md` is excluded because install copies it separately and then rebuilds
|
|
18
|
+
* it against what landed, rather than shipping the toolkit's own catalog.
|
|
19
|
+
*/
|
|
20
|
+
export function planInstall(sourceDir: string): StandardsSource[] {
|
|
21
|
+
if (!existsSync(sourceDir)) return []
|
|
22
|
+
|
|
23
|
+
return readdirSync(sourceDir, { withFileTypes: true })
|
|
24
|
+
.filter(
|
|
25
|
+
(entry) =>
|
|
26
|
+
entry.isFile() &&
|
|
27
|
+
entry.name.endsWith('.md') &&
|
|
28
|
+
entry.name !== INDEX_FILE,
|
|
29
|
+
)
|
|
30
|
+
.map((entry) => ({ path: join(sourceDir, entry.name), name: entry.name }))
|
|
31
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Copies every standard and returns the labels to log. Routes through
|
|
36
|
+
* `copyPreservingMode` because the `cp` it replaces left an existing
|
|
37
|
+
* destination's mode alone, so a target file held at 600 stays there.
|
|
38
|
+
*/
|
|
39
|
+
export async function applyInstall(
|
|
40
|
+
files: readonly StandardsSource[],
|
|
41
|
+
destDir: string,
|
|
42
|
+
): Promise<string[]> {
|
|
43
|
+
await mkdir(destDir, { recursive: true })
|
|
44
|
+
|
|
45
|
+
await Promise.all(
|
|
46
|
+
files.map((file) =>
|
|
47
|
+
copyPreservingMode(file.path, join(destDir, file.name)),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return files.map((file) => join(STANDARDS_REL, file.name))
|
|
52
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import { createGovAdapter } from '@/gov/adapter'
|
|
4
|
+
import { createSnippetsAdapter } from '@/snippets/adapter'
|
|
5
|
+
import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
|
|
6
|
+
import {
|
|
7
|
+
readStamp,
|
|
8
|
+
STAMP_DOMAINS,
|
|
9
|
+
type Stamp,
|
|
10
|
+
stampedCommit,
|
|
11
|
+
type StampDomain,
|
|
12
|
+
} from '@/sync/stamp'
|
|
13
|
+
import { createStandardsAdapter } from '@/standards/adapter'
|
|
14
|
+
import { isDirectory } from '@/target'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The toolkit path whose commits change what each domain holds. `claude/skills/`
|
|
18
|
+
* is deliberately absent: skills load live from the plugin directory, so they
|
|
19
|
+
* never go stale and belong in the read-only section instead.
|
|
20
|
+
*/
|
|
21
|
+
const SYNCED_SOURCES: Record<StampDomain, string> = {
|
|
22
|
+
standards: 'standards/',
|
|
23
|
+
snippets: 'snippets/',
|
|
24
|
+
governance: 'governance/rules/',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ADAPTERS: Record<StampDomain, (root: string) => SyncAdapter> = {
|
|
28
|
+
standards: createStandardsAdapter,
|
|
29
|
+
snippets: createSnippetsAdapter,
|
|
30
|
+
governance: createGovAdapter,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const INSTALL_MARKERS: Record<StampDomain, readonly string[]> = {
|
|
34
|
+
standards: ['.claude', 'standards'],
|
|
35
|
+
snippets: ['.claude', 'snippets'],
|
|
36
|
+
governance: ['.claude', 'rules'],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StateCounts {
|
|
40
|
+
readonly matching: number
|
|
41
|
+
readonly stale: number
|
|
42
|
+
readonly customized: number
|
|
43
|
+
readonly drifted: number
|
|
44
|
+
readonly orphaned: number
|
|
45
|
+
readonly stranded: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DomainReport {
|
|
49
|
+
readonly domain: StampDomain
|
|
50
|
+
readonly stamped: boolean
|
|
51
|
+
/** This domain's own anchor, not the target's most recent sync. */
|
|
52
|
+
readonly commit?: string
|
|
53
|
+
readonly syncedAt?: string
|
|
54
|
+
readonly counts: StateCounts
|
|
55
|
+
readonly entries: readonly ScanEntry[]
|
|
56
|
+
readonly upstream: readonly UpstreamCommit[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UpstreamCommit {
|
|
60
|
+
readonly sha: string
|
|
61
|
+
readonly subject: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CheckReport {
|
|
65
|
+
readonly covers: readonly StampDomain[]
|
|
66
|
+
readonly domains: readonly DomainReport[]
|
|
67
|
+
readonly newSkills: readonly string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function installedStampDomains(target: string): StampDomain[] {
|
|
71
|
+
return STAMP_DOMAINS.filter((domain) =>
|
|
72
|
+
isDirectory(join(target, ...INSTALL_MARKERS[domain])),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
77
|
+
return {
|
|
78
|
+
matching: count(entries, 'matching'),
|
|
79
|
+
stale: count(entries, 'stale'),
|
|
80
|
+
customized: count(entries, 'customized'),
|
|
81
|
+
drifted: count(entries, 'drifted'),
|
|
82
|
+
orphaned: count(entries, 'orphaned'),
|
|
83
|
+
stranded: count(entries, 'stranded'),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the target has diverged from the toolkit in a way a sync could close.
|
|
89
|
+
* Orphaned files are excluded: a project-authored rule never converges, and
|
|
90
|
+
* counting it would leave `--exit-code` failing forever with no remedy.
|
|
91
|
+
*/
|
|
92
|
+
export function hasDrift(report: CheckReport): boolean {
|
|
93
|
+
return report.domains.some(
|
|
94
|
+
(domain) =>
|
|
95
|
+
domain.counts.stale +
|
|
96
|
+
domain.counts.customized +
|
|
97
|
+
domain.counts.drifted +
|
|
98
|
+
domain.counts.stranded >
|
|
99
|
+
0,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Bounds each domain's upstream read by that domain's own anchor and its own
|
|
105
|
+
* source path. A shared anchor would let a gov sync advance the revision
|
|
106
|
+
* standards measures from, silently dropping a standards change out of the read.
|
|
107
|
+
*/
|
|
108
|
+
export async function buildCheckReport(
|
|
109
|
+
toolkitRoot: string,
|
|
110
|
+
target: string,
|
|
111
|
+
): Promise<CheckReport> {
|
|
112
|
+
const stamp = readStamp(target)
|
|
113
|
+
|
|
114
|
+
const domains = await Promise.all(
|
|
115
|
+
installedStampDomains(target).map((domain) =>
|
|
116
|
+
buildDomainReport(toolkitRoot, target, stamp, domain),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const anchors = domains
|
|
121
|
+
.map((domain) => domain.commit)
|
|
122
|
+
.filter((commit): commit is string => commit !== undefined)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
covers: stamp?.covers ?? [],
|
|
126
|
+
domains,
|
|
127
|
+
newSkills: await readNewSkills(toolkitRoot, anchors),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function buildDomainReport(
|
|
132
|
+
toolkitRoot: string,
|
|
133
|
+
target: string,
|
|
134
|
+
stamp: Stamp | undefined,
|
|
135
|
+
domain: StampDomain,
|
|
136
|
+
): Promise<DomainReport> {
|
|
137
|
+
const plan = planSync(ADAPTERS[domain](toolkitRoot), target)
|
|
138
|
+
const record = stamp?.domains[domain]
|
|
139
|
+
const since = stampedCommit(stamp, domain)
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
domain,
|
|
143
|
+
stamped: record !== undefined,
|
|
144
|
+
commit: since,
|
|
145
|
+
syncedAt: record?.syncedAt,
|
|
146
|
+
counts: countStates(plan.entries),
|
|
147
|
+
entries: plan.entries,
|
|
148
|
+
upstream:
|
|
149
|
+
since === undefined
|
|
150
|
+
? []
|
|
151
|
+
: await readUpstream(toolkitRoot, since, SYNCED_SOURCES[domain]),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function parseUpstream(log: string): UpstreamCommit[] {
|
|
156
|
+
const commits: UpstreamCommit[] = []
|
|
157
|
+
|
|
158
|
+
for (const line of log.split('\n')) {
|
|
159
|
+
const trimmed = line.trim()
|
|
160
|
+
if (trimmed === '') continue
|
|
161
|
+
|
|
162
|
+
const boundary = trimmed.indexOf(' ')
|
|
163
|
+
if (boundary === -1) continue
|
|
164
|
+
|
|
165
|
+
commits.push({
|
|
166
|
+
sha: trimmed.slice(0, boundary),
|
|
167
|
+
subject: trimmed.slice(boundary + 1),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return commits
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A skill is new when its `SKILL.md` was added, not when a support file was. */
|
|
175
|
+
export function parseNewSkills(paths: string): string[] {
|
|
176
|
+
const names = paths
|
|
177
|
+
.split('\n')
|
|
178
|
+
.map((line) => line.trim())
|
|
179
|
+
.filter((line) => line.endsWith('/SKILL.md'))
|
|
180
|
+
.map((line) => line.split('/').at(-2))
|
|
181
|
+
.filter((name): name is string => name !== undefined)
|
|
182
|
+
|
|
183
|
+
return [...new Set(names)].sort()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readUpstream(
|
|
187
|
+
root: string,
|
|
188
|
+
since: string,
|
|
189
|
+
sourcePath: string,
|
|
190
|
+
): Promise<UpstreamCommit[]> {
|
|
191
|
+
const log = await read(root, [
|
|
192
|
+
'log',
|
|
193
|
+
'--oneline',
|
|
194
|
+
'--no-decorate',
|
|
195
|
+
`${since}..HEAD`,
|
|
196
|
+
'--',
|
|
197
|
+
sourcePath,
|
|
198
|
+
])
|
|
199
|
+
|
|
200
|
+
return parseUpstream(log)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Skills are not domain-scoped, so the read runs from the oldest anchor across
|
|
205
|
+
* domains. Over-reporting a skill costs a line, while measuring from the newest
|
|
206
|
+
* would hide one that arrived before the most recent domain sync.
|
|
207
|
+
*/
|
|
208
|
+
async function readNewSkills(
|
|
209
|
+
root: string,
|
|
210
|
+
anchors: readonly string[],
|
|
211
|
+
): Promise<string[]> {
|
|
212
|
+
const since = await oldestAnchor(root, anchors)
|
|
213
|
+
if (since === undefined) return []
|
|
214
|
+
|
|
215
|
+
const paths = await read(root, [
|
|
216
|
+
'diff',
|
|
217
|
+
'--name-only',
|
|
218
|
+
'--diff-filter=A',
|
|
219
|
+
`${since}..HEAD`,
|
|
220
|
+
'--',
|
|
221
|
+
'claude/skills/',
|
|
222
|
+
])
|
|
223
|
+
|
|
224
|
+
return parseNewSkills(paths)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function oldestAnchor(
|
|
228
|
+
root: string,
|
|
229
|
+
anchors: readonly string[],
|
|
230
|
+
): Promise<string | undefined> {
|
|
231
|
+
const unique = [...new Set(anchors)]
|
|
232
|
+
let oldest = unique[0]
|
|
233
|
+
|
|
234
|
+
for (const candidate of unique.slice(1)) {
|
|
235
|
+
if (await isAncestor(root, candidate, oldest)) oldest = candidate
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return oldest
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function isAncestor(
|
|
242
|
+
root: string,
|
|
243
|
+
candidate: string,
|
|
244
|
+
reference: string,
|
|
245
|
+
): Promise<boolean> {
|
|
246
|
+
const result = await execa(
|
|
247
|
+
'git',
|
|
248
|
+
['-C', root, 'merge-base', '--is-ancestor', candidate, reference],
|
|
249
|
+
{ reject: false },
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return result.exitCode === 0
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A toolkit outside a git clone, or a stamped revision this clone has never
|
|
257
|
+
* seen, yields no range. The per-file report still stands on its own.
|
|
258
|
+
*/
|
|
259
|
+
async function read(root: string, args: readonly string[]): Promise<string> {
|
|
260
|
+
const result = await execa('git', ['-C', root, ...args], { reject: false })
|
|
261
|
+
return result.exitCode === 0 ? result.stdout : ''
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function count(
|
|
265
|
+
entries: readonly ScanEntry[],
|
|
266
|
+
state: ScanEntry['state'],
|
|
267
|
+
): number {
|
|
268
|
+
return entries.filter((entry) => entry.state === state).length
|
|
269
|
+
}
|