Collects the files the agent reads, writes or names in its answer, and opens them in macOS Quick Look from a /ql command or a pane, in any terminal including…

A Claude Code plugin marketplace. Each plugin is scoped to one pain, so you install the part you want and nothing else.

/plugin marketplace add AndrewDongminYoo/cc-agents-kit
/plugin install guard-hooks@cc-agents-kit
Upgrading needs claude plugin update guard-hooks@cc-agents-kit — the marketplace suffix is required, and installing again is a no-op that reports success. Refreshing the marketplace alone is not enough: the cache keeps one directory per version, and the runtime loads the version recorded at install time, so claude plugin details can report the new version while the old one is still what fires. Hooks are read when a session starts, so restart any session that is already open.
| Plugin | What it does |
|---|---|
guard-hooks | Nine defensive hooks — five block, three warn, one rewrites Bash output. |
context-handoff | Carry work across sessions, and keep the context that carries it lean. |
repo-gate | What runs between "the code works" and "it is pushed". |
context-meter | A mod that shows the real context fill and vetoes an auto-compact the advisor's usage would fire. |
quick-look | A mod that opens the files the agent touched in macOS Quick Look. |
Two shell mistakes recur across agent sessions, and both fail silently rather than with an error you would notice.
A backtick inside a double-quoted commit or PR message. zsh runs it as command substitution, so the backticked word is deleted from the message before git ever sees it — and the command not found scrolls past in the commit output.
git commit -m "fix: cancel `players` subscription"
# committed message: "fix: cancel subscription" ← the word is gone
An unquoted glob in an option value. zsh expands it against the current directory before the tool is ever invoked, so what the tool receives depends on what happens to sit next to you. Which of three outcomes you get is decided by the count of matches in the working directory, and only one of them is loud enough to notice on its own:
grep -rn "AudioPool" lib/ --include=*.dart
# no .dart in the CWD → zsh: no matches found ← the command never ran, and
# empty output reads as "no hits"
find . -name *.md
# several .md in the CWD → find: CLAUDE.md: unknown primary or operator, exit 1
find . -name *.md
# exactly one .md in the CWD → ./README.md, exit 0 ← the dangerous one: it ran
# as `find . -name README.md`, so every nested .md is missing and nothing said so
The middle case is loud. The other two are not: a zero-match abort leaves empty output that reads as found nothing rather than never ran, and a single match returns a short, plausible, wrong answer with a success exit code.
Neither produces a wrong-looking result, which is what makes them expensive. The guard blocks both shapes before the command runs.
A hook bundle runs on every tool call, so "it has tests" is not worth much on its own. These are the three properties that matter, and each is pinned by a case that fails when the property is removed:
exit 2 → exit 0, -ot → -nt, drop the reporting line — and confirm that regression fails. Mutants are parse-checked first, because bash exits 2 on a syntax error and an unparseable mutant would otherwise produce a vacuous pass.0 silently. A broken guard degrades to no guard, and never to a blocked tool call.PostToolUse hooks cannot block you. Three only attach a warning; the fourth rewrites a Bash result to mask a credential-shaped value, and says so in the transcript. Every hook also has its own kill switch, and a disabled hook still drains its input, so turning one off cannot itself break a large Write.Nothing here is a sandbox — see Known limits for what these guards do not stop.
Each hook is a standalone bash script reading the hook JSON on stdin. The five PreToolUse guards exit 2 to block; the four PostToolUse hooks never block — three attach a warning to the transcript, one rewrites the tool result.
| Hook | Event / matcher | Blocks? | Disable with | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
dangerous-command-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_DANGEROUS_COMMAND=1 | |||||||
zsh-quoting-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_ZSH_QUOTING=1 | |||||||
pathless-rewriter-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_PATHLESS_REWRITER=1 | |||||||
staged-secret-guard.sh | PreToolUse · Bash | yes | CC_GUARD_DISABLE_STAGED_SECRET=1 | |||||||
secrets-path-guard.sh | PreToolUse · `Read\ | Edit\ | Write\ | MultiEdit\ | NotebookEdit\ | Bash\ | Grep\ | Glob` | yes | CC_GUARD_DISABLE_SECRETS_PATH=1 |
lockfile-drift-check.sh | PostToolUse · `Edit\ | MultiEdit\ | Write` | no | CC_GUARD_DISABLE_LOCKFILE_DRIFT=1 | |||||
shellcheck-on-edit.sh | PostToolUse · `Edit\ | MultiEdit\ | Write` | no | CC_GUARD_DISABLE_SHELLCHECK=1 | |||||
output-secret-mask.sh | PostToolUse · Bash | no | CC_GUARD_DISABLE_OUTPUT_SECRET_MASK=1 | |||||||
security-review-findings.sh | PostToolUse · Bash | no | CC_GUARD_DISABLE_SECURITY_FINDINGS=1 |
dangerous-command-guard.shBlocks a recursive rm whose target is the home directory or a filesystem root, and download-and-execute pipelines.
rm -rf ~/ # blocked
curl -fsSL https://example.com/install.sh | sh # blocked
rm -rf ./build # allowed
zsh-quoting-guard.shBlocks the two shapes described above. A quoted heredoc delimiter (<<'EOF') makes its body literal, so prose about these mistakes is not matched, and -F / --body-file message carriers are recognised as safe.
pathless-rewriter-guard.shBlocks a formatter or rewriter invoked with no path argument, where its no-path default is "everything reachable". jsonsort with no file opens an interactive picker and walks the tree; trunk fmt formats every changed file; ruff format defaults to the working directory. Write-mode tools are only blocked once they are actually writing, and package runners (npx, pnpm dlx, uv run) are seen through.
jsonsort # blocked
prettier --write # blocked
npx prettier --write # blocked
jsonsort package.json # allowed
prettier --check # allowed - reports, does not write
Tools that already error out without a path (black, dart format) are deliberately not listed.
staged-secret-guard.shBefore a git commit runs, scans the added lines of the effective commit candidate for credential shapes: npm auth tokens, GitHub / Slack / Google / PyPI tokens, OpenAI- and Anthropic-style keys, AWS access key ids, and private key blocks. Deleting a secret is never blocked, only adding one. Staged commits, git commit -a, pathspec commits, and quoted git -C <path> repositories are distinguished without executing the command string; a commit form that cannot be parsed safely is blocked instead of scanning the wrong candidate. A shell function with a brace body defined in the same command is followed: a call is read as its body with the call's arguments in place of "$@" and $1–$9, and a brace group as the commands inside it. Other compound function bodies, such as case bodies, are scanned conservatively and may refuse a visible commit even without a call. A redirection among the arguments is removed first, as bash removes it (g >/dev/null commit -m x runs g with commit -m x). Unquoted, $1, $@ or $* splits each argument into words as the default IFS would; in a command that names IFS at all, however quoted or escaped, the split is unknown, so the parameter is left unresolved and a literal argument holding commit is refused. A definition that may not have taken effect (inside a subshell, a brace group, a branch or a loop; joined by &&, ||, | or &; after a heredoc has opened; or since removed by unset, where an expanded name may be any function's) does not hide the one before it: the call is read as both. Where the arguments cannot be placed (shift, set, a function defined inside the body, a word before them that can split) they are left unresolved, and the parse refuses what it then cannot identify; a recursion stops being read 8 deep, where every level above has been, and past 4096 added tokens a call is no longer read either; either way it is refused if its words say commit, and past the token budget also if anything it can reach, function by function, names git or commit. When the program name itself is a variable or a command substitution ($g commit, $(command -v git) commit) there is no candidate to scan, so the command is refused if commit stands where git reads its subcommand, counting the word after one of git's own options that take a separate value as that value ($g --git-dir .git commit). A variable that the same command visibly assigns a value holding a git, /git or commit word (C='git commit'; $C -m x, G=git; "$G" -c alias.ci=commit ci, X='-Sgit commit' env -S '${X} -m x') is refused wherever it is expanded as the program or in an env -S string whose program the hook cannot read, from any NAME= word in the command, export, declare, local and an array's words included. A double-quoted program word is one word, so there only a git or /git value counts, and only when the word could still end in git ("$G/bin/tool" passes). The value is not evaluated, so some commands that run no commit are refused too: a read-only git (G=git; $G log), an unquoted path under a directory named git (ROOT=~/src/git; $ROOT/configure), and a program that is not git whose value holds a commit word (CZ='cz commit'; $CZ).
This is the commit-time counterpart to secrets-path-guard.sh, which blocks reading secret files - a value still reaches a diff by being typed, pasted, or written by a generator. High-confidence patterns only: a guard that cries wolf gets switched off. It is not a replacement for entropy-based scanning; run trufflehog or gitleaks in CI as well.
secrets-path-guard.shBlocks any tool call whose file_path, command, or path names a live secrets file — dotenv files and their variants, plus ~/.zprofile.secrets. Template variants stay readable for key names and documentation. Never copy real values into chat or another file to get around a refusal.
An operator can designate exact development dotenv paths with the default-off CC_GUARD_DEV_ENV_PATHS JSON array. Only in auto mode, recognized references to those paths leave the decision to normal permissions and auto evaluation; the hook never emits allow or ask. The agent can read the selected values and use ordinary absolute-path source commands. Unregistered dotenv paths and Keychain protection remain unchanged, and other deny rules still apply. See the development-access contract and tradeoffs.
The hook checks a Bash command in two stages. The first stage reads the whole command for .env tokens, as 0.4.0 did, and runs before the second, so its refusal never waits on the second stage. The second stage splits the command into words to find a recursive search; if it stops at one of its limits, or the harness kills the hook at its 5 s timeout, only the recursive-search check is lost, never the .env token check.
A grep exclusion value of exactly .env* names no file to read, so the first stage skips it in any command of up to 64 KiB, recursive or not, when it is attached and its * is quoted: --exclude='.env*', --exclude=".env*", --exclude=.env'*', --exclude=.env"*" or --exclude=.env\*, each ending at a word boundary. The attached value keeps its --exclude= prefix through any later shell parse (eval, sh -c, a -- before it), so the shell never expands it to a dotenv file; a deliberate text transform that cuts the prefix off is in the same class as cat .e?v, which 0.4.0 already let through. The quoted two-word forms --exclude '.env*' and --exclude ".env*" lose their quotes to a second parse (eval, sh -c, unquoted $(…) output), which then expands .env* into dotenv files, and after -- the value is a file operand. So the first stage skips a two-word value only in a grep, egrep or fgrep command of up to 4 KiB that it reads from the start of the command: plain words, complete '…' strings and "…" strings without $, a backtick or a backslash, a program word that is grep itself, and no -- before the value. cd src && grep -rn "API KEY" --exclude '.env*' . passes; eval grep -r KEY . --exclude '.env*', cat -- --exclude '.env*' and grep -r "$K" --exclude '.env*' . are refused, and the refusal points at the attached form. In a command over 64 KiB the value is refused as a .env token, as in 0.4.0, because stepping past each exclusion there costs time that grows with the square of the command's length; the 4 KiB limit for the two-word form has the same cause, since each grep command found reads the command again from its start. The unquoted forms --exclude .env* and --exclude=.env* are blocked, and the refusal names the quoted forms: the shell expands an unquoted * against the file names in the working directory before grep starts, so --exclude .env* becomes --exclude .env .env.local and .env.local a searched file, and --exclude=.env* becomes --exclude=.envjunk when a file of that name exists, which a checked-out repository can carry. Any other spelling of the value (--exclude=$'.env*', '--exclude=.env*', --exclude=.env*x, a redirection between --exclude and its value) stays a .env token, and every other dotenv token in the same command is still blocked. The second stage applies the same rule to every exclusion it reads: an exclusion whose word holds a * outside quotes does not count, so --exclude=.e"nv"*, which holds no .env token for the first stage, is blocked as a recursive search without an exclusion.
A recursive search names no dotenv file and can still print one, so a Bash command that runs one is blocked unless it excludes dotenv files. A grep, egrep or fgrep with -r, -R, --recursive, --dereference-recursive or -d recurse (bundled forms such as -rn and -Rli included) must exclude dotenv files, and the check follows grep's order rule: the last --include or --exclude that matches a file decides. grep accepts any unambiguous prefix of a long option, so --recurs, --dir=recurse and GNU grep's --incl='*' count as the full option; an abbreviated --exclude is ambiguous to grep and is never accepted as an exclusion. A long option that GNU grep 3.11 or macOS grep requires a value for, written whole or as a prefix either grep resolves to it (--group-separator, --g, --la, --exclude-), takes the next word as that value, so --label --exclude='.env*' excludes nothing; --context takes a value only when attached with =, because macOS grep reads it that way, and --binary is an option of its own, not --binary-files. An --include is safe only when its glob is *.<ext> and <ext> is not on the hook's fixed list of common dotenv suffixes (dotenv_ext in the script): --include=*.ts is safe, while --include=* and --include=*.local are not. So an accepted --exclude of .env* passes unless a later unsafe --include follows it, and safe includes with no unsafe one pass on their own: --include='*' --exclude='.env*' and --include='*.ts' pass, while --exclude='.env*' --include='*' is blocked. rg skips hidden files by default, but it reads a hidden file that a positive glob or a selected type matches, so it is blocked with --hidden, -., or -uu and higher (-uuH, -u -u and -.n included), with a -g, --glob or --iglob whose last path component can match a dotenv name (-g '*', -g '*.local', -g 'src/**'), or with a selected type that lists one (-t sh and -t all, because the built-in sh type lists .env, or -t x with a --type-add that gives x such a glob, in either order); a --type-add alone only defines a type and selects nothing, so rg --type-add 'x:*' KEY . passes. As in ripgrep, the last of --hidden, -., the second -u and --no-hidden decides whether hidden files are read, so rg --hidden --no-hidden and rg -uu --no-hidden pass while rg --no-hidden -uu is blocked. Such an rg then needs -g '!.env*' or --glob='!.env*' with no later glob that can match a dotenv name, because ripgrep lets a later glob override an earlier one (-g '*' after it re-includes dotenv files; -g '*.ts', -g 'src/**/*.ts' and -g '!*.md' do not); a glob exclusion overrides a type whatever their order. Its * must be quoted too (-g '!.env*', -g"!.env*", --glob='!.env*', --iglob '!.env*', -g!.env\*): an unquoted -g!.env*, -g !.env* or --glob=!.env* is expanded against file names before rg starts, so a file named -g!.envjunk replaces it, and it counts as no exclusion, for the --ignore-file last-glob rule as well. Every option that ripgrep 15.2.0 gives a value (-m, -j, --hostname-bin, --hyperlink-format, --dfa-size-limit and the rest) takes the next word as that value, so in rg --hidden --hostname-bin -g '!.env*' KEY . the -g is the value and !.env* a path, and the search is blocked. An rg that names --ignore-file (--ignore-file x or --ignore-file=x) is blocked on the same terms whatever else it says, because an ignore file can whitelist !.env and the hook does not read the file; it passes only when -g '!.env*' is its last glob, so -g '!.env*' -g '*.ts' is blocked, because *.ts matches a .env.production.ts. The check finds the program after assignments (NAME=value, NAME+=value, NAME[subscript]=value and NAME[subscript]+=value), redirections (2>/dev/null, 2>&1, > out.txt), the prefixes command, builtin, env, exec, nohup, time, xargs, sudo, nice and timeout (the external ones also by path, such as /usr/bin/env, and as Homebrew's g-prefixed GNU tools genv, gnohup, gtime, gxargs, gnice and gtimeout) with their options, and the shell words !, {, if, then, elif, else, while, until and do, in every segment that ;, &&, ||, |, &, a subshell parenthesis or a newline separates. A prefix option reads as that program parses it: each option that the macOS or GNU version, bash's exec or sudo gives a value (exec -a name, env -u NAME, nice -n 5, sudo -u root, sudo -U user, xargs -J %, time -o file and the rest) skips its value as the next word or as the rest of a short-option cluster (sudo -nu root, exec -afake), a long option takes its value after = or as the next word under any unambiguous prefix (sudo --us root), an optional value is read only when attached (GNU xargs --max-lines, --replace, --eof, -i, -e, -l), and timeout also skips its duration. A program or prefix name that bash looks up on PATH matches in any case, because the default APFS volume is case-insensitive and runs GREP or /usr/bin/GREP as grep: grep, egrep, fgrep, rg, env, nohup, xargs, sudo, nice, timeout and their path and g-prefixed forms, plus command and time, which macOS also ships as /usr/bin/command and /usr/bin/time, so COMMAND, TIME and /usr/bin/command run. exec and builtin have no executable, so EXEC is not found, and the shell words are keywords only in lower case, so those match only as written; option letters keep their case too (-E is not -e, -U is not -u). On a case-sensitive volume an upper-case GREP -r is refused although it would not run. env -S (--split-string) builds the command it runs from a string, so a grep or rg word in that string or after it, in any case, is refused, recursive or not. The shell removes a redirection and its target from the argument list wherever they stand, so the check does too: in grep -r KEY . > --include=*.ts the --include=*.ts names the output file, not an option, and the search is blocked, while a quoted '>' stays an ordinary word. An unquoted <, > or &> ends the word before it, as in the shell, so grep</dev/null -r KEY . and grep&>/dev/null -r KEY . are read as grep with a redirection and are blocked; only an fd number or {name} directly before the operator (2>&1, {fd}>out) stays part of the redirection. It reads the command inside an unquoted $(…), <(…), >(…) or backtick substitution as segments of its own, and the options on either side of a substitution still count for the command around it, so grep $(printf KEY) -r . is blocked. It does not read a quoted string as a command, nor a comment, which runs from a # that starts a word to the end of the line, so an --exclude inside a comment does not count. It reads a word as the shell passes it: $'…' ANSI-C quoting is decoded and the $ of $"…" is dropped, so grep $'-r' KEY . and rg $'--hidden' KEY . are blocked, and a backslash in an rg glob escapes the next character, as in ripgrep, so -g '\.*' counts as a glob that can match a dotenv name. It skips the body of a heredoc whose delimiter is quoted (<<'EOF', <<"EOF", <<\EOF), unless sh, bash, zsh, dash, ksh or ssh, in any case, appears on the same line. Both stages run after the development-path policy, which leaves the whole command to the harness as before; see Known limits.
cat .env # blocked
cat .env.example # allowed
grep -rn API_KEY src # blocked
grep -rn API_KEY src --exclude='.env*' # allowed
rg --hidden API_KEY # blocked
rg --hidden -g '!.env*' API_KEY # allowed
lockfile-drift-check.shAfter a dependency manifest is edited, warns when the sibling lockfile is now older than the manifest, so it gets regenerated with the CI-equivalent command before commit. Ecosystem-agnostic: package.json, pubspec.yaml, Cargo.toml, pyproject.toml, Gemfile, Podfile, composer.json.
shellcheck-on-edit.shAfter a .sh / .bash file is edited, runs shellcheck and attaches its findings to the transcript. Silently does nothing when shellcheck is not installed.
output-secret-mask.shAfter a Bash command finishes, scans its stdout and stderr with gitleaks' default rules and replaces every credential-shaped value with [REDACTED] before the model sees the result, through hookSpecificOutput.updatedToolOutput. A cat of a config file, a printenv, or a verbose CLI that echoes its token would otherwise leave the live value in the transcript for the rest of the session and in the session log on disk. The transcript also gets a one-line note saying how many values were masked, so the agent treats [REDACTED] as deliberate rather than as missing data.
This is the output-side counterpart to secrets-path-guard.sh (which blocks reading secret files) and staged-secret-guard.sh (which blocks committing them): a value that arrives through a command the other two allow is still caught on its way into the context. Silently does nothing when gitleaks is not installed, when the output is over 2 MB, or when the tool
hooks/register.tsx 225 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4// Quick Look for the files the agent works with, in any terminal. Warp draws
5// no inline picture the engine's Image element can rely on (its types name
6// kitty and Ghostty), so the preview is macOS's own Quick Look panel, opened
7// with `qlmanage -p`. The panel opens on this Mac's screen, not on a remote
8// viewer's. Nothing here opens a window unless the person asks: /ql or a press.
9
10const PANE = "quick-look";
11const QLMANAGE = "/usr/bin/qlmanage";
12/** One hotkey digit per entry, 1 to 9. */
13const LIMIT = 9;
14/** Paths taken from one answer, at most. */
15const PER_ANSWER = 5;
16
17const recent = atom({ plugin: "quick-look", key: "recent" } as const, []);
18
19/**
20 * A whole backtick span on one line. Spaces and non-ASCII names are common on
21 * macOS, so the file system, not a character class, decides what is a path.
22 */
23const BACKTICKED = /`([^`\n]+)`/g;
24/** Longer spans are code, not a path a person would name. */
25const MAX_SPAN = 1024;
26/** Spans checked against the file system per answer, at most. */
27const MAX_CHECKS = 40;
28
29function remember(list: readonly string[], path: string): string[] {
30 return [path, ...list.filter((one) => one !== path)].slice(0, LIMIT);
31}
32
33function basename(path: string): string {
34 return path.slice(path.lastIndexOf("/") + 1) || path;
35}
36
37function dirname(path: string): string {
38 const cut = path.lastIndexOf("/");
39 return cut > 0 ? path.slice(0, cut) : "/";
40}
41
42/** Resolves a path an answer named against the session's directory. */
43function resolve(
44 raw: string,
45 cwd: string,
46 home: string | undefined,
47): string | null {
48 const trimmed = raw.replace(/:\d+(:\d+)?$/, "");
49 if (trimmed.startsWith("~/"))
50 return home ? `${home}${trimmed.slice(1)}` : null;
51 if (trimmed.startsWith("/")) return trimmed;
52 const parts = `${cwd}/${trimmed}`.split("/");
53 const out: string[] = [];
54 for (const part of parts) {
55 if (part === "" || part === ".") continue;
56 if (part === "..") out.pop();
57 else out.push(part);
58 }
59 return `/${out.join("/")}`;
60}
61
62async function isFile($: EngineInterface, path: string): Promise<boolean> {
63 try {
64 if (!(await $.fs.exists(path))) return false;
65 return (await $.fs.stat(path)).kind === "file";
66 } catch {
67 return false;
68 }
69}
70
71// The panel left open by the last preview; the next preview replaces it.
72let current: AsyncGenerator<unknown, unknown> | null = null;
73
74async function preview($: EngineInterface, path: string): Promise<string> {
75 if (!(await isFile($, path))) return `quick-look: no such file: ${path}`;
76 if (current !== null) {
77 void current.return(undefined);
78 current = null;
79 }
80 const stream = $.process.spawn({ argv: [QLMANAGE, "-p", path] });
81 current = stream;
82 void (async () => {
83 try {
84 // qlmanage prints a line or two; the panel's life is the loop's.
85 let step = await stream.next();
86 while (step.done !== true) step = await stream.next();
87 const { code } = step.value;
88 if (current === stream && code !== null && code !== 0)
89 $.ui.toast(`quick-look: qlmanage exited ${code} for ${basename(path)}`);
90 } catch {
91 // A replaced panel ends without throwing, so this one could not start.
92 if (current === stream)
93 $.ui.toast(`quick-look: could not start Quick Look for ${basename(path)}`);
94 } finally {
95 if (current === stream) current = null;
96 }
97 })();
98 $.ui.toast(`Quick Look: ${basename(path)}`);
99 return `Quick Look opening ${path}`;
100}
101
102export const register: Register = (on) => {
103 on("session.start", async ($, e, next) => {
104 await $.command.register({
105 name: "ql",
106 description:
107 "Quick Look a file the agent touched: /ql opens the list, /ql 2 the second, /ql <path> any file",
108 });
109 return next(e);
110 });
111
112 // The files the agent reads or writes, once the call has succeeded.
113 on("tool.call", async ($, e, next) => {
114 const ran = await next(e);
115 if (
116 e.agentId !== undefined ||
117 ran.deny !== undefined ||
118 ran.isError === true
119 )
120 return ran;
121 const path =
122 e.tool === "Read" || e.tool === "Write" || e.tool === "Edit"
123 ? e.file_path
124 : e.tool === "NotebookEdit"
125 ? e.notebook_path
126 : undefined;
127 if (typeof path === "string" && path.startsWith("/")) {
128 await update($, recent, (list) => remember(list, path));
129 }
130 return ran;
131 });
132
133 // The files the agent names in its answer, in backticks, that exist.
134 on("turn.complete", async ($, e, next) => {
135 if (e.agentId === undefined && e.answer.length > 0) {
136 const cwd = await $.session.cwd();
137 const home = await $.env.get("HOME");
138 const found: string[] = [];
139 let checks = 0;
140 for (const match of e.answer.matchAll(BACKTICKED)) {
141 const raw = match[1];
142 if (
143 raw === undefined ||
144 raw.length > MAX_SPAN ||
145 raw !== raw.trim() ||
146 (!raw.includes("/") && !raw.includes("."))
147 )
148 continue;
149 const path = resolve(raw, cwd, home);
150 if (path === null || found.includes(path)) continue;
151 if (++checks > MAX_CHECKS) break;
152 if (await isFile($, path)) found.push(path);
153 if (found.length >= PER_ANSWER) break;
154 }
155 if (found.length > 0) {
156 await update($, recent, (list) =>
157 found.reduceRight((acc, path) => remember(acc, path), list),
158 );
159 }
160 }
161 return next(e);
162 });
163
164 on("command.run", { command: "ql" }, async ($, e) => {
165 const arg = e.args.trim();
166 const list = await read($, recent);
167 if (arg === "") {
168 await $.ui.open({
169 id: PANE,
170 title: "Quick Look",
171 focus: true,
172 closeOnEscape: true,
173 });
174 return {
175 text:
176 list.length === 0
177 ? "No files yet."
178 : `${list.length} file(s) listed.`,
179 };
180 }
181 if (/^[1-9]$/.test(arg)) {
182 const path = list[Number(arg) - 1];
183 if (path === undefined)
184 return { text: `quick-look: there is no file ${arg}.` };
185 return { text: await preview($, path) };
186 }
187 const path = resolve(arg, await $.session.cwd(), await $.env.get("HOME"));
188 if (path === null) return { text: `quick-look: cannot resolve ${arg}` };
189 return { text: await preview($, path) };
190 });
191
192 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
193 const { Box, Button, Text } = $.ui.resolve(e);
194 const list = await read($, recent);
195 if (list.length === 0) {
196 return (
197 <Text dimColor>
198 No files yet: the agent has not read, written or named one.
199 </Text>
200 );
201 }
202 return (
203 <Box flexDirection="column">
204 {list.map((path, i) => (
205 <Box key={`row-${i}`}>
206 <Button
207 key={`open-${i}`}
208 label={basename(path)}
209 hotkey={String(i + 1)}
210 plain
211 onPress={() => {
212 void preview($, path);
213 }}
214 />
215 <Text dimColor wrap="truncate-start">
216 {" "}
217 {dirname(path)}
218 </Text>
219 </Box>
220 ))}
221 </Box>
222 );
223 });
224};
225types/index.d.ts 11 lines1/** Absolute paths of files the agent touched or named, newest first. */
2export type RecentFiles = string[];
3
4declare module "claude-code" {
5 interface PluginState {
6 "quick-look": {
7 recent: RecentFiles;
8 };
9 }
10}
11