iany.me

iany.me

16 bookmarks
Custom sorting
让 Windows 输入法的语音快捷键在英文键盘下也能触发
让 Windows 输入法的语音快捷键在英文键盘下也能触发

中文输入法的语音输入通常有个全局快捷键,我用的是 RAlt + Space。问题是它只在这个输入法处于激活状态时才生效。我平时用英文键盘布局写代码,想说句话就得先切到中文。

我本来就在用 AutoHotkey,比如之前分享过解决 Windows 中文输入法下 Shift + Space 快捷键的困扰。我就想加上脚本去监听 RAlt + Space,如果当前是中文键盘就跳过不处理,让快捷键直接触发输入法的语音输入。如果是英文键盘就先切换然后模拟再发送一次快捷键。但实际发现,有些输入法的快捷键是没有办法通过 AutoHotkey 的 Send 触发的。搜索了一下,原因是这些输入法在 HID 驱动层监听快捷键,软件生成的键盘事件到不了这一层。我只测试了豆包输入法和微信输入法。豆包输入法无法使用 AutoHotkey 触发语音输入,而微信输入法可以。

AutoHotkey 配合微信输入法,就可以在任意键盘布局下一键激活语音输入了。

TL;DR

先配置微信输入法,修改快捷键。我使用按住 RAlt 来语音输入,或者使用 RAlt + Space 开启和结束语音输入。

下面的 AutoHotkey v2 脚本的效果是:英文布局下按 RAlt + Space,脚本自动切到中文输入法、补发一次快捷键触发语音,语音结束后再切回英文;中文布局下脚本完全不介入。

#HotIf !ChineseIMEActive() $>!Space::{ prev := ForegroundHKL() ActivateIME("00000804") deadline := A_TickCount + 1000 while !ChineseIMEActive() && A_TickCount < deadline Sleep 20 Send "{Blind}{RAlt down}{Space}" RestoreLayoutAfterVoice(prev) } #HotIf RestoreLayoutAfterVoice(hkl) { static panel := "语音输入 ahk_class wetype.flutter.setting" if !WinWait(panel, , 3) return if !WinWaitClose(panel, , 60) return if ChineseIMEActive() ApplyHKL(hkl) } ChineseIMEActive() => (ForegroundHKL() & 0xFFFF) = 0x0804 ForegroundHKL() { hwnd := DllCall("GetForegroundWindow", "ptr") tid := DllCall("GetWindowThreadProcessId", "ptr", hwnd, "ptr", 0, "uint") return DllCall("GetKeyboardLayout", "uint", tid, "ptr") } ActivateIME(klid) { if hkl := DllCall("LoadKeyboardLayout", "str", klid, "uint", 0x101, "ptr") ApplyHKL(hkl) } ApplyHKL(hkl) { hwnd := DllCall("GetForegroundWindow", "ptr") tid := DllCall("GetWindowThreadProcessId", "ptr", hwnd, "ptr", 0, "uint") me := DllCall("GetCurrentThreadId", "uint") DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 1) DllCall("ActivateKeyboardLayout", "ptr", hkl, "uint", 0) DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 0) PostMessage 0x0050, 0, hkl, , "ahk_id " hwnd }

为什么英文布局下按了没反应

现代中文输入法是 TSF(Text Services Framework)的 text service。它的按键钩子只在这个输入法是当前窗口的活动输入法时才会挂上。英文布局下,输入法进程虽然在后台跑着,但它根本收不到你的按键。唯一的路是:先切输入法,再把快捷键补发一次。

中文状态下跳过

一开始我在热键里判断如果已经是中文就跳过切换,但按键依然被脚本接管再转发。更干净的做法是用 #HotIf 让这个热键在中文状态下不起作用。

#HotIf !ChineseIMEActive() $>!Space::{ ... } #HotIf

这样中文态走的是 100% 原生路径,零延迟。$ 前缀是必须的,它保证不会递归触发形成死循环。

切换输入法

切换用三件套。LoadKeyboardLayout 拿到 HKL,再通过 AttachThreadInput 把自己挂到前台线程上、调 ActivateKeyboardLayout,最后补一个 WM_INPUTLANGCHANGEREQUEST:

DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 1) DllCall("ActivateKeyboardLayout", "ptr", hkl, "uint", 0) DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 0) PostMessage 0x0050, 0, hkl, , "ahk_id " hwnd

ActivateKeyboardLayout 单独调只对自己的线程生效,所以必须先 AttachThreadInput。

要注意 HKL 层面分不出具体是哪个中文输入法:微软拼音、微信输入法、豆包的 langid 都是 0x0804,切过去的是上次用的那个。所以判断是否已经是中文,比较低 16 位就够了:

ChineseIMEActive() => (ForegroundHKL() & 0xFFFF) = 0x0804

切换是异步的,PostMessage 发完不代表已经生效,所以要轮询等到真的切过去。

语音结束后切回英文

既然是英文布局下触发的,说完话自然应该回到英文。麻烦的是怎么知道说完了。

微信输入法的语音面板是一个独立的顶层窗口,等它关闭就行。找窗口类名的办法是写个脚本记录一段时间内新出现的所有窗口,然后去触发一次语音:

class=wetype.flutter.setting title=语音输入 exe=wetype_update.exe

于是切回的逻辑就是等这个窗口出现、再等它消失:

RestoreLayoutAfterVoice(hkl) { static panel := "语音输入 ahk_class wetype.flutter.setting" if !WinWait(panel, , 3) return if !WinWaitClose(panel, , 60) return if ChineseIMEActive() ApplyHKL(hkl) }

三个判断的作用依次是:

面板没出现,此时不切回。因为分不清真的失败和检测漏了。

面板超过 60 秒还开着,同样不切回。避免逻辑一直挂着。

切回前确认当前还是中文。

类名和标题是从微信输入法 2.1.4.6 抓的,升级后可能变。真变了的表现是切回功能静默失效(停在中文),不影响触发本身。

·blog.iany.me·
让 Windows 输入法的语音快捷键在英文键盘下也能触发
Install Plannotator for Claude Code Without Touching Global Config
Install Plannotator for Claude Code Without Touching Global Config

Plannotator gives Claude Code a browser UI for marking up plans, files, and pull requests. It ships as two separate things—a Claude Code plugin and a set of skills—and the obvious install path for each one drops it into your user-level environment, where it loads in every project forever. You can keep the whole thing inside one repo instead. Trying it out shouldn’t cost you a permanent resident in ~/.claude.

Setup

Three commands, all run from the project directory:

claude plugin marketplace add backnotprop/plannotator --scope local claude plugin install plannotator@plannotator --scope local bunx skills add backnotprop/plannotator/apps/skills/core --agent claude-code --yes

The plugin and the skills are independent installs. The plugin brings the hooks that intercept plan mode; the skills teach Claude how to drive the CLI. You want both, and each has its own notion of scope.

Where everything lands

--scope local is the key. Claude Code has three scopes—user, project, and local—and only local keeps the change out of both your home directory and the repo’s committed settings:

The marketplace and plugin declarations go into .claude/settings.local.json in the project.

The skills are copied into .claude/skills/<name>/.

A skills-lock.json is written at the project root.

Nothing touches ~/.claude. Open Claude in any other directory and Plannotator isn’t there.

The skills path gotcha

This is the part that will bite you. The obvious command is wrong:

Don't do this

bunx skills add backnotprop/plannotator --agent claude-code --yes

Pointed at the repo root, skills add finds and installs release-plannotator, review-renovate, and update-deps. Those are the skills the Plannotator maintainers use to develop Plannotator—drafting release notes, reviewing Renovate PRs, auditing dependency bumps. They’re useless to you, and they’ll happily start suggesting themselves in your own repo.

