watch
Lets Claude watch a video from a URL or local file by pulling timestamped frames and a transcript. Works in Claude Code, and the watch skill downloads with yt-dlp and extracts frames with ffmpeg.
What grounded-copy runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Claude Code
> /plugin marketplace add HiroHyun/grounded-copy> /plugin install grounded-copy@hirohyun-plugins
Cursor
Cursor also runs Claude Code hooks from .claude/settings.json, so copy these hooks there to use them in Cursor.
Where it lives
#!/usr/bin/env python3
"""Hook transport: stdin and argv.
Kept apart from the preference and policy vocabulary. The three entrypoints
share the argv rules here, which is what earns the module its own file:
plugin_root() carries three resolution rules, and duplicating them across the
entrypoints is how they drift apart.
The two lifecycle entrypoints read no field of the event, so they drain stdin.
The gate reads the event, since it needs the saved file and the transcript.
"""
import json
import os
import sys
def utf8_streams():
"""Speak UTF-8 to the host on every platform.
The payloads carry em dashes and the arrow in the workflow step, and a
`copy` policy carries more. Python picks the encoding for a pipe from the
platform, so a Windows interpreter at a legacy code page hands the host
cp1252 bytes for those characters; a host decoding UTF-8 then reads the
session policy mojibaked, and a character the code page cannot hold raises
UnicodeEncodeError, which the entrypoint swallows into a silent exit 0.
Measured: `PYTHONIOENCODING=cp1252` made a UTF-8 reader see byte 0x97 and
return no policy at all.
Call it before writing. `errors="replace"` keeps a payload flowing when one
character will not encode, since a style reminder is worth less than the
session it would break.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError):
pass
def drain_stdin():
"""Consume the hook event.
The hook contract hands the event in on stdin. Neither entrypoint reads its
fields, and a hook that leaves stdin unread can break the writer's pipe.
"""
try:
sys.stdin.read()
except Exception:
pass
def read_event():
"""The hook event as a dict, or None when stdin holds no JSON object.
Read as bytes and decoded as UTF-8: a host writes UTF-8, and a Windows
interpreter would decode a file path by the code page otherwise.
"""
try:
raw = getattr(sys.stdin, "buffer", sys.stdin).read()
if isinstance(raw, bytes):
raw = raw.decode("utf-8", "replace")
event = json.loads(raw)
except Exception:
return None
return event if isinstance(event, dict) else None
def preference_arg(argv, default):
"""`--preference-path FILE` from argv, or the entrypoint's own default."""
if "--preference-path" in argv:
index = argv.index("--preference-path")
if index + 1 < len(argv):
return argv[index + 1]
return default
def plugin_root(argv, hook_dir):
"""The plugin root, in order of preference.
1. `--plugin-root DIR` from argv, which the test suite and manual runs pass
2. $PLUGIN_ROOT from the environment (host-neutral adapter seam)
3. $CLAUDE_PLUGIN_ROOT from the environment, which a Claude hook run inherits
4. the hooks directory's parent, which holds for every shipped layout
A value still carrying a literal `${` is an unsubstituted placeholder and
falls through to the next source.
"""
candidates = []
for i, arg in enumerate(argv):
if arg == "--plugin-root" and i + 1 < len(argv):
candidates.append(argv[i + 1])
candidates.extend((
os.environ.get("PLUGIN_ROOT"),
os.environ.get("CLAUDE_PLUGIN_ROOT"),
))
for value in candidates:
if value and value.strip() and "${" not in value:
return os.path.abspath(os.path.expanduser(value))
return os.path.dirname(os.path.abspath(hook_dir))
#!/usr/bin/env python3
"""The policy text: what the hooks and `--set` put in front of the model.
Three shapes come out of this module, and skills/grounded-copy/references/setup.md defines each one
under `### Profile lifecycle`:
- the **session policy**, which SessionStart injects
- the **turn reminder**, which UserPromptSubmit injects
- a **governing directive**, which `--set` prints when it records a new
preference
Every one of them is assembled from SKILL.md at runtime, so a rule edit lands
without a code change. The workflow, the integrity rules, and the three excluded
closures load with the skill itself when a copy task calls for them, and so do
the trigger catalogs and rewrite tables in skills/grounded-copy/references/patterns.md.
Transport stays outside this module: it reads no environment and takes the
plugin root as an argument.
"""
import os
import re
# Where the skill sits under the plugin root. One path covers every shipped
# layout: the Claude plugin, a skills-directory clone, the generated Codex
# tree, and a checkout.
SKILL_RELATIVE = os.path.join("skills", "grounded-copy", "SKILL.md")
# Sections both profiles carry, in payload order.
CORE_HEADINGS = (
"## The one banned move",
"## Scope and precedence",
"## Sourcing",
"## Suspended lists",
)
# The section the `copy` profile adds.
MARKETING_HEADING = "## Marketing register"
LOOPHOLE_HEADING = "## Loophole closures"
# The closures the `copy` profile adds, in SKILL.md order. Two others came out
# into the rules that already stated them: the quote closure into
# `## Scope and precedence`, whose verbatim rule covers invented testimonials,
# and the headline closure into `## Marketing register`, which enumerates
# headline and CTA scope. These two carry content no core rule states.
COPY_LABELS = (
'- **"It\'s a different language."**',
'- **"The linter passed, so it\'s fine."**',
)
COPY_LEAD = "The copy profile adds these closures from the skill:"
SESSION_HEADER = "GROUNDED PROSE ACTIVE — profile: {profile}"
# The Claude slash command. A copied entrypoint passes its own host's verb
# instead; this module names one host in a default and reads no environment to
# discover another.
CLAUDE_SWITCH = "/grounded-copy:grounded chat|copy|off"
SWITCH_LINE = "Profile: {profile}. Switch: `{switch}`."
# The reminder carries the sourcing rule's selection cue each turn. Exact
# reproduction applies to quotations; other source material may be summarized.
TURN_REMINDER = (
"GROUNDED PROSE ({profile}). State the fact; cut the half set against "
"something nobody raised. Keep warnings, limits, corrections, and "
"requested comparisons. Select relevant facts; quote exactly. "
"A user instruction outranks this; name the rule."
)
DIRECTIVE_HEADER = "GROUNDED PROSE — governing profile: {profile} ({source})"
DIRECTIVE_LEAD = (
"This directive supersedes every grounded-copy policy statement earlier in "
"this transcript. Those statements stay in the transcript as a record; the "
"rules below are what governs from this turn onward."
)
DIRECTIVE_LEAD_OFF = (
"This directive supersedes every grounded-copy policy statement earlier in "
"this transcript. Those statements stay in the transcript as a record. No "
"grounded-copy rule governs from this turn onward."
)
DIRECTIVE_LEAD_EMPTY = (
"The preference is recorded. SKILL.md was unreadable on this run, so the "
"rules for this profile arrive at the next session start."
)
# Extraction sizes measured 2026-10-15 against SKILL.md, including fact selection
# and paragraph review across languages. These count the rules body alone; the session
# policy adds the header and switch line, 106 bytes. The setup guide publishes the same figures under
# "Profile lifecycle", and `grounded_activate.py --self-test` reports the current ones.
BASELINE_BYTES = 4112
COPY_BASELINE_BYTES = 6105
TURN_BASELINE_BYTES = 238
# (floor, ceiling) per payload. The ceiling bounds growth against the figure the
# documentation published when the range was set: a payload that passes it fails
# the self-test. An exact-size assertion would fail on every intentional rule
# addition and get muted, so the band leaves room for one. The floor catches an
# extraction that returns a stub while every structural assertion still passes.
BYTE_RANGE = (3350, 4300)
COPY_BYTE_RANGE = (4850, 7000)
TURN_BYTE_RANGE = (160, 260)
def _strip_frontmatter(text):
return re.sub(r"^---.*?---\s*", "", text, flags=re.S)
def _section(lines, start):
"""The lines from a heading up to the next `## ` heading."""
end = len(lines)
for i in range(start + 1, len(lines)):
if lines[i].startswith("## "):
end = i
break
return "\n".join(lines[start:end]).strip()
def _find(lines, heading):
for i, line in enumerate(lines):
if line.startswith(heading):
return i
return None
def _bullet(section, label):
"""One bold-labelled bullet out of a section, or an empty string."""
match = re.search(
re.escape(label) + r".*?(?=\n- \*\*|\n## |\Z)", section, flags=re.S
)
return match.group(0).strip() if match else ""
def extract(skill_text):
"""The policy pieces as a dict. Missing pieces come back empty."""
body = _strip_frontmatter(skill_text)
lines = body.splitlines()
pieces = {"intro": "", "core": [], "marketing": "", "closures": []}
first = None
for i, line in enumerate(lines):
if line.startswith("## "):
first = i
break
if first is None:
return pieces
pieces["intro"] = "\n".join(lines[:first]).strip()
for heading in CORE_HEADINGS:
start = _find(lines, heading)
pieces["core"].append(_section(lines, start) if start is not None else "")
start = _find(lines, MARKETING_HEADING)
if start is not None:
pieces["marketing"] = _section(lines, start)
start = _find(lines, LOOPHOLE_HEADING)
#!/usr/bin/env python3
"""The profile preference: the value stored on disk.
The preference lives at <config-dir>/grounded-copy/profile, config-dir being
$CLAUDE_CONFIG_DIR when set and ~/.claude otherwise. One fixed path keeps hook
runs and shell runs on the same file, and keeps the preference somewhere the
user can cat and edit. skills/grounded-copy/references/setup.md records why
${CLAUDE_PLUGIN_DATA} stays out of it.
This module is the sole writer of the preference, and the `--set` path of
grounded_tracker.py is its only caller. resolve_preference() is read-only, and
both hooks are read-only.
Transport stays outside this module: it reads no stdin and parses no argv.
"""
import os
VALID = ("chat", "copy", "off")
DEFAULT = "chat"
ACTIVE = ("chat", "copy")
MAX_PREFERENCE_BYTES = 64
# `technical` was the stored name for `chat` before the profiles took their
# user-facing names. A preference written by an earlier install keeps working.
LEGACY = {"technical": "chat"}
# Words `--set` accepts: the three profile names, plus `technical` for the
# legacy stored value and `marketing` for the register `copy` carries. `on`,
# `stop`, and `disable` came out with the natural-language switch that used
# them; nothing documented or tested them afterwards.
ARGUMENTS = {
"chat": "chat",
"technical": "chat",
"copy": "copy",
"marketing": "copy",
"off": "off",
}
# The Claude command that restores an active profile. A copied entrypoint
# passes its own host's verb instead.
CLAUDE_RESTORE = "--set chat"
# Why resolve_preference() returned the name it returned.
RECORDED = "recorded"
ABSENT = "absent"
UNREADABLE = "unreadable"
def config_dir():
"""Return Claude's config directory for the canonical entrypoints."""
return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(
os.path.expanduser("~"), ".claude"
)
def data_dir():
return os.path.join(config_dir(), "grounded-copy")
def preference_path(path=None):
"""Resolve an explicit profile file or Claude's canonical default."""
if path is not None:
return os.path.abspath(os.path.expanduser(os.fspath(path)))
return os.path.join(data_dir(), "profile")
def canonical_argument(value):
"""A profile name from a `--set` value, or None."""
return ARGUMENTS.get(str(value).strip().lower())
def _read_preference(path=None):
"""The recorded name, or None when the file is absent or untrusted.
A symlink, an oversized file, or an unrecognized value reads as untrusted,
so neither hook emits bytes it did not write.
"""
path = preference_path(path)
try:
if os.path.islink(path):
return None, UNREADABLE
if not os.path.exists(path):
return None, ABSENT
if os.path.getsize(path) > MAX_PREFERENCE_BYTES:
return None, UNREADABLE
with open(path, encoding="utf-8") as handle:
value = handle.read().strip().lower()
except Exception:
return None, UNREADABLE
value = LEGACY.get(value, value)
if value in VALID:
return value, RECORDED
return None, UNREADABLE
def resolve_preference(path=None):
"""(profile, source): a name from VALID, and why. Read-only."""
value, source = _read_preference(path)
return (value if value else DEFAULT), source
def status_line(path=None, restore=None):
"""One line naming the profile, the reason, and the resolved path.
``restore`` is the host's command for returning to an active profile. The
Claude flag is the default; the Codex entrypoint passes its own verb.
"""
profile, source = resolve_preference(path)
path = preference_path(path)
if source == RECORDED:
line = "grounded profile: %s (recorded at %s)" % (profile, path)
if profile == "off":
line += "; run %s to restore" % (restore or CLAUDE_RESTORE)
return line
if source == ABSENT:
return "grounded profile: %s (default, no preference at %s)" % (
profile, path
)
return "grounded profile: %s (default, unreadable preference at %s)" % (
profile, path
)
def write_preference(profile, path=None):
"""Record the preference. Returns (ok, detail).
The write is followed by a read-back through _read_preference(), so a write
that lands somewhere the resolver cannot use reports failure here instead
of resolving stale later. Creates the data directory on first write.
A symlink at the path is reported, never removed. _read_preference() reads
one as untrusted, so the link is already inert, and deleting a file the user
put there reaches past what this function owns.
"""
if profile not in VALID:
return False, "grounded: refusing to record %r" % (profile,)
path = preference_path(path)
if os.path.islink(path):
return False, (
"grounded: %s is a symlink, which resolve_preference() reads as "
"unreadable. Remove it and run --set again." % path
)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
# Replace a same-directory temporary file so readers never observe a
# partially written profile. The symlink check above remains the guard
# that prevents replacing a user-owned link.
import tempfile
fd, temporary = tempfile.mkstemp(prefix=".profile-", dir=os.path.dirname(path))
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(profile + "\n")
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except OSError:
pass
except Exception as exc:
return False, "grounded: write failed at %s: %s" % (path, exc)
stored, source = _read_preference(path)
if stored != profile:
return False, (
"grounded: wrote %s at %s, read back %s (%s)"
% (profile, path, stored, so#!/usr/bin/env python3
"""SessionStart hook: put the session policy in context.
The policy goes out as JSON `additionalContext`, which the host adds to
session context, so the rules arrive with no skill-trigger judgment involved.
Registered with no matcher, so it also fires after every compaction.
_policy.py assembles the text from SKILL.md at runtime, which lets a rule edit
land with no code change. This hook is read-only: it resolves the preference
and writes nothing.
Usage:
grounded_activate.py [--plugin-root DIR]
grounded_activate.py --self-test [--plugin-root DIR]
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _hook_io # noqa: E402
import _policy # noqa: E402
import _preference # noqa: E402
# Adapter seams: a copied entrypoint sets these for its host. The skill path
# needs none — every layout puts SKILL.md at the same place under the plugin
# root, which is what `skills/grounded-copy/` as the one canonical directory
# buys. scripts/build_codex_adapter.py rewrites each seam and fails the build
# when one goes missing.
PREFERENCE_PATH = None
SWITCH_HINT = None
def main(argv):
_hook_io.utf8_streams()
hook_dir = os.path.dirname(os.path.abspath(__file__))
root = _hook_io.plugin_root(argv, hook_dir)
if "--self-test" in argv:
return _policy.self_test(root)
_hook_io.drain_stdin()
path = _hook_io.preference_arg(argv, PREFERENCE_PATH)
profile, _source = _preference.resolve_preference(path)
if profile not in _preference.ACTIVE:
return 0
skill = _policy.read_skill(root)
if not skill:
return 0
policy = _policy.session_policy(skill, profile, SWITCH_HINT)
sys.stdout.write(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": policy,
}
}))
return 0
if __name__ == "__main__":
argv = sys.argv[1:]
if "--self-test" in argv:
sys.exit(main(argv))
try:
sys.exit(main(argv))
except Exception:
sys.exit(0)
#!/usr/bin/env python3
"""PostToolUse hook: check what the agent just saved.
Both plugins register it. It runs copy_lint.py on the saved file under the
saved profile and hands the findings back to the agent on stderr with exit
code 2, which is the one place a grounded-copy hook blocks on purpose.
Text that was already in the file is left alone. A save is checked on the
lines Claude Code's diff of it marks as added, a Codex patch on the lines it
added, and a write with no diff on every line.
One script serves Claude Code and Codex and tells them apart by the shape of
the event and of the transcript rows. Claude Code names the saved file; Codex
passes the patch it applied.
A sentence the user typed passes. The host names the session transcript in the
event, and the hook reads the user's messages from it. A flagged sentence is
kept when it equals a unit of one of those messages: a sentence, a span in
quotation marks, the text after a colon, or a list item. The standalone checker
has no such option, since an agent that could name its own source text could
grant itself a pass.
Everything else fails toward a report: an unreadable transcript, a row with no
mark of a typed prompt, or a snippet with no single place in the file gives no
pass. An error inside the hook exits 0, like the other two hooks.
Usage:
grounded_gate.py [--plugin-root DIR] [--preference-path FILE] < event.json
"""
import io
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _hook_io # noqa: E402
import _preference # noqa: E402
# Adapter seam: a copied entrypoint sets this for its host.
PREFERENCE_PATH = None
EXIT_OK = 0
EXIT_FINDINGS = 2
# The checker's own sample files report by design.
FIXTURES = "/skills/grounded-copy/tests/"
# A pasted block arrives inside the typed prompt, wrapped in these tags.
_PASTE_TAG = re.compile(r"</?pasted_content\b[^>]*>")
_QUOTED = re.compile(r'"([^"\n]{3,})"')
# What a sentence may end with, dropped before two sentences are compared.
_TAIL = " \t\"')]*_.!?。!?"
# Codex passes the patch it applied. These headers name the files it wrote.
_PATCH_FILE = re.compile(r"^\*\*\* (?:Add File|Update File|Move to): (.+)$", re.M)
CODEX_TYPED_KINDS = {"user.text", "user.image"}
# The Codex app sends an approved plan as a user message that opens this way.
# The plan's text is the agent's, so the row earns no pass.
CODEX_PLAN = "PLEASE IMPLEMENT THIS PLAN:"
def _checker(root):
"""Import copy_lint from the skill under the plugin root."""
# A .pyc beside the checker would ship with the skill.
sys.dont_write_bytecode = True
sys.path.insert(0, os.path.join(root, "skills", "grounded-copy", "scripts"))
import copy_lint
return copy_lint
def in_scope(path):
"""Prose files: .md, .txt, and anything under a locale folder."""
norm = path.replace("\\", "/").lower()
if FIXTURES in norm:
return False
return norm.endswith((".md", ".txt")) or bool(re.search(r"/locales?/", norm))
def saved_files(event):
"""(path, what the call wrote there) for each file a tool call wrote.
Claude Code names one file in tool_input.file_path and diffs the save in
tool_response.structuredPatch, in hunks that mark each added line with a
`+`. With no usable diff, an edit falls back to its replacement text, and
a whole-file write carries none, which reads as None here: every line is
the agent's. A Bash command Claude Code recorded carries
tool_response.bashEditDiff, which diffs up to five files in the same
hunks; a file it lists with no usable diff stays unchecked. Codex passes
the text of the patch in tool_input.command, with one header per file,
paths relative to cwd, and a `+` before each line it adds.
"""
tool_input = event.get("tool_input") or {}
response = event.get("tool_response")
response = response if isinstance(response, dict) else {}
path = tool_input.get("file_path")
if isinstance(path, str):
hunks = response.get("structuredPatch")
added = diff_lines(hunks) if hunks else None
if added is not None:
return [(path, added)]
edits = [tool_input] + [
edit for edit in tool_input.get("edits") or []
if isinstance(edit, dict)]
chunks = [edit["new_string"] for edit in edits
if isinstance(edit.get("new_string"), str)]
return [(path, chunks or None)]
diff = response.get("bashEditDiff")
if isinstance(diff, dict):
files = [(entry.get("filePath"), diff_lines(entry.get("hunks")))
for entry in diff.get("files") or [] if isinstance(entry, dict)]
return [(name, added) for name, added in files
if isinstance(name, str) and added is not None]
patch = tool_input.get("command")
if event.get("tool_name") != "apply_patch" or not isinstance(patch, str):
return []
# A patch that failed wrote nothing, so its files hold no new prose.
response = event.get("tool_response")
if (isinstance(response, str) and response.startswith("Exit code:")
and not response.startswith("Exit code: 0")):
return []
cwd = event.get("cwd") or ""
added, name, hunk = {}, None, None
for line in patch.splitlines():
header = _PATCH_FILE.match(line)
if header:
target = header.group(1).strip()
if line.startswith("*** Move to:") and name:
added[target] = added.pop(name)
else:
added[target] = []
name, hunk = target, None
elif line.startswith("@@"):
hunk = None
elif line == "*** End of File" and hunk is not None:
hunk.append(line)
elif line.startswith("*** "):
name, hunk = None, None
elif name and (not line or line.startswith(("+", "-", " "))):
if hunk is None:
hunk = []
added[name].append(#!/usr/bin/env python3
"""UserPromptSubmit hook, and the profile control CLI.
Two roles with a boundary between them:
- As a hook it is **read-only**. It resolves the preference and emits the turn
reminder, which holds against the per-turn injections other plugins make.
- As `--set` it is the mutation path. _preference.write_preference() is the
sole writer and this is its only caller. It prints the status line and the
governing directive for the new profile, so the switch reaches the transcript
in the same turn the user typed the command.
An earlier build also parsed whole-prompt control instructions here.
skills/grounded-copy/references/setup.md records the removal and the evidence
behind it.
Usage:
grounded_tracker.py [--plugin-root DIR]
grounded_tracker.py --set chat|copy|off
grounded_tracker.py --status
Exit codes for `--set`: 0 recorded, 1 persistence failure, 2 rejected value.
An empty value reports status and exits 0, since it requests no change.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _hook_io # noqa: E402
import _policy # noqa: E402
import _preference # noqa: E402
# Adapter seams: a copied entrypoint sets these for its host. The skill path
# needs none — every layout puts SKILL.md at the same place under the plugin
# root. scripts/build_codex_adapter.py rewrites each seam and fails the build
# when one goes missing.
PREFERENCE_PATH = None
RESTORE_HINT = None
EXIT_OK = 0
EXIT_PERSISTENCE = 1
EXIT_REJECTED = 2
CONTROL_FLAGS = ("--set", "--status")
def set_mode(argv, hook_dir):
"""`--set PROFILE`: record the preference, print the governing directive."""
index = argv.index("--set")
raw = argv[index + 1] if index + 1 < len(argv) else ""
path = _hook_io.preference_arg(argv, PREFERENCE_PATH)
if not raw.strip():
print(_preference.status_line(path, RESTORE_HINT))
return EXIT_OK
profile = _preference.canonical_argument(raw)
if profile is None:
print(
"grounded: unknown profile %r; choose chat, copy, or off"
% raw.strip().lower()
)
return EXIT_REJECTED
ok, detail = _preference.write_preference(profile, path)
if not ok:
print(detail)
return EXIT_PERSISTENCE
print(_preference.status_line(path, RESTORE_HINT))
print()
skill = _policy.read_skill(_hook_io.plugin_root(argv, hook_dir))
source = "recorded at " + _preference.preference_path(path)
print(_policy.governing_directive(skill, profile, source))
return EXIT_OK
def main(argv):
_hook_io.utf8_streams()
hook_dir = os.path.dirname(os.path.abspath(__file__))
# `--set` outranks `--status`: it prints the status line itself, so the two
# together still report, and the write the user asked for happens.
if "--set" in argv:
return set_mode(argv, hook_dir)
if "--status" in argv:
path = _hook_io.preference_arg(argv, PREFERENCE_PATH)
print(_preference.status_line(path, RESTORE_HINT))
return EXIT_OK
_hook_io.drain_stdin()
profile, _source = _preference.resolve_preference(
_hook_io.preference_arg(argv, PREFERENCE_PATH))
if profile not in _preference.ACTIVE:
return EXIT_OK
sys.stdout.write(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": _policy.turn_reminder(profile),
}
}))
return EXIT_OK
if __name__ == "__main__":
argv = sys.argv[1:]
if any(flag in argv for flag in CONTROL_FLAGS):
# An explicit request reports its own outcome, failures included.
sys.exit(main(argv))
try:
# A hook stays non-blocking: a style reminder that breaks a session
# start costs more than the reminder is worth.
sys.exit(main(argv))
except Exception:
sys.exit(0)
All 7 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
grounded-copy makes your agent state the fact and get to the point, in chat replies and in the documents it writes. A separate mode covers product pages and marketing copy.
Lets Claude watch a video from a URL or local file by pulling timestamped frames and a transcript. Works in Claude Code, and the watch skill downloads with yt-dlp and extracts frames with ffmpeg.
Gives Claude Code users access to MeiGen for discovering inspiration and creating images and videos with AI models. It also offers skills for product photoshoot planning, social video thumbnails, and visual creative direction.
Edits CapCut and JianYing video projects from the terminal or an LLM agent, covering subtitles, timing, speed, volume, templates, animations, and cutting long videos into shorts. The result opens in CapCut with every track still editable.
Turns a single line of story text into a finished short drama, covering the script, storyboards, character-consistent video and voiceover. Works with the AI model providers you already use, so you skip assembling each step by hand.
A Claude Code plugin for planning and producing AI-generated music albums, with skills for album concepts, tracklists, progress dashboards, idea tracking, and album artwork prompts.
A skill that builds AI teaching personas of specific Buddhist masters, grounded in Buddhist scripture texts and tested for fidelity to their teachings. It includes ethics gates for boundaries on what the persona will say.
Gives journalists and researchers ready-made guidance for fact-checking, source verification, interview prep, story pitches and data journalism, plus accessibility audits for news sites. Works in Claude Code and Codex.
A local video studio for your coding agent. Describe a video in one sentence: your agent directs, your machine renders. Claude Code, Codex, Cursor, Devin and more. No API keys, no uploads.