Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/superpowers/plans/2026-07-13-media-use-sfx-cli-advisory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Media-use SFX CLI Advisory Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Surface an actionable install/update advisory when bundled SFX succeeds specifically because the HeyGen CLI is missing or outdated.

**Architecture:** Extend the existing HeyGen error classifier with process-local remediation state, consume it only after `bundled.sfx` wins, and expose it through the existing result formatter. Preserve successful fallback, existing stderr diagnostics, telemetry, and provider ordering.

**Tech Stack:** Node.js ESM, `node:test`, media-use resolver CLI, generated skills manifest.

---

### Task 1: Pin remediation state behavior

**Files:**
- Modify: `skills/media-use/scripts/lib/heygen-cli.test.mjs`
- Modify: `skills/media-use/scripts/lib/heygen-cli.mjs`

- [ ] **Step 1: Write failing tests**

Add tests that call `reportHeygenFailure` with `ENOENT`, an outdated-version error, and a non-actionable error, then assert a new `consumeHeygenRemediation()` returns `{ code, message }` once for only `not_found` and `outdated`.

- [ ] **Step 2: Verify RED**

Run: `node --test skills/media-use/scripts/lib/heygen-cli.test.mjs`
Expected: FAIL because `consumeHeygenRemediation` is not exported.

- [ ] **Step 3: Implement minimal state**

Store the latest `not_found` or `outdated` result inside `reportHeygenFailure`; export a consume-once function that returns and clears it. Do not store auth, quota, network, or generic failures.

- [ ] **Step 4: Verify GREEN**

Run the same test; expected: PASS.

### Task 2: Attach the advisory only to implicit bundled fallback

**Files:**
- Modify: `skills/media-use/scripts/resolve.test.mjs`
- Modify: `skills/media-use/scripts/resolve.mjs`

- [ ] **Step 1: Write failing resolver tests**

Add subprocess cases for missing CLI and outdated CLI that resolve bundled SFX and assert `advisory.message` contains the canonical install/update command. Add negative cases for `--local-only`, `--provider bundled.sfx`, and a healthy catalog miss.

- [ ] **Step 2: Verify RED**

Run: `node --test skills/media-use/scripts/resolve.test.mjs`
Expected: FAIL because successful JSON results do not contain `advisory`.

- [ ] **Step 3: Implement result plumbing**

After provider resolution, consume remediation only when the winner is `bundled.sfx`, the run is not local-only, and no provider override was supplied. Attach it to the record/result; print a concise human hint and include the structured object in JSON.

- [ ] **Step 4: Verify GREEN**

Run the resolver test; expected: all cases PASS.

### Task 3: Regenerate and verify

**Files:**
- Modify: `skills-manifest.json`

- [ ] **Step 1: Format changed files**

Run `bunx oxfmt` on the four changed source/test files.

- [ ] **Step 2: Regenerate manifest**

Run: `bun packages/cli/scripts/gen-skills-manifest.ts`

- [ ] **Step 3: Run required checks**

Run:

```bash
node --test skills/media-use/scripts/lib/heygen-cli.test.mjs skills/media-use/scripts/lib/registry.test.mjs skills/media-use/scripts/resolve.test.mjs
bun run lint:skills
bun run test:skills
bun run format:check
bun packages/cli/scripts/gen-skills-manifest.ts --check
```

Expected: zero failures and an in-sync manifest.

- [ ] **Step 4: Commit and push**

Commit the implementation and generated manifest, push to `fix/media-use-bundled-sfx`, then wait for PR #2257 CI to become terminal and green.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Media-use SFX CLI advisory design

## Goal

When SFX resolution succeeds through the bundled fallback because the HeyGen CLI is missing or outdated, tell the agent how to restore the broader HeyGen catalog path. Keep bundled fallback successful and never install software automatically.

## Behavior

- Reuse the existing HeyGen CLI error classification produced by the failed catalog provider call.
- Retain only the latest actionable `not_found` or `outdated` remediation for the current resolver process.
- When `bundled.sfx` wins after that failure, include a structured advisory in JSON output and print the same concise hint in human output.
- Use the existing canonical commands:
- Missing: `curl -fsSL https://static.heygen.ai/cli/install.sh | bash && heygen auth login --oauth`
- Outdated: `heygen update`
- Do not emit this advisory for authentication, quota, network, or legitimate empty-catalog results; `--local-only`; or an explicitly forced bundled provider.
- Do not execute the install or update command.

## Components

1. `heygen-cli.mjs` records and exposes one process-local actionable remediation alongside the existing logging and telemetry behavior.
2. `resolve.mjs` consumes that remediation only when the winning provider is `bundled.sfx`, then attaches it to the result.
3. The result formatter exposes the advisory consistently to JSON and human consumers.