The user-facing skills live in subdirectories, and skills add accepts an owner/repo/path spec:

Path

Skills

apps/skills/core

plannotator (CLI reference), plannotator-annotate (annotate markdown, config files, HTML, URLs, folders), plannotator-last (annotate the latest assistant message), plannotator-review (browser code review for a worktree or PR URL)

apps/skills/extra

plannotator-compound, plannotator-setup-goal, plannotator-visual-explainer

core is what you want. Before installing anything from a repo you don’t know, list first:

bunx skills add backnotprop/plannotator/apps/skills/core -l

-l clones and prints the skill names and descriptions without writing a thing—worth doing for any skills repo, not just this one.

Teardown

Removing is fiddlier than installing:

bunx skills remove plannotator plannotator-annotate plannotator-last plannotator-review --agent claude-code --yes claude plugin disable plannotator@plannotator --scope local

To get rid of it completely rather than just disabling it:

claude plugin uninstall plannotator@plannotator --scope local -y claude plugin marketplace remove plannotator

Two things make the skills side awkward:

skills remove matches by skill name, not by package. The symmetric-looking command does nothing:

$ bunx skills remove backnotprop/plannotator --agent claude-code --yes ■ No matching skills found for: backnotprop/plannotator

It exits successfully and removes nothing. So any teardown script has to spell out every skill name, and it silently goes stale when upstream adds or renames one. The skills-lock.json does record the source of each installed skill, so the CLI has the information—it just doesn’t use it for removal.

Notes for scripting it

Two behaviours matter if you wrap these in a script:

claude plugin install is idempotent and re-enables a disabled plugin. Running it again on an installed plugin prints “already installed” and exits 0; running it on a disabled one turns it back on. So one command works as both “install” and “enable”—no need to branch.

claude plugin disable exits 1 when the plugin is already disabled. In a script that stops at the first failure, that will abort the rest of your teardown. Remove the skills before disabling the plugin, so a redundant disable can’t strand the skills in your project.

Which gives a pair of scripts worth keeping around. Setup:

#!/bin/sh set -e claude plugin marketplace add backnotprop/plannotator --scope local claude plugin install plannotator@plannotator --scope local bunx skills add backnotprop/plannotator/apps/skills/core --agent claude-code --yes

Teardown:

#!/bin/sh set -e bunx skills remove plannotator plannotator-annotate plannotator-last plannotator-review --agent claude-code --yes claude plugin disable plannotator@plannotator --scope local

Run both from the project directory—every path they touch is relative to it.

What it replaced

Before this I had my own small skill for the review half of the job: guided-pr-review. It captures a PR against its real merge base, groups the diff into risk-ordered chunks with related context and the questions worth answering, and builds a standalone HTML page with line-level annotations that the agent then answers. A single SKILL.md plus optional Python helpers—standard library, Git, and gh—so it drops into any project’s .claude/skills/ and works on whatever agent you point it at.

It does that one thing, and does it well. Plannotator does considerably more: plan review through the plan-mode hooks, annotating arbitrary files, folders, and URLs, annotating the last assistant message, exporting and sharing reviews. Once a tool covers the whole surface, keeping a homemade one around for a corner of it stops making sense.

Why bother

Claude Code’s context is a budget. Every globally installed plugin and skill spends a slice of it in every session, including the ones where you’re editing a config file and want nothing more than an editor. Local scope makes trying a tool reversible: install it in the one repo where it earns its keep, and if it doesn’t, delete the directory and it’s gone.

·blog.iany.me·
Install Plannotator for Claude Code Without Touching Global Config
Vibe Coding a Pixel Clock
Vibe Coding a Pixel Clock

The Ulanzi TC002 Pixbar Smart Pixel Clock II is a clock with a 16×52 LED pixel canvas and a built-in battery. What makes it interesting to me is the HTTP API: I can send it GIFs and text, so I can decide what belongs on that little screen.

I vibe coded ulanzi-tc002 to work with it. Two uses have become part of my day: animated pixel art from my Grok Bot, and a display that tells me whether my coding agents are still working.

For the pixel art, I ask my Grok Bot to generate a random animated GIF and send it to the clock. The bot reads my calendar, local weather, and current world news to choose something relevant to the time. There is room for surprise, but the result has some connection to what is happening around me.

The chat hiastory shows how this works: an easel and palette for painting practice at 19:00, then a twinkling crescent moon for a clear evening with nothing coming up on my calendar.

That combination is what I enjoy: a little useful information, expressed through a funny animation. A tiny pixel canvas gives the bot a different way to present the day. I can glance at it and get something timely, with a bit of personality.

The other use is more practical. I put my coding agents’ status on the clock so I can see when they have stopped working.

The battery matters here. When I step away from my computer, I can take the clock with me. Once the display shows that the agents have stopped, I know it is time to go back and check the results. The clock gives me enough information to decide when to return; I still review the actual work at the computer.

Both uses share the same small piece of plumbing. My project runs a server that manages named text and image pages on the clock, with an HTTP API, a command-line client, and MCP tools. Images are fitted to the 52-pixel-wide, 16-pixel-high canvas, animated GIFs loop on the device, and longer text scrolls. I can turn the clock’s knob to switch between pages.

That makes it easy to connect another script or bot: give it a page and let it update the content. The clock handles the display, while the software decides what to show.

This has been a satisfying use of vibe coding. I had two small things I wanted in my everyday environment, and now they have a physical place: a funny animation to glance at, and a signal that my agents are ready for my attention.

·blog.iany.me·
Vibe Coding a Pixel Clock
Forwarding Desktop Notifications to Pushover
Forwarding Desktop Notifications to Pushover

I already forwarded mako notifications to Pushover on Linux. The hook is tiny: mako runs a command with the notification id, the script reads makoctl list -j, skips loops and Pushover itself, and POSTs to the Pushover API.

Windows has no equivalent of on-notify=exec. I wanted one binary, dn-pushover, that does the same job on both desktops: titles become [DN][hostname] … so a notification that bounces back is easy to drop.

Linux stayed simple. Windows did not.

How it works

Linux. dn-pushover setup writes a managed block into ~/.config/mako/config:

on-notify=exec dn-pushover "$id"

Then it reloads mako. If makoctl is missing, setup fails instead of pretending to work. An old mako-pushover block is migrated so deleting the old binary cannot leave a dead hook behind.

Windows. There is no desktop-wide exec hook. The supported API is UserNotificationListener: after the user grants access, an app can read other apps’ toast notifications. setup therefore:

Checks Pushover credentials.

Builds a small C# WinForms helper (first time only; later setups reuse the exe).

Packs a sparse identity MSIX (manifest + logos; the exe lives next to it via -ExternalLocation).

Signs it and registers it.

Launches the helper. You click Allow Access and accept the Windows prompt.

The helper baselines whatever is already in the notification center, then polls for new toasts and runs dn-pushover --forward with JSON on stdin.

teardown stops the helper. teardown --cleanup also unregisters the package and deletes %LOCALAPPDATA%\dn-pushover. There is no autostart: after logout, run setup again.

The JS side is shared: prefix, truncation, skip [DN] / [mako] / recognizable Pushover traffic, then send().

Gotchas

Linux is a hook. Windows is an identity.

UserNotificationListener is not “read the notification database.” It is a capability on a packaged app. An unpackaged Win32 process can call RequestAccessAsync and even get Allowed while GetNotificationsAsync returns an empty list and Settings never shows the app.

