@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,178 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const EXCLUDED_STACKS = ['claude']
|
|
5
|
+
|
|
6
|
+
export interface GitignoreSection {
|
|
7
|
+
readonly header: string
|
|
8
|
+
readonly entries: readonly string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Manifest {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly parent?: string
|
|
14
|
+
readonly dir: string
|
|
15
|
+
readonly configsDir: string
|
|
16
|
+
readonly seedsDir: string
|
|
17
|
+
readonly referenceFile: string
|
|
18
|
+
readonly scripts: Readonly<Record<string, string>>
|
|
19
|
+
readonly scriptOverrides: Readonly<Record<string, string>>
|
|
20
|
+
readonly gitignore: readonly GitignoreSection[]
|
|
21
|
+
readonly devPackages: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChainOptions {
|
|
25
|
+
readonly skipStack?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toolingDir(root: string): string {
|
|
29
|
+
return join(root, 'tooling')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function manifestPath(root: string, stack: string): string {
|
|
33
|
+
return join(toolingDir(root), stack, 'manifest.toml')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Lists every file under a directory, dotfiles included. Bun.Glob skips
|
|
38
|
+
* entries beginning with a dot unless `dot` is set, and tooling configs are
|
|
39
|
+
* almost entirely dotfiles, so omitting it silently matches nothing.
|
|
40
|
+
*/
|
|
41
|
+
export function listFiles(dir: string): string[] {
|
|
42
|
+
if (!existsSync(dir)) return []
|
|
43
|
+
return [
|
|
44
|
+
...new Bun.Glob('**/*').scanSync({ cwd: dir, onlyFiles: true, dot: true }),
|
|
45
|
+
].sort()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reverses a chain so the furthest ancestor is applied first and nearer
|
|
50
|
+
* stacks overwrite it.
|
|
51
|
+
*/
|
|
52
|
+
export function ancestorsFirst(chain: readonly Manifest[]): Manifest[] {
|
|
53
|
+
return [...chain].reverse()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stackExists(root: string, stack: string): boolean {
|
|
57
|
+
return existsSync(join(toolingDir(root), stack))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isStackExcluded(stack: string): boolean {
|
|
61
|
+
return EXCLUDED_STACKS.includes(stack)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Lists installable stack names, filtering the `claude` stack the way
|
|
66
|
+
* `is_tooling_stack_excluded` in `scripts/lib/tooling.sh` did. Claude is
|
|
67
|
+
* managed by `aitk claude`, not `aitk tooling`.
|
|
68
|
+
*/
|
|
69
|
+
export function listStacks(root: string): string[] {
|
|
70
|
+
const dir = toolingDir(root)
|
|
71
|
+
if (!existsSync(dir)) return []
|
|
72
|
+
|
|
73
|
+
return [...new Bun.Glob('*/manifest.toml').scanSync({ cwd: dir })]
|
|
74
|
+
.map((entry) => entry.split('/')[0])
|
|
75
|
+
.filter((name) => !isStackExcluded(name))
|
|
76
|
+
.sort()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads one manifest into a typed shape. Returns undefined when the file is
|
|
81
|
+
* absent, matching the bash guards that silently skipped a missing manifest.
|
|
82
|
+
*/
|
|
83
|
+
export function loadManifest(
|
|
84
|
+
root: string,
|
|
85
|
+
stack: string,
|
|
86
|
+
): Manifest | undefined {
|
|
87
|
+
const path = manifestPath(root, stack)
|
|
88
|
+
if (!existsSync(path)) return undefined
|
|
89
|
+
|
|
90
|
+
const parsed = asTable(Bun.TOML.parse(readFileSync(path, 'utf8')))
|
|
91
|
+
const stackTable = asTable(parsed.stack)
|
|
92
|
+
const parent =
|
|
93
|
+
typeof stackTable.extends === 'string' ? stackTable.extends : ''
|
|
94
|
+
const scriptsTable = asTable(parsed.scripts)
|
|
95
|
+
const dir = join(toolingDir(root), stack)
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
name: stack,
|
|
99
|
+
parent: parent === '' ? undefined : parent,
|
|
100
|
+
dir,
|
|
101
|
+
configsDir: join(dir, 'configs'),
|
|
102
|
+
seedsDir: join(dir, 'seeds'),
|
|
103
|
+
referenceFile: join(dir, 'reference.md'),
|
|
104
|
+
scripts: pickStrings(scriptsTable),
|
|
105
|
+
scriptOverrides: pickStrings(asTable(scriptsTable.override)),
|
|
106
|
+
gitignore: readGitignoreSections(asTable(parsed.gitignore)),
|
|
107
|
+
devPackages: readDevPackages(parsed.dependencies),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Walks the `extends` chain once, self first. Callers that apply parent
|
|
113
|
+
* values before child values reverse the result. Replaces the seventeen
|
|
114
|
+
* copies of `grep '^extends' | cut -d'"' -f2` the bash carried.
|
|
115
|
+
*
|
|
116
|
+
* A stack matching `skipStack` truncates the chain at that point, mirroring
|
|
117
|
+
* the `[ "$stack" = "${SKIP_STACK:-}" ] && return` guard.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveChain(
|
|
120
|
+
root: string,
|
|
121
|
+
stack: string,
|
|
122
|
+
options: ChainOptions = {},
|
|
123
|
+
): Manifest[] {
|
|
124
|
+
const chain: Manifest[] = []
|
|
125
|
+
const seen = new Set<string>()
|
|
126
|
+
let current: string | undefined = stack
|
|
127
|
+
|
|
128
|
+
while (current !== undefined) {
|
|
129
|
+
if (current === options.skipStack) break
|
|
130
|
+
if (seen.has(current)) break
|
|
131
|
+
seen.add(current)
|
|
132
|
+
|
|
133
|
+
const manifest: Manifest | undefined = loadManifest(root, current)
|
|
134
|
+
if (!manifest) break
|
|
135
|
+
|
|
136
|
+
chain.push(manifest)
|
|
137
|
+
current = manifest.parent
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return chain
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function asTable(value: unknown): Record<string, unknown> {
|
|
144
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
145
|
+
return {}
|
|
146
|
+
}
|
|
147
|
+
return value as Record<string, unknown>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pickStrings(table: Record<string, unknown>): Record<string, string> {
|
|
151
|
+
const result: Record<string, string> = {}
|
|
152
|
+
for (const [key, value] of Object.entries(table)) {
|
|
153
|
+
if (typeof value === 'string') result[key] = value
|
|
154
|
+
}
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readGitignoreSections(
|
|
159
|
+
table: Record<string, unknown>,
|
|
160
|
+
): GitignoreSection[] {
|
|
161
|
+
const sections: GitignoreSection[] = []
|
|
162
|
+
for (const [header, value] of Object.entries(table)) {
|
|
163
|
+
if (!Array.isArray(value)) continue
|
|
164
|
+
const entries = value.filter(
|
|
165
|
+
(entry): entry is string => typeof entry === 'string' && entry !== '',
|
|
166
|
+
)
|
|
167
|
+
sections.push({ header, entries })
|
|
168
|
+
}
|
|
169
|
+
return sections
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readDevPackages(dependencies: unknown): string[] {
|
|
173
|
+
const dev = asTable(asTable(dependencies).dev)
|
|
174
|
+
if (!Array.isArray(dev.packages)) return []
|
|
175
|
+
return dev.packages.filter(
|
|
176
|
+
(entry): entry is string => typeof entry === 'string' && entry !== '',
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import type { Manifest } from '@/tooling/manifest'
|
|
3
|
+
|
|
4
|
+
export interface PackageJson {
|
|
5
|
+
readonly dependencies?: Record<string, string>
|
|
6
|
+
readonly devDependencies?: Record<string, string>
|
|
7
|
+
readonly scripts?: Record<string, string>
|
|
8
|
+
readonly [key: string]: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ScriptState {
|
|
12
|
+
readonly key: string
|
|
13
|
+
readonly state: 'missing' | 'drifted' | 'matching'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DepState {
|
|
17
|
+
readonly spec: string
|
|
18
|
+
readonly name: string
|
|
19
|
+
readonly state: 'missing' | 'present'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readPackage(path: string): PackageJson | undefined {
|
|
23
|
+
if (!existsSync(path)) return undefined
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(path, 'utf8')) as PackageJson
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function serializePackage(pkg: PackageJson): string {
|
|
32
|
+
return `${JSON.stringify(pkg, null, 2)}\n`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Splits a dependency spec into its package name, handling the scoped case
|
|
37
|
+
* where the leading `@` is part of the name rather than a version separator.
|
|
38
|
+
* `@scope/pkg@^1.2.3` yields `@scope/pkg`, `react@19` yields `react`.
|
|
39
|
+
*/
|
|
40
|
+
export function parseSpec(spec: string): { name: string; pin: string } {
|
|
41
|
+
let name = spec
|
|
42
|
+
|
|
43
|
+
if (spec.startsWith('@')) {
|
|
44
|
+
const rest = spec.slice(1)
|
|
45
|
+
if (rest.includes('@')) name = `@${rest.slice(0, rest.indexOf('@'))}`
|
|
46
|
+
} else {
|
|
47
|
+
name = spec.split('@')[0]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pin = spec === name ? '' : spec.slice(name.length + 1)
|
|
51
|
+
return { name, pin }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function firstInteger(value: string): string | undefined {
|
|
55
|
+
return value.match(/[0-9]+/)?.[0]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compares a manifest pin against what package.json records. A spec counts as
|
|
60
|
+
* missing when absent, or when the pinned major differs from the installed
|
|
61
|
+
* major. Anything looser than a major mismatch is left alone.
|
|
62
|
+
*/
|
|
63
|
+
function isSatisfied(installed: string | undefined, pin: string): boolean {
|
|
64
|
+
if (installed === undefined) return false
|
|
65
|
+
if (pin === '') return true
|
|
66
|
+
|
|
67
|
+
const pinMajor = firstInteger(pin)
|
|
68
|
+
const installedMajor = firstInteger(installed)
|
|
69
|
+
if (pinMajor === undefined) return true
|
|
70
|
+
|
|
71
|
+
return pinMajor === installedMajor
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves dev dependencies across the chain, ancestors first, deduplicated by
|
|
76
|
+
* package name so the outermost stack that names a package wins.
|
|
77
|
+
*/
|
|
78
|
+
export function collectDeps(
|
|
79
|
+
chain: readonly Manifest[],
|
|
80
|
+
pkg: PackageJson,
|
|
81
|
+
): DepState[] {
|
|
82
|
+
const installed = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
83
|
+
const seen = new Set<string>()
|
|
84
|
+
const states: DepState[] = []
|
|
85
|
+
|
|
86
|
+
for (const manifest of [...chain].reverse()) {
|
|
87
|
+
for (const spec of manifest.devPackages) {
|
|
88
|
+
const { name, pin } = parseSpec(spec)
|
|
89
|
+
if (name === '' || seen.has(name)) continue
|
|
90
|
+
seen.add(name)
|
|
91
|
+
|
|
92
|
+
states.push({
|
|
93
|
+
spec,
|
|
94
|
+
name,
|
|
95
|
+
state: isSatisfied(installed[name], pin) ? 'present' : 'missing',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return states
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compares manifest scripts against package.json, nearest stack first, so a
|
|
105
|
+
* child definition shadows the parent it extends.
|
|
106
|
+
*/
|
|
107
|
+
export function collectScripts(
|
|
108
|
+
chain: readonly Manifest[],
|
|
109
|
+
pkg: PackageJson,
|
|
110
|
+
): ScriptState[] {
|
|
111
|
+
const scripts = pkg.scripts ?? {}
|
|
112
|
+
const seen = new Set<string>()
|
|
113
|
+
const states: ScriptState[] = []
|
|
114
|
+
|
|
115
|
+
for (const manifest of chain) {
|
|
116
|
+
for (const [key, value] of Object.entries(manifest.scripts)) {
|
|
117
|
+
if (seen.has(key)) continue
|
|
118
|
+
seen.add(key)
|
|
119
|
+
|
|
120
|
+
const current = scripts[key]
|
|
121
|
+
if (current === undefined) {
|
|
122
|
+
states.push({ key, state: 'missing' })
|
|
123
|
+
} else if (current !== value) {
|
|
124
|
+
states.push({ key, state: 'drifted' })
|
|
125
|
+
} else {
|
|
126
|
+
states.push({ key, state: 'matching' })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return states
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fills scripts the target does not define, nearest stack first, then replays
|
|
136
|
+
* `[scripts.override]` from the furthest ancestor inward so the nearest stack
|
|
137
|
+
* has the last word. This is the ordering `apply_stack_scripts` produced
|
|
138
|
+
* through its recursion.
|
|
139
|
+
*/
|
|
140
|
+
export function applyScripts(
|
|
141
|
+
pkg: PackageJson,
|
|
142
|
+
chain: readonly Manifest[],
|
|
143
|
+
): { pkg: PackageJson; added: string[]; overridden: string[] } {
|
|
144
|
+
const scripts: Record<string, string> = { ...pkg.scripts }
|
|
145
|
+
const added: string[] = []
|
|
146
|
+
const overridden: string[] = []
|
|
147
|
+
|
|
148
|
+
for (const manifest of chain) {
|
|
149
|
+
for (const [key, value] of Object.entries(manifest.scripts)) {
|
|
150
|
+
if (scripts[key] === undefined) {
|
|
151
|
+
scripts[key] = value
|
|
152
|
+
added.push(key)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const manifest of [...chain].reverse()) {
|
|
158
|
+
for (const [key, value] of Object.entries(manifest.scriptOverrides)) {
|
|
159
|
+
if (scripts[key] === value) continue
|
|
160
|
+
scripts[key] = value
|
|
161
|
+
if (!overridden.includes(key)) overridden.push(key)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { pkg: { ...pkg, scripts }, added, overridden }
|
|
166
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { mergeSections } from '@/tooling/gitignore'
|
|
4
|
+
import { ancestorsFirst, listFiles, type Manifest } from '@/tooling/manifest'
|
|
5
|
+
import {
|
|
6
|
+
collectDeps,
|
|
7
|
+
collectScripts,
|
|
8
|
+
type DepState,
|
|
9
|
+
readPackage,
|
|
10
|
+
type ScriptState,
|
|
11
|
+
} from '@/tooling/package'
|
|
12
|
+
|
|
13
|
+
export interface ConfigState {
|
|
14
|
+
readonly rel: string
|
|
15
|
+
readonly stack: string
|
|
16
|
+
readonly state: 'new' | 'drifted' | 'matching'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SeedState {
|
|
20
|
+
readonly rel: string
|
|
21
|
+
readonly state: 'missing' | 'present'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EntryState {
|
|
25
|
+
readonly entry: string
|
|
26
|
+
readonly state: 'missing' | 'present'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReferenceState {
|
|
30
|
+
readonly stack: string
|
|
31
|
+
readonly state: 'pending' | 'matching'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScanResult {
|
|
35
|
+
readonly configs: readonly ConfigState[]
|
|
36
|
+
readonly seeds: readonly SeedState[]
|
|
37
|
+
readonly scripts: readonly ScriptState[]
|
|
38
|
+
readonly deps: readonly DepState[]
|
|
39
|
+
readonly gitignore: readonly EntryState[]
|
|
40
|
+
readonly references: readonly ReferenceState[]
|
|
41
|
+
readonly hasPackageJson: boolean
|
|
42
|
+
readonly totalChanges: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ScanOptions {
|
|
46
|
+
readonly includeReferences: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isIdentical(a: string, b: string): boolean {
|
|
50
|
+
if (!existsSync(b)) return false
|
|
51
|
+
return readFileSync(a).equals(readFileSync(b))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compares every stack in the chain against the target and reports what would
|
|
56
|
+
* change. Nothing is written. Which stack wins a duplicate differs per
|
|
57
|
+
* category and mirrors the bash: configs, seeds, and scripts resolve nearest
|
|
58
|
+
* stack first, while dependencies, gitignore entries, and references resolve
|
|
59
|
+
* from the furthest ancestor inward.
|
|
60
|
+
*/
|
|
61
|
+
export function scan(
|
|
62
|
+
chain: readonly Manifest[],
|
|
63
|
+
target: string,
|
|
64
|
+
options: ScanOptions,
|
|
65
|
+
): ScanResult {
|
|
66
|
+
const configs: ConfigState[] = []
|
|
67
|
+
const seenConfigs = new Set<string>()
|
|
68
|
+
|
|
69
|
+
for (const manifest of chain) {
|
|
70
|
+
for (const rel of listFiles(manifest.configsDir)) {
|
|
71
|
+
if (seenConfigs.has(rel)) continue
|
|
72
|
+
seenConfigs.add(rel)
|
|
73
|
+
|
|
74
|
+
const source = join(manifest.configsDir, rel)
|
|
75
|
+
const dest = join(target, rel)
|
|
76
|
+
const state = !existsSync(dest)
|
|
77
|
+
? 'new'
|
|
78
|
+
: isIdentical(source, dest)
|
|
79
|
+
? 'matching'
|
|
80
|
+
: 'drifted'
|
|
81
|
+
|
|
82
|
+
configs.push({ rel, stack: manifest.name, state })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const seeds: SeedState[] = []
|
|
87
|
+
const seenSeeds = new Set<string>()
|
|
88
|
+
|
|
89
|
+
for (const manifest of chain) {
|
|
90
|
+
for (const rel of listFiles(manifest.seedsDir)) {
|
|
91
|
+
if (seenSeeds.has(rel)) continue
|
|
92
|
+
seenSeeds.add(rel)
|
|
93
|
+
|
|
94
|
+
seeds.push({
|
|
95
|
+
rel,
|
|
96
|
+
state: existsSync(join(target, rel)) ? 'present' : 'missing',
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const packagePath = join(target, 'package.json')
|
|
102
|
+
const pkg = readPackage(packagePath)
|
|
103
|
+
const scripts = pkg ? collectScripts(chain, pkg) : []
|
|
104
|
+
const deps = pkg ? collectDeps(chain, pkg) : []
|
|
105
|
+
|
|
106
|
+
const gitignorePath = join(target, '.gitignore')
|
|
107
|
+
const gitignoreContent = existsSync(gitignorePath)
|
|
108
|
+
? readFileSync(gitignorePath, 'utf8')
|
|
109
|
+
: ''
|
|
110
|
+
const gitignore = scanGitignore(chain, gitignoreContent)
|
|
111
|
+
|
|
112
|
+
const references = options.includeReferences
|
|
113
|
+
? scanReferences(chain, target)
|
|
114
|
+
: []
|
|
115
|
+
|
|
116
|
+
const totalChanges =
|
|
117
|
+
configs.filter((entry) => entry.state !== 'matching').length +
|
|
118
|
+
seeds.filter((entry) => entry.state === 'missing').length +
|
|
119
|
+
scripts.filter((entry) => entry.state !== 'matching').length +
|
|
120
|
+
deps.filter((entry) => entry.state === 'missing').length +
|
|
121
|
+
gitignore.filter((entry) => entry.state === 'missing').length +
|
|
122
|
+
references.filter((entry) => entry.state === 'pending').length
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
configs,
|
|
126
|
+
seeds,
|
|
127
|
+
scripts,
|
|
128
|
+
deps,
|
|
129
|
+
gitignore,
|
|
130
|
+
references,
|
|
131
|
+
hasPackageJson: pkg !== undefined,
|
|
132
|
+
totalChanges,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Replays the merge against a copy of the file so an entry that an earlier
|
|
138
|
+
* section or stack would add is not also reported as missing.
|
|
139
|
+
*/
|
|
140
|
+
function scanGitignore(
|
|
141
|
+
chain: readonly Manifest[],
|
|
142
|
+
content: string,
|
|
143
|
+
): EntryState[] {
|
|
144
|
+
const states: EntryState[] = []
|
|
145
|
+
const seen = new Set<string>()
|
|
146
|
+
let projected = content
|
|
147
|
+
|
|
148
|
+
for (const manifest of ancestorsFirst(chain)) {
|
|
149
|
+
for (const section of manifest.gitignore) {
|
|
150
|
+
for (const entry of section.entries) {
|
|
151
|
+
if (seen.has(entry)) continue
|
|
152
|
+
seen.add(entry)
|
|
153
|
+
|
|
154
|
+
const result = mergeSections(projected, [
|
|
155
|
+
{ header: section.header, entries: [entry] },
|
|
156
|
+
])
|
|
157
|
+
states.push({
|
|
158
|
+
entry,
|
|
159
|
+
state: result.added.length > 0 ? 'missing' : 'present',
|
|
160
|
+
})
|
|
161
|
+
projected = result.content
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return states
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scanReferences(
|
|
170
|
+
chain: readonly Manifest[],
|
|
171
|
+
target: string,
|
|
172
|
+
): ReferenceState[] {
|
|
173
|
+
const states: ReferenceState[] = []
|
|
174
|
+
|
|
175
|
+
for (const manifest of ancestorsFirst(chain)) {
|
|
176
|
+
if (!existsSync(manifest.referenceFile)) continue
|
|
177
|
+
|
|
178
|
+
const dest = join(target, '.claude', 'tooling', `${manifest.name}.md`)
|
|
179
|
+
states.push({
|
|
180
|
+
stack: manifest.name,
|
|
181
|
+
state: isIdentical(manifest.referenceFile, dest) ? 'matching' : 'pending',
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return states
|
|
186
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { execa } from 'execa'
|
|
12
|
+
import {
|
|
13
|
+
type VideoMetadata,
|
|
14
|
+
formatUploadDate,
|
|
15
|
+
renderFrontmatter,
|
|
16
|
+
slugify,
|
|
17
|
+
} from '@/transcripts/metadata'
|
|
18
|
+
import { dedupeRollingCues, parseVtt, renderBody } from '@/transcripts/vtt'
|
|
19
|
+
|
|
20
|
+
const YT_DLP_TIMEOUT_MS = 120_000
|
|
21
|
+
|
|
22
|
+
export function ensureYtDlp(): void {
|
|
23
|
+
if (Bun.which('yt-dlp') === null) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'yt-dlp not found on PATH. Install it from https://github.com/yt-dlp/yt-dlp.',
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FetchResult {
|
|
31
|
+
readonly metadata: VideoMetadata
|
|
32
|
+
readonly subPath: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function runYtDlp(url: string, workDir: string): Promise<FetchResult> {
|
|
36
|
+
const outputTemplate = join(workDir, '%(id)s.%(ext)s')
|
|
37
|
+
let stdout: string
|
|
38
|
+
try {
|
|
39
|
+
const result = await execa(
|
|
40
|
+
'yt-dlp',
|
|
41
|
+
[
|
|
42
|
+
'--dump-json',
|
|
43
|
+
'--no-simulate',
|
|
44
|
+
'--write-sub',
|
|
45
|
+
'--write-auto-sub',
|
|
46
|
+
'--sub-langs',
|
|
47
|
+
'en,en-orig',
|
|
48
|
+
'--sub-format',
|
|
49
|
+
'vtt',
|
|
50
|
+
'--skip-download',
|
|
51
|
+
'-o',
|
|
52
|
+
outputTemplate,
|
|
53
|
+
url,
|
|
54
|
+
],
|
|
55
|
+
{ timeout: YT_DLP_TIMEOUT_MS },
|
|
56
|
+
)
|
|
57
|
+
stdout = result.stdout
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`yt-dlp failed for ${url}: ${lastLine(error)}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const info = JSON.parse(stdout) as Record<string, unknown>
|
|
63
|
+
const videoId = String(info['id'] ?? '').trim()
|
|
64
|
+
if (!videoId) throw new Error('yt-dlp returned no video id')
|
|
65
|
+
|
|
66
|
+
const metadata: VideoMetadata = {
|
|
67
|
+
videoId,
|
|
68
|
+
title: String(info['title'] || 'untitled'),
|
|
69
|
+
channel: String(info['channel'] || info['uploader'] || 'unknown'),
|
|
70
|
+
durationSeconds: Number(info['duration'] || 0),
|
|
71
|
+
published: formatUploadDate(asString(info['upload_date'])),
|
|
72
|
+
url: String(info['webpage_url'] || url),
|
|
73
|
+
}
|
|
74
|
+
const subPath = findSubtitle(workDir, videoId)
|
|
75
|
+
return { metadata, subPath }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface WriteOptions {
|
|
79
|
+
readonly outDir: string
|
|
80
|
+
readonly keepTimestamps: boolean
|
|
81
|
+
readonly fetchedAt: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function writeTranscript(
|
|
85
|
+
metadata: VideoMetadata,
|
|
86
|
+
subPath: string | null,
|
|
87
|
+
{ outDir, keepTimestamps, fetchedAt }: WriteOptions,
|
|
88
|
+
): string {
|
|
89
|
+
mkdirSync(outDir, { recursive: true })
|
|
90
|
+
const slug = `${slugify(metadata.title)}--${metadata.videoId}`
|
|
91
|
+
const target = join(outDir, `${slug}.md`)
|
|
92
|
+
|
|
93
|
+
const hasTranscript = subPath !== null
|
|
94
|
+
let body = ''
|
|
95
|
+
if (subPath !== null) {
|
|
96
|
+
const cues = parseVtt(readFileSync(subPath, 'utf8'))
|
|
97
|
+
body = renderBody(dedupeRollingCues(cues), { keepTimestamps })
|
|
98
|
+
} else {
|
|
99
|
+
body =
|
|
100
|
+
'Transcript unavailable. Auto-captions are not enabled for this video.\n'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const frontmatter = renderFrontmatter(metadata, { hasTranscript, fetchedAt })
|
|
104
|
+
const heading = `# ${metadata.title}\n\n`
|
|
105
|
+
writeFileSync(target, frontmatter + heading + body)
|
|
106
|
+
return target
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface FetchOptions {
|
|
110
|
+
readonly outDir: string
|
|
111
|
+
readonly keepTimestamps: boolean
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchOne(
|
|
115
|
+
url: string,
|
|
116
|
+
{ outDir, keepTimestamps }: FetchOptions,
|
|
117
|
+
): Promise<string> {
|
|
118
|
+
const workDir = mkdtempSync(join(tmpdir(), 'yt-transcripts-'))
|
|
119
|
+
try {
|
|
120
|
+
const { metadata, subPath } = await runYtDlp(url, workDir)
|
|
121
|
+
return writeTranscript(metadata, subPath, {
|
|
122
|
+
outDir,
|
|
123
|
+
keepTimestamps,
|
|
124
|
+
fetchedAt: todayLocal(),
|
|
125
|
+
})
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(workDir, { recursive: true, force: true })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findSubtitle(workDir: string, videoId: string): string | null {
|
|
132
|
+
const matches = readdirSync(workDir)
|
|
133
|
+
.filter((name) => name.startsWith(videoId) && name.endsWith('.vtt'))
|
|
134
|
+
.sort()
|
|
135
|
+
return matches.length ? join(workDir, matches[0]) : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function todayLocal(): string {
|
|
139
|
+
const now = new Date()
|
|
140
|
+
const year = now.getFullYear()
|
|
141
|
+
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
142
|
+
const day = String(now.getDate()).padStart(2, '0')
|
|
143
|
+
return `${year}-${month}-${day}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function asString(value: unknown): string | undefined {
|
|
147
|
+
return typeof value === 'string' ? value : undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function lastLine(error: unknown): string {
|
|
151
|
+
const stderr =
|
|
152
|
+
error instanceof Error && 'stderr' in error ? String(error.stderr) : ''
|
|
153
|
+
const lines = stderr.trim().split('\n').filter(Boolean)
|
|
154
|
+
if (lines.length) return lines[lines.length - 1]
|
|
155
|
+
return error instanceof Error ? error.message : String(error)
|
|
156
|
+
}
|