Files
London Dávila d58743b5eb Doctor skill: track schema_version 3, quote the gh issue title, list ~/.grok (#262)
doctor still described `schema_version` 1 and 2 + bailed above 2, but
CLI emitted 3 since 0.22.0 and skill pins `@tiny-fish/cli@latest`. as
shipped it degraded to `--pretty` on every run. The feedback skill also
passed an unquoted, user-derived title to `gh issue create`. three fixes
landed in tinyfish-web-agent-integrations#29; this keeps the two copies
of the plugin textually identical

- `doctor`: schema 3 — `cli_version`, `ok_harnesses`/`ok_cli`,
`checks[].scope`, `repairs[].action`, and the exit code coming from the
harness scope alone, so `ok_cli: false` beside exit `0` is a real state.
Drops the `registered, API key present but unverified` string the CLI no
longer emits; `--fix` examples run through `npx` like step 1.
- `feedback`: `--title "$title" --body-file "$body_file"`, injection
warning widened to the title, and `eval` / constructed command strings
prohibited.
- `plugins/tinyfish/README.md`: adds `~/.grok` to the config dirs doctor
reads, per the CLI's `CONFIG_DIRS`. The rest of that list matches.
- 1.2.3 → 1.2.4 in `plugin.json` and the marketplace entry —
`validate-skills.mjs` fails if they diverge

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:45:11 -06:00

91 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
// Generates skill variants from skills-src/*.md. The claude target feeds the marketplace
// plugin; the generic target is served to every other harness by the `skills` CLI.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const SRC = join(ROOT, "skills-src");
// Codex rejects frontmatter descriptions longer than 1024 chars.
const DESC_LIMIT = 1024;
const GENERIC_REAUTH = `re-authenticate in the harness itself.
| Harness | Re-auth |
|---|---|
| Codex, Hermes | no login command — auth runs on first tool use; trigger a TinyFish tool and finish the browser sign-in |
| OpenCode | \`opencode mcp auth tinyfish\` |
| Claude Code | \`/mcp\` in-app, or \`claude mcp login tinyfish\` |
| Grok | no login command — in Grok Build, \`/mcps\`, select \`tinyfish\`, press \`i\`. A keyed registration instead reads \`TINYFISH_API_KEY\` from Grok's environment and has no OAuth fallback: re-run \`tinyfish connect grok --api-key <key>\` and start Grok from a new terminal |
| OpenClaw, Cursor | key-based — \`tinyfish auth login\`, then \`tinyfish connect <harness>\` to rewrite the header |`;
const TARGETS = {
claude: {
dir: (name) => join(ROOT, "plugins", "tinyfish", "skills", name),
vars: {
HARNESS_FLAG: " --harness claude-code",
HARNESS_ENTRY: "`--harness claude-code` narrows `harnesses[]` to exactly one entry, so there is no ambiguity about which harness it describes.",
REAUTH: "tell the user to run `/mcp`, pick tinyfish, and sign in.",
FEEDBACK: "offer `/tinyfish:feedback` to file it",
},
},
generic: {
dir: (name) => join(ROOT, "skills", name),
vars: {
HARNESS_FLAG: "",
HARNESS_ENTRY:
"Run without `--harness`, so `harnesses[]` carries one entry per harness doctor knows — installed or not. Read the entry whose `harness` matches the agent you are running in, never the first one, and check its `detected` first: an absent harness reports `detected: false`, `registered: \"no\"`, `auth_mode: \"unknown\"`, which is not a fault to repair. doctor only knows `claude-code`, `codex`, `cursor`, `grok`, `hermes`, `openclaw`, `opencode`; if you are none of those, no entry describes you and step 2 is your only evidence.",
REAUTH: GENERIC_REAUTH,
FEEDBACK: "file it at https://github.com/tinyfish-io/tinyfish-cookbook/issues",
},
},
};
// Explicit per-target names: the `skills` CLI matches --skill on frontmatter name only,
// and feedback is Claude-specific, so neither may default to "every target".
const SKILLS = [
{ src: "doctor.md", names: { claude: "doctor", generic: "tinyfish-doctor" } },
{ src: "feedback.md", names: { claude: "feedback" } },
];
function parse(src, file) {
const m = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!m) throw new Error(`${file}: missing frontmatter`);
const fm = Object.fromEntries(
m[1].split("\n").map((l) => {
const i = l.indexOf(":");
return [l.slice(0, i).trim(), l.slice(i + 1).trim()];
})
);
if (!fm.name || !fm.description) throw new Error(`${file}: frontmatter needs name + description`);
if (fm.description.length > DESC_LIMIT)
throw new Error(`${file}: description ${fm.description.length} chars > ${DESC_LIMIT} (Codex limit)`);
return { fm, body: m[2] };
}
function render(body, vars, file, target) {
const out = Object.entries(vars).reduce(
(acc, [k, v]) => acc.replaceAll(`{{${k}}}`, v),
body
);
const leftover = out.match(/\{\{[A-Z_]+\}\}/);
if (leftover) throw new Error(`${file} → ${target}: unsubstituted placeholder ${leftover[0]}`);
return out;
}
let count = 0;
for (const skill of SKILLS) {
const { fm, body } = parse(readFileSync(join(SRC, skill.src), "utf8"), skill.src);
for (const [target, name] of Object.entries(skill.names)) {
const t = TARGETS[target];
if (!t) throw new Error(`${skill.src}: unknown target ${target}`);
const out = `---\nname: ${name}\ndescription: ${fm.description}\n---\n${render(body, t.vars, skill.src, target)}`;
const dir = t.dir(name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), out);
count++;
}
}
console.log(`generated ${count} skill files`);