A loose Add-AppxPackage -Register development package (SignatureKind=None) has the same problem. Windows will not list it under Privacy & security → Notifications (the read list, not System → Notifications, which is the send list).

What worked was a signed sparse MSIX whose publisher matches the exe’s app.manifest msix element, with uap3:Capability Name="userNotificationListener" and AppListEntry not set to none.

Signing is not optional, and per-user trust is not enough

Self-signed is fine for a personal machine. The private key stays in CurrentUser\My. Importing the public cert into CurrentUser\TrustedPeople does not make you a public CA; it only means this user trusts this publisher.

MSIX deployment still failed with 0x800B0109 (untrusted root) until the public cert was also in LocalMachine\TrustedPeople. That needs a one-time UAC. Later setups skip the import if the thumbprint is already there.

powershell.exe -NoProfile has no Cert: drive. Import-Module PKI under $ErrorActionPreference = 'Stop' blew up on duplicate type data. Certificate create/import had to use X509Store / CertificateRequest instead of New-SelfSignedCertificate.

The SDK is not the runtime

A .NET 10 runtime cannot dotnet publish. First Windows setup needs a .NET 10 SDK (Scoop: versions/dotnet-sdk-lts) plus Windows SDK tools makeappx and signtool. Setup runs PowerShell with -NoProfile, so Scoop’s shims are not on PATH; it has to look in ~/scoop/apps/dotnet-sdk-lts/current/ itself.

Setup also skips rebuilding if the helper exe already exists, and used to skip re-registering if the package was already installed. That is how a stale unsigned package and an old helper survived “I already ran setup.” teardown --cleanup is the rebuild switch. Signed packages under WindowsApps must actually be removed; if cleanup no-ops, Get-AppxPackage will still show them.

The compiled Bun binary is a different program

import.meta.main is false inside a Bun-compiled .exe. Guarding main() with only that flag made dn-pushover setup exit 0 with no output. Detect the virtual B:/~BUN/ / $bunfs entrypoint as well.

Bun.stdin.text() in that same compiled Windows exe did not read the helper’s piped JSON. --forward exited 0, sent nothing. readStdin() from Node’s process.stdin did. A WinForms AcceptButton also treated the Enter key from the terminal as Allow Access, so setup “returned immediately” after the first successful grant.

Substring /pushover/i on the app id matched DnPushover.NotificationListener and dropped every toast. Skip the Pushover product, not any name that contains those letters.

Debug process

The failure mode was always the same from the outside: a Windows toast appeared, Pushover stayed quiet. The pipeline has three stages; each can succeed while the next never runs.

Is the listener alive? Process + %LOCALAPPDATA%\dn-pushover\listener.log. Empty log with a running process meant polls were not seeing new toasts (or the form was hidden and the timer never ticked). Listener ready; N existing toast(s) means those N will never be forwarded — send a new toast after ready.

Does JS send? Pipe JSON into dn-pushover --forward and watch the Pushover API. A 200 body {"status":1,"request":"…"} means credentials and the API are fine. If that works and the listener still does not, the helper is not invoking the CLI you think it is (config.json points at dist\dn-pushover.exe), or the CLI is skipping the payload.

What did --forward actually do? Temporary logging of argv, stdin length, skip vs send, and the API status. That is how Bun.stdin.text() was caught: argv logged, stdin never read, process exit 0, no HTTP. After the stdin fix, the same toast showed apiStatus: 200. Edge then showed the Pushover copy with a [DN] title; that one is supposed to be skipped so you do not loop.

Direct fetch to https://api.pushover.net/1/messages.json (user key + desktop-notification token, no secrets in logs) is the ground truth. If that returns status: 1 and the device still shows nothing, you are looking at the wrong Pushover app or device, not at this script.

Practical checklist

Linux: makoctl on mako’s PATH; dn-pushover setup; section-specific on-notify rules win over the global hook.

Windows: .NET 10 SDK + Windows SDK; one UAC to trust CN=dn-pushover in LocalMachine\TrustedPeople; dn-pushover setup; click Allow Access; Privacy list is access, not senders.

After changing helper C# or the Bun CLI: teardown --cleanup then setup, and use the rebuilt dist binary.

Test with a toast created after the listener is ready. Expect [DN][hostname] on Pushover.

·blog.iany.me·
Forwarding Desktop Notifications to Pushover
My AI-Assisted Development Workflow
My AI-Assisted Development Workflow

AI agents write code fastest when the work is sliced small and a human stays on judgment. My workflow runs a four-step loop: plan the feature into units, hand the branch stack to the agent, review each PR myself, then let the agent address my comments.

I wrote a separate guide on the branch-management mechanics: ♯ A Practical Guide to gh-stack for AI-Assisted Development. That post covers worktrees, rebase habits, and JSON sync. This post is the loop around that tool.

  1. Plan and split into units

Start with a written plan, not “build the feature.” The plan names the goal, records the product decisions already made, and breaks work into Implementation Units: U1, U2, U3, and so on.

Each unit is small enough to fit one agent session and one human review. Specific, not vague. “Design the data model for board columns” is a unit. “Do the backend” is not. Dependencies between units are explicit – U3 depends on U2 because it queries the schema U2 defines.

Before any code, I do a quick plan review across three axes: product (is this the right thing?), feasibility (can we build it this way?), and scope (are the units the right size?). Fixing a bad unit boundary in a markdown file takes a minute. Fixing it across five stacked PRs takes an afternoon.

When the plan turns out to be wrong mid-implementation, I stop and revise it before writing more code. The plan is the source of truth, not the branches.

  1. Let AI manage branches with gh-stack

Once the units are stable, I give the agent one prompt to build the stack:

Implement U1 and U2 from the plan. Use gh-stack to add a branch per unit. Include the Jira ticket number in branch names.

The agent creates branches, writes scoped commits, opens stacked PRs with each PR based on the unit below, and runs gh stack rebase --upstack when a lower layer changes. I do not hand-manage the branch chain day to day.

One worktree per stack, one branch per unit. The gh-stack guide has the full setup for worktrees, branch naming, and how to keep the JSON in sync.

  1. Review the PR and leave comments

This step belongs to me.

I read each stacked PR as a focused slice. Small units mean the diff is narrow enough to actually read. I leave inline comments or a review summary – whatever makes the problem clear. I do not fix code during the review pass.

Good review comments say what is wrong, what I want instead, and which layer owns the change. Ambiguous comments waste the next agent session. The review is the quality gate; I treat it like one.

  1. Let AI address the review comments

I hand the PR back with a prompt:

Address review comments on PR #42. Stay on the matching stack branch. Add new commits; don’t amend unless I ask.

The agent reads the threads, fixes issues on the correct layer, replies to comments, and resolves what it can. Anything that requires a product call comes back to me. After fixes on a lower layer, it rebases upstack.

Then I re-review or approve and merge bottom-up. One loop per unit. Repeat.

Why this shape works

Step

Owner

Plan and units

Human + agent

Branches and PRs

Agent + gh-stack

Review

Human

Address comments

Agent

Agents do well with bounded units and explicit comment lists. They do worse at deciding where one unit ends and another begins, or at choosing between two product directions. The plan and the review are where those decisions happen – both belong to the human.

The loop: plan → stack → review → fix.

·blog.iany.me·
My AI-Assisted Development Workflow
A Practical Guide to gh-stack for AI-Assisted Development
A Practical Guide to gh-stack for AI-Assisted Development

Stacked pull requests let you review a feature in logical slices rather than one enormous diff. The challenge is managing the branches, synchronizing PRs, and surviving rebases without breaking the chain. gh-stack handles the scaffolding. It tracks your branch stack in a local JSON file, generates PRs that base each one to its parent, and works on any GitHub repo regardless of whether your organization has enabled native stacked PR support.

