keeps secrets out of every transcript: api keys, tokens and private keys read as a stable placeholder in every kept row, and come back only inside the tool…

🎞️ the frame: a place where most of the machine's setup lives as data.
<a href="https://github.com/dvakatsiienko/frame/actions/workflows/ci.yml"><img src="https://raw.githubusercontent.com/dvakatsiienko/frame/badges/ci.svg" alt="ci"></a> <img src="assets/badges/tests.svg" alt="tests"> <img src="assets/badges/renovate.svg" alt="renovate"> <img src="assets/badges/node.svg" alt="node"> <img src="assets/badges/pnpm.svg" alt="pnpm"> <img src="assets/badges/skills.svg" alt="skills"> <img src="assets/badges/mirrored.svg" alt="mirrored">
<img src="assets/banner.svg" align="left" width="100%" alt="frame — the inventory of one mac"> <img src="assets/mac.svg" align="right" width="36%" alt="an 80s mac showing ~ frame">
home/ is ~home/.claude/ is the claude code setup every session reads: memories, rules, skills (plugin-x), hooks, output styles, and sline, the statusline. cclio/ is the coordinator's home, the session that plans and routes work to background coders. x/ is x, the agent-first cli in go + charm: one verb registry, json for agents and boards for humans (x lane commits and pushes from a sandboxed worktree). hotkeys/ maps every keyboard chord on the machine and serves chords, the map's app. x-speak (a schedule/ daemon) reads the selected text aloud on F4 (with nothing selected it pauses / resumes, as ⇧F4 always does) and stops on F5, rewriting ids, versions, paths and code into something a voice can say; speak/ is its voice admin (pnpm speak:admin).
<img src="home/.claude/sline/showcase/sline.svg" width="100%" alt="sline, the statusline, as a session climbs from fresh to heavy">
chords is a keyboard map app: every key binding on every modifier layer, how often each one fires, and which keys are still free. powered by launchd daemon that counts key presses.
<img src="hotkeys/chords/showcase.png" width="100%" alt="chords: the hyper layer on a NuPhy Air75, each bound key with its app and press count">
the mirror links dotfiles and configs. a path under home/ is the same path under ~. the link map is derived by walking the tree, so adding a file to home/ auto-tracks it.
pnpm frame:link # status
pnpm frame:link apply # link everything not linked yet
pnpm frame:link register ~/.foo # move a file into the mirror and link it back
pnpm frame:link untrack ~/.gitconfig # hand a file back to ~
<img src="assets/frame-link.gif" width="720" alt="pnpm frame:link, ending on everything mirrored">
the mac's setup is kept as data, because data does not rot and scripts do: the Brewfile, the macos defaults, the duti file bindings, and the launchd jobs under schedule/.
pnpm macos:setup # brew bundle, macos defaults, duti, vim-plug
on a fresh machine, install the command line tools first — the clone itself needs git, and macos ships only a shim that opens the install dialog. then clone to ~/frame and run the seed, or hand it to an agent («seed this mac from frame»):
xcode-select --install # stop 0: the dialog, ~2 min
git clone https://github.com/dvakatsiienko/frame ~/frame && cd ~/frame
script/seed.sh # command line tools → brew → fnm, pnpm, node → pnpm i → macos:setup → sline → claude cli → frame:link apply
script/seed.sh --claude # the same, with ~/.claude linked
script/seed.sh --without-appstore # skip the app store apps and their sign-in
script/seed.sh --dry-run # what it would do, nothing changed
one status line per step, safe to re-run. it stops with needs your hands: … (exit 2) where only a human can act — the command line tools dialog, the homebrew password, files in the way of a link, the app store and 1password sign-ins — and the next run picks up from there. when it ends, open a new terminal: a shell opened before the seed never sources the zsh stubs it wrote.
hooks/register.ts 81 lines1import type { EngineInterface, Register } from 'claude-code';
2
3import {
4 type Vault,
5 mapStrings,
6 maskText,
7 redactText,
8 restoreText,
9} from './mask.ts';
10
11// the vault survives a hot reload and dies with the session; never `$.store`, which every session shares
12const VAULT = { key: 'vault', plugin: 'x-mod-redact' } as const;
13
14const errorText = (err: unknown) =>
15 err instanceof Error ? err.message : String(err);
16
17// a secret swapped for its placeholder in a row; new placeholders join the vault, retried when a parallel row wrote first
18async function redactRow<T>($: EngineInterface, content: T) {
19 for (let attempt = 0; attempt < 3; attempt++) {
20 const read = await $.state.get(VAULT);
21 const vault: Vault = { ...read.value };
22 const before = Object.keys(vault).length;
23 const next = mapStrings(content, (text) => redactText(text, vault));
24 if (Object.keys(vault).length === before) return next;
25 const r = await $.state.set(VAULT, vault, { ifVersion: read.version });
26 if (r.isSet) return next;
27 }
28 throw new Error('the vault kept changing under three writes');
29}
30
31// a vault error never lets a secret through: the value is masked one way instead, with a line in the log
32async function redactOrMask<T>($: EngineInterface, value: T) {
33 try {
34 return await redactRow($, value);
35 } catch (err) {
36 $.ui.log(`x-mod-redact: ${errorText(err)}; masked one way instead`);
37 return mapStrings(value, maskText);
38 }
39}
40
41// x-mod-redact: every row a session keeps, and every tool result, reads secrets as placeholders before it is stored and sent;
42// a Bash command gets the secrets back. Only Bash: a Write or an Edit quoting a placeholder would put the live key into a file.
43export const register: Register = (on) => {
44 on('session.append', async ($, e, next) => {
45 const content = await redactOrMask($, e.message.content);
46 if (content === e.message.content) return next(e);
47 return next({ ...e, message: { ...e.message, content } });
48 });
49
50 // `/clear` and `/resume` leave the conversation: its placeholders stop meaning anything at once
51 on('command.run', async ($, e, next) => {
52 const r = await next(e);
53 if (e.command === 'clear' || e.command === 'resume')
54 await $.state
55 .set(VAULT, {})
56 .catch(() =>
57 $.ui.log(`x-mod-redact: the vault outlived /${e.command}`),
58 );
59 return r;
60 });
61
62 on('tool.call', async ($, e, next) => {
63 let input = e;
64 if (e.tool === 'Bash') {
65 const { value: vault } = await $.state
66 .get(VAULT)
67 .catch(() => ({ value: undefined }));
68 if (vault)
69 input = mapStrings(e, (text) => restoreText(text, vault));
70 }
71 const r = await next(input);
72 if (r.deny !== undefined) return r;
73 // the engine keeps the tool's own result beside the row (`toolUseResult`), which `session.append` never sees
74 const result = await redactOrMask($, r.result);
75 const text =
76 r.text === undefined ? undefined : await redactOrMask($, r.text);
77 if (result === r.result && text === r.text) return r;
78 return { ...r, result, ...(text === undefined ? {} : { text }) };
79 });
80};
81hooks/mask.ts 79 lines1// the shapes a live credential takes; a 1Password `op://` reference is not one
2const SECRETS = [
3 /\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}/g,
4 /\bgh[pousr]_[A-Za-z0-9]{30,}/g,
5 /\bgithub_pat_[A-Za-z0-9_]{40,}/g,
6 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
7 /\bAKIA[0-9A-Z]{16}\b/g,
8 /\blin_api_[A-Za-z0-9]{30,}/g,
9 /\bops_[A-Za-z0-9_-]{40,}/g,
10];
11const PRIVATE_KEY =
12 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
13const PLACEHOLDER = /‹[^‹›\n]{1,6}…[0-9a-f]{8}›/g;
14
15// placeholder → secret, for one session
16export type Vault = Record<string, string>;
17
18function fnv1a(text: string) {
19 let h = 0x811c9dc5;
20 for (let i = 0; i < text.length; i++) {
21 h ^= text.charCodeAt(i);
22 h = Math.imul(h, 0x01000193) >>> 0;
23 }
24 return h.toString(16).padStart(8, '0');
25}
26
27// no glob characters: the model writes a placeholder into shell commands (`[redacted]` broke a zsh echo in the live probe)
28export const placeholderOf = (secret: string) =>
29 `‹${secret.slice(0, 6)}…${fnv1a(secret)}›`;
30
31// A text with every secret masked one way: its first six characters and `…‹redacted›`.
32export function maskText(text: string): string {
33 let out = text.replace(PRIVATE_KEY, '[redacted private key]');
34 for (const re of SECRETS)
35 out = out.replace(re, (m) => `${m.slice(0, 6)}…‹redacted›`);
36 return out;
37}
38
39// A text with every secret swapped for its placeholder, each new one added to `vault`; a placeholder taken by another value masks one way.
40export function redactText(text: string, vault: Vault): string {
41 const swap = (secret: string) => {
42 const tag = placeholderOf(secret);
43 const held = vault[tag];
44 if (held !== undefined && held !== secret)
45 return `${secret.slice(0, 6)}…‹redacted›`;
46 vault[tag] = secret;
47 return tag;
48 };
49 let out = text.replace(PRIVATE_KEY, swap);
50 for (const re of SECRETS) out = out.replace(re, swap);
51 return out;
52}
53
54// A text with every placeholder the vault holds swapped back for its secret; one it does not hold stays as written.
55export const restoreText = (text: string, vault: Vault) =>
56 text.replace(PLACEHOLDER, (tag) => vault[tag] ?? tag);
57
58// Every string in a value passed through `fn`; the same reference back when nothing changed.
59export function mapStrings<T>(value: T, fn: (text: string) => string): T {
60 if (typeof value === 'string') {
61 const next = fn(value);
62 return (next === value ? value : next) as T;
63 }
64 if (Array.isArray(value)) {
65 const next = value.map((v) => mapStrings(v, fn));
66 return (next.some((v, i) => v !== value[i]) ? next : value) as T;
67 }
68 if (value && typeof value === 'object') {
69 let changed = false;
70 const next: Record<string, unknown> = {};
71 for (const [k, v] of Object.entries(value)) {
72 next[k] = mapStrings(v, fn);
73 if (next[k] !== v) changed = true;
74 }
75 return (changed ? next : value) as T;
76 }
77 return value;
78}
79types/redact.d.ts 9 lines1// placeholder → secret, for one session
2export type RedactVault = Record<string, string>;
3
4declare module 'claude-code' {
5 interface PluginState {
6 'x-mod-redact': { vault: RedactVault };
7 }
8}
9