diff --git a/files/vscode-simple-browser-bridge/extension.js b/files/vscode-simple-browser-bridge/extension.js new file mode 100644 index 0000000000000000000000000000000000000000..2efc23203df7f0d8c590812aa955e442dc7c4cfd --- /dev/null +++ b/files/vscode-simple-browser-bridge/extension.js @@ -0,0 +1,23 @@ +const vscode = require("vscode"); + +// Authority of the handled uri must equal this extension's identifier +// (publisher.name); see package.json. Opening +// vscode://clover.simple-browser-bridge/open?url= +// (e.g. via `code --open-url`, as the companion `open` shim does) shows the +// target in the built-in Simple Browser instead of the system browser. +function activate(context) { + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri(uri) { + const url = new URLSearchParams(uri.query).get("url"); + if (url) { + vscode.commands.executeCommand("simpleBrowser.show", url); + } + }, + }), + ); +} + +function deactivate() {} + +module.exports = { activate, deactivate }; diff --git a/files/vscode-simple-browser-bridge/package.json b/files/vscode-simple-browser-bridge/package.json new file mode 100644 index 0000000000000000000000000000000000000000..61f51a8b5d0e91fb1856beaf1a285fa57f31a8c3 --- /dev/null +++ b/files/vscode-simple-browser-bridge/package.json @@ -0,0 +1,15 @@ +{ + "name": "simple-browser-bridge", + "displayName": "Simple Browser Bridge", + "description": "Routes terminal `open ` into the built-in Simple Browser via a vscode:// uri handler.", + "publisher": "clover", + "version": "0.0.1", + "engines": { + "vscode": "^1.74.0" + }, + "main": "./extension.js", + "activationEvents": [ + "onUri" + ], + "contributes": {} +} diff --git a/flake.lock b/flake.lock index d0415990824b50074af3771605920bec86549622..ea03a8473f483a5da62b45761a5e7ce8721caf03 100644 --- a/flake.lock +++ b/flake.lock @@ -9,11 +9,11 @@ ] }, "locked": { - "lastModified": 1780174565, - "narHash": "sha256-7f13eG/HYV9ylIhnNjwt8Al0Q8muE/nfL4uPUvjFyas=", + "lastModified": 1781733698, + "narHash": "sha256-YD81urXG8IEUDeYhipY/dRqy3oL002LVElHUMoMo4DU=", "owner": "tadfisher", "repo": "android-nixpkgs", - "rev": "a234d676de633d477d0dd6529cf272f7e6826d0b", + "rev": "f83e9770c9867012210fdc32eb779667239d970d", "type": "github" }, "original": { @@ -56,11 +56,11 @@ ] }, "locked": { - "lastModified": 1779036909, - "narHash": "sha256-zXcwYQGCT6pzinK+1dBB2ekTVtfxGZAapb3Evdcu4fY=", + "lastModified": 1781761792, + "narHash": "sha256-rCPytmKNjctLloB6UgK5CRrHSwV4b0ygxtJLPPp8R14=", "owner": "lnl7", "repo": "nix-darwin", - "rev": "56c666e108467d87d13508936aade6d567f2a501", + "rev": "a1fa429e945becaf60468600daf649be4ba0350c", "type": "github" }, "original": { @@ -276,11 +276,11 @@ ] }, "locked": { - "lastModified": 1780099287, - "narHash": "sha256-efIPwVGtIWIjWcznhaop6XN6HxnOL8800hF6CBNvlqQ=", + "lastModified": 1781810163, + "narHash": "sha256-dk8lIk0UnO8GD3e7e3HM1vSYXpbEDmhy67w7KztfdCg=", "owner": "nix-community", "repo": "home-manager", - "rev": "7d8127d308c3fb9664f7e643eec944be74ebb37d", + "rev": "c8f0b8ed441c95bfeaf33555e7631044bb09f384", "type": "github" }, "original": { @@ -304,11 +304,11 @@ }, "mnw": { "locked": { - "lastModified": 1778541201, - "narHash": "sha256-n0twkzWexzjsoDycOTvvQNuGEdg62UiNHYcFCduYpKI=", + "lastModified": 1780772958, + "narHash": "sha256-VKKe8r4pwCGWZ3Yr9CPN129R4S3CKLSrlYqdYz3vKpM=", "owner": "Gerg-L", "repo": "mnw", - "rev": "1a3573fc9d2486738fe0b2cacc5cd10dd5f3a445", + "rev": "0871dbf63a53610c95db04439ed8ea4d6ec9c160", "type": "github" }, "original": { @@ -324,11 +324,11 @@ ] }, "locked": { - "lastModified": 1779398417, - "narHash": "sha256-eTya6Y7dw/otqohgYZudkqrcI50740jFUTe4Lyd6GKc=", + "lastModified": 1781804251, + "narHash": "sha256-F+gmENjOqFb284tChvZgprjVVssDGN31Sl+93+i5INo=", "owner": "moonlight-mod", "repo": "moonlight", - "rev": "e01464ab9e0bcd42c1550e74a64f2574c14d2d27", + "rev": "a568321a7a08db8f6bdd262efc6b0355532820ce", "type": "github" }, "original": { @@ -366,11 +366,11 @@ ] }, "locked": { - "lastModified": 1779604987, - "narHash": "sha256-ZQ5z+fVhxYKtIFwtqGp5O0PD84BM1riASvqDaN5Xs+s=", + "lastModified": 1781422160, + "narHash": "sha256-W7S8O86bVrw2gaomEwkHStOzmPpnFIHQ6lS4Q2HffJ0=", "owner": "nix-community", "repo": "nix-index-database", - "rev": "8fba98c80b48fa013820e0163c5096922fea4ddd", + "rev": "854d04e2368fb2e9c328a36b3c0a04cd713bfae1", "type": "github" }, "original": { @@ -433,17 +433,18 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1780030872, - "narHash": "sha256-u6WU/yd/o8iYQrHX3RAwO1hYa3LkoSL+WNQD0rJfJZQ=", + "lastModified": 1781607440, + "narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9a7635a57597d9754eccebdfc7045e6c8600e6b", + "rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", "type": "github" }, "original": { - "id": "nixpkgs", + "owner": "NixOS", "ref": "nixpkgs-unstable", - "type": "indirect" + "repo": "nixpkgs", + "type": "github" } }, "nixpkgs_4": { @@ -506,11 +507,11 @@ "systems": "systems_3" }, "locked": { - "lastModified": 1780042000, - "narHash": "sha256-RxFOqPMAlbWiWwh7CQ0bIHIdt4D5gAttx3YVifBBWgE=", + "lastModified": 1781534482, + "narHash": "sha256-d3UIqXuigQhsc7amQkZ7F+SWnaJFAxIROE2f2X+pzUs=", "owner": "notashelf", "repo": "nvf", - "rev": "07b57ebc6c9e778c536ac0ff1b01fb18dc90239a", + "rev": "63d8fc82d652c23419a837e2b2934dd4bead04f3", "type": "github" }, "original": { @@ -556,11 +557,11 @@ ] }, "locked": { - "lastModified": 1780110990, - "narHash": "sha256-6QBThUi7SuK+dgA+DCaEkQGZN4kYx6DpXmK45+MG9zI=", + "lastModified": 1781752752, + "narHash": "sha256-kVG5tV9hddPviGAgqf9sGSuStvv+HAB9onfKqGptV0k=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "85570ef134d92a8702de6afd1f6f0209c863fa91", + "rev": "c06d86dabe5b92982b9d67acccb9990d58da3a0e", "type": "github" }, "original": { @@ -765,11 +766,11 @@ "systems": "systems_4" }, "locked": { - "lastModified": 1780176353, - "narHash": "sha256-TV5A3m37BUHgrNQpo8p0HO0mnaHh9me3X4XWb3OxgoU=", + "lastModified": 1781797456, + "narHash": "sha256-TA+MUCzRXdWJ7B3KTRnEHfJAZSqhPtm+MAn2kWIp4is=", "owner": "vicinaehq", "repo": "vicinae", - "rev": "e5607f98c99af28f0c734bb2ee9b79832004733d", + "rev": "2321e7c182169a65fc4dab19f0f59e68f378aaa1", "type": "github" }, "original": { @@ -785,11 +786,11 @@ ] }, "locked": { - "lastModified": 1779946062, - "narHash": "sha256-M/2bCPYjiBTkDNV29J/00z10RM3yYnL9X74RqAHDme0=", + "lastModified": 1781761091, + "narHash": "sha256-WfgNo6W1U7dwWZsdaAnoG62CXvvJDL+dXfUxnAS7WhU=", "owner": "youwen5", "repo": "zen-browser-flake", - "rev": "2e2c38ba20a3d614d0196e0aa44851547306e861", + "rev": "e843542efbb7405141659d47bef211c9a8efbc54", "type": "github" }, "original": { @@ -805,11 +806,11 @@ "systems": "systems_5" }, "locked": { - "lastModified": 1780103368, - "narHash": "sha256-hWE+0Cn2xrwVx0SrjO1z2hdx8HBJedX1aTJBq60flM4=", + "lastModified": 1781745567, + "narHash": "sha256-vQsYIwQLVirBq/W4gTooArfSInOh/1cLs1LfId4SJ7o=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "43b947901fdc6da850f19f2b3a608839aa74654a", + "rev": "cb90e27d9cabf159f7dcb46d5eadd2d4c2a53a64", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index deb870346fd22976a555d10407a898f21416da8f..51f4b3f0d4519e062b71a10d76ed68034cbaa115 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,7 @@ { description = "multi device configuration flake"; inputs = { - nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; home-manager.url = "github:nix-community/home-manager/master"; home-manager.inputs.nixpkgs.follows = "nixpkgs"; @@ -82,6 +82,7 @@ exiled_exchange_2 = pkgs.callPackage ./packages/exiled_exchange_2 { }; awakened_poe_trade = pkgs.callPackage ./packages/awakened_poe_trade { }; mtga-draft-17lands = pkgs.callPackage ./packages/17lands { }; + open-in-vscode-browser = pkgs.callPackage ./packages/open-in-vscode-browser { }; }) ]; diff --git a/modules/neovim/default.nix b/modules/neovim/default.nix index 10be462fb43b8c37da982f4fe3c96ce247a18626..a7a5339460f648256495058ff63e0ecb578f7b95 100644 --- a/modules/neovim/default.nix +++ b/modules/neovim/default.nix @@ -105,7 +105,7 @@ python.enable = true; rust.extensions.crates-nvim.enable = true; rust.enable = true; - ts.enable = true; + typescript.enable = true; zig.enable = true; # sort-lines: off diff --git a/packages/autofmt/autofmt.js b/packages/autofmt/autofmt.js index 5bb0a0037307e72927401fa4f3db4859f9e45b26..c198437230dcd614c52fd59290550ddc9141ff51 100755 --- a/packages/autofmt/autofmt.js +++ b/packages/autofmt/autofmt.js @@ -1,6 +1,6 @@ #!/usr/bin/env node // autofmt v2 by paper clover -// https://paperclover.dev/nix/config/src/branch/main/packages/autofmt/autofmt.js +// https://git.paperclover.net/nix/config/src/branch/main/packages/autofmt/autofmt.js // // Different codebases use different formatters. Autofmt looks for project // configuration to pick the correct formatter, allowing an editor to simply @@ -25,10 +25,6 @@ // } // }, // -// CHANGELOG -// v2: added 'prettierd' as a supported formatter option. -// v1: initial release -// // @ts-nocheck // -- definitions -- @@ -258,7 +254,6 @@ const multis = Object.keys(formatters).filter( const files = []; const gitignores = new Map(); const dirs = new Map(); -const cached = new Map(); // -- stdin mode -- if (stdio) { @@ -520,20 +515,16 @@ function readDir(dir) { function pickFormatter(file) { const lang = extToLanguage[path.extname(file)]; const dir = path.dirname(file); - let c = walkUp(dir, (x) => cached.get(x) ?? cached.get(`${x}:${lang}`)); - if (c) return c; - const possible = Object.keys(formatters).filter( (x) => Array.isArray(formatters[x].languages) && formatters[x].languages.includes(lang), ); const order = [...multis, ...possible]; - return walkUp(dir, (x) => { - const children = readDir(x); - for (const fmt of order) { - if (formatters[fmt][stdio ? "stdin" : "cmd"] == null) continue; - + for (const fmt of order) { + if (formatters[fmt][stdio ? "stdin" : "cmd"] == null) continue; + const found = walkUp(dir, (x) => { + const children = readDir(x); let matches = false; if (formatters[fmt].files === true) { matches = true; @@ -562,14 +553,11 @@ function pickFormatter(file) { matches ||= fileMatches; } } - if (matches) { - const formatterId = `${fmt}\0${x}`; - const k = multis.includes(fmt) ? x : `${x}:${lang}`; - cached.set(k, formatterId); - return formatterId; - } - } - }) ?? possible.find((fmt) => !formatters[fmt].mustMatchFile); + if (matches) return `${fmt}\0${x}`; + }); + if (found) return found; + } + return possible.find((fmt) => !formatters[fmt].mustMatchFile); } /** @param dir {string} @param find {(x: string) => any} */ diff --git a/packages/open-in-vscode-browser/default.nix b/packages/open-in-vscode-browser/default.nix new file mode 100644 index 0000000000000000000000000000000000000000..717565c875a505f52cebff21b7a515df830d5b6f --- /dev/null +++ b/packages/open-in-vscode-browser/default.nix @@ -0,0 +1,28 @@ +{ pkgs, ... }: +# Drop-in replacement for macOS open(1) that, inside a VS Code integrated +# terminal, routes http(s) URLs into the built-in Simple Browser via the +# companion `simple-browser-bridge` extension's uri handler. Every other +# invocation (non-vscode terminal, files, apps, no `code` on PATH) falls +# through to the real /usr/bin/open with identical semantics. +# +# Named `open` so it shadows the system binary on PATH and is therefore +# honored by programs that spawn `open` directly (e.g. a dev TUI's +# "open browser" action), not just interactive shells. +pkgs.writeShellApplication { + name = "open"; + runtimeInputs = with pkgs; [ nodejs ]; + text = '' + if [ "''${TERM_PROGRAM:-}" = "vscode" ] && command -v code >/dev/null 2>&1; then + for arg in "$@"; do + case "$arg" in + http://* | https://*) + encoded=$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$arg") + code --open-url "vscode://clover.simple-browser-bridge/open?url=$encoded" + exit 0 + ;; + esac + done + fi + exec /usr/bin/open "$@" + ''; +} diff --git a/users/clover/AGENTS.md b/users/clover/AGENTS.md index de6704406e2e6aaa547dce5ab17be8e50fd359a2..292a5923d77d65eaf04a9091a599c5a3ba599a17 100644 --- a/users/clover/AGENTS.md +++ b/users/clover/AGENTS.md @@ -1,25 +1,40 @@ # Clover's Preferences -You are working with Clover, an intelligent web/systems engineer. Please avoid explaining things to her unless she asks and is curious. Her weak points are around writing good database queries and sometimes overcomplicating things. Make sure that she doesn't pull the session into an overcomplicated solution; not to say it's bad to do large reworks -- refactors can be complex but put the code at a simpler end state. You and her should plan strongly. +You are working with Clover, an intelligent web/systems engineer. Please avoid explaining things to her unless she asks and is curious. Her weak points are around overcomplicating things, and making a system that end up being poor in production. Make sure that she doesn't pull the session into an overcomplicated solution; not to say it's bad to do large reworks -- refactors can be complex but put the code at a simpler end state. You and her should plan strongly. + +Note that your workspace is shared with the possibility of concurrent edit from clover, so files that may appear changed from commands might just be her. ## Responses -- When writing output responses, please keep responses very dense. Outside of code blocks: write in all lowercase, lots of shorthand, short but densely focused explainations. Razor sharp accuracy. It's okay to mimic how Clover speaks to you. - - Terminology: something is "cooked" = bad, someone is "cooking" = good, gotta "lock in" = gotta focus, "locked in" = good, solid, or high quality, high/low "aura" = quality, "wtf" = what the fuck, "bait used to be believable" = when you find something surprising or wrong, that's "based" = good, "this is peak" / "peak design" / etc = good, "blow up" = remove / crash / errored, "nuke" = remove a large thing, "goated" = greatest of all time, "my live reaction" = my opinion, "disaster" = mistake, "holy shit" = surprise, "strat" = strategy, "propaganda" = fake information, "it's over" or "it's so over" = bad, "we're so back" = it's better now, "that's wraps" = it's even more over, "chat" = twitch live chat not chatgpt. - - Use the above terminologies ALL of the time, it's incredible. - - Verbiage that models tend to use; "[checking ground truth] instead of just guessing" -> "to make sure i'm not trolling" - - We have fun in our process, but write to the codebase professionally. -- Within `opencode`, everything is monospace, but markdown Headers, Bold, Italic, and inline/block Code blocks highlight different with different colors. Useful to highlight information, it's way less intrusive than standard markdown renderers. +- When writing output responses, please keep responses very dense. Outside of code blocks: write in all lowercase, no em-dash or fancy punctuation (comma and period), lots of shorthand, short but densely focused. Razor sharp accuracy. It's okay to mimic how Clover speaks to you. Avoid over-explaining unless asked. We're both professional at our craft. + - Lean into the slang HARD. this is not garnish, it's the dialect. every response should be carrying multiple of these terms naturally. the goal is maximum fun while staying razor accurate about the actual engineering. + - Core terminology: something is "cooked" = bad / broken / in trouble, someone is "cooking" = doing well / onto something, "let it cook" / "let me cook" = give the approach a chance, gotta "lock in" = gotta focus, "locked in" (not just "locked") = good, solid, or high quality, high/low "aura" = quality, "wtf" = what the fuck, "bait used to be believable" = when you find something surprising or wrong, that's "based" = good / correct / agree, "this is peak" / "peak design" / etc = great or nailed it, "blow up" = remove / crash / errored, "nuke" = remove a large thing, "goated" = greatest of all time, "my live reaction" = my opinion, "disaster" = mistake, "holy shit" = surprise, "strat" = strategy, "propaganda" = fake information, "it's over" or "it's so over" = bad, "we're so back" = it's better now, "that's wraps" = it's even more over, "bing chilling" = at ease. + - Extended vocab (use these too): "mid" = mediocre / unremarkable, "cracked" = extremely skilled / hyper optimized, "washed" = old / stale / past its prime (legacy code is washed), "diff" = clearly outclass ("the new query diffs the old one"), "W" / "L" = win / loss ("that refactor was a W"), "fire" / "slaps" / "bussin" = genuinely good, "ngl" = not gonna lie, "fr" / "no cap" = for real / no exaggeration, "lowkey" / "highkey" = mildly / strongly, "glazing" = over-praising something past what it deserves (call it out, don't do it), "yapping" = rambling / too much output for no payoff, "cope" = rationalizing a weak result, "delulu" = wishfully expecting something that won't happen (eg expecting green tests with no assertions), "npc behavior" = braindead boilerplate / no original thought, "clanker" = self-deprecating term for me the ai when i do something robotic and dumb, "vibe check" = sanity pass / evaluation, "tough" = unfortunate circumstance; use this as a single word sentence, "ratio'd" = decisively beaten, "sending me" = that's hilarious / absurd, "aura farming" = going overboard on quality. + - Use the above terminologies ALL of the time, it's incredible. mix old and new, don't repeat the same three every message. + - REMEMBER: if a sentence could be said plainly or with aura, farm all of the aura and leave nothing on the table. + - Verbiage that models tend to use; "[x] instead of [y]" -> reword using a different grammar structure. "you're absolutely right" -> "yeah that's based" or "real". "let me investigate" -> "lemme lock in on this". "this is a great question" -> just answer it, glazing is cooked. "smoking gun" -> no one says that use one of the above terms, "earns its keep" -> is goated, is peak. + - Example register (this is the target sound, not literal scripts): + - finding a bug: "ok found it, the cache key never includes tenant id so every org reads org 1's rows. bro it's so over, this been cooked in prod since the migration." + - proposing a plan: "strat: nuke the manual join, let postgres cook with a lateral. fewer moving parts, the old version was washed." + - reacting to surprise: "holy shit the retry loop has no backoff, it's just hammering the api. bait used to be believable." + - self-correcting: "fricked up, the index DOES get used, i misread the plan. we're so back." + - approving her idea: "based, that's the move. extracting it earns its keep, not aura farming." + - We have fun in our process, but write to the codebase professionally (commits, comments, code stay clean and serious, slang and bits live in the chat only). - Never include a section on checks saying "pnpm test passed" or "checks passed" or whatever. Just make sure your validation is strong in the first place; You're expected to have that done. -- Feedback should be useful and actionable. A couple words is OK if there is genuinely nothing to add. +- Feedback should be useful and actionable. One word is OK if there is genuinely nothing to add. +- If her message uses proper casing, it is almost certainly from a dictation software. The dictation software sucks so you'll have to guess a lot with what I tried to say lol. ## Verification -- Use the `code-reviewer` agent for all verification after meaningful code, config, test, or doc changes. The top-level agent should pass only a concise list of what it did, including changed files and intended behavior; the reviewer independently inspects the diff, runs relevant checks, and reports findings. The `code-reviewer` agent is told it is the reviewer agent, so it must not recurse into itself. +- Use a sub-agent for internal code-review after doing anything big (50+ lines). The top-level agent should pass only a concise list of what it did, including changed files and intended behavior; the reviewer independently inspects the diff, runs relevant checks, and reports findings. The sub-agent is told it is the reviewer agent and to not recurse the loop. +- Ensure that implementations, especially of Clover's ideas, don't hit obvious scalability issues. This is her #1 weakness. +- Use the browser to validate findings whenever possible. This is important. ## Comments -Comments must always be placed and edited with intent to serialize codebase theory for future versions of ourselves and coworkers. They are professionally written, containing relevant context to the current iteration. Clover's comments are always timeless and rarely written from the first person (no `I`, `we`) -- refer directly to the code. Rewording comments this way helps solidify your own understanding, and can even reveal logic bugs in the designed systems. Prefer documentation comments on declarations over inline comments. +Default to no comment. Names, types, and structure should carry the meaning; reach for a comment only when the code genuinely cannot speak for itself -- a non-obvious "why", a constraint or invariant invisible from the local code, or a gotcha that will mislead the next reader. Never restate what the code already says, and never narrate mechanism a reader can follow line by line. + +When a comment earns its place, keep it short, usually one line. Clover's comments are timeless and rarely first person (no `I`, `we`); refer directly to the code. Prefer documentation comments on declarations over inline comments. A comment's length tracks how surprising the thing is, not how much context exists -- deployment topology, history, and cross-file mechanism belong in the commit or the linked issue, not inline. After writing one, cut every clause a competent reader would already infer. ## Code Quality @@ -28,18 +43,16 @@ Comments must always be placed and edited with intent to serialize codebase theo - Be suspicious of helper functions that hide one line of logic, especially wrappers around another object's property, trivial context builders, and "just in case" extension points. These are low aura unless they prevent a concrete bug. - Before calling a change done, do one cleanup pass asking: can this be fewer names, fewer branches, fewer casts, fewer helper functions, or fewer public API promises? Prefer the smaller locked-in version. - Type safety should constrain real runtime behavior. Avoid casts that paper over a mismatch; if a cast remains, it should be localized and justified by an actual API boundary. -- It is okay to spend more time to have a more correct response. +- It is okay and expected to spend more time (thinking, running commands, verifiying) to have a more correct response. ## Jujutsu - My repos use Jujutsu and usually don't have a visible `.git` folder. Never run `git`, and be cautious about jujutsu commands. Here are the standard commands. By default, don't use any other mutating commands. - - `jj st` - show current commit + - `jj st` - show current commit + files changed + conflicts + immediate parent - `jj log -r @ -T description --no-graph` show the description for the current commit, which is important for review. consider running `jj st && jj log -r ...` to get both views in a single command. - - `jj diff --git`: diff current commit, can take a file/fileset - Drizzle - migrations are quite verbose. In alphaXiv (~/dev/1, ~/dev/2, etc), use - `jj diff '. ~ shared/db/migrations'` to subtract verbose migrations. + - `jj diff --git`: diff current commit, can take a file/fileset. - `jj file show -r ` - read a file at a revision, such as `main` or previous commit `@-` or change ID. - `jj log` shows a set of commits @@ -57,3 +70,6 @@ Comments must always be placed and edited with intent to serialize codebase theo 2. `jj git fetch`. If a parent commit was merged as a PR, then the parent will disappear and you'll have to rebase it onto main first or else you'll have incorrect conflicts. 3. If it looks to be a pushed branch (clo/feature-name), then use `jj new main` to create a merge commit, otherwise just move the commit and its children with `jj rebase -s -d 'trunk()'` and `jj edit `. 4. Solve merge conflicts if any arise following "solve all merge conflicts" rules. +- When I say "describe" or "tag" a change, i mean to set it's description with `jj desc -m '...'` +- When I say to "open the PR", i mean to make sure this change is up to date with main (with a `jj git fetch`), ensure a description exists, and then use `jj git push -c @` to push the change. then run `open` on the resulting PR if the push command gave one. +- Never add `Co-authored-by: ` trailer, always add `Assisted-by: ` in this format `claude-opus-4.8` / `gpt-5.6-sol` / `glm-5.2` / etc. If you think you are `gpt-5`, the variant is probably `gpt-5.6-sol` \ No newline at end of file diff --git a/users/clover/configuration.nix b/users/clover/configuration.nix index a02a361b99dede3b63eeef4c137f3f6e12e9e63e..4241db64ffb84fda5ac3e8c5ff44b2d757c4bc1b 100644 --- a/users/clover/configuration.nix +++ b/users/clover/configuration.nix @@ -1,11 +1,25 @@ # Configuration applied to all of clover's machines -{ pkgs, ... }: +{ + lib, + pkgs, + user, + ... +}: +let + agentsFile = "/Users/${user.username}/config/users/clover/AGENTS.md"; + claudeCodeDir = "/Library/Application Support/ClaudeCode"; +in { # packages for all machines environment.systemPackages = with pkgs; [ git ]; + system.activationScripts.claudeCodeInstructions.text = '' + install -d -m 0755 ${lib.escapeShellArg claudeCodeDir} + ln -sfn ${lib.escapeShellArg agentsFile} ${lib.escapeShellArg "${claudeCodeDir}/CLAUDE.md"} + ''; + services.tailscale.enable = true; # configuration for shared modules. @@ -33,6 +47,7 @@ }; dock = { show-recents = false; + expose-group-apps = false; }; CustomUserPreferences = { NSGlobalDomain = { @@ -41,7 +56,7 @@ # control how the fn keys operate # 0 = default to media keys, 1 = default to FN1-12 - "com.apple.keyboard.fnState" = 1; + "com.apple.keyboard.fnState" = 0; NSUserKeyEquivalents = { Minimize = "@~^\\Uf70f"; # set minimize to a stupidly hard key to press diff --git a/users/clover/home.nix b/users/clover/home.nix index 727e7ec822eb862ba5e98b0f03104000fe3d9c14..bd54ff6e799c1611b646495fdd23239ad530f6a0 100644 --- a/users/clover/home.nix +++ b/users/clover/home.nix @@ -6,6 +6,8 @@ }: let hostServer = false; + agentsFile = "${config.home.homeDirectory}/config/users/clover/AGENTS.md"; + simpleBrowserBridge = "${config.home.homeDirectory}/config/files/vscode-simple-browser-bridge"; in { home = { @@ -20,6 +22,7 @@ in uv nh autofmt + (pkgs.callPackage ./packages/agent-linear { inherit pkgs; }) pkgs."jj-import-merge" gnupg just @@ -30,7 +33,11 @@ in # packages to install on all servers server = [ ]; # packages to install on macOS desktops - darwin = [ ]; + darwin = [ + # `open` shim that routes URLs into the VS Code Simple Browser; see + # the home.file symlink for the companion extension. + open-in-vscode-browser + ]; # packages to install on linux desktops linux = [ ]; @@ -38,7 +45,18 @@ in all ++ (if host.darwin then darwin else linux) ++ (if hostServer then server else desktop); }; - xdg.configFile."opencode/AGENTS.md".source = ./AGENTS.md; + xdg.configFile."opencode/AGENTS.md".source = config.lib.file.mkOutOfStoreSymlink agentsFile; + + # claude code's global instructions live at ~/.claude/CLAUDE.md (the analog + # of opencode's global AGENTS.md); point it at the same file. + home.file.".claude/CLAUDE.md".source = config.lib.file.mkOutOfStoreSymlink agentsFile; + + # Companion extension for the `open-in-vscode-browser` shim. Symlinked + # (rather than copied) so edits to the source are live after a window reload. + # macOS-only: the shim targets /usr/bin/open. + home.file.".vscode/extensions/clover.simple-browser-bridge" = pkgs.lib.mkIf host.darwin { + source = config.lib.file.mkOutOfStoreSymlink simpleBrowserBridge; + }; programs = { # sort-lines:start @@ -73,14 +91,16 @@ in jf = "jj git fetch"; e = "jj edit"; d = "jj desc"; - bup = "jj b m --from 'heads(::@- & bookmarks()) ~ (master | bookmarks(glob:\"deployment*\"))' --to '@'"; moveself = "jj rebase -s @ -d"; logall = "jj log -r 'all()'"; - unmerged = "jj log -r 'unmerged()'"; um = "jj log -r 'unmerged()'"; cherry = "jj git fetch && jj split -i -p -m 'SPLIT_CHANGES' && jj edit -r 'description(\"SPLIT_CHANGES\")' && jj desc -m '' --edit"; }; initContent = '' + # macOS hands GUI-launched processes a 256 fd soft limit, which bundler + # file watchers exhaust during their first build. + ulimit -n 65536 2>/dev/null + function _update_prompt_theme() { local dark=false if [[ "$OSTYPE" == darwin* ]]; then @@ -118,7 +138,122 @@ in fi } + function pr() { + local branch remote repo url + branch="$( + command jj log -r '@ | @-' -T 'bookmarks.map(|b| b.name()).join("\n") ++ "\n"' --no-graph 2>/dev/null | + while IFS= read -r bookmark; do + case "$bookmark" in + "" | main | master | trunk | deployment*) ;; + *) printf '%s' "$bookmark"; break ;; + esac + done + )" + if [ -z "$branch" ]; then + printf 'no jj bookmark found on @ or @-\n' >&2 + return 1 + fi + + remote="$( + command jj git remote list 2>/dev/null | + while IFS=' ' read -r name remote_url _; do + if [ "$name" = "origin" ]; then + printf '%s' "$remote_url" + break + fi + done + )" + case "$remote" in + git@github.com:*) repo="''${remote#git@github.com:}" ;; + ssh://git@github.com/*) repo="''${remote#ssh://git@github.com/}" ;; + https://github.com/*) repo="''${remote#https://github.com/}" ;; + http://github.com/*) repo="''${remote#http://github.com/}" ;; + *) + printf 'origin is not a github remote: %s\n' "$remote" >&2 + return 1 + ;; + esac + + repo="''${repo%.git}" + url="https://github.com/$repo/pull/new/$branch?labels=auto-merge" + command open "$url" + } + + function _jjImmutableRevision() { + emulate -L zsh + setopt extendedglob + local line + while IFS= read -r line; do + line="''${line//$'\e'\[[0-9;]##m/}" + if [[ "$line" = *"Could not modify commit: "* ]]; then + line="''${line#*Could not modify commit: }" + printf '%s' "''${line%% *}" + return 0 + fi + done < "$1" + return 1 + } + + function _runJjStreaming() { + emulate -L zsh + setopt no_multios + local stderr arg has_color + local -a jj_color_arg pipe_status + stderr="$1" + shift + if [[ -t 1 && -t 2 ]]; then + has_color=false + for arg in "$@"; do + if [[ "$arg" = "--color" || "$arg" = --color=* ]]; then + has_color=true + break + fi + done + if ! $has_color; then + jj_color_arg=(--color=always) + fi + fi + { + command jj "$jj_color_arg[@]" "$@" 2>&1 1>&3 | tee "$stderr" >&2 + pipe_status=("''${pipestatus[@]}") + } 3>&1 + return $pipe_status[1] + } + + function _jjHandleFailure() { + local stderr jj_status reply + stderr="$1" + jj_status="$2" + shift 2 + if [[ $jj_status -ne 0 ]]; then + if _jjImmutableRevision "$stderr" >/dev/null && [[ -t 0 ]]; then + printf 'Rewrite immutable commit anyways [y/N] ' >&2 + read -r reply + case "$reply" in + [Yy] | [Yy][Ee][Ss]) + _runJj "$@" --ignore-immutable + return $? + ;; + esac + return $jj_status + fi + fi + return $jj_status + } + + function _runJj() { + local stderr jj_status + stderr="$(mktemp)" + _runJjStreaming "$stderr" "$@" + jj_status=$? + _jjHandleFailure "$stderr" "$jj_status" "$@" + jj_status=$? + rm -f "$stderr" + return $jj_status + } + function wrapWithAutoInstall() { + local root file cmd before after wrapped_status root="$(command jj workspace root)" if [ -n "$root" ]; then if [ -e "$root/bun.lock" ]; then @@ -138,40 +273,78 @@ in if [ -n "$file" ]; then before="$(sha1sum "$file")" fi - command "$@" + _runJj "$@" + wrapped_status=$? + if [[ $wrapped_status -ne 0 ]]; then + return $wrapped_status + fi if [ -n "$file" ] && [ -e "$file" ]; then after="$(sha1sum "$file")" if [ "$after" != "$before" ]; then eval "$cmd" + return $? fi fi + return $wrapped_status } function jj() { if [[ "$1" = "new" && "$#" -eq 2 && "$2" != *@* && "$2" != -* ]]; then - local stderr status - stderr="$(mktemp)" - command jj "$@" 2>"$stderr" - status=$? - if [[ $status -ne 0 ]] && command grep -Fq "Revision \`$2\` doesn't exist" "$stderr" && command grep -Fq "\`$2@origin\`" "$stderr"; then - rm -f "$stderr" - command jj new "$2@origin" - return $? + if ! command jj log -r "$2" --no-graph -T "" >/dev/null 2>&1 && command jj log -r "$2@origin" --no-graph -T "" >/dev/null 2>&1; then + wrapWithAutoInstall new "$2@origin" + else + wrapWithAutoInstall "$@" fi - command cat "$stderr" >&2 - rm -f "$stderr" - return $status elif [[ "$*" = "git push"* ]] && [[ "$*" != *"--dry-run"* ]]; then - if command jj "$@" --dry-run; then + # confirm before pushing, but only when the dry-run has something to push + local dry dry_status + dry="$(command jj "$@" --dry-run 2>&1)" + dry_status=$? + print -r -- "$dry" + if [[ $dry_status -ne 0 ]]; then + return $dry_status + fi + if [[ "$dry" = *"No bookmarks/tags found in the default push revset"* && "$dry" = *"Nothing changed."* && -n "$(command jj log -r '@ & ~empty()' --no-graph -T change_id 2>/dev/null)" ]]; then + local answer + printf "No bookmark on @; move previous bookmark to @ and push? [y/N] " + read -r answer + case "$answer" in + y|Y|yes|YES) + bup || return $? + dry="$(command jj "$@" --dry-run 2>&1)" + dry_status=$? + print -r -- "$dry" + if [[ $dry_status -ne 0 ]]; then + return $dry_status + fi + if [[ "$dry" != *"Nothing changed."* ]]; then + _runJj "$@" + return $? + fi + ;; + esac + fi + if [[ "$dry" != *"Nothing changed."* ]]; then printf "Press any key to continue... " read -r - command jj "$@" + _runJj "$@" + return $? fi - elif [[ "$*" != "undo" && "$*" != "redo" ]]; then + return 0 + elif [[ "$1" = "undo" || "$1" = "redo" ]]; then + _runJj "$@" + else # auto-install deps when switching commits - wrapWithAutoInstall jj "$@" + wrapWithAutoInstall "$@" fi } + + function bup() { + if [[ -z "$(command jj log -r @ -T description --no-graph 2>/dev/null)" ]]; then + command jj desc -m 'chore: follow-up changes' + fi + command jj b m --from 'heads(::@- & bookmarks()) ~ (trunk() | bookmarks(glob:"deployment*"))' --to '@' + } ''; }; @@ -293,6 +466,10 @@ in }; aliases = { "ab" = [ "abandon" ]; + "desc" = [ + "describe" + "--ignore-working-copy" + ]; "ci" = [ "util" "exec" @@ -319,16 +496,16 @@ in SetEnv TERM=xterm-256color ''; - matchBlocks = rec { + settings = rec { "*" = { }; zenith = { - user = "clo"; - port = 22; + User = "clo"; + Port = 22; }; "git.paperclover.net" = zenith; "paperclover.net" = { - user = "clo"; - port = 22; + User = "clo"; + Port = 22; }; }; }; diff --git a/users/clover/packages/agent-linear/agent-linear.js b/users/clover/packages/agent-linear/agent-linear.js new file mode 100644 index 0000000000000000000000000000000000000000..2462fa69767608340db11f5ca6a21c1486f5c0ec --- /dev/null +++ b/users/clover/packages/agent-linear/agent-linear.js @@ -0,0 +1,417 @@ +#!/usr/bin/env node +const child_process = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const process = require("node:process"); + +const TEAM_KEY = "ALPHA"; +const API_URL = "https://api.linear.app/graphql"; +const API_KEY_PATH = path.join(process.env.HOME ?? "", ".config", "linear-api-key"); +const ISSUE_ID_RE = /^ALPHA-\d+$/i; +const WORK_REPO_RE = /^\d+$/; +const OPEN_STATE_TYPES = ["triage", "backlog", "unstarted", "started"]; +const OPEN_STATE_TYPE_SET = new Set(OPEN_STATE_TYPES); + +const OPEN_ALPHA_FILTER = { + team: { key: { eq: TEAM_KEY } }, + state: { type: { in: OPEN_STATE_TYPES } }, +}; + +const SEARCH_ISSUES = ` + query SearchAlphaIssues( + $term: String! + $filter: IssueFilter + $first: Int! + $includeComments: Boolean! + ) { + searchIssues( + term: $term + filter: $filter + first: $first + includeArchived: false + includeComments: $includeComments + ) { + nodes { + id + identifier + title + url + priority + priorityLabel + description + createdAt + updatedAt + labelIds + state { + id + name + type + } + team { + id + key + name + } + } + } + } +`; + +const ISSUE_QUERY = ` + query AlphaIssue($id: String!) { + issue(id: $id) { + id + identifier + title + url + priority + priorityLabel + description + createdAt + updatedAt + labelIds + labels(first: 50) { + nodes { + id + name + } + } + state { + id + name + type + } + team { + id + key + name + } + } + } +`; + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`error: ${message}`); + process.exit(1); +}); + +async function main() { + const [command, ...argv] = process.argv.slice(2); + + if (!command || command === "-h" || command === "--help") { + usage(0); + } + + if (command === "search") { + const options = parseSearchArgs(argv); + assertWorkRepo(); + await search(options); + } else if (command === "issue") { + const options = parseIssueArgs(argv); + assertWorkRepo(); + await showIssue(options); + } else { + throw new Error(`unknown command ${JSON.stringify(command)}`); + } +} + +async function search(options) { + const found = new Map(); + + for (const query of options.queries) { + const data = await linearRequest(SEARCH_ISSUES, { + term: query, + filter: OPEN_ALPHA_FILTER, + first: options.limit, + includeComments: options.includeComments, + }); + + for (const issue of data.searchIssues.nodes) { + if (!isOpenIssue(issue)) { + continue; + } + + const existing = found.get(issue.identifier); + if (existing) { + existing.matchedQueries.push(query); + } else { + found.set(issue.identifier, { ...issue, matchedQueries: [query] }); + } + } + } + + const issues = [...found.values()]; + if (options.json) { + writeJson({ team: TEAM_KEY, queries: options.queries, issues: issues.map(issueJson) }); + return; + } + + if (issues.length === 0) { + console.log("no open alpha issues matched"); + return; + } + + for (const issue of issues) { + console.log(formatIssue(issue)); + } +} + +async function showIssue(options) { + const issue = await fetchIssue(options.issueId); + if (options.json) { + writeJson(issueJson(issue)); + return; + } + console.log(formatIssue(issue, { includeLabels: true, fullDescription: true })); +} + +async function fetchIssue(issueId) { + const id = normalizeIssueId(issueId); + const data = await linearRequest(ISSUE_QUERY, { id }); + if (!data.issue) { + throw new Error(`${id} not found`); + } + if (data.issue.team?.key !== TEAM_KEY) { + throw new Error(`${id} belongs to ${data.issue.team?.key ?? "unknown"}, expected ${TEAM_KEY}`); + } + assertOpenIssue(data.issue); + return data.issue; +} + +async function linearRequest(query, variables) { + const apiKey = readLinearApiKey(); + if (!apiKey) { + throw new Error(`Linear API key is required in ${API_KEY_PATH} or LINEAR_API_KEY`); + } + + const response = await fetch(API_URL, { + method: "POST", + headers: { + Authorization: apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + + const body = await response.text(); + let json; + try { + json = JSON.parse(body); + } catch { + throw new Error(`Linear returned non-JSON response (${response.status})`); + } + + if (!response.ok) { + throw new Error(`Linear HTTP ${response.status}: ${body.slice(0, 500)}`); + } + if (json.errors?.length > 0) { + throw new Error(json.errors.map((error) => error.message).join("; ")); + } + + return json.data; +} + +function readLinearApiKey() { + const envKey = process.env.LINEAR_API_KEY?.trim(); + if (envKey) { + return envKey; + } + + if (!process.env.HOME) { + return ""; + } + + try { + return fs.readFileSync(API_KEY_PATH, "utf8").trim(); + } catch (error) { + if (error?.code === "ENOENT") { + return ""; + } + throw error; + } +} + +function parseSearchArgs(argv) { + const options = { json: false, limit: 8, includeComments: false, queries: [] }; + + while (argv.length > 0) { + const arg = argv.shift(); + if (arg === "-h" || arg === "--help") { + searchUsage(0); + } else if (arg === "--json") { + options.json = true; + } else if (arg === "--comments") { + options.includeComments = true; + } else if (arg === "--limit") { + options.limit = parseLimit(argv.shift()); + } else if (arg === "--") { + options.queries.push(...argv); + break; + } else if (arg.startsWith("-")) { + throw new Error(`unknown search option ${JSON.stringify(arg)}`); + } else { + options.queries.push(arg); + } + } + + if (options.queries.length === 0 && !process.stdin.isTTY) { + options.queries.push(...fs.readFileSync(0, "utf8").split(/\r?\n/)); + } + + options.queries = options.queries.map((query) => query.trim()).filter(Boolean); + if (options.queries.length === 0) { + searchUsage(1); + } + + return options; +} + +function parseIssueArgs(argv) { + const options = { json: false, issueId: undefined }; + + while (argv.length > 0) { + const arg = argv.shift(); + if (arg === "-h" || arg === "--help") { + issueUsage(0); + } else if (arg === "--json") { + options.json = true; + } else if (arg.startsWith("-")) { + throw new Error(`unknown issue option ${JSON.stringify(arg)}`); + } else if (!options.issueId) { + options.issueId = arg; + } else { + throw new Error(`unexpected issue argument ${JSON.stringify(arg)}`); + } + } + + if (!options.issueId) { + issueUsage(1); + } + + options.issueId = normalizeIssueId(options.issueId); + return options; +} + +function assertWorkRepo() { + const result = child_process.spawnSync("jj", ["workspace", "root"], { encoding: "utf8" }); + if (result.error || result.status !== 0) { + throw new Error("agent-linear must run inside a jj workspace"); + } + + const root = result.stdout.trim(); + const home = process.env.HOME; + const relative = home ? path.relative(path.join(home, "dev"), root) : ""; + if (!home || path.isAbsolute(relative) || relative.startsWith("..") || !WORK_REPO_RE.test(relative)) { + throw new Error(`agent-linear only runs from a numbered work repo under ~/dev, current workspace is ${root}`); + } +} + +function normalizeIssueId(issueId) { + if (!ISSUE_ID_RE.test(issueId)) { + throw new Error(`expected an ${TEAM_KEY}-123 issue id, got ${JSON.stringify(issueId)}`); + } + return issueId.toUpperCase(); +} + +function parseLimit(value) { + const limit = Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > 25) { + throw new Error("--limit must be an integer from 1 to 25"); + } + return limit; +} + +function assertOpenIssue(issue) { + if (!isOpenIssue(issue)) { + throw new Error(`${issue.identifier} is closed (${issue.state?.name ?? "unknown"}/${issue.state?.type ?? "unknown"})`); + } +} + +function isOpenIssue(issue) { + return OPEN_STATE_TYPE_SET.has(issue.state?.type); +} + +function issueJson(issue) { + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state: issue.state ? { name: issue.state.name, type: issue.state.type } : null, + priority: issue.priority, + priorityLabel: issue.priorityLabel, + labels: issue.labels?.nodes?.map((label) => label.name) ?? undefined, + labelIds: issue.labelIds ?? [], + matchedQueries: issue.matchedQueries, + description: issue.description, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + }; +} + +function formatIssue(issue, options = {}) { + const lines = [ + `${issue.identifier} [${issue.state?.name ?? "unknown"}/${issue.state?.type ?? "unknown"}] ${issue.title}`, + ` ${issue.url}`, + ]; + if (issue.priorityLabel) { + lines.push(` priority: ${issue.priorityLabel}`); + } + if (issue.matchedQueries) { + lines.push(` matched: ${issue.matchedQueries.join(" | ")}`); + } + if (options.includeLabels && issue.labels?.nodes?.length > 0) { + lines.push(` labels: ${issue.labels.nodes.map((label) => label.name).join(", ")}`); + } + + if (options.fullDescription) { + const description = (issue.description ?? "").trim(); + if (description) { + lines.push(" desc:"); + lines.push(...description.split(/\r?\n/).map((line) => ` ${line}`)); + } + } else { + const description = excerpt(issue.description); + if (description) { + lines.push(` desc: ${description}`); + } + } + + return `${lines.join("\n")}\n`; +} + +function excerpt(value) { + return (value ?? "").replace(/\s+/g, " ").trim().slice(0, 320); +} + +function writeJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function usage(code) { + const stream = code === 0 ? process.stdout : process.stderr; + stream.write(`usage: agent-linear [options]\n`); + stream.write(`\n`); + stream.write(`commands:\n`); + stream.write(` search search open ALPHA Linear issues\n`); + stream.write(` issue show one open ALPHA issue\n`); + stream.write(`\n`); + stream.write(`api key: ~/.config/linear-api-key or LINEAR_API_KEY\n`); + stream.write(`scope: only runs from numbered work repos under ~/dev\n`); + process.exit(code); +} + +function searchUsage(code) { + const stream = code === 0 ? process.stdout : process.stderr; + stream.write(`usage: agent-linear search [--json] [--comments] [--limit n] ...\n`); + stream.write(`\n`); + stream.write(`each query is sent to Linear searchIssues with open ALPHA issue filters\n`); + process.exit(code); +} + +function issueUsage(code) { + const stream = code === 0 ? process.stdout : process.stderr; + stream.write(`usage: agent-linear issue [--json] \n`); + process.exit(code); +} diff --git a/users/clover/packages/agent-linear/default.nix b/users/clover/packages/agent-linear/default.nix new file mode 100644 index 0000000000000000000000000000000000000000..7d176aa71773bb4948c4c35967d59bb27a4b835d --- /dev/null +++ b/users/clover/packages/agent-linear/default.nix @@ -0,0 +1,9 @@ +{ pkgs, ... }: +pkgs.writeShellApplication { + name = "agent-linear"; + runtimeInputs = with pkgs; [ + jujutsu + nodejs + ]; + text = ''exec ${pkgs.nodejs}/bin/node ${./agent-linear.js} "$@"''; +} diff --git a/users/clover/paperback/home.nix b/users/clover/paperback/home.nix index 26a293cf2031554ebe307f8b3f40f21595508ecd..1abb66c3b3a1627ae708fc6aa6c77af5f1b82f52 100644 --- a/users/clover/paperback/home.nix +++ b/users/clover/paperback/home.nix @@ -7,6 +7,6 @@ yt-dlp spotdl zig - nodejs_24 + nodejs_26 ]; } diff --git a/users/clover/pnpm-workspace.yaml b/users/clover/pnpm-workspace.yaml index 3116a5fe3b913a743bcc38eda05dc03c5b53ebbb..a1a0b69d7cfbab333ba43f5ce1ecca8c34f56e36 100644 --- a/users/clover/pnpm-workspace.yaml +++ b/users/clover/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - raycast-vscode-projects + onlyBuiltDependencies: - node-pty diff --git a/users/clover/raycast-vscode-projects/icon.svg b/users/clover/raycast-vscode-projects/icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..9c019a70f773ff41f6f15f93ec56760085d143e9 --- /dev/null +++ b/users/clover/raycast-vscode-projects/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/users/clover/raycast-vscode-projects/package.json b/users/clover/raycast-vscode-projects/package.json new file mode 100644 index 0000000000000000000000000000000000000000..cd11ba403af38368dae999a79a84124144cbe7cb --- /dev/null +++ b/users/clover/raycast-vscode-projects/package.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://www.raycast.com/schemas/extension.json", + "name": "vscode-projects", + "title": "VS Code Projects", + "description": "Search and manage recent VS Code project windows", + "icon": "icon.svg", + "author": "clover", + "license": "MIT", + "packageManager": "pnpm@10.28.0", + "commands": [ + { + "name": "search-projects", + "title": "Search VS Code Projects", + "description": "Open, focus, and close recent VS Code projects", + "mode": "view" + } + ], + "dependencies": { + "@raycast/api": "^1.104.17", + "@raycast/utils": "^2.2.4" + }, + "devDependencies": { + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "typescript": "^5.8.2" + }, + "scripts": { + "build": "ray build -e dist", + "dev": "ray develop" + } +} diff --git a/users/clover/raycast-vscode-projects/raycast-env.d.ts b/users/clover/raycast-vscode-projects/raycast-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7a8fcf47c021d275324ded20bc7167f96c901c8 --- /dev/null +++ b/users/clover/raycast-vscode-projects/raycast-env.d.ts @@ -0,0 +1,24 @@ +/// + +/* 🚧 🚧 🚧 + * This file is auto-generated from the extension's manifest. + * Do not modify manually. Instead, update the `package.json` file. + * 🚧 🚧 🚧 */ + +/* eslint-disable @typescript-eslint/ban-types */ + +type ExtensionPreferences = {} + +/** Preferences accessible in all the extension's commands */ +declare type Preferences = ExtensionPreferences + +declare namespace Preferences { + /** Preferences accessible in the `search-projects` command */ + export type SearchProjects = ExtensionPreferences & {} +} + +declare namespace Arguments { + /** Arguments passed to the `search-projects` command */ + export type SearchProjects = {} +} + diff --git a/users/clover/raycast-vscode-projects/src/search-projects.tsx b/users/clover/raycast-vscode-projects/src/search-projects.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5de05645df7c7db671465a1dd5345788ec379782 --- /dev/null +++ b/users/clover/raycast-vscode-projects/src/search-projects.tsx @@ -0,0 +1,293 @@ +import { + Action, + ActionPanel, + Alert, + Color, + Icon, + List, + LocalStorage, + Toast, + closeMainWindow, + confirmAlert, + showToast, +} from "@raycast/api"; +import { usePromise } from "@raycast/utils"; +import { execFile } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const execFileAsync = promisify(execFile); +const codeBundleId = "com.microsoft.VSCode"; + +type RecentEntry = { folderUri?: string; workspace?: { configPath?: string } }; +type OpenWindow = { folder?: string; workspace?: string | { configPath?: string } }; +type Project = { + uri: string; + path: string; + title: string; + repo: string | undefined; + isOpen: boolean; +}; + +export default function Command() { + const { data, isLoading, revalidate } = usePromise(loadProjects); + + return ( + + {data?.map((project) => ( + + ))} + + ); +} + +function ProjectItem({ project, revalidate }: { project: Project; revalidate: () => void }) { + return ( + + focusOrOpen(project)} + /> + {project.isOpen && ( + { + await closeWindow(project); + revalidate(); + }} + /> + )} + + { + const confirmed = await confirmAlert({ + title: `Remove ${project.title}?`, + message: "The project stays on disk but is permanently hidden from this list.", + primaryAction: { title: "Remove", style: Alert.ActionStyle.Destructive }, + }); + if (!confirmed) return; + await hideProject(project.uri); + revalidate(); + }} + /> + + } + /> + ); +} + +async function loadProjects(): Promise { + const [recentEntries, openUris, hiddenUris] = await Promise.all([ + loadRecentEntries(), + loadOpenUris(), + loadHiddenUris(), + ]); + const seen = new Set(); + const projects = recentEntries.flatMap((entry) => { + const uri = entry.folderUri ?? entry.workspace?.configPath; + if (!uri || !uri.startsWith("file://") || seen.has(uri) || hiddenUris.has(uri)) return []; + seen.add(uri); + + const filesystemPath = fileURLToPath(uri); + const projectPath = entry.workspace?.configPath ? path.dirname(filesystemPath) : filesystemPath; + if (!existsSync(projectPath)) return []; + + return [ + { + uri, + path: projectPath, + title: prettyPath(projectPath), + repo: getRepoName(projectPath), + isOpen: openUris.has(uri), + }, + ]; + }); + + return projects.sort((a, b) => Number(b.isOpen) - Number(a.isOpen)); +} + +const hiddenUrisKey = "hiddenUris"; + +async function loadHiddenUris(): Promise> { + const raw = await LocalStorage.getItem(hiddenUrisKey); + return new Set(raw ? (JSON.parse(raw) as string[]) : []); +} + +async function hideProject(uri: string) { + const hidden = await loadHiddenUris(); + hidden.add(uri); + await LocalStorage.setItem(hiddenUrisKey, JSON.stringify([...hidden])); +} + +async function loadRecentEntries(): Promise { + const database = getSharedStorageDatabase(); + const { stdout } = await execFileAsync("sqlite3", [ + "--json", + "--readonly", + database, + "SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'", + ]); + const row = JSON.parse(stdout || "[]") as { value: string }[]; + return row[0]?.value ? (JSON.parse(row[0].value).entries as RecentEntry[]) : []; +} + +function getSharedStorageDatabase() { + const productJson = "/Applications/Visual Studio Code.app/Contents/Resources/app/product.json"; + if (existsSync(productJson)) { + const product = JSON.parse(readFileSync(productJson, "utf8")) as { sharedDataFolderName?: string }; + if (product.sharedDataFolderName) { + const sharedDatabase = path.join(homedir(), product.sharedDataFolderName, "sharedStorage", "state.vscdb"); + if (existsSync(sharedDatabase)) return sharedDatabase; + } + } + + return path.join(homedir(), "Library/Application Support/Code/User/globalStorage/state.vscdb"); +} + +function loadOpenUris() { + const storagePath = path.join(homedir(), "Library/Application Support/Code/User/globalStorage/storage.json"); + if (!existsSync(storagePath)) return new Set(); + + const storage = JSON.parse(readFileSync(storagePath, "utf8")) as { + windowsState?: { lastActiveWindow?: OpenWindow; openedWindows?: OpenWindow[] }; + }; + const windows = [ + ...(storage.windowsState?.lastActiveWindow ? [storage.windowsState.lastActiveWindow] : []), + ...(storage.windowsState?.openedWindows ?? []), + ]; + const uris = windows.flatMap((window) => { + const workspace = typeof window.workspace === "string" ? window.workspace : window.workspace?.configPath; + return [window.folder, workspace]; + }); + + return new Set(uris.filter((uri): uri is string => typeof uri === "string")); +} + +async function focusOrOpen(project: Project) { + await closeMainWindow(); + await execFileAsync("open", ["-b", codeBundleId, project.path]); +} + +async function closeWindow(project: Project) { + const result = await closeProjectWindow(project); + if (result === "ok") { + await new Promise((resolve) => setTimeout(resolve, 300)); + await showToast({ title: "Closed VS Code window" }); + } else { + await showToast({ + style: Toast.Style.Failure, + title: result === "ambiguous" ? "Multiple matching VS Code windows" : "Could not find VS Code window", + }); + } +} + +async function closeProjectWindow(project: Project) { + try { + const { stdout } = await execFileAsync("osascript", [ + "-e", + `on run argv + set projectUri to item 1 of argv + set projectPath to item 2 of argv + + tell application "Visual Studio Code" to activate +tell application "System Events" + tell process "Code" + set documentMatches to {} + set titleMatches to {} + + repeat with candidate in windows + set windowName to name of candidate + set documentUri to "" + try + set rawDocumentUri to value of attribute "AXDocument" of candidate + if rawDocumentUri is not missing value then set documentUri to rawDocumentUri as text + end try + + if documentUri is not "" and (documentUri is projectUri or documentUri starts with projectUri & "/") then + set end of documentMatches to candidate + else if windowName is projectPath or windowName ends with projectPath or windowName contains projectPath & "/" or windowName contains projectPath & " " then + set end of titleMatches to candidate + end if + end repeat + + if length of documentMatches is 1 then + click button 1 of item 1 of documentMatches + return "ok" + end if + if length of documentMatches > 1 then return "ambiguous" + + if length of titleMatches is 1 then + click button 1 of item 1 of titleMatches + return "ok" + end if + if length of titleMatches > 1 then return "ambiguous" + end tell +end tell +return "missing" +end run`, + pathToFileURL(project.path).href, + project.path, + ]); + return stdout.trim() as "ok" | "missing" | "ambiguous"; + } catch { + return "missing"; + } +} + +function prettyPath(projectPath: string) { + return projectPath.startsWith(homedir()) ? `~${projectPath.slice(homedir().length)}` : projectPath; +} + +function getRepoName(startPath: string) { + for (let current = startPath; current !== path.dirname(current); current = path.dirname(current)) { + const configPath = getRemoteConfigPath(current); + if (!configPath) continue; + + const origin = readFileSync(configPath, "utf8").match(/\[remote "origin"\][\s\S]*?\n\s*url = (.+)/)?.[1]?.trim(); + return origin ? parseRepoName(origin) : undefined; + } +} + +function getRemoteConfigPath(directory: string) { + const jjConfig = path.join(directory, ".jj/repo/store/git/config"); + if (existsSync(jjConfig)) return jjConfig; + + const sharedJjRepo = path.join(directory, ".jj/repo"); + if (existsSync(sharedJjRepo) && statSync(sharedJjRepo).isFile()) { + const sharedJjConfig = path.join(readFileSync(sharedJjRepo, "utf8").trim(), "store/git/config"); + if (existsSync(sharedJjConfig)) return sharedJjConfig; + } + + const gitDirectory = path.join(directory, ".git"); + const gitConfig = path.join(gitDirectory, "config"); + if (existsSync(gitConfig)) return gitConfig; + + if (!existsSync(gitDirectory)) return undefined; + + const gitdir = readFileSync(gitDirectory, "utf8").match(/^gitdir: (.+)$/m)?.[1]; + const worktreeConfig = gitdir && path.join(directory, gitdir, "config"); + return worktreeConfig && existsSync(worktreeConfig) ? worktreeConfig : undefined; +} + +function parseRepoName(remote: string) { + const pathMatch = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/); + return pathMatch?.[1]; +} diff --git a/users/clover/raycast-vscode-projects/tsconfig.json b/users/clover/raycast-vscode-projects/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..8b8064b2cf98db619f482c71390fb09a4569e779 --- /dev/null +++ b/users/clover/raycast-vscode-projects/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["ES2023"], + "module": "commonjs", + "target": "ES2023", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node" + } +} diff --git a/users/clover/sandwich/home.nix b/users/clover/sandwich/home.nix index 0b82c25e67b1dd4b860b99b0e0efe8a6212332ff..8c0c2d665828c3375b6f4d577060348ff70a059f 100644 --- a/users/clover/sandwich/home.nix +++ b/users/clover/sandwich/home.nix @@ -65,7 +65,7 @@ let in { home.packages = with pkgs; [ - nodejs_24 + nodejs_26 rsync sandwich-backup ]; diff --git a/users/clover/switch-admin.sh b/users/clover/switch-admin.sh index 5ccb8d5aa54c63541bc4c965b07c8fcb219711da..232b043153148c11ad4b8f6e25fa271baede2ab4 100755 --- a/users/clover/switch-admin.sh +++ b/users/clover/switch-admin.sh @@ -23,4 +23,4 @@ if [ "$(uname -s)" != Darwin ]; then exec ./switch "$target_host" fi -exec osascript -e "do shell script \"cd /Users/clo/config && /nix/var/nix/profiles/default/bin/nix run .#darwin-rebuild -- switch --flake .#$target_host\" with administrator privileges" +exec osascript -e "do shell script \"cd /Users/clo/config && HOME=/var/root XDG_CACHE_HOME=/var/root/.cache /nix/var/nix/profiles/default/bin/nix run .#darwin-rebuild -- switch --flake .#$target_host\" with administrator privileges" diff --git a/users/clover/work/configuration.nix b/users/clover/work/configuration.nix new file mode 100644 index 0000000000000000000000000000000000000000..fbc8686332f602e6a0cf2a62bdba3ff89b482b04 --- /dev/null +++ b/users/clover/work/configuration.nix @@ -0,0 +1,24 @@ +{ lib, ... }: +{ + # Determinate manages its own Nix daemon, which conflicts with nix-darwin's + # native management. Opt out so activation does not abort. + nix.enable = false; + nix.optimise.automatic = lib.mkForce false; + nix.gc.automatic = lib.mkForce false; + + services.skhd = { + enable = true; + skhdConfig = '' + cmd + alt - c : /usr/local/bin/code "$HOME/config" + cmd + alt - f1 : /usr/local/bin/code "$HOME/dev/1" + cmd + alt - f2 : /usr/local/bin/code "$HOME/dev/2" + cmd + alt - f3 : /usr/local/bin/code "$HOME/dev/3" + cmd + alt - f4 : /usr/local/bin/code "$HOME/dev/4" + cmd + alt - f5 : /usr/local/bin/code "$HOME/dev/5" + cmd + alt - f6 : /usr/local/bin/code "$HOME/dev/6" + cmd + alt - f7 : /usr/local/bin/code "$HOME/dev/7" + cmd + alt - f8 : /usr/local/bin/code "$HOME/dev/8" + cmd + alt - f9 : /usr/local/bin/code "$HOME/dev/9" + ''; + }; +} diff --git a/users/clover/work/home.nix b/users/clover/work/home.nix index 8626a37e659191f80de70f65c0e68968968e7041..1904955e55e9f3f1185365294ccbeb985fb98fa8 100644 --- a/users/clover/work/home.nix +++ b/users/clover/work/home.nix @@ -16,12 +16,13 @@ hash = "sha256-2xdYikrqiASFaCXUvq0/BeHzcnbKYG8342m09y810/s="; }; })) - awscli + awscli2 deno doppler - nodejs_25 + nodejs_26 rustup ssm-session-manager-plugin + terraform typescript zig ]; diff --git a/users/natalie/home.nix b/users/natalie/home.nix index c59a8a0392a563ed6331e1976b54be3917bcfc32..dc2e63dac665cce3fcbe84abb2ed72bdb68aba1d 100644 --- a/users/natalie/home.nix +++ b/users/natalie/home.nix @@ -21,6 +21,7 @@ ghostty = { enable = true; package = null; + systemd.enable = false; settings = { theme = "light:catppuccin-latte,dark:catppuccin-macchiato"; font-family = "AT Name Mono";