This is the workflow I use, including how an AI coding agent fits into it.

Start with a Worktree

Before creating any branches, set up a dedicated Git worktree for the stack.

git worktree add ../project-login-stack login-stack-base cd ../project-login-stack

It’s not required but my personal preference. Stacks and worktrees map one-to-one. This matters for a few reasons:

You can switch between stacks by switching directories, not branches.

Your shell’s working directory tells you exactly which stack you’re in.

Using an Agent to Build the Stack

Stacked PRs only work well if the individual branches are focused. Broad, unfocused branches defeat the point. This is where AI agents help.

When starting a feature, describe it to the agent and ask it to divide the work into discrete units. A typical breakdown might look like:

Database layer – schema and migrations

Service layer – business logic and data access

API layer – endpoints and serializers

UI layer – frontend components

The agent creates a branch for each, makes commits scoped to that layer, and stacks them in order. You end up with four focused PRs instead of one that mixes migrations, API code, and React components in the same diff.

Rebasing Without Breaking the Stack

For day-to-day stack updates, use gh stack rebase --upstack. Switch to any branch in the stack using gh stack up/down/switch, add new commits, and gh stack rebase --upstack to reorganize the branch chain. Avoid git commit --amend in this workflow – gh-stack cannot track amended commits.

When you need to manually rebase the stack such as moving commits between branches in the stack, check out the topmost branch and rebase from there. With rebase.updateRefs enabled, Git updates every intermediate branch ref as it replays commits down the chain. Starting from a middle branch leaves the branches above it with stale refs.

~/.gitconfig or repo .gitconfig

[rebase] updateRefs = true

gh stack top # top of the stack git rebase -i develop

Prefer gh stack rebase --upstack for the common case. Reserve manual git rebase from the top for when you need to move commits between branches in the stack.

The JSON Sync Problem

gh-stack records branch metadata in .git/gh-stack. When you squash or amend commits, or rebase from a middle layer, the SHA that gh-stack recorded no longer matches the branch’s actual HEAD. The JSON goes stale and breaks most features of gh-stack

The fix is to sync the JSON with the current state of your branches. Doing this manually means running git log --oneline for each branch, reading the current SHAs, and editing the JSON to match. Not difficult, but tedious – especially across a deep stack.

An AI assistant handles this well. Give it access to the repository and ask:

Read the current HEAD of each branch in my gh-stack and update .git/gh-stack so each entry’s recorded SHA matches.

Putting It Together

Stacked PRs reduce review friction. gh-stack makes stacking practical without requiring GitHub-side configuration. The four habits that make it work:

One worktree per stack. Isolation by directory, not branch.

Agent-planned stacks. Focused units make PRs reviewable.

gh stack rebase --upstack for routine updates. Manual git rebase from the top when moving commits between branches in the stack.

AI-assisted JSON sync. Fast recovery after a squash or amend.

·blog.iany.me·
A Practical Guide to gh-stack for AI-Assisted Development
Build an MCP with OAuth on Cloudflare Workers
Build an MCP with OAuth on Cloudflare Workers

I wanted MCP clients to create Google Tasks for me. The Google Tasks API is gated behind a business license I don’t have. IFTTT already bridges webhooks to Google Tasks—but a Maker webhook URL is a bearer secret. Putting that key in local MCP config is a bad idea.

The fix is a thin Cloudflare Worker plus Cloudflare Access Managed OAuth:

Worker holds the IFTTT key as a secret and speaks MCP over HTTP

Access sits in front, handles OAuth login with your IdP

Worker verifies the Access JWT (Cf-Access-Jwt-Assertion) before running tools

The full example: doitian/google-task-ifttt-webhook-mcp. Most of it was scaffolded by AI; the interesting part is the shape, not the TypeScript.

The problem

Piece

Constraint

Google Tasks API

Needs a Workspace-style setup I don’t want to pay for

IFTTT Maker webhook

Works, but the URL is the credential

MCP clients

Expect remote HTTP tools with OAuth

MCP Client → Cloudflare Access (OAuth) → Worker (JWT) → IFTTT → Google Tasks

Clients never see IFTTT_MAKER_TASK_KEY. They only hold a short-lived Access token after login.

IFTTT side

One applet:

If: Maker Webhooks → Receive a web request, event name task

Then: Google Tasks → Create a task (title / notes / due from the payload)

The Worker posts to:

https://maker.ifttt.com/trigger/task/json/with/key/{KEY}

with { "title", "notes?", "due?" }. The key never leaves Cloudflare.

Three secrets

npx wrangler secret put IFTTT_MAKER_TASK_KEY npx wrangler secret put CF_ACCESS_TEAM_DOMAIN npx wrangler secret put CF_ACCESS_AUD

Secret

Role

IFTTT_MAKER_TASK_KEY

Maker webhook key

CF_ACCESS_TEAM_DOMAIN

Zero Trust team domain (e.g. myteam.cloudflareaccess.com)

CF_ACCESS_AUD

Access application audience tag (validates JWT aud)

No custom authorize page, no signing keys you mint yourself. Access is the authorization server.

Cloudflare Access

In the Zero Trust dashboard:

Access → Applications → Add → Self-hosted

Point the app at your Worker hostname (*.workers.dev or a custom domain)

Pick an IdP (Google, GitHub, email OTP, …) and an allow policy for yourself

Enable Managed OAuth (Beta) — this is what MCP clients need for the OAuth dance

Copy the team domain and Application Audience (AUD)

Access issues JWTs; MCP clients send them as Cf-Access-Jwt-Assertion. The Worker verifies with the team’s JWKS:

const JWKS = createRemoteJWKSet( new URL(${issuer}/cdn-cgi/access/certs), ); await jwtVerify(token, JWKS, { issuer, ...(env.CF_ACCESS_AUD ? { audience: env.CF_ACCESS_AUD } : {}), });

(jose on Workers with nodejs_compat.)

Worker: just MCP + verify

Business logic is tiny:

POST / or POST /mcp — JSON-RPC (initialize, tools/list, tools/call)

One tool: create_google_task (title required; notes, due optional)

Reject anything without a valid Access JWT

No OAuth endpoints in the Worker. Access owns login; the Worker only checks the assertion header.

Client config

Point the client at the Worker URL and enable OAuth. Example shape (keys vary by client):

{ "mcp": { "google-tasks": { "type": "remote", "url": "https://google-task-mcp.yourdomain.com" } } }

The client discovers OAuth via Access, opens the browser for your IdP, then attaches the Access JWT on MCP calls.

Why this shape works with AI

The task decomposes cleanly:

MCP handler — fixed JSON-RPC + one tool schema

IFTTT fetch — secret from env

Access JWT verify — JWKS + aud

Wrangler secrets — nothing in git

Describe the constraints (“don’t expose the IFTTT URL”, “Cloudflare Access OAuth”, “Worker MCP”) and get a deployable skeleton. Review JWT verification and secret handling; treat the tool body as glue.

Takeaways

Skip the official Tasks API if IFTTT already has the integration.

Keep Maker keys in Worker secrets, not client config.

Cloudflare Access Managed OAuth turns IdP login into MCP-friendly OAuth without rolling your own authorize/token endpoints.

The Worker only needs to verify Cf-Access-Jwt-Assertion and call IFTTT.

Source: github.com/doitian/google-task-ifttt-webhook-mcp.

·blog.iany.me·
Build an MCP with OAuth on Cloudflare Workers
Fix Claude Desktop PATH with CLAUDE_ENV_FILE
Fix Claude Desktop PATH with CLAUDE_ENV_FILE

