diff --git a/plugins/knowledge/.claude-plugin/plugin.json b/plugins/knowledge/.claude-plugin/plugin.json index e14d388e4e..087271a663 100644 --- a/plugins/knowledge/.claude-plugin/plugin.json +++ b/plugins/knowledge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "knowledge", - "version": "0.19.0", + "version": "0.19.1", "description": "Ingests external knowledge into synthesized artifacts: book-distill (PDF or EPUB into author-attributed reference files), video-digest (one YouTube or X video: transcript, links, repo applicability), course-digest (Dometrain and Teachable courses into recommendations), docpage-digest (one documentation page into a verified knowledge slice), and map-corpus (a multi-resource corpus into a classified link map and an approved docpage-digest queue). setup sets where artifacts land.", "author": { "name": "Melodic Software", diff --git a/plugins/knowledge/CHANGELOG.md b/plugins/knowledge/CHANGELOG.md index c0867f1da8..3346a8ab6c 100644 --- a/plugins/knowledge/CHANGELOG.md +++ b/plugins/knowledge/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to the `knowledge` plugin are recorded here. The `version` i `.claude-plugin/plugin.json` is the delivery vehicle. A consumer receives a change only after that version increases. +## [0.19.1] - 2026-10-03 + +### Fixed + +- **`video-digest` and `course-digest` install into and load from the knowledge plugin's own data + directory, whatever `CLAUDE_PLUGIN_DATA` the Bash tool's shell holds.** Another plugin's + SessionStart hook can export its own data directory under that name for every Bash call, and + `setup-deps.mjs` installed the extraction dependencies there. `run.mjs` and `setup-deps.mjs` + now take a leading `--data-dir`, which every documented command passes as + `"${CLAUDE_PLUGIN_DATA}"` (or `""` in the spoke files). Without the flag they + accept an inherited value only when it names this plugin, and `setup-deps.mjs` stops before + writing anything when no directory resolves. The pre-computed dependency checks read the + substituted path, and the bootstrap recovery command names the launcher by absolute path with + the resolved `--data-dir`. + ## [0.19.0] - 2026-10-02 ### Added diff --git a/plugins/knowledge/skills/course-digest/SKILL.md b/plugins/knowledge/skills/course-digest/SKILL.md index d0cf1202ac..aaa051950c 100644 --- a/plugins/knowledge/skills/course-digest/SKILL.md +++ b/plugins/knowledge/skills/course-digest/SKILL.md @@ -11,8 +11,8 @@ shell: bash ## Pre-computed context ```! -{ printf 'course-extraction deps: '; node -e "const fs=require('fs'),path=require('path'),p=process.env.CLAUDE_PLUGIN_DATA;console.log(p&&fs.existsSync(path.join(p,'node_modules','@melodic','video-digestion'))?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')" 2>/dev/null || echo "MISSING - node not found (see Prerequisites)"; } -{ printf 'Playwright Chromium: '; node -e "const fs=require('fs'),path=require('path');const b=process.env.PLAYWRIGHT_BROWSERS_PATH||(process.env.CLAUDE_PLUGIN_DATA&&path.join(process.env.CLAUDE_PLUGIN_DATA,'ms-playwright'));const ok=b&&fs.existsSync(b)&&fs.readdirSync(b).some(n=>n.startsWith('chromium'));console.log(ok?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')" 2>/dev/null || echo "MISSING - node not found (see Prerequisites)"; } +{ printf 'course-extraction deps: '; node -e "const fs=require('fs'),path=require('path'),p=process.argv[1];console.log(p&&fs.existsSync(path.join(p,'node_modules','@melodic','video-digestion'))?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')" "${CLAUDE_PLUGIN_DATA}" 2>/dev/null || echo "MISSING - node not found (see Prerequisites)"; } +{ printf 'Playwright Chromium: '; node -e "const fs=require('fs'),path=require('path');const b=process.env.PLAYWRIGHT_BROWSERS_PATH||(process.argv[1]&&path.join(process.argv[1],'ms-playwright'));const ok=b&&fs.existsSync(b)&&fs.readdirSync(b).some(n=>n.startsWith('chromium'));console.log(ok?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')" "${CLAUDE_PLUGIN_DATA}" 2>/dev/null || echo "MISSING - node not found (see Prerequisites)"; } { printf 'ffmpeg: '; command -v ffmpeg >/dev/null 2>&1 && { ffmpeg -version 2>/dev/null | head -1; :; } || echo "MISSING — install ffmpeg (see Prerequisites)"; } { printf 'ImageMagick: '; command -v magick >/dev/null 2>&1 && { magick -version 2>/dev/null | head -1; :; } || echo "MISSING — install ImageMagick 7 (see Prerequisites)"; } ``` @@ -35,7 +35,7 @@ This skill's `.work/` root resolves through the knowledge plugin's own `library_ ## Prerequisites (verify before starting) -1. **course-extraction deps**. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/setup-deps.mjs"`. Installs the pipeline's node dependencies into `${CLAUDE_PLUGIN_DATA}` (persists across plugin updates) and provisions Playwright's Chromium into `${CLAUDE_PLUGIN_DATA}/ms-playwright`. Idempotent. Safe to re-run, and re-run after a plugin update. +1. **course-extraction deps**. `node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/setup-deps.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}"`. Installs the pipeline's node dependencies into `${CLAUDE_PLUGIN_DATA}` (persists across plugin updates) and provisions Playwright's Chromium into `${CLAUDE_PLUGIN_DATA}/ms-playwright`. Idempotent. Safe to re-run, and re-run after a plugin update. 2. **Platform auth**. Set `COURSE_EMAIL`/`COURSE_PASSWORD` (Dometrain → Clerk) or `TEACHABLE_EMAIL`/`TEACHABLE_PASSWORD` (Teachable) in your shell before invoking; they inherit into the pipeline's node subprocess. The env-var prefix is course-config-driven via `platformConfig.authEnvPrefix`. Session cookies persist under `${CLAUDE_PLUGIN_DATA}/auth/.auth-state.json` and are reused across runs. **Interactive manual login is the fallback** when no credentials are set. It opens a browser window for you to log in. NOTE: the manual-login prompt (`node:readline` + headed browser) may not function under headless plugin execution; env-var + cookie-reuse carry the skill regardless, and manual login is a known limitation there, not a blocker. These credentials live in shell environment variables, not in plugin `userConfig`: this plugin's `userConfig` options are non-secret scalars, and a stored option value is not a secret store. 3. **ffmpeg**, required for video frame extraction (scene detection, interval capture). Check: `ffmpeg -version`. Install: `winget install Gyan.FFmpeg` (Windows), `brew install ffmpeg` (macOS), `sudo apt install ffmpeg` (Linux). Floor 7.1+ (newer codecs, AV1, Opus, degrade or fail below this). 4. **ImageMagick 7**, required by `classify-frames.js` for contact sheet generation (`magick montage`). Check: `magick -version`. Install: `winget install ImageMagick.ImageMagick` (Windows), `brew install imagemagick` (macOS), `sudo apt install imagemagick` (Linux). Ubuntu <26.04 ships v6. V7 may require building from source. @@ -45,10 +45,10 @@ If any prerequisite fails, stop and inform the user. Re-run `setup-deps.mjs` for ## Running the pipeline scripts -Every extraction script runs through the launcher, which resolves the vendored node dependencies from `${CLAUDE_PLUGIN_DATA}` and pins Playwright's browser path: +Every extraction script runs through the launcher, which resolves the vendored node dependencies from the data directory its leading `--data-dir` names and pins Playwright's browser path: ```bash -node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" [args…] +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" [args…] ``` Gate on `setup-deps.mjs` first (Prerequisites above). @@ -59,16 +59,16 @@ The Playwright batch script handles transcripts, video frame extraction, and cou ```bash # Full extraction: transcripts + frames + metadata -node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir --extract-frames +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" extract-course.js --course-dir --extract-frames # Transcripts only (faster, no ffmpeg needed) -node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" extract-course.js --course-dir # Frames only (skip transcripts already extracted) -node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir --extract-frames --skip-transcripts +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" extract-course.js --course-dir --extract-frames --skip-transcripts # Course metadata only -node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir --metadata-only +node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" extract-course.js --course-dir --metadata-only ``` Script uses Playwright's bundled Chromium with a fresh temp context (not Chrome itself. Chrome 136+ blocks CDP on default profiles). Auth handled via `addCookies()` after context launch: with credentials set it logs in and saves state; subsequent runs inject cached cookies automatically. Skips already-extracted lessons (crash-safe, resumable). Runs headless by default (`--show-browser` to show the browser). @@ -88,7 +88,7 @@ Script uses Playwright's bundled Chromium with a fresh temp context (not Chrome ```bash # Run in background with nohup — no timeout limit -nohup node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" extract-course.js --course-dir > extraction.log 2>&1 & +nohup node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" extract-course.js --course-dir > extraction.log 2>&1 & echo $! # save PID # Monitor progress periodically @@ -237,12 +237,20 @@ Repo-applicability analysis follows the template in [reference/analysis-template ## Spoke paths -The `context/` files write this skill's directory as ``, which is `${CLAUDE_SKILL_DIR}`. -Put that path in place of the placeholder before running a command. Those files arrive through the -Read tool as plain bytes, so a `${…}` token in them would reach the Bash tool unsubstituted, and the -Bash tool's environment has no `CLAUDE_SKILL_DIR` to expand it from. Basis: the plugins reference, +The `context/` files write this skill's directory as ``, which is `${CLAUDE_SKILL_DIR}`, +and the plugin data directory as ``, which is `${CLAUDE_PLUGIN_DATA}`. Put those paths +in place of the placeholders before running a command. Those files arrive through the Read tool as +plain bytes, so a `${…}` token in them would reach the Bash tool unsubstituted, and the Bash tool's +environment has no `CLAUDE_SKILL_DIR` to expand it from. + +Every `run.mjs` and `setup-deps.mjs` command takes the leading `--data-dir` flag shown above. +The Bash tool's environment does not carry this plugin's `CLAUDE_PLUGIN_DATA`, and another +plugin's SessionStart hook can put its own data directory there under that name. The scripts +therefore take the directory from the flag, and accept an inherited value only when it names this +plugin. Basis: the plugins reference, , verified -2026-09-30; recheck when that table adds supporting files to where a `${…}` reference resolves. +2026-10-02; recheck when that table adds supporting files to where a `${…}` reference resolves, or +lists the Bash tool among the processes that receive the variables. ## Storage diff --git a/plugins/knowledge/skills/course-digest/context/storage-schema.md b/plugins/knowledge/skills/course-digest/context/storage-schema.md index 263c51bfa0..fd35031f33 100644 --- a/plugins/knowledge/skills/course-digest/context/storage-schema.md +++ b/plugins/knowledge/skills/course-digest/context/storage-schema.md @@ -1,6 +1,6 @@ # Storage Schema -All course data lives under the invoking project's `library_dir` setting (or `${CLAUDE_PLUGIN_DATA}` when no library dir is configured), as `courses///`. +All course data lives under the invoking project's `library_dir` setting (or `` when no library dir is configured), as `courses///`. ## Platform naming diff --git a/plugins/knowledge/skills/course-digest/context/workflow.md b/plugins/knowledge/skills/course-digest/context/workflow.md index 68d9fd3502..377d884ef2 100644 --- a/plugins/knowledge/skills/course-digest/context/workflow.md +++ b/plugins/knowledge/skills/course-digest/context/workflow.md @@ -79,10 +79,10 @@ Repo → Validate → THEN Synthesize. **Steps (sequential):** -1. `node "/extraction/run.mjs" classify-frames.js --course-dir --phase contact-sheets`: generate labeled thumbnail grids -2. `node "/extraction/run.mjs" classify-frames.js --course-dir --phase dedup`: near-duplicate detection -3. `node "/extraction/run.mjs" generate-manifests.js --course-dir `: curate frame sets per lesson -4. `node "/extraction/run.mjs" classify-frames.js --course-dir --phase summary`: print frame inventory +1. `node "/extraction/run.mjs" --data-dir "" classify-frames.js --course-dir --phase contact-sheets`: generate labeled thumbnail grids +2. `node "/extraction/run.mjs" --data-dir "" classify-frames.js --course-dir --phase dedup`: near-duplicate detection +3. `node "/extraction/run.mjs" --data-dir "" generate-manifests.js --course-dir `: curate frame sets per lesson +4. `node "/extraction/run.mjs" --data-dir "" classify-frames.js --course-dir --phase summary`: print frame inventory **Output:** Contact sheets, dedup report, manifests per lesson. @@ -129,7 +129,7 @@ downloaded source code ZIPs. If neither exists, skip. **Steps (GitHub repo path):** -1. `node "/extraction/run.mjs" analyze-code-repo.js --course-dir `: clone to temp, detect structure, write metadata +1. `node "/extraction/run.mjs" --data-dir "" analyze-code-repo.js --course-dir `: clone to temp, detect structure, write metadata 2. Clone again to `code/repo/` for Phase 3 access: `git clone --depth 1 --single-branch code/repo/` 3. Review `code/analysis.json` for repo structure (per-section vs single-state) 4. Build section-to-module mapping table: which repo sections correspond to which course modules @@ -170,7 +170,7 @@ only, then discard. **Steps:** -1. `node "/extraction/run.mjs" validate-extraction.js --course-dir `: run all quality checks +1. `node "/extraction/run.mjs" --data-dir "" validate-extraction.js --course-dir `: run all quality checks 2. Review `validation-report.json` and fix any FAIL items before proceeding 3. On re-runs: compare against previous `validation-report.json` for regressions diff --git a/plugins/knowledge/skills/course-digest/extraction/entry-data-dir.test.js b/plugins/knowledge/skills/course-digest/extraction/entry-data-dir.test.js new file mode 100644 index 0000000000..e4fe8a3eeb --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/entry-data-dir.test.js @@ -0,0 +1,84 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { afterAll, describe, expect, it } from "vitest"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const sandboxes = []; +afterAll(() => { + for (const sandbox of sandboxes) fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +/** Another plugin's data directory, empty, the way a leaked CLAUDE_PLUGIN_DATA names it. */ +function foreignDataDir() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "course-setup-deps-")); + sandboxes.push(root); + const foreign = path.join(root, "codex-openai-codex"); + fs.mkdirSync(foreign); + return foreign; +} + +function run(script, args, env) { + return spawnSync(process.execPath, [path.join(dir, script), ...args], { + encoding: "utf8", + timeout: 20000, + env: { ...process.env, ...env }, + }); +} + +describe("data directory resolution in the Bash-run entry points", () => { + it("setup-deps refuses an inherited CLAUDE_PLUGIN_DATA naming another plugin and writes nothing there", () => { + const foreign = foreignDataDir(); + const result = run("setup-deps.mjs", [], { CLAUDE_PLUGIN_DATA: foreign }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('--data-dir "${CLAUDE_PLUGIN_DATA}"'); + expect(fs.readdirSync(foreign)).toEqual([]); + }); + + it("setup-deps rejects an unsubstituted placeholder before creating anything", () => { + const foreign = foreignDataDir(); + const result = run("setup-deps.mjs", ["--data-dir", ""], { CLAUDE_PLUGIN_DATA: foreign }); + expect(result.status).toBe(2); + expect(result.stderr).toContain("unsubstituted placeholder"); + expect(fs.readdirSync(foreign)).toEqual([]); + }); + + it("run.mjs hands the child the --data-dir value, not an inherited one naming another plugin", () => { + const foreign = foreignDataDir(); + const knowledge = path.join(path.dirname(foreign), "knowledge-melodic-software"); + const dest = path.join(path.dirname(foreign), "course"); + const stub = pathToFileURL(path.join(dir, "test-support", "register-playwright-stub.mjs")).href; + const result = run( + "run.mjs", + ["--data-dir", knowledge, "build-course-json.js", "--course-url", "https://example.test/courses/enrolled/1", "--output-dir", dest], + { CLAUDE_PLUGIN_DATA: foreign, NODE_OPTIONS: `--import ${stub}` }, + ); + expect(result.stderr).toContain("playwright stub: chromium.launch blocked in tests"); + expect(fs.existsSync(path.join(knowledge, "auth"))).toBe(true); + expect(fs.readdirSync(foreign)).toEqual([]); + }); + + it("run.mjs drops an inherited value naming another plugin when no flag is given", () => { + const foreign = foreignDataDir(); + const home = path.dirname(foreign); + const dest = path.join(home, "course"); + const stub = pathToFileURL(path.join(dir, "test-support", "register-playwright-stub.mjs")).href; + const result = run( + "run.mjs", + ["build-course-json.js", "--course-url", "https://example.test/courses/enrolled/1", "--output-dir", dest], + { CLAUDE_PLUGIN_DATA: foreign, HOME: home, USERPROFILE: home, NODE_OPTIONS: `--import ${stub}` }, + ); + expect(result.stderr).toContain("playwright stub: chromium.launch blocked in tests"); + expect(fs.readdirSync(foreign)).toEqual([]); + expect(fs.existsSync(path.join(home, ".claude", "course-digest", "auth"))).toBe(true); + }); + + it("run.mjs rejects an unsubstituted placeholder instead of launching the script", () => { + const result = run("run.mjs", ["--data-dir", "${CLAUDE_PLUGIN_DATA}", "validate-extraction.js"], {}); + expect(result.status).toBe(2); + expect(result.stderr).toContain("unsubstituted placeholder"); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/plugin-data.js b/plugins/knowledge/skills/course-digest/extraction/lib/plugin-data.js new file mode 100644 index 0000000000..acf75683a7 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/plugin-data.js @@ -0,0 +1,46 @@ +/** + * The knowledge plugin's data directory for `run.mjs` and `setup-deps.mjs`, which + * Claude runs through the Bash tool. That tool's environment does not carry + * `CLAUDE_PLUGIN_DATA`, and another plugin's SessionStart hook can persist its own + * data directory there under that name, so the skill passes a leading + * `--data-dir "${CLAUDE_PLUGIN_DATA}"`, substituted when the skill loads, and that + * value wins. Without the flag an inherited value is used only when its last path + * segment names this plugin (`knowledge-`), the order the marketplace's + * on-demand-dependencies convention sets. Basis: plugins reference, "Where each + * variable resolves", as of 2026-10-02. + * + * Pure, so the launcher's contract is unit-testable without spawning a process. + */ + +/** + * Split a leading `--data-dir ` off the launcher's argv. + * + * @param {string[]} argv + * @returns {{ dataDir: string|undefined, rest: string[] }} + */ +export function takeDataDirFlag(argv) { + if (argv[0] !== "--data-dir") { + return { dataDir: undefined, rest: [...argv] }; + } + if (argv.length < 2) { + throw new Error("`--data-dir` requires a directory value"); + } + return { dataDir: argv[1], rest: argv.slice(2) }; +} + +/** + * @param {string|undefined} flagValue + * @param {NodeJS.ProcessEnv} env + * @returns {string|undefined} undefined when neither source names this plugin + */ +export function resolvePluginData(flagValue, env) { + if (flagValue) { + if (flagValue.includes("${") || flagValue.includes("")) { + throw new Error(`\`--data-dir\` got an unsubstituted placeholder: \`${flagValue}\``); + } + return flagValue; + } + const inherited = env.CLAUDE_PLUGIN_DATA; + const lastSegment = inherited?.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? ""; + return /^knowledge(-|$)/.test(lastSegment) ? inherited : undefined; +} diff --git a/plugins/knowledge/skills/course-digest/extraction/lib/plugin-data.test.js b/plugins/knowledge/skills/course-digest/extraction/lib/plugin-data.test.js new file mode 100644 index 0000000000..005e9e73a6 --- /dev/null +++ b/plugins/knowledge/skills/course-digest/extraction/lib/plugin-data.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { resolvePluginData, takeDataDirFlag } from "./plugin-data.js"; + +describe("takeDataDirFlag", () => { + it("splits a leading --data-dir off the script and its args", () => { + expect(takeDataDirFlag(["--data-dir", "/d/knowledge-m", "extract-course.js", "--course-dir", "/c"])).toEqual({ + dataDir: "/d/knowledge-m", + rest: ["extract-course.js", "--course-dir", "/c"], + }); + }); + + it("leaves argv alone when the flag is not leading", () => { + expect(takeDataDirFlag(["extract-course.js", "--data-dir", "/d"])).toEqual({ + dataDir: undefined, + rest: ["extract-course.js", "--data-dir", "/d"], + }); + }); + + it("throws when --data-dir has no value", () => { + expect(() => takeDataDirFlag(["--data-dir"])).toThrow(/`--data-dir` requires a directory value/); + }); +}); + +describe("resolvePluginData", () => { + const knowledge = "C:/fixture/claude/plugins/data/knowledge-melodic-software"; + const codex = "C:/fixture/claude/plugins/data/codex-openai-codex"; + + it("prefers the flag over an inherited value naming another plugin", () => { + expect(resolvePluginData(knowledge, { CLAUDE_PLUGIN_DATA: codex })).toBe(knowledge); + }); + + it("ignores an inherited value naming another plugin when no flag is given", () => { + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: codex })).toBeUndefined(); + }); + + it("keeps an inherited value naming this plugin when no flag is given", () => { + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: knowledge })).toBe(knowledge); + const windows = "C:\\fixture\\claude\\plugins\\data\\knowledge-inline\\"; + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: windows })).toBe(windows); + }); + + it("does not take a prefix match for this plugin's name", () => { + const lookalike = "/var/fixture/claude/plugins/data/knowledgebase-other"; + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: lookalike })).toBeUndefined(); + }); + + it("throws on a placeholder that reached the script unsubstituted", () => { + expect(() => resolvePluginData("${CLAUDE_PLUGIN_DATA}", {})).toThrow(/unsubstituted placeholder/); + expect(() => resolvePluginData("", {})).toThrow(/unsubstituted placeholder/); + }); +}); diff --git a/plugins/knowledge/skills/course-digest/extraction/run.mjs b/plugins/knowledge/skills/course-digest/extraction/run.mjs index 4861899b93..23e0559816 100755 --- a/plugins/knowledge/skills/course-digest/extraction/run.mjs +++ b/plugins/knowledge/skills/course-digest/extraction/run.mjs @@ -18,17 +18,35 @@ * path in lockstep. An explicit value already in the environment wins (honored, * not overwritten). * - * Usage: node run.mjs [args…] + * The leading `--data-dir` names the data directory (`lib/plugin-data.js`). The child + * sees only the value `resolvePluginData` accepts, never an inherited + * `CLAUDE_PLUGIN_DATA` that names another plugin. + * + * Usage: node run.mjs [--data-dir ] [args…] */ import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolvePluginData, takeDataDirFlag } from "./lib/plugin-data.js"; + const here = path.dirname(fileURLToPath(import.meta.url)); -const [script, ...rest] = process.argv.slice(2); +const usage = "Usage: node run.mjs [--data-dir ] [args…]\n"; + +let dataDir; +let script; +let rest; +try { + const taken = takeDataDirFlag(process.argv.slice(2)); + [script, ...rest] = taken.rest; + dataDir = resolvePluginData(taken.dataDir, process.env); +} catch (error) { + process.stderr.write(`${error.message}\n${usage}`); + process.exit(2); +} if (!script) { - process.stderr.write("Usage: node run.mjs [args…]\n"); + process.stderr.write(usage); process.exit(2); } @@ -39,9 +57,10 @@ if (rel.startsWith("..") || path.isAbsolute(rel)) { process.exit(2); } -const env = { ...process.env }; -if (!env.PLAYWRIGHT_BROWSERS_PATH && env.CLAUDE_PLUGIN_DATA) { - env.PLAYWRIGHT_BROWSERS_PATH = path.join(env.CLAUDE_PLUGIN_DATA, "ms-playwright"); +const { CLAUDE_PLUGIN_DATA: _inherited, ...env } = process.env; +if (dataDir) { + env.CLAUDE_PLUGIN_DATA = dataDir; + env.PLAYWRIGHT_BROWSERS_PATH ||= path.join(dataDir, "ms-playwright"); } const registerHook = path.join(here, "register-hook.mjs"); diff --git a/plugins/knowledge/skills/course-digest/extraction/setup-deps.mjs b/plugins/knowledge/skills/course-digest/extraction/setup-deps.mjs index 560678fbf7..8bdc2368bf 100755 --- a/plugins/knowledge/skills/course-digest/extraction/setup-deps.mjs +++ b/plugins/knowledge/skills/course-digest/extraction/setup-deps.mjs @@ -21,7 +21,10 @@ * directory (the same value run.mjs sets at launch) keeps install path and * runtime lookup path in lockstep. * - * Usage: node setup-deps.mjs + * The data directory comes from `--data-dir`, resolved like `run.mjs` resolves it + * (`lib/plugin-data.js`), and nothing is written until it resolves. + * + * Usage: node setup-deps.mjs --data-dir */ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; @@ -29,12 +32,27 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolvePluginData, takeDataDirFlag } from "./lib/plugin-data.js"; + const here = path.dirname(fileURLToPath(import.meta.url)); -const data = process.env.CLAUDE_PLUGIN_DATA; +const usage = "Usage: node setup-deps.mjs --data-dir \n"; + +let data; +try { + const { dataDir, rest } = takeDataDirFlag(process.argv.slice(2)); + if (rest.length > 0) { + throw new Error("setup-deps.mjs takes only `--data-dir `"); + } + data = resolvePluginData(dataDir, process.env); +} catch (error) { + process.stderr.write(`${error.message}\n${usage}`); + process.exit(2); +} if (!data) { process.stderr.write( - "CLAUDE_PLUGIN_DATA is not set. Run this inside Claude Code with the knowledge plugin installed.\n", + 'The knowledge plugin data directory is unknown. Pass --data-dir "${CLAUDE_PLUGIN_DATA}" from the skill; ' + + "an inherited CLAUDE_PLUGIN_DATA that names another plugin is ignored.\n", ); process.exit(1); } diff --git a/plugins/knowledge/skills/setup/SKILL.md b/plugins/knowledge/skills/setup/SKILL.md index 44a4b6dfe2..9d5aaf60ef 100644 --- a/plugins/knowledge/skills/setup/SKILL.md +++ b/plugins/knowledge/skills/setup/SKILL.md @@ -84,8 +84,8 @@ reports "already configured". `${CLAUDE_PLUGIN_DATA}/ms-playwright`): ```bash - node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/setup-deps.mjs" - node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/setup-deps.mjs" + node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/setup-deps.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" + node "${CLAUDE_PLUGIN_ROOT}/skills/course-digest/extraction/setup-deps.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" ``` A stored fingerprint gates reinstalls, so re-running is safe and cheap. After provisioning, diff --git a/plugins/knowledge/skills/video-digest/SKILL.md b/plugins/knowledge/skills/video-digest/SKILL.md index db7ee01dc2..6111beb78b 100644 --- a/plugins/knowledge/skills/video-digest/SKILL.md +++ b/plugins/knowledge/skills/video-digest/SKILL.md @@ -11,7 +11,7 @@ shell: bash ## Pre-computed context ```! -{ printf 'video-extraction deps: '; node -e "const fs=require('fs'),path=require('path'),p=process.env.CLAUDE_PLUGIN_DATA;console.log(p&&fs.existsSync(path.join(p,'node_modules','@melodic','video-digestion'))?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')" 2>/dev/null || echo "MISSING - node not found (see Prerequisites)"; } +{ printf 'video-extraction deps: '; node -e "const fs=require('fs'),path=require('path'),p=process.argv[1];console.log(p&&fs.existsSync(path.join(p,'node_modules','@melodic','video-digestion'))?'installed':'MISSING - run setup-deps.mjs (see Prerequisites)')" "${CLAUDE_PLUGIN_DATA}" 2>/dev/null || echo "MISSING - node not found (see Prerequisites)"; } { printf 'yt-dlp: '; command -v yt-dlp >/dev/null 2>&1 && { yt-dlp --version 2>/dev/null | head -1; :; } || echo "MISSING — install yt-dlp (see Prerequisites)"; } { printf 'ffmpeg: '; command -v ffmpeg >/dev/null 2>&1 && { ffmpeg -version 2>/dev/null | head -1; :; } || echo "MISSING — install ffmpeg (watch action only)"; } { printf 'ImageMagick: '; command -v magick >/dev/null 2>&1 && { magick -version 2>/dev/null | head -1; :; } || echo "MISSING — install ImageMagick 7 (watch action only)"; } @@ -80,7 +80,7 @@ own). Resolution rungs in `context/watch-pipeline.md`. ## Transcript action ```bash -node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" transcript/run-transcript.js "" +node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" transcript/run-transcript.js "" ``` 1. **Acquire**. Captions + info JSON (`--skip-download`) @@ -106,8 +106,8 @@ release, FIFO, stale reclaim, parallel terminals, companion briefs, is in `conte read it for any queue action. Template: `templates/queue.md`. ```bash -node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" acquisition/preflight-metadata.js "" [""...] -node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" watch/queue-claim.js {list|claim |release |stale-check} +node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" acquisition/preflight-metadata.js "" [""...] +node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" watch/queue-claim.js {list|claim |release |stale-check} ``` ## Watch action @@ -124,7 +124,7 @@ Ordered phase spine. Each phase's procedure, inputs, and outputs: `context/watch harvest): ```bash - node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" watch/run-watch.js "" [--skip-research] [--target ] [--max-frame-gap-sec ] + node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" watch/run-watch.js "" [--skip-research] [--target ] [--max-frame-gap-sec ] ``` `--max-frame-gap-sec` sets the longest stretch between timed frames before a gap-fill frame is @@ -162,7 +162,7 @@ it carries depends on which 0-case it is. See `reference/sources/x.md`. ## Resume action ```bash -node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" watch/run-resume.js "" +node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}" watch/run-resume.js "" ``` Reads the slice `watch.json`, identifies the next incomplete phase (`acquire` → `transcript` → @@ -191,13 +191,21 @@ before writing or staging slice artifacts. ## Spoke paths -The `context/` and `reference/` files write this skill's directory as ``, which is -`${CLAUDE_SKILL_DIR}`. Put that path in place of the placeholder before running a command or +The `context/`, `reference/` and `templates/` files write this skill's directory as ``, +which is `${CLAUDE_SKILL_DIR}`, and the plugin data directory as ``, which is +`${CLAUDE_PLUGIN_DATA}`. Put those paths in place of the placeholders before running a command or writing it into a brief. Those files arrive through the Read tool as plain bytes, so a `${…}` token in them would reach the Bash tool unsubstituted, and the Bash tool's environment has no -`CLAUDE_SKILL_DIR` to expand it from. Basis: the plugins reference, +`CLAUDE_SKILL_DIR` to expand it from. + +Every `run.mjs` and `setup-deps.mjs` command takes the leading `--data-dir` flag shown above. +The Bash tool's environment does not carry this plugin's `CLAUDE_PLUGIN_DATA`, and another +plugin's SessionStart hook can put its own data directory there under that name. The scripts +therefore take the directory from the flag, and accept an inherited value only when it names this +plugin. Basis: the plugins reference, , verified -2026-09-30; recheck when that table adds supporting files to where a `${…}` reference resolves. +2026-10-02; recheck when that table adds supporting files to where a `${…}` reference resolves, or +lists the Bash tool among the processes that receive the variables. ## Gotchas @@ -210,7 +218,7 @@ patterns live in the source spokes. Verify before starting (stop and route to the fix path on failure): -1. **video-extraction deps**. `node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/setup-deps.mjs"`. +1. **video-extraction deps**. `node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/setup-deps.mjs" --data-dir "${CLAUDE_PLUGIN_DATA}"`. Installs the pipeline's node dependencies into `${CLAUDE_PLUGIN_DATA}` (persists across plugin updates); idempotent. Safe to re-run, and re-run after a plugin update. 2. **yt-dlp**, required for all actions. Floor **2026.6**. Install: `winget install yt-dlp.yt-dlp` diff --git a/plugins/knowledge/skills/video-digest/context/output-contract.md b/plugins/knowledge/skills/video-digest/context/output-contract.md index 206355d62f..988b6fa439 100644 --- a/plugins/knowledge/skills/video-digest/context/output-contract.md +++ b/plugins/knowledge/skills/video-digest/context/output-contract.md @@ -21,7 +21,7 @@ this skill's content as `${user_config.library_dir}`: **leading** `--work-root` flag on **every** `run.mjs` invocation in this skill: ```bash - node "/extraction/run.mjs" --work-root "${CLAUDE_PROJECT_DIR}/${user_config.library_dir}" [args…] + node "/extraction/run.mjs" --data-dir "" --work-root "${CLAUDE_PROJECT_DIR}/${user_config.library_dir}" [args…] ``` `run.mjs` forwards it to the extraction child as an environment variable (a double-quoted CLI @@ -45,8 +45,8 @@ this skill's content as `${user_config.library_dir}`: token, invoke `run.mjs` **without** `--work-root`. `resolveWorkRoot()` falls back to `${CLAUDE_PROJECT_DIR}` (then `process.cwd()`), landing artifacts at the consuming repo root. -The `setup-deps.mjs` install step is exempt. It installs node dependencies into -`${CLAUDE_PLUGIN_DATA}`, not the work root. +The `setup-deps.mjs` install step takes no `--work-root`. It installs node dependencies into the +plugin data directory its `--data-dir ""` flag names, not the work root. `run.mjs` translates `--work-root` into `VIDEO_DIGEST_WORK_ROOT`, the variable `resolveWorkRoot()` reads before the fallbacks above. Every extraction variable lives in that diff --git a/plugins/knowledge/skills/video-digest/context/quality-gates.md b/plugins/knowledge/skills/video-digest/context/quality-gates.md index 8c0cea7cb4..6d0a77c4ce 100644 --- a/plugins/knowledge/skills/video-digest/context/quality-gates.md +++ b/plugins/knowledge/skills/video-digest/context/quality-gates.md @@ -50,7 +50,7 @@ This table lists the **blocking artifacts per phase** (which must exist before t ## Outcome verification (host verify script) -`node "/extraction/run.mjs" evals/check-watch-outcomes.js "" --write-report` +`node "/extraction/run.mjs" --data-dir "" evals/check-watch-outcomes.js "" --write-report` | ID | Binary criterion | FAIL → | | --- | --- | --- | @@ -84,7 +84,7 @@ Host verify scripts prove **traceability and shape** (JSON valid, batch files on ## Research gate (host verify script) -`node "/extraction/run.mjs" evals/check-research-complete.js ""` +`node "/extraction/run.mjs" --data-dir "" evals/check-research-complete.js ""` | Criterion | FAIL → | | --- | --- | diff --git a/plugins/knowledge/skills/video-digest/context/watch-pipeline.md b/plugins/knowledge/skills/video-digest/context/watch-pipeline.md index 6ebe024b45..73327f80a1 100644 --- a/plugins/knowledge/skills/video-digest/context/watch-pipeline.md +++ b/plugins/knowledge/skills/video-digest/context/watch-pipeline.md @@ -36,7 +36,7 @@ On resume: if companion is unmarked, run 0b before vision even when CLI phases a ## CLI bootstrap ```bash -node "/extraction/run.mjs" watch/run-watch.js "" [--skip-research] [--target ] [--max-frame-gap-sec ] +node "/extraction/run.mjs" --data-dir "" watch/run-watch.js "" [--skip-research] [--target ] [--max-frame-gap-sec ] ``` Pass an explicit `--target ` through from the invoking `watch --target ` command. @@ -68,7 +68,7 @@ snapshotted to `key-frames/contact-sheets/` for local disaster recovery, see Before `watch` or `resume` when frames are needed: ```bash -node "/extraction/setup-deps.mjs" +node "/extraction/setup-deps.mjs" --data-dir "" ``` STOP if the hub's pre-computed context shows MISSING for yt-dlp, ffmpeg, or ImageMagick. Cloud @@ -82,7 +82,7 @@ After CLI bootstrap, parallelize like `/knowledge:course-digest` Phase 3: | --- | --- | --- | | Parallel | Transcript agent | Claims + timestamps → `research/research-agenda.md` draft | | Parallel | Visual agent | Contact-sheet triage → detail reads → `key-frames/visual-frames.md` + on-screen URLs | -| Parallel | Link/repo agent | WebFetch previews + `node "/extraction/run.mjs" harvesting/analyze-harvested-repos.js ` when GitHub links exist | +| Parallel | Link/repo agent | WebFetch previews + `node "/extraction/run.mjs" --data-dir "" harvesting/analyze-harvested-repos.js ` when GitHub links exist | | Sequential | Research fan-out | external research (standard or deep) per claim cluster → `RESEARCH.md` + `research/findings/` | | Sequential | Synthesis agent | `recommendations/menu.md` + `recommendations/takeaways.md` (hub: `recommendations/README.md`) | | Sequential | Interview handoff | `recommendations/interview.md` → offer `/planning:interview` for POC/full-slice picks | @@ -91,7 +91,7 @@ Mark each phase in `watch.json` after the wave completes (idempotent, re-running already-marked phase is a no-op): ```bash -node "/extraction/run.mjs" watch/watch-state.js mark-phase +node "/extraction/run.mjs" --data-dir "" watch/watch-state.js mark-phase ``` `mark-phase synthesis` delegates to `close` (Phase 9). @@ -99,7 +99,7 @@ node "/extraction/run.mjs" watch/watch-state.js mark-phase /extraction/run.mjs" watch/vision-gated-promote.js "" +node "/extraction/run.mjs" --data-dir "" watch/vision-gated-promote.js "" ``` (`promote-key-frames.js` remains for ad-hoc single copies, not the completion path.) @@ -109,7 +109,7 @@ node "/extraction/run.mjs" watch/vision-gated-promote.js " After CLI bootstrap (or on resume), materialize and maintain the slice checklist: ```bash -node "/extraction/run.mjs" watch/init-watch-checklist.js "" +node "/extraction/run.mjs" --data-dir "" watch/init-watch-checklist.js "" ``` Use `--force` to regenerate per-sheet rows after `contactSheetCount` changes. Tick `[ ]` → `[x]` @@ -164,7 +164,7 @@ Checklist: `watching/frame-triage-checklist.json`; **JSON SSOT** + rendered mark - **Pass 1 contact-sheet triage:** One subagent per sheet from `tempSession.contactSheetsDir` (or `key-frames/contact-sheets/`). Write `key-frames/triage/batches/sheet_NNN.json` (cells per `sheet-frame-index.json`). Merge: - `node "/extraction/run.mjs" watch/merge-triage-json.js ""`; + `node "/extraction/run.mjs" --data-dir "" watch/merge-triage-json.js ""`; validate: `validate-triage-json.js`; render: `render-triage-log.js`. - **Pass 2 detail reads:** All `keep-detail` frames + transcript interleave (`key-frames/selection.json` timeline). Escalate text-dense frames to **1920×1080**. @@ -181,7 +181,7 @@ Checklist: `watching/frame-triage-checklist.json`; **JSON SSOT** + rendered mark `render-quality-audit.js` + `render-key-frames-manifest.js`. **Delete** failures with `pass: false`. - **Repair pass (when filename verify fails):** - `node "/extraction/run.mjs" watch/repair-synthesis-promotions.js ""` + `node "/extraction/run.mjs" --data-dir "" watch/repair-synthesis-promotions.js ""` Semantic renames from `gapNote`, reject generic pipeline placeholders, fix forbidden sessions. ## Phase 5: high-volume advisory @@ -203,7 +203,7 @@ Default-on. Gate: `mark-phase research` only after `check-research-c and agenda clusters are `done` or `deferred`: ```bash -node "/extraction/run.mjs" evals/check-research-complete.js "" +node "/extraction/run.mjs" --data-dir "" evals/check-research-complete.js "" ``` - `research/claim-inventory.md` must exist; draft or expand `research/research-agenda.md` with @@ -282,14 +282,14 @@ Write `recommendations/interview.md` with the menu + *"Should we go further?"*; Mandatory host verify script, before closing the slice: ```bash -node "/extraction/run.mjs" evals/check-watch-outcomes.js "" --write-report +node "/extraction/run.mjs" --data-dir "" evals/check-watch-outcomes.js "" --write-report ``` Writes `verification/Z-watch-outcomes.md`. Once it exits 0 and the blocking checklist items (8.1-8.4, 9.1, 9.2, 9.4) are ticked, close the slice: ```bash -node "/extraction/run.mjs" watch/watch-state.js close "" +node "/extraction/run.mjs" --data-dir "" watch/watch-state.js close "" ``` `close` is the only writer of `status: complete`. It marks synthesis, re-runs the outcome checks @@ -339,13 +339,13 @@ correction. A probe on ffmpeg 8.0.1 found the reported times already relative to Standalone pipeline (when video + VTT already acquired): ```bash -node "/extraction/run.mjs" watching/run-watching-pipeline.js "" "" +node "/extraction/run.mjs" --data-dir "" watching/run-watching-pipeline.js "" "" ``` Metadata-only link harvest: ```bash -node "/extraction/run.mjs" harvesting/run-harvest.js "" [--url ""] +node "/extraction/run.mjs" --data-dir "" harvesting/run-harvest.js "" [--url ""] ``` The owning source adapter is resolved from `--url` when given, else from the info JSON's diff --git a/plugins/knowledge/skills/video-digest/context/watch-queue.md b/plugins/knowledge/skills/video-digest/context/watch-queue.md index ba5c35a30c..0d1f2fe07d 100644 --- a/plugins/knowledge/skills/video-digest/context/watch-queue.md +++ b/plugins/knowledge/skills/video-digest/context/watch-queue.md @@ -59,7 +59,7 @@ Claim metadata (`claimedAt`, `claimedBy`) lives in `claims/.json`, not in the 1. Run exclusive claim (skill or CLI): ```bash -node "/extraction/run.mjs" watch/queue-claim.js claim [--video-id ] +node "/extraction/run.mjs" --data-dir "" watch/queue-claim.js claim [--video-id ] ``` Exit code `2` = row already taken. For FIFO `watch`, try the next `pending` row; for `watch `, stop with a clear message rather than bootstrapping duplicate work. @@ -68,7 +68,7 @@ Exit code `2` = row already taken. For FIFO `watch`, try the next `pending` row; 2. Read URL from row; bootstrap: ```bash -node "/extraction/run.mjs" watch/run-watch.js "" +node "/extraction/run.mjs" --data-dir "" watch/run-watch.js "" ``` 1. Execute skill phases 2–9 (or `run-resume.js` if slice exists and temp valid). @@ -76,7 +76,7 @@ node "/extraction/run.mjs" watch/run-watch.js "" 3. Release claim: ```bash -node "/extraction/run.mjs" watch/queue-claim.js release +node "/extraction/run.mjs" --data-dir "" watch/queue-claim.js release ``` ### FIFO auto-dequeue (`watch` with no URL) @@ -99,7 +99,7 @@ Scan rows in `#` order. For each `pending` row, attempt `claim `. On `EEXIST` If `claims/.json` exists and `claimedAt` is older than **7 days**, skill may: ```bash -node "/extraction/run.mjs" watch/queue-claim.js stale-check +node "/extraction/run.mjs" --data-dir "" watch/queue-claim.js stale-check ``` Then reset row `#n` from `in_progress` → `pending` and delete the stub (abandoned run). @@ -144,7 +144,7 @@ When the operator supplies companion URL(s) with queue intent, record them befor Before appending rows, validate each URL and fetch its title + channel through the same auth-fallback path acquisition uses (a bot-checked video that `watch` could acquire with cookies is NOT rejected at queue time): ```bash -node "/extraction/run.mjs" acquisition/preflight-metadata.js "" [""...] +node "/extraction/run.mjs" --data-dir "" acquisition/preflight-metadata.js "" [""...] ``` Emits a JSON array (one entry per URL). Per entry use `action` to decide: diff --git a/plugins/knowledge/skills/video-digest/extraction/lib/run-args.js b/plugins/knowledge/skills/video-digest/extraction/lib/run-args.js index 4f18b13344..1f266d9e42 100644 --- a/plugins/knowledge/skills/video-digest/extraction/lib/run-args.js +++ b/plugins/knowledge/skills/video-digest/extraction/lib/run-args.js @@ -8,6 +8,7 @@ * option is passed as a cross-platform double-quoted CLI arg and translated here * into the environment variable the extraction child already reads. The env vars * are an internal launcher-to-child interface, not a consumer-facing channel. + * `--data-dir` carries the plugin data directory the same way (see `resolvePluginData`). * * Both helpers are pure so the launcher's contract is unit-testable without * spawning a child process. @@ -19,6 +20,7 @@ * missing-value error message. */ const LEADING_FLAGS = { + "--data-dir": { key: "dataDir", env: "CLAUDE_PLUGIN_DATA", valueLabel: "directory value" }, "--work-root": { key: "workRoot", env: "VIDEO_DIGEST_WORK_ROOT", valueLabel: "directory value" }, "--js-runtimes": { key: "jsRuntimes", @@ -49,6 +51,7 @@ const LEADING_FLAGS = { /** * @typedef {object} RunArgs + * @property {string} [dataDir] * @property {string} [workRoot] * @property {string} [jsRuntimes] * @property {string} [cookiesFile] @@ -112,6 +115,33 @@ export function buildChildEnv(baseEnv, flags = {}) { return { ...baseEnv, ...overlay }; } +/** + * The knowledge plugin's data directory for a script Claude runs through the Bash tool. + * + * That tool's environment does not carry `CLAUDE_PLUGIN_DATA`, and another plugin's + * SessionStart hook can persist its own data directory there under that name, so the + * skill passes `--data-dir "${CLAUDE_PLUGIN_DATA}"`, substituted when the skill loads, + * and that value wins. Without the flag an inherited value is used only when its last + * path segment names this plugin (`knowledge-`), the order the + * marketplace's on-demand-dependencies convention sets. Basis: plugins reference, + * "Where each variable resolves", as of 2026-10-02. + * + * @param {string|undefined} flagValue + * @param {NodeJS.ProcessEnv} env + * @returns {string|undefined} undefined when neither source names this plugin + */ +export function resolvePluginData(flagValue, env) { + if (flagValue) { + if (flagValue.includes("${") || flagValue.includes("")) { + throw new Error(`\`--data-dir\` got an unsubstituted placeholder: \`${flagValue}\``); + } + return flagValue; + } + const inherited = env.CLAUDE_PLUGIN_DATA; + const lastSegment = inherited?.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? ""; + return /^knowledge(-|$)/.test(lastSegment) ? inherited : undefined; +} + const ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|%([A-Za-z_][A-Za-z0-9_]*)%/g; /** diff --git a/plugins/knowledge/skills/video-digest/extraction/lib/run-args.test.js b/plugins/knowledge/skills/video-digest/extraction/lib/run-args.test.js index b24e825023..ab7958e29e 100644 --- a/plugins/knowledge/skills/video-digest/extraction/lib/run-args.test.js +++ b/plugins/knowledge/skills/video-digest/extraction/lib/run-args.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildChildEnv, expandPathValue, parseRunArgs } from "./run-args.js"; +import { buildChildEnv, expandPathValue, parseRunArgs, resolvePluginData } from "./run-args.js"; describe("parseRunArgs", () => { it("returns no flags when none are present", () => { @@ -55,6 +55,21 @@ describe("parseRunArgs", () => { expect(parsed.rest).toEqual(["--work-root", "list"]); }); + it("extracts --data-dir alongside --work-root in either order", () => { + const data = "/var/fixture/claude/plugins/data/knowledge-melodic-software"; + const expected = { dataDir: data, workRoot: "/proj", script: "watch/run-watch.js", rest: ["https://x"] }; + expect(parseRunArgs(["--data-dir", data, "--work-root", "/proj", "watch/run-watch.js", "https://x"])).toEqual( + expected, + ); + expect(parseRunArgs(["--work-root", "/proj", "--data-dir", data, "watch/run-watch.js", "https://x"])).toEqual( + expected, + ); + }); + + it("throws when --data-dir has no value", () => { + expect(() => parseRunArgs(["--data-dir"])).toThrow(/`--data-dir` requires a directory value/); + }); + it("throws when --work-root has no value", () => { expect(() => parseRunArgs(["--work-root"])).toThrow(/requires a directory value/); }); @@ -112,6 +127,48 @@ describe("buildChildEnv", () => { buildChildEnv(base, { workRoot: "/proj" }); expect(base).toEqual({ PATH: "/usr/bin" }); }); + + it("hands the child the data dir as CLAUDE_PLUGIN_DATA", () => { + expect(buildChildEnv({ PATH: "/usr/bin" }, { dataDir: "/data/knowledge-m" })).toEqual({ + PATH: "/usr/bin", + CLAUDE_PLUGIN_DATA: "/data/knowledge-m", + }); + }); +}); + +describe("resolvePluginData", () => { + const knowledge = "C:/fixture/claude/plugins/data/knowledge-melodic-software"; + const codex = "C:/fixture/claude/plugins/data/codex-openai-codex"; + + it("prefers the flag over an inherited value naming another plugin", () => { + expect(resolvePluginData(knowledge, { CLAUDE_PLUGIN_DATA: codex })).toBe(knowledge); + }); + + it("ignores an inherited value naming another plugin when no flag is given", () => { + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: codex })).toBeUndefined(); + expect(resolvePluginData("", { CLAUDE_PLUGIN_DATA: codex })).toBeUndefined(); + }); + + it("keeps an inherited value naming this plugin when no flag is given", () => { + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: knowledge })).toBe(knowledge); + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: `${knowledge}/` })).toBe(`${knowledge}/`); + const windows = "C:\\fixture\\claude\\plugins\\data\\knowledge-inline"; + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: windows })).toBe(windows); + }); + + it("does not take a prefix match for this plugin's name", () => { + const lookalike = "/var/fixture/claude/plugins/data/knowledgebase-other"; + expect(resolvePluginData(undefined, { CLAUDE_PLUGIN_DATA: lookalike })).toBeUndefined(); + }); + + it("returns undefined when nothing is set", () => { + expect(resolvePluginData(undefined, {})).toBeUndefined(); + }); + + it("throws on a placeholder that reached the script unsubstituted", () => { + expect(() => resolvePluginData("${CLAUDE_PLUGIN_DATA}", {})).toThrow(/unsubstituted placeholder/); + expect(() => resolvePluginData("", {})).toThrow(/unsubstituted placeholder/); + }); }); describe("expandPathValue", () => { diff --git a/plugins/knowledge/skills/video-digest/extraction/run.mjs b/plugins/knowledge/skills/video-digest/extraction/run.mjs index 1bc3406301..a64428b99f 100755 --- a/plugins/knowledge/skills/video-digest/extraction/run.mjs +++ b/plugins/knowledge/skills/video-digest/extraction/run.mjs @@ -21,8 +21,12 @@ * or `${NAME}` / `%NAME%` env-var references — expanded here (`expandPathValue`) * so machine-varying roots never require a literal path in stored configuration. * - * Usage: node run.mjs [--work-root ] [--js-runtimes ] [--cookies-file ] - * [--cookies-from-browser ] [--max-concurrent-acquires ] + * `--data-dir` names the plugin data directory the hook resolves dependencies from. + * The child sees only the value `resolvePluginData` accepts, never an inherited + * `CLAUDE_PLUGIN_DATA` that names another plugin. + * + * Usage: node run.mjs [--data-dir ] [--work-root ] [--js-runtimes ] + * [--cookies-file ] [--cookies-from-browser ] [--max-concurrent-acquires ] * [--acquire-phase-gap ] [args…] */ import { spawnSync } from "node:child_process"; @@ -30,18 +34,19 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { buildChildEnv, expandPathValue, parseRunArgs } from "./lib/run-args.js"; +import { buildChildEnv, expandPathValue, parseRunArgs, resolvePluginData } from "./lib/run-args.js"; const here = import.meta.dirname; let parsed; const usage = - "Usage: node run.mjs [--work-root ] [--js-runtimes ] [--cookies-file ] " + + "Usage: node run.mjs [--data-dir ] [--work-root ] [--js-runtimes ] [--cookies-file ] " + "[--cookies-from-browser ] [--max-concurrent-acquires ] " + "[--acquire-phase-gap ] [args…]\n"; try { parsed = parseRunArgs(process.argv.slice(2)); + parsed.dataDir = resolvePluginData(parsed.dataDir, process.env); if (parsed.workRoot) { parsed.workRoot = expandPathValue(parsed.workRoot, process.env, os.homedir()); } @@ -63,10 +68,11 @@ if (rel.startsWith("..") || path.isAbsolute(rel)) { process.exit(2); } +const { CLAUDE_PLUGIN_DATA: _inherited, ...inheritedEnv } = process.env; const registerHook = path.join(here, "register-hook.mjs"); const result = spawnSync( process.execPath, ["--import", pathToFileURL(registerHook).href, target, ...rest], - { stdio: "inherit", env: buildChildEnv(process.env, parsed) }, + { stdio: "inherit", env: buildChildEnv(inheritedEnv, parsed) }, ); process.exit(result.status ?? 1); diff --git a/plugins/knowledge/skills/video-digest/extraction/setup-deps.mjs b/plugins/knowledge/skills/video-digest/extraction/setup-deps.mjs index e66f3b9ea6..ea749ac370 100755 --- a/plugins/knowledge/skills/video-digest/extraction/setup-deps.mjs +++ b/plugins/knowledge/skills/video-digest/extraction/setup-deps.mjs @@ -14,19 +14,37 @@ * real installs so they resolve from the data directory rather than a symlink * back into the plugin cache. * - * Usage: node setup-deps.mjs + * The data directory comes from `--data-dir`, resolved like `run.mjs` resolves it + * (`resolvePluginData`), and nothing is written until it resolves. + * + * Usage: node setup-deps.mjs --data-dir */ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { parseRunArgs, resolvePluginData } from "./lib/run-args.js"; + const here = import.meta.dirname; -const data = process.env.CLAUDE_PLUGIN_DATA; +const usage = "Usage: node setup-deps.mjs --data-dir \n"; + +let data; +try { + const { dataDir, script, rest: _rest, ...otherFlags } = parseRunArgs(process.argv.slice(2)); + if (script !== undefined || Object.keys(otherFlags).length > 0) { + throw new Error("setup-deps.mjs takes only `--data-dir `"); + } + data = resolvePluginData(dataDir, process.env); +} catch (error) { + process.stderr.write(`${error.message}\n${usage}`); + process.exit(2); +} if (!data) { process.stderr.write( - "CLAUDE_PLUGIN_DATA is not set. Run this inside Claude Code with the knowledge plugin installed.\n", + 'The knowledge plugin data directory is unknown. Pass --data-dir "${CLAUDE_PLUGIN_DATA}" from the skill; ' + + "an inherited CLAUDE_PLUGIN_DATA that names another plugin is ignored.\n", ); process.exit(1); } diff --git a/plugins/knowledge/skills/video-digest/extraction/setup-deps.test.js b/plugins/knowledge/skills/video-digest/extraction/setup-deps.test.js new file mode 100644 index 0000000000..3bdb38e558 --- /dev/null +++ b/plugins/knowledge/skills/video-digest/extraction/setup-deps.test.js @@ -0,0 +1,53 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +const setupDeps = path.join(import.meta.dirname, "setup-deps.mjs"); +const sandboxes = []; +afterAll(() => { + for (const dir of sandboxes) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** Another plugin's data directory, empty, the way a leaked CLAUDE_PLUGIN_DATA names it. */ +function foreignDataDir() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "setup-deps-")); + sandboxes.push(root); + const dir = path.join(root, "codex-openai-codex"); + fs.mkdirSync(dir); + return dir; +} + +function runSetupDeps(args, env) { + return spawnSync(process.execPath, [setupDeps, ...args], { + encoding: "utf8", + timeout: 20000, + env: { ...process.env, ...env }, + }); +} + +describe("setup-deps.mjs data directory", () => { + it("refuses an inherited CLAUDE_PLUGIN_DATA that names another plugin and writes nothing there", () => { + const foreign = foreignDataDir(); + const result = runSetupDeps([], { CLAUDE_PLUGIN_DATA: foreign }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('--data-dir "${CLAUDE_PLUGIN_DATA}"'); + expect(fs.readdirSync(foreign)).toEqual([]); + }); + + it("rejects an unsubstituted placeholder before creating anything", () => { + const foreign = foreignDataDir(); + const result = runSetupDeps(["--data-dir", ""], { CLAUDE_PLUGIN_DATA: foreign }); + expect(result.status).toBe(2); + expect(result.stderr).toContain("unsubstituted placeholder"); + expect(fs.readdirSync(foreign)).toEqual([]); + }); + + it("rejects a flag it does not take", () => { + const result = runSetupDeps(["--work-root", "/proj"], { CLAUDE_PLUGIN_DATA: foreignDataDir() }); + expect(result.status).toBe(2); + expect(result.stderr).toContain("takes only `--data-dir `"); + }); +}); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js b/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js index 3ad8ba42ff..177be1c10c 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js @@ -11,6 +11,7 @@ import path from "node:path"; import { isMainModule } from "@melodic/video-digestion/shared/main-module"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { resolvePluginData } from "../lib/run-args.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { resolveTempSession } from "../lib/temp-session-paths.js"; import { watchStatePath } from "./watch-state.js"; @@ -99,6 +100,10 @@ export function detectRecoverableBootstrap(sliceDir) { } /** + * The command the agent runs through the Bash tool, whose environment has neither + * plugin variable: the launcher's absolute path, and the data directory `run.mjs` + * resolved for this process when it names this plugin. + * * @param {string} sliceDir * @returns {string} */ @@ -108,7 +113,10 @@ export function formatRecoverCommand(sliceDir) { return ""; } const { workDir, framesDir, contactSheetsDir } = detection.tempSession; - return `node "\${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" watch/recover-watch-bootstrap.js "${sliceDir}" "${workDir}" "${framesDir}" "${contactSheetsDir}"`; + const launcher = path.join(import.meta.dirname, "..", "run.mjs").split(path.sep).join("/"); + const dataDir = resolvePluginData(undefined, process.env); + const dataFlag = dataDir ? ` --data-dir "${dataDir}"` : ""; + return `node "${launcher}"${dataFlag} watch/recover-watch-bootstrap.js "${sliceDir}" "${workDir}" "${framesDir}" "${contactSheetsDir}"`; } /** diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.test.js b/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.test.js index 0671e6c9f7..fd554ae4d9 100644 --- a/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.test.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.test.js @@ -1,8 +1,9 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { detectRecoverableBootstrap, @@ -84,6 +85,33 @@ describe("detectRecoverableBootstrap", () => { expect(formatRecoverCommand(tmp)).not.toContain("youtube-digest"); }); + it("names the launcher by absolute path and passes the plugin's own data dir", () => { + const { framesDir, sheetsDir } = makeFrameAndSheetDirs(); + const tmp = makeSliceDir({ + phases: {}, + tempSession: { workDir: makeWorkDir("captions.vtt"), framesDir, contactSheetsDir: sheetsDir }, + }); + const knowledgeData = "/var/fixture/claude/plugins/data/knowledge-melodic-software"; + + vi.stubEnv("CLAUDE_PLUGIN_DATA", knowledgeData); + try { + const match = /^node "([^"]+)" --data-dir "([^"]+)" watch\/recover-watch-bootstrap\.js /.exec( + formatRecoverCommand(tmp), + ); + expect(match).not.toBeNull(); + const [, launcher, dataDir] = /** @type {RegExpExecArray} */ (match); + expect(path.isAbsolute(launcher)).toBe(true); + expect(launcher.endsWith("/skills/video-digest/extraction/run.mjs")).toBe(true); + expect(fs.existsSync(launcher)).toBe(true); + expect(dataDir).toBe(knowledgeData); + vi.stubEnv("CLAUDE_PLUGIN_DATA", "/var/fixture/claude/plugins/data/codex-openai-codex"); + expect(formatRecoverCommand(tmp)).not.toContain("--data-dir"); + expect(formatRecoverCommand(tmp)).not.toContain("codex-openai-codex"); + } finally { + vi.unstubAllEnvs(); + } + }); + it("accepts an auto-caption-only workDir (*-orig.vtt)", () => { const { framesDir, sheetsDir } = makeFrameAndSheetDirs(); const tmp = makeSliceDir({ @@ -115,6 +143,40 @@ describe("detectRecoverableBootstrap", () => { }); }); +describe("run.mjs hands its child only the resolved data dir", () => { + const launcher = path.join(import.meta.dirname, "..", "run.mjs"); + const codex = "/var/fixture/claude/plugins/data/codex-openai-codex"; + + /** The recover command the CLI prints when run through the launcher with these args. */ + function recoverCommandVia(launcherArgs) { + const { framesDir, sheetsDir } = makeFrameAndSheetDirs(); + const slice = makeSliceDir({ + phases: {}, + tempSession: { workDir: makeWorkDir("captions.vtt"), framesDir, contactSheetsDir: sheetsDir }, + }); + const result = spawnSync( + process.execPath, + [launcher, ...launcherArgs, "watch/detect-recoverable-bootstrap.js", slice, "--json"], + { encoding: "utf8", timeout: 20000, env: { ...process.env, CLAUDE_PLUGIN_DATA: codex } }, + ); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout).recoverCommand; + } + + it("replaces an inherited value naming another plugin with the --data-dir value", () => { + const knowledge = "/var/fixture/claude/plugins/data/knowledge-melodic-software"; + const command = recoverCommandVia(["--data-dir", knowledge]); + expect(command).toContain(`--data-dir "${knowledge}"`); + expect(command).not.toContain("codex-openai-codex"); + }); + + it("never passes on an inherited value naming another plugin when no flag is given", () => { + const command = recoverCommandVia([]); + expect(command).not.toContain("--data-dir"); + expect(command).not.toContain("codex-openai-codex"); + }); +}); + describe("resolveWorkArtifacts", () => { it("resolves an auto-caption-only workDir via the *-orig.vtt fallback", () => { const artifacts = resolveWorkArtifacts(makeWorkDir("captions.en-orig.vtt")); diff --git a/plugins/knowledge/skills/video-digest/reference/sources/youtube.md b/plugins/knowledge/skills/video-digest/reference/sources/youtube.md index 925daacc27..6a72ccff10 100644 --- a/plugins/knowledge/skills/video-digest/reference/sources/youtube.md +++ b/plugins/knowledge/skills/video-digest/reference/sources/youtube.md @@ -64,7 +64,7 @@ Example combining a non-default library dir with a forced cookie source (unset o contribute no flag): ```bash -node "/extraction/run.mjs" \ +node "/extraction/run.mjs" --data-dir "" \ --work-root "${CLAUDE_PROJECT_DIR}/${user_config.library_dir}" \ --cookies-from-browser "${user_config.yt_dlp_cookies_from_browser}" \ [args…] diff --git a/plugins/knowledge/skills/video-digest/templates/watch-checklist.md b/plugins/knowledge/skills/video-digest/templates/watch-checklist.md index 81bd98c5b2..31d0a99cc3 100644 --- a/plugins/knowledge/skills/video-digest/templates/watch-checklist.md +++ b/plugins/knowledge/skills/video-digest/templates/watch-checklist.md @@ -12,7 +12,7 @@ Tick only after verification evidence. Criteria SSOT: `quality-gates.md` (the `/ ## Phase 0: Prerequisites -- [ ] **0.1** video-extraction deps installed. Verify: `node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/setup-deps.mjs"` exit 0 +- [ ] **0.1** video-extraction deps installed. Verify: `node "/extraction/setup-deps.mjs" --data-dir ""` exit 0 - [ ] **0.2** yt-dlp available. Verify: SKILL pre-computed context ≠ MISSING - [ ] **0.3** ffmpeg available (watch only). Verify: SKILL pre-computed context ≠ MISSING - [ ] **0.4** ImageMagick 7 available (watch only). Verify: SKILL pre-computed context ≠ MISSING @@ -89,7 +89,7 @@ Tick only after verification evidence. Criteria SSOT: `quality-gates.md` (the `/ - [ ] **7.2** Per done cluster: finding file or inline in `RESEARCH.md`. Verify: research outcome gate per cluster - [ ] **7.3** `RESEARCH.md` slice summary. Verify: ≥200 chars; conflicts + gaps sections - [ ] **7.4** Top harvested URLs fetched; repos analyzed to temp if GitHub links. Verify: fetch log / `analyze-harvested-repos.js` when applicable -- [ ] **7.5** Research verify. Verify: `node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" evals/check-research-complete.js ""` exit 0 +- [ ] **7.5** Research verify. Verify: `node "/extraction/run.mjs" --data-dir "" evals/check-research-complete.js ""` exit 0 - [ ] **7.6** `mark-phase research` only after 7.5. Verify: `watch.json` ## Phase 8: Synthesis @@ -105,7 +105,7 @@ Tick only after verification evidence. Criteria SSOT: `quality-gates.md` (the `/ ## Phase 9: Outcome verification (mandatory before complete) -- [ ] **9.1** Host verify. Verify: `node "${CLAUDE_PLUGIN_ROOT}/skills/video-digest/extraction/run.mjs" evals/check-watch-outcomes.js "" --write-report` exit 0 +- [ ] **9.1** Host verify. Verify: `node "/extraction/run.mjs" --data-dir "" evals/check-watch-outcomes.js "" --write-report` exit 0 - [ ] **9.2** `verification/Z-watch-outcomes.md` shows PASS. Verify: all `fail` severity checks green - [ ] **9.3** `watch-state.js close ` exit 0 after 9.1, 9.2 and 9.4; it is the only writer of `status: complete`. Verify: `watch.json` `status: complete` - [ ] **9.4** Vision fidelity spot-check (required for a vision-complete claim). Verify: ≥10 synthesis PNG images name↔content + ≥3 contact sheets verdict↔JPG; notes in Resume notes below. Verify script exit 0 alone is structural only.