## Tests

- Missing CLI followed by bundled fallback returns the install advisory.
- Outdated CLI followed by bundled fallback returns the update advisory.
- A healthy CLI with a legitimate catalog miss returns bundled SFX without an install/update advisory.
- `--local-only` and explicit bundled-provider resolution do not suggest installation.
- Existing fallback, registry, skill, format, and manifest checks remain green.

## Safety

This is recommendation-only. It does not cross the software-install consent boundary, run a shell, or turn a successful local fallback into an error.
4 changes: 2 additions & 2 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
"files": 10
},
"media-use": {
"hash": "e3f818afb0afbb34",
"files": 122
"hash": "8fd94ced0d1daee6",
"files": 124
},
"motion-graphics": {
"hash": "dafcdce07d221aa4",
Expand Down
58 changes: 58 additions & 0 deletions skills/media-use/scripts/lib/bundled-sfx-provider.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { existsSync, readFileSync } from "node:fs";
import { extname, join } from "node:path";

const LIB_DIR = join(import.meta.dirname, "..", "..", "audio", "assets", "sfx");

const normalize = (value) =>
String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();

export function extensionForBundledSfxFile(filename) {
return extname(filename) || ".mp3";
}

function score(intent, key, entry) {
const query = normalize(intent);
const name = normalize(key);
if (query === name) return 100;
if (query.includes(name) || name.includes(query)) return 50;
const haystack = new Set(normalize(`${key} ${entry.description || ""}`).split(/\s+/));
return query.split(/\s+/).filter((token) => token && haystack.has(token)).length;
}

export const bundledSfxProvider = {
async search(intent) {
const manifestPath = join(LIB_DIR, "manifest.json");
if (!existsSync(manifestPath)) return null;

let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch {
return null;
}

const ranked = Object.entries(manifest)
.map(([key, entry]) => ({ key, entry, score: score(intent, key, entry) }))
.filter(({ entry, score }) => entry?.file && score > 0)
.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key));
const best = ranked[0];
if (!best) return null;

const localPath = join(LIB_DIR, best.entry.file);
if (!existsSync(localPath)) return null;
return {
localPath,
ext: extensionForBundledSfxFile(best.entry.file),
source: "bundled",
metadata: {
description: best.entry.description || best.key,
duration: best.entry.duration ?? null,
provider: "bundled.sfx",
provenance: { library_key: best.key },
},
};
},
};
9 changes: 9 additions & 0 deletions skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { extensionForBundledSfxFile } from "./bundled-sfx-provider.mjs";