Claude Code runs tools and shell commands on your behalf. Launched from a terminal, the CLI inherits your shell environment and everything just works. But under Claude Desktop, Claude Code is spawned from a GUI/desktop session that never sourced your interactive shell config—so if you rely on version managers like mise, asdf, or nix, the tools they install may not be on Claude’s PATH. The fix is a single setting: CLAUDE_ENV_FILE.

How it works

Set CLAUDE_ENV_FILE in .claude/settings.local.json to point at a shell file. Claude sources it before running any command.

{ "env": { "CLAUDE_ENV_FILE": ".claude/mise.env" } }

That file is a plain shell snippet—export statements, one per line:

export PATH="/home/ian/.local/share/mise/shims:/home/ian/project/node_modules/.bin:$PATH" export DATABASE_URL="postgres://localhost/db"

The path can be relative—it’s resolved from the project root—so .claude/mise.env works on any machine and can be committed alongside the project. Write both, and the next Claude session picks up everything.

Why not just put PATH in settings.json?

You can set env.PATH directly in settings.local.json, but there’s a catch: Claude does not support variable substitution like ${env:PATH}. You’d have to hardcode the full PATH, which breaks the moment dependencies change or you move between machines.

With CLAUDE_ENV_FILE you get full shell semantics—$PATH expands at runtime, conditionals work, and subshells are fair game.

Why shell init doesn’t help either: the snapshot

There’s a deeper reason the obvious fixes fail. At session start, Claude Code freezes a shell snapshot to ~/.claude/shell-snapshots/snapshot-zsh-*.sh, and the Bash tool sources that file before every command it runs.

The snapshot’s final line is export PATH=<frozen value>, and that value is Claude Code’s own process PATH captured at startup—not re-derived from your shell config. (You can spot it: it contains directories no rc file adds, like Claude’s own plugin and session bin paths.) Because this export PATH is the last thing sourced before each command, it clobbers anything your shell init did:

mise activate in .zshenv/.zshrc runs, prepends the shims—then the snapshot’s trailing export PATH overwrites it, burying the shims back where they started.

Even setting env.PATH in settings.json as a plain subprocess variable loses, because the snapshot is sourced after the process environment is set up.

CLAUDE_ENV_FILE wins because it’s sourced after the snapshot. Its export PATH="…/shims:$PATH" is the last word, so the shims land first for every Bash tool call. That’s the whole trick: it’s not just convenient shell semantics—it’s the one hook that runs late enough to survive the snapshot.

Generating the env file automatically

Writing the env file by hand gets tedious. If you use mise, you can generate both files with a task—I vibe-coded preset-claude.js for exactly this (it’s a custom mise task, not a built-in). It:

Reads env._.path from mise.toml (so per-project node_modules/.bin directories land on PATH).

Expands {{config_root}} to absolute paths.

Writes .claude/mise.env with shims, extra paths, and all mise env vars.

Sets CLAUDE_ENV_FILE in .claude/settings.local.json.

mise run preset:claude

One command, and Claude sees the exact same tools you do.

What about other version managers?

The pattern works for anything that can write a shell env file:

asdf: asdf direnv envrc > .claude/asdf.env

nix: nix print-dev-env | grep ^export > .claude/nix.env

direnv: direnv export json | jq -r 'to_entries[] | "export (.key)="(.value)""' > .claude/direnv.env

Then point CLAUDE_ENV_FILE at it and you’re set.

Caveats

The file is sourced, not executed—shebang lines are ignored.

Changes to settings.local.json take effect on the next Claude session.

If CLAUDE_ENV_FILE is a relative path, it’s resolved from the project root.

A small config file saves you from fighting invisible PATH issues. Set it once, and Claude runs the same tools you do.

·blog.iany.me·
Fix Claude Desktop PATH with CLAUDE_ENV_FILE
Power of Monoid Beauty of Simplicity
Power of Monoid Beauty of Simplicity

A monoid is one of the smallest useful abstractions in algebra: a set closed under an associative binary operation, with an identity element. That simplicity is exactly why it shows up everywhere—from summing numbers and concatenating strings to powering divide-and-conquer algorithms and elegant data structures like finger trees. This post walks through what monoids are, why they give you “compute power” for free when you can phrase a problem in terms of them, and how to think about choosing the right monoid and predicate when you do.

What is a monoid?

A monoid is a set $S$ equipped with a binary operator $\bullet$ and an identity element $e$ (Wikipedia).

The operator is closed on $S$. For all $a, b \in S$, the result $a \bullet b$ is also in $S$.

The operator is associative: For all $a,b,c \in S$, $(a \bullet b) \bullet c = a \bullet (b \bullet c)$

The identity element $e$ satisfies $e \bullet a = a \bullet e = a$ for all $a \in S$.

For example, The integer numbers with the operator addition (+) is a monoid, where the identity element is 0. The integer numbers with the operator multiplication (x) is also a monoid with the identity element 1.

The set of finite lists with the operator concatenation is a monoid since:

The operator is closed because concatenation of two finite lists is also a finite list.

The operator is associative, because both $(a \bullet b) \bullet c$ and $a \bullet (b \bullet c)$ result in a new list by placing elements of $a, b, c$ consecutively.

The identity element is the empty list.

The integer numbers with the operator min is a counterexample. The operator is closed and associative, but there is no identity element. Given any integer $e$, there’s always a smaller integer $a$ such that $e \bullet a = a \ne e$. However, min on the integer set with a lower bound is a monoid, such as the positive integers where the identity element is the lower bound 1.

Divide and conquer: why associativity matters

At first glance, associativity may seem too trivial to be useful in programming. However, associativity is what enables powerful divide-and-conquer strategies, where problems can be split into parts, solved independently, and then safely recombined.

Exponentiation by Squaring

Let’s begin with a simple application: repeatedly applying the binary operator to the same element.

[ \underbrace{a \bullet a \bullet \cdots \bullet a}_{a \text{ appears } n \text{ times}} ]

Instead of applying the binary operator $n-1$ times sequentially, we exploit associativity to group every two instances of $a$ together recursively. This gives us a smaller problem when n is even:

[ \underbrace{(a \bullet a) \bullet \cdots \bullet (a \bullet a)}_{(a \bullet a) \text{ appears } \frac{n}{2} \text{ times}} ]

When n is odd:

[ a \bullet \underbrace{(a \bullet a) \bullet \cdots \bullet (a \bullet a)}_{(a \bullet a) \text{ appears } \frac{n-1}{2} \text{ times}} ]

We only need to compute $a \bullet a$ once to turn the problem of size $n$ to size $n/2$. Repeating the process on $(a \bullet a)$ gives the Exponentiation by Squaring algorithm, which requires at most $\displaystyle 2 \lfloor \log _{2}n\rfloor$ computations that is more efficient than $n-1$ when $n$ is greater than 4.

For integers or real numbers under multiplication (×), exponentiation by squaring is an efficient algorithm to compute positive integer powers. Since the set of elliptic curve points under point addition forms a monoid, this same method can also be used to compute Elliptic Curve Scalar Multiplication.

General Divide-and-Conquer Search Algorithm

We can generalize the divide-and-conquer method to search an element in a sequence based solely on associativity.

The result of applying the monoid operator to a sequence from left to right serves as a summary of that sequence. If a predicate can determine whether a given element is in the sequence based solely on this summary, we can devise a general divide-and-conquer search algorithm.

Let’s say we want to search for a target element in the sequence $t_1, \ldots, t_n$, where each $t_i$ belongs to a monoid $(S, \bullet, e)$. Here, $S$ is the underlying set, $\bullet$ is the binary operation, and $e$ is the identity element.

