SLOPSHOPPER

x-mod-redact

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…

newguard
A shopper browsing a rack in a slop shop
README

🎞️ 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">

🧭 toc

  • fleet — skills, memory, sline, hotkeys
  • chords — the keyboard map, live
  • mirror — home/ is ~
  • machine — brew, defaults, launchd
  • link it — a fresh mac install

🛸 fleet

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

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">

🪞 mirror

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">

💻 machine

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

🔗 link it

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.

Source 3 files
hooks/register.ts 81 lines
1import 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};
81
hooks/mask.ts 79 lines
1// 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}
79
types/redact.d.ts 9 lines
1// 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