test("derives bundled SFX extension from the manifest filename", () => {
assert.equal(extensionForBundledSfxFile("impact.wav"), ".wav");
assert.equal(extensionForBundledSfxFile("whoosh.ogg"), ".ogg");
assert.equal(extensionForBundledSfxFile("extensionless"), ".mp3");
});
13 changes: 13 additions & 0 deletions skills/media-use/scripts/lib/heygen-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,22 @@ function classifyHeygenErrorResult(err) {
// same "awaited so a short-lived run flushes it" discipline telemetry.mjs's
// track() already documents, just reachable from a sync call site.
const pendingFailureTracking = new Set();
// resolve.mjs is a single-shot CLI (one resolve per process), so one shared
// consume-once slot is sufficient. If resolve becomes an in-process/concurrent
// API, move this state into a per-resolve context before reusing that path.
let pendingRemediation = null;

export function consumeHeygenRemediation() {
const remediation = pendingRemediation;
pendingRemediation = null;
return remediation;
}

export function reportHeygenFailure(err, context, trackEvent = track) {
const { code, message } = classifyHeygenErrorResult(err);
if (code === "not_found" || code === "outdated") {
pendingRemediation = { code, message };
}
if (ACTIONABLE_MESSAGES.has(message)) {
console.error(message);
} else {
Expand Down
34 changes: 34 additions & 0 deletions skills/media-use/scripts/lib/heygen-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { test } from "node:test";
import {
classifyHeygenError,
classifyHeygenErrorCode,
consumeHeygenRemediation,
flushHeygenFailureTracking,
HEYGEN_NOT_AUTHENTICATED_MESSAGE,
HEYGEN_NOT_FOUND_MESSAGE,
Expand Down Expand Up @@ -149,6 +150,39 @@ test("tracks not-found failures without changing actionable output", () => {
]);
});

test("records missing and outdated CLI remediation once", () => {
consumeHeygenRemediation();
captureFailureReport({ code: "ENOENT" }, "heygen audio sounds list", () => {});
assert.deepEqual(consumeHeygenRemediation(), {
code: "not_found",
message: HEYGEN_NOT_FOUND_MESSAGE,
});
assert.equal(consumeHeygenRemediation(), null);

captureFailureReport(
{ stderr: Buffer.from("heygen v0.1.5 does not support --headers") },
"heygen audio sounds list",
() => {},
);
assert.deepEqual(consumeHeygenRemediation(), {
code: "outdated",
message: HEYGEN_OUTDATED_MESSAGE,
});
assert.equal(consumeHeygenRemediation(), null);
});

test("does not record non-install remediation", () => {
consumeHeygenRemediation();
for (const error of [
{ stderr: Buffer.from("HTTP 401 Unauthorized") },
{ stderr: Buffer.from("quota exhausted") },
{ stderr: Buffer.from("provider unavailable") },
]) {
captureFailureReport(error, "heygen audio sounds list", () => {});
assert.equal(consumeHeygenRemediation(), null);
}
});

test("tracks generic failures without including raw detail", () => {
const trackingCalls = [];
const stderrCalls = captureFailureReport(
Expand Down
8 changes: 6 additions & 2 deletions skills/media-use/scripts/lib/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import { bgmProvider } from "./bgm-provider.mjs";
import { sfxProvider } from "./sfx-provider.mjs";
import { bundledSfxProvider } from "./bundled-sfx-provider.mjs";
import { imageProvider, iconProvider } from "./image-provider.mjs";
import { brandProvider } from "./brand-provider.mjs";
import {
Expand All @@ -44,10 +45,13 @@ const A = (name, caps) => ({ name, ...caps }); // local, free
const N = (name, caps) => ({ name, network: true, ...caps }); // remote, free
const P = (name, caps) => ({ name, network: true, paid: true, ...caps }); // remote, paid

// heygen-CLI first (and currently only). All remote providers are skipped by --local-only.
// heygen-CLI first. All remote providers are skipped by --local-only.
const REGISTRY = {
bgm: [N("heygen.audio.sounds", { search: bgmProvider.search })],
sfx: [N("heygen.audio.sounds", { search: sfxProvider.search })],
sfx: [
N("heygen.audio.sounds", { search: sfxProvider.search }),
A("bundled.sfx", { search: bundledSfxProvider.search }),
],
image: [
N("heygen.asset.search", { search: imageProvider.search }),
// Catalog miss -> generate. Local first (best FLUX-class model the machine's
Expand Down
11 changes: 10 additions & 1 deletion skills/media-use/scripts/lib/registry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test("heygen provider is first for every type it serves", () => {

test("sanctioned providers only: heygen, local mflux/kokoro, codex, design spec, logo tiers", () => {
const allowed =
/^heygen|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
/^heygen|^bundled\.sfx$|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
for (const t of listTypes()) {
for (const p of getProviders(t)) {
assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`);
Expand Down Expand Up @@ -52,6 +52,15 @@ test("voice cascade: HeyGen TTS first, Kokoro remains the local fallback", () =>
assert.ok(!ps[1].paid, "local Kokoro is free");
});

test("sfx cascade: HeyGen catalog first, bundled library remains the local fallback", () => {
const ps = getProviders("sfx");
assert.equal(ps[0].name, "heygen.audio.sounds");
assert.ok(ps[0].network, "HeyGen SFX catalog is network-only");
assert.equal(ps[1].name, "bundled.sfx");
assert.equal(typeof ps[1].search, "function");
assert.ok(!ps[1].network, "bundled SFX remain available offline");
});

test("ctx.provider forces one generator (e.g. 'make an image WITH codex')", async () => {
const providers = [
{ name: "heygen.asset.search", network: true, search: async () => null },
Expand Down
11 changes: 11 additions & 0 deletions skills/media-use/scripts/resolve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
HEYGEN_INSTALL_COMMAND,
HEYGEN_MIN_VERSION,
HEYGEN_UPDATE_COMMAND,
consumeHeygenRemediation,
firstSemver,
flushHeygenFailureTracking,
versionLessThan,
Expand Down Expand Up @@ -429,6 +430,16 @@ async function run() {
},
};

const heygenRemediation = consumeHeygenRemediation();
if (
searchResult.metadata?.provider === "bundled.sfx" &&
!localOnly &&
!args.provider &&
heygenRemediation
) {
record.advisory = heygenRemediation;
}

appendRecord(projectDir, record);
regenerateIndex(projectDir);
// Auto-promote: surface every fetched asset in the global cache so it's
Expand Down
Loading
Loading