We don’t know which kind of predicates works for the search algorithm. Let’s give a best guess that the predicate $p$ is a function of the monoid “summary” that $p(t_1 \bullet \cdots \bullet t_n)$ is true if and only if $t$ is in the sequence $t_1, \dots, t_n$.

Assume that $p(t_1 \bullet \cdots \bullet t_n)$ is true, thus the target element $t$ is in the sequence. We divide the sequence into two halves: $t_1,\ldots,t_k$ and $t_{k+1},\ldots,t_n$, where $1 \le k \le n$. We then evaluate $p(t_1 \bullet \cdots \bullet t_k)$ to determine whether the target element lies in the first or second half, and continue the search.

Based on this observation, we can deduce the following property of the predicate: there exists an index $x$ such that

[ p(t_1 \bullet \cdots \bullet t_k) := \begin{cases} \text{false} & \text{if } k < x, \ \text{true} & \text{if } k \ge x. \end{cases} ]

$t_x$ is the target element if such $x$ exists; otherwise, the target element does not exist in the sequence.

Intuitively, the target element is the turning point at which the predicate on the running summary changes from false to true.

Note that $p$ makes sense only on the summary of any prefix of the sequence. If we need to continue the search in the second half, we must remember the summary of the scanned prefix.

Now we can define the search algorithm $\mathrm{Search}(p, s, {t_i,\ldots,t_j})$ where

$s$ is the summary of scanned prefix $t_1 \bullet \cdots \bullet t_{i-1}$ when $i > 1$ or the identity element $e$ otherwise.

$t_i, \ldots, t_j$ is the sub-range to search next.

$p$ is the predicate as defined above

The algorithm proceeds as follows:

If $p(s \bullet t_i \bullet \cdots \bullet t_j$) is false, the target element does not exist. The algorithm aborts with an error.

Otherwise, if there’s only one element ($i = j$), $t_i$ is the target element. The algorithm aborts with the found result.

Otherwise, choose a pivot index $i \le m \lt j$ to split the sequence into two nonempty halves: $t_i, \ldots, t_m$ and $t_{m+1},\ldots,t_j$. Test $p(s \bullet t_i \bullet \cdots \bullet t_m)$ that

If it is true, continue the search in the first half: $\mathrm{Search}(p, s, {t_i,\ldots,t_m})$

Otherwise, continue the search in the second half: $\mathrm{Search}(p, s \bullet (t_i \bullet \cdots \bullet t_m),{t_{m+1},\ldots,t_j})$

The algorithm starts with $\mathrm{Search}(p, e, {t_1, \ldots, t_k})$.

Application: Random-Access Sequence

An application of the search algorithm is accessing the nth element in the sequence.

We initialize the sequence to all 1s and use the monoid of non-negative integers with addition $(\mathbb{N},+,0)$:

[ \underbrace{1, \ldots, 1}_{n \text{ times}} ]

The predicate to find the i-th (starting from 0) element is:

[ p_i(s) := s > i ]

It may seem silly to search for the i-th 1 in a sequence of 1s, but we can store any data in the sequence and attach the monoid values as annotations to guide the search algorithm.

Application: Max-Priority Queue

Another application is finding the element with the max priority.

We use the monoid of non-negative integers with operator min $(\mathbb{N},\mathrm{min},0)$ and assume that the minimum value has the maximum priority.

The predicate to find the element with the max priority is

[ p(s) := s = m ]

Where $m$ is the monoid summary of the entire sequence—that is, the minimum value in the sequence. The predicate checks whether the summary equals to $m$.

Annotated Search Tree

A natural way to support the divide-and-conquer search is an annotated binary tree. Store the sequence elements at the leaves, and at each node store the monoid summary of the subtree—e.g. the sum of lengths or the minimum priority in that subtree. The predicate can then be evaluated on the left subtree’s annotation to decide whether to descend left or right, and the prefix summary is updated when going right by combining it with the left subtree’s summary.

A plain binary tree can degenerate to a list in the worst case, so operations may become linear. A more advanced structure, the finger tree1, keeps the tree balanced and supports efficient access at both ends and in the middle; each node carries a monoidal “measure” of its subtree, and the same search strategy applies. In Haskell, Data.Sequence from the containers library implements sequences as finger trees with size (length) as the measure, giving $O(\log n)$ indexing, splitting, and concatenation.

Utility of the Identity Element

The general divide-and-conquer algorithm does not require a monoid—only a semigroup. A semigroup is a fancy word for a set equipped with a closed, associative binary operator but lacking an identity element. The presence of an identity element makes monoids convenient to work with.

The identity element serves as a natural default value or starting point for algorithms. For instance, in the search algorithm, the summary of the scanned prefix is initialized to $e$. Without an identity element, we need an additional flag to indicate whether any prefix has been scanned, and the algorithm would have to branch conditionally based on that flag.

The art of choosing monoid and predicate

In the random-access example we used $(\mathbb{N}, +, 0)$ and annotated each position with $1$—the summary of a segment is its length, and the predicate $s > i$ tells us whether the $i$-th element lies in the prefix we have so far. In the max-priority queue we used $(\mathbb{N}, \min, 0)$ (or a bounded variant): the summary is the minimum value in the segment, and the predicate $s = m$ identifies the segment that contains the global minimum. In both cases, the monoid was chosen so that the combined summary over a range is exactly what the predicate needs to decide where to go ne

·blog.iany.me·
Power of Monoid Beauty of Simplicity
Use Bun for Shell Scripts
Use Bun for Shell Scripts

I’ve moved most of my small scripts to Bun. It sidesteps Windows’ lack of shebang support and the Git-for-Windows/WSL bash dance, and gives you one runtime for both ad-hoc shell-style commands and full scripts.

Problems Bun solves

Windows doesn’t execute #!/usr/bin/env python3 or #!/bin/bash, so scripts written for Linux or macOS usually need a separate PowerShell version. Bun compiles JavaScript to native executables, so a single script can run cross-platform without shebangs.

Mise file tasks have started to support shebangs on Windows in the latest release, but they rely on system bash.exe, which often points at WSL. Bun does not. You run bun ./script.js and embed shell commands via Bun.$; the script uses Bun’s bundled bash environment.

Why Bun for shell scripts

The command bun build ./script.ts --compile --outfile script produces a single binary, so no shebang or interpreter is needed on Windows. The Bun.build API is available too; I use a build.js script to walk a directory and compile all executables. Pre-built binaries start quickly—Bun’s docs cite ~5ms vs Node’s ~25ms for a simple script, which helps for small, frequently run scripts.

Bun has strong support for shell-style scripting. Bun Shell runs bash commands; the main entry point is Bun.$, which constructs shell commands from template literals for safe interpolation, similar to google/zx. On Windows, Bun ships with MSYS, so you get a consistent bash environment and common Linux CLI tools without extra setup.

Other useful features:

Bun.secrets—Securely store and retrieve secrets using the OS keystore (Windows Credential Manager, macOS Keychain, Linux libsecret). Secrets - Bun

Built-in support for glob patterns, YAML parsing, ANSI colors, and more. Bun Runtime

Short example

import { $ } from "bun"; const out = await $echo hello.text(); console.log(out); // hello

Run it with bun run script.js or bun ./script.js.

Obsidian Notes

JavaScript Shell Scripting

·blog.iany.me·
Use Bun for Shell Scripts
Use tmux for PowerShell in Windows Terminal
Use tmux for PowerShell in Windows Terminal

You can get tmux session persistence and multiplexing in Windows Terminal by running tmux inside WSL and setting the default shell to PowerShell. New panes and windows will then start pwsh.exe instead of a Linux shell. Here’s a minimal setup using small wrappers and the tmux -C attach trick to configure new sessions.

Why tmux under WSL with PowerShell?

Since tmux does not run natively on Windows, the standard approach is to use it through WSL. However, if you launch wsl tmux from Windows Terminal, all panes will default to your WSL shell. When working in a Windows directory, I prefer to use native PowerShell within the Windows environment.

To ensure every tmux pane runs PowerShell, configure tmux to use pwsh.exe as its default command. When you launch pwsh.exe from WSL within a Windows directory, it brings us back to the Windows environment.

Wrappers so you can call tmux from PowerShell

Put these in a directory on your PATH (e.g. ~/Documents/PowerShell/bin).

PowerShell — tmux.ps1:

wsl tmux $args

Cmd — tmux.cmd:

@echo off wsl tmux %*

From PowerShell or Cmd you can then run tmux new, tmux attach, etc., and they all go to WSL’s tmux.

Creating PowerShell Sessions

The next piece is a script that creates a new session whose default command is pwsh.exe, or attaches if that session already exists. Example usage: tmux-pwsh dev or tmux-pwsh work.

The script sets the default command only for sessions it creates; it does not affect sessions started with a standard tmux new-session.

tmux-pwsh.ps1:

param( [Parameter(Mandatory=$true)] [string]$SessionName ) $pwshCommand = "exec pwsh.exe -nologo"

Check if session exists using exact name match

wsl tmux has-session -t "=$SessionName" 2>$null if ($LASTEXITCODE -ne 0) { wsl tmux new-session -d -s $SessionName $pwshCommand echo "set-option default-command "$pwshCommand"" | ` wsl tmux -C attach -t "=$SessionName" >$null } wsl tmux attach -t "=$SessionName"

Note:

The = prefix does an exact session name match and must be escaped when sending the command from PowerShell to WSL.

The new session specifies command pwsh.exe via the command argument.

Running wsl tmux set-option immediately after creating the session does not work because there’s a brief window where the session is not available for setting options. Instead, sending set-option via tmux -C attach reliably applies the setting.

After this, splitting panes and creating new windows in that session will all start pwsh.exe -nologo in Windows Terminal.

Summary

Use WSL for tmux and PowerShell as the default command by setting tmux’s default command to exec pwsh.exe -nologo.

tmux.ps1 / tmux.cmd — thin wrappers so tmux from Windows means wsl tmux.

tmux-pwsh.ps1 <name> — create (or attach to) a named session that uses PowerShell as the default command.

Once these are on your PATH, run e.g. tmux-pwsh dev from Windows Terminal to get a tmux session where every pane is PowerShell.

·blog.iany.me·
Use tmux for PowerShell in Windows Terminal
Backup Ignored Files with Git Remote Branch
Backup Ignored Files with Git Remote Branch

When working with Git repositories, there are often files that need to be backed up but shouldn’t be committed to the main branch. These might include local development settings, IDE configuration files, personal notes, or development scripts that are specific to your workflow. The challenge is finding a way to back up these ignored files without polluting the main repository history.

The Original Solution

My original approach was to use a separate repository to store backup files for all repositories and create symbolic links. This worked, but had several drawbacks:

Backup files were disconnected from their source repositories, making it harder to track what belongs where

Backing up new files required moving them to the backup repository and then creating symbolic links

A Better Approach: Remote Branch

Instead of using a separate repository, we can use a remote branch within the same repository. This approach offers several advantages:

Everything stays in one repository

When you clone the repository, the backup branch comes with it

Backup files are stored in a separate branch, keeping the main branch clean

Full Git history for backup files, just like any other branch

The Script

With AI assistance, I developed git-store-file, a tool for managing ignored files by storing them in a remote branch (default: origin/_store). This keeps backup files separate from your working branch without interfering with regular development. The script is available in both Bash and PowerShell versions.

Installation

Download the script and make it executable:

Bash version

curl -o ~/bin/git-store-file https://raw.githubusercontent.com/doitian/dotfiles-public/master/default/bin/git-store-file chmod +x ~/bin/git-store-file

PowerShell version

Download to a directory in your PATH

Usage

The script has four main commands: store, status, restore, and ls.

Store Files

Store one or more files to the remote branch, including files that are ignored by Git:

Store a single file

git-store-file config.local.json

Store multiple files

git-store-file config.local.json secrets.env

Use a custom branch name

git-store-file --branch backup config.local.json

Use a custom remote

git-store-file --remote upstream config.local.json

When you run the store command, the script will:

Create a temporary Git index to avoid conflicts with your working directory

Load the target branch’s current state into the temporary index

Add the specified files using git add -f to include ignored files

Create a commit with the changes

Display commit statistics and prompt for confirmation

Push the commit to the remote branch

Check Status

Check which files stored in the remote branch have been modified in your local working directory:

Check status

git-store-file status

Show diff for modified files

git-store-file status --diff

Restore Files

Restore files from the remote branch to your working directory:

Restore a specific file

git-store-file restore config.local.json

Restore all files from the branch

git-store-file restore

List Files

List all files stored in the remote branch:

git-store-file ls

Options

-b, --branch BRANCH: Specify the branch name (default: _store)

-r, --remote REMOTE: Specify the remote name (default: origin)

-h, --help: Show help message

Examples

Store local configuration

git-store-file .env.local

Check what's changed

git-store-file status -d

Restore after cloning

git-store-file restore .env.local

List all backed up files

git-store-file ls

How It Works

The script uses Git’s low-level plumbing commands to manipulate the repository without affecting your working directory:

Temporary Index: Uses the GIT_INDEX_FILE environment variable to create a temporary index, completely isolated from your working directory

Read Tree: Loads the target branch’s tree structure into the temporary index

Force Add: Uses git add -f to add files even if they’re listed in .gitignore

Commit Tree: Creates a commit object directly using git commit-tree, bypassing the normal commit workflow

Direct Push: Pushes the commit hash directly to the remote branch reference

This low-level approach ensures that:

Your working directory remains unchanged

The main branch is never affected

Ignored files can be stored without modifying .gitignore

The backup branch maintains full Git history

Conclusion

The git-store-file script solves the problem of backing up ignored files in a clean, Git-native way. By leveraging a remote branch, it keeps everything in one repository while maintaining clear separation between your main codebase and backup files. Whether you’re managing local configurations, IDE settings, or personal development scripts, this tool provides a simple and reliable backup solution that integrates seamlessly with your existing Git workflow.

·blog.iany.me·
Backup Ignored Files with Git Remote Branch
How to Activate mise for Cursor When It Runs Shell Commands
How to Activate mise for Cursor When It Runs Shell Commands

When using mise with Cursor, you may notice that the mise environment is not activated when Cursor executes shell commands. This occurs because Cursor launches a non-login shell, which initializes differently than an interactive login shell and therefore does not automatically source your usual mise setup.

The Problem

Cursor runs commands in a non-login shell, which means:

For zsh: Only .zshenv is loaded (not .zshrc)

For bash: Only .bash_profile is loaded (not .bashrc)

If you’ve configured mise activation in .zshrc or .zprofile, it won’t be available when Cursor executes commands.

The Solution

The solution is to activate mise in .zshenv (for zsh) or .bash_profile (for bash) when the file is loaded by Cursor. You can detect Cursor by checking for the CURSOR_AGENT environment variable.

Add this to your ~/.zshenv or ~/.bash_profile:

Activate mise when running in Cursor

if [[ -n "$CURSOR_AGENT" ]]; then eval "$(mise activate)" fi

Why This Works

.zshenv (or .bash_profile) is always sourced for non-login shells

The CURSOR_AGENT environment variable is set when Cursor runs commands

By conditionally activating mise only when CURSOR_AGENT is present, you avoid potential conflicts or slowdowns in other non-login shell contexts

This ensures mise and all its configured tools are available when Cursor executes terminal commands

After adding this configuration, rerun Cursor chats, and mise should be available in all Cursor shell commands.

·blog.iany.me·
How to Activate mise for Cursor When It Runs Shell Commands
Temporary Vi Mode in PowerShell
Temporary Vi Mode in PowerShell

I miss the feature in readline (Bash/Zsh) where Ctrl+x, Ctrl+v switches to Vi command mode temporarily. In that workflow, entering Insert mode switches back to Emacs mode. PSReadLine has a command ViCommandMode, but binding it directly in Emacs mode will report errors on every key input.

The solution requires handling the mode change event to toggle the global EditMode between Emacs and Vi.

When entering “Insert” mode (the default for Emacs users), we force the mode to Emacs and set up the trigger for the temporary Vi mode. When that trigger (Ctrl+x, Ctrl+v) is fired, we switch the edit mode to Vi and jump to command mode.

Here is the configuration for your $PROFILE:

function OnViModeChange { if ($args[0] -eq 'Insert') { Set-PSReadLineOption -EditMode Emacs Set-PSReadlineKeyHandler -Chord "Ctrl+x,Ctrl+v" -ScriptBlock { Set-PSReadLineOption -EditMode Vi [Microsoft.PowerShell.PSConsoleReadLine]::ViCommandMode() }

Add other Emacs key bindings here.

Switching EditMode resets key bindings to their defaults.

Remember to reconfigure them.

Set-PSReadlineKeyHandler -Chord "Ctrl+w" -Function BackwardKillWord

} } Set-PSReadLineOption -ViModeIndicator Script Set-PSReadLineOption -ViModeChangeHandler $Function:OnViModeChange Set-PSReadLineOption -EditMode Emacs

Reuse the function to start Emacs mode

OnViModeChange Insert

Switching EditMode resets key bindings to their defaults. Remember to reconfigure them.

·blog.iany.me·
Temporary Vi Mode in PowerShell
Hosting Static Site on Cloudflare R2
Hosting Static Site on Cloudflare R2

This post explains how to set up a static site on Cloudflare R2.

I recently migrated my blog to Cloudflare R2, and the process went smoothly until I encountered R2’s lack of native support for rewriting URLs to index.html files. This post explains how I resolved this issue.

Setting Up

Create the bucket.

Add the custom domain blog.iany.me. To redirect iany.me and www.iany.me to the blog, add these as custom domains as well. Since I host the domain in Cloudflare, the DNS record is automatically configured.

Publishing

I use rclone to deploy the site to R2. Below is the GitHub workflow I employ. Noting that the fetch-depth option for actions/checkout retrieves the complete repository history. This works with Hugo’s --enableGitInfo flag to accurately determine article creation dates.

name: Deploy Hugo Site to Cloudflare R2 on: push: branches:

  • master workflow_dispatch: jobs: build-and-deploy: runs-on: ubuntu-latest steps:
  • name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0
  • name: Install latest Hugo run: | TAG=$(curl -s https://api.github.com/repos/gohugoio/hugo/releases/latest
    | grep '"tag_name":'
    | head -1
    | sed -E 's/."("+)"./\1/') curl -L "https://github.com/gohugoio/hugo/releases/download/${TAG}/hugo_${TAG#v}_Linux-64bit.tar.gz"
    -o hugo.tar.gz tar -xzf hugo.tar.gz hugo sudo mv hugo /usr/local/bin/ hugo version
  • name: Install rclone run: | curl https://rclone.org/install.sh | sudo bash
  • name: Build site with Hugo run: hugo --minify --enableGitInfo
  • name: Configure rclone for Cloudflare R2 env: R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} run: | rclone config create r2 s3
    provider Cloudflare
    access_key_id "${R2_ACCESS_KEY_ID}"
    secret_access_key "${R2_SECRET_ACCESS_KEY}"
    endpoint "${R2_ENDPOINT}"
    --quiet
  • name: Deploy to Cloudflare R2 env: R2_BUCKET: ${{ secrets.R2_BUCKET }} run: | rclone copy public/ r2:"${R2_BUCKET}"
    --checksum
    --no-traverse
    --verbose

Rules

Go to the dashboard of the domain iany.me and go to the section Rules.

Redirect Rules

I have added 3 redirect rules:

When incoming requests have a hostname iany.me or www.iany.me, redirect to concat("https://blog.iany.me", http.request.uri.path).

Redirect https://blog.iany.me/*/index.html to https://blog.iany.me/${1}/.

Redirect http://* to https://${1}

Although the Redirect Rules manual says if multiple rules make the same modification, the last executed rule wins, I have to put rule 2 before 3.

URL Rewrite Rules

I added 2 rewrite rules for index.html:

When the request URL is https://blog.iany.me or https://blog.iany.me/, rewrite the URL path to /index.html.

When the request URL matches https://blog.iany.me/*/, rewrite the path to ${1}/index.html

Cache Rules

I added 2 cache rules for images and static content:

Set cache TTL to 1 month for png, jpg, jpeg, and svg files.

Set cache TTL to 1 year for files in /js, /fonts, /css, and /uploads.

·blog.iany.me·
Hosting Static Site on Cloudflare R2
Renaming Browser Tab Names
Renaming Browser Tab Names

Renaming browser tab names may seem like a simple task, but it can actually be quite challenging.

Most browsers sync the tab name with the web page title. Therefore, it seems simple to set tab name by setting document.title:

document.title = "Custom Tab Name";

However, many web pages use JavaScript to alter the page title, which would override the custom name. While Object.defineProperty in JavaScript can override the property setter function, it’s not possible to access the original setter function for document.title. Fortunately, browsers sync page titles with the first tag, so setting its content acts as a workaround to set the page title.

Here is the bookmark for renaming a tab. I also add %t as the token for the original page tab title. This is handy such as adding a custom prefix with Work: %t, or restoring the original title without reloading the page by using %t.

// Rename the document title using a bookmarklet. // // As a user, // - Once I have renamed the tab, it should not be overwritten. // - I can include the token %t as placeholder for the real title. // - To restore, I can rename the tab to %t // // Install: Copy the code to // // https://caiorss.github.io/bookmarklet-maker/ // // and generate the bookmarklet. // Init only once if (!("_renameTitle" in document)) { // Force creating the title tag. document.title = document.title || ""; const titleEl = document.getElementsByTagName("title")[0]; const titleTokenRegex = /%t/g; // Remembers the real title let titleWithoutRenaming = document.title; // User set title let titleWithRenaming = ''; // Rename the document title to v. // If v contains the token %t, replace all occurences to the // real title. document._renameTitle = (v) => { titleWithRenaming = v; titleEl.innerText = v.replace(titleTokenRegex, titleWithoutRenaming); }; Object.defineProperty(document, "title", { // Other code will use document.title setter to change the title. // // Remember the new value as the real title but still use the // title set by user. set: (v) => { titleWithoutRenaming = v; // Once document has defined the title property, its value is // not synchronized to the tab name. // // Here uses a workaround to set the title tag content. titleEl.innerText = titleWithRenaming.replace(titleTokenRegex, v); }, get: () => titleEl.innerText, }); } const title = prompt("Rename tab (Use token %t for original title)", document.title); // title is null when user cancel the dialog. if (title !== null) { document._renameTitle(title); }

·blog.iany.me·
Renaming Browser Tab Names