❯~/pixelastic

How I taught my agent to refactor its own code

Most of my code is now written by my agents, one issue at a time. Each issue is carefully planned, tested, linted, reviewed and committed. I'm happy with the code quality of each issue taken individually, but if I string all the issues together, I still sometimes end up with some code duplication and useless indirection.

I also tend to review the whole plan implementation at the end, rather than after each issue. This gives me the big picture and is where I do the heaviest refactorings.

I mix everything

But even before agents were a thing, my approach to refactoring was very organic. I know the theory about not refactoring everything at once. Starting small. Committing often. Not changing implementation and tests at the same time.

But in practice, it's very hard for me to not fix something I saw as obviously wrong. It's very hard to only fix the small typo when there was a blatant code duplication next to it. I end up removing old code, updating documentation, changing function signatures and renaming variables all in the same pass.

Agents, on the other hand are pretty good at following a clear set of steps. So maybe I could teach my agent the rigorous refactoring hygiene I never managed to teach myself. That's why one day I started writing down, as a skill, what good refactoring looks like to me.

I split it in three layers: reduce, rewrite and restructure. You start small, and increase complexity and impact as I proceed. Each pass is meant to make the next pass easier.

The example

Here is an example to show what I mean.

import fs from 'node:fs';
import { formatDate } from './dates.js';
import { fetchOrder, fetchCustomer } from './api.js';

function legacyFormat(order) {
  return order.id + ':' + order.total;
}

function buildSummary(order, name) {
  return {
    id: order.id,
    customer: name,
    price: order.totalWithTax,
    date: formatDate(order.createdAt),
  };
}

export async function getOrderSummary(orderId) {
  const order = await fetchOrder(orderId);
  const cust = await fetchCustomer(order.customerId);
  // console.log('order', order);
  let name;
  if (cust) {
    if (cust.name) {
      name = cust.name;
    } else {
      name = cust.email;
    }
  } else {
    name = 'Unknown';
  }
  const data = buildSummary(order, name);
  return data;
}

export async function getInvoiceSummary(orderId) {
  const order = await fetchOrder(orderId);
  const cust = await fetchCustomer(order.customerId);
  let name = 'Unknown';
  if (cust) name = cust.name || cust.email;
  return buildSummary(order, name);
}

Reduce

The goal of the first pass is to reduce the size of the code by removing all the noise. Commented code, unused imports or orphaned functions are dead code that has no impact on the behavior and only makes understanding the code harder.

It has always been true for humans, and it's still true for agents. If I don't need it, I remove it. Less code means less bugs.

import fs from 'node:fs'; 
import { formatDate } from './dates.js';
import { fetchOrder, fetchCustomer } from './api.js';

function legacyFormat(order) { 
  return order.id + ':' + order.total;
}

function buildSummary(order, name) {
  return {
    id: order.id,
    customer: name,
    price: order.totalWithTax,
    date: formatDate(order.createdAt),
  };
}

export async function getOrderSummary(orderId) {
  const order = await fetchOrder(orderId);
  const cust = await fetchCustomer(order.customerId);
  // console.log('order', order);
  let name;
  if (cust) {
    if (cust.name) {
      name = cust.name;
    } else {
      name = cust.email;
    }
  } else {
    name = 'Unknown';
  }
  const data = buildSummary(order, name);
  return data;
}

export async function getInvoiceSummary(orderId) {
  const order = await fetchOrder(orderId);
  const cust = await fetchCustomer(order.customerId);
  let name = 'Unknown';
  if (cust) name = cust.name || cust.email;
  return buildSummary(order, name);
}

Now that the noise is gone, I move to the next phase: rewrite.

Rewrite

In this second pass, I improve each function locally, without (yet) thinking about how all the functions interact with each other globally.

I start by renaming variables so their name removes any ambiguity as to what they represent: I use full names rather than abbreviations and avoid generic names like data.

The behavior is exactly the same. This is purely cosmetic, but it helps me understand what objects and concepts I'm dealing with.

import { formatDate } from './dates.js';
import { fetchOrder, fetchCustomer } from './api.js';

function buildSummary(order, namecustomerName) {
  return {
    id: order.id,
    customer: namecustomerName,
    price: order.totalWithTax,
    date: formatDate(order.createdAt),
  };
}

export async function getOrderSummary(orderId) {
  const order = await fetchOrder(orderId);
  const custcustomer = await fetchCustomer(order.customerId);
  let namecustomerName;
  if (custcustomer) {
    if (custcustomer.name) {
      namecustomerName = custcustomer.name;
    } else {
      namecustomerName = custcustomer.email;
    }
  } else {
    namecustomerName = 'Unknown';
  }
  const datasummary = buildSummary(order, namecustomerName);
  return datasummary;
}

export async function getInvoiceSummary(orderId) {
  const order = await fetchOrder(orderId);
  const custcustomer = await fetchCustomer(order.customerId);
  let namecustomerName = 'Unknown';
  if (custcustomer) namecustomerName = custcustomer.name || custcustomer.email;
  return buildSummary(order, namecustomerName);
}

Another example is replacing deeply nested ifs with some early return, or ensuring similar looking functions are coded in a similar fashion. When there are several ways to skin a cat, I make sure I at least skin the cat consistently the same way throughout my code.

import { formatDate } from './dates.js';
import { fetchOrder, fetchCustomer } from './api.js';

function buildSummary(order, customerName) {
  return {
    id: order.id,
    customer: customerName,
    price: order.totalWithTax,
    date: formatDate(order.createdAt),
  };
}

export async function getOrderSummary(orderId) {
  const order = await fetchOrder(orderId);
  const customer = await fetchCustomer(order.customerId);
  let customerName; 
  if (customer) {
    if (customer.name) {
      customerName = customer.name;
    } else {
      customerName = customer.email;
    }
  } else {
    customerName = 'Unknown';
  }
  const summary = buildSummary(order, customerName);
  return summary;
  if (!customer) return buildSummary(order, 'Unknown'); 
  return buildSummary(order, customer.name || customer.email); 
}

export async function getInvoiceSummary(orderId) {
  const order = await fetchOrder(orderId);
  const customer = await fetchCustomer(order.customerId);
  let customerName = 'Unknown'; 
  if (customer) customerName = customer.name || customer.email;
  return buildSummary(order, customerName);
  if (!customer) return buildSummary(order, 'Unknown'); 
  return buildSummary(order, customer.name || customer.email); 
}

In that phase, I never change the tests, I only improve the code. Tests are my safeguard that my changes make the code better, without changing the behavior.

In my example here, once I've changed the deeply nested ifs into simpler early returns, the shape of both getOrderSummary and getInvoiceSummary is very similar.

I might have a way to refactor that further, but this comes in the final phase: restructure.

Restructure

Now that I've cleaned up the inside of each function, it is time to clean up how those functions interact with each other.

Both getOrderSummary and getInvoiceSummary do exactly the same thing, so I remove them and keep only a higher level getSummary function. The buildSummary function is no longer shared between two functions, so I inline it in getSummary.

In that phase, I am allowed to update tests. As I am changing the interface of the code, I must also update the relevant tests accordingly. In my example, that means moving the buildSummary tests into the getSummary tests.

import { formatDate } from './dates.js';
import { fetchOrder, fetchCustomer } from './api.js';

function buildSummary(order, customerName) { 
  return {
    id: order.id,
    customer: customerName,
    price: order.totalWithTax,
    date: formatDate(order.createdAt),
  };
}

export async function getOrderSummary(orderId) { 
  const order = await fetchOrder(orderId);
  const customer = await fetchCustomer(order.customerId);
  if (!customer) return buildSummary(order, 'Unknown');
  return buildSummary(order, customer.name || customer.email);
}

export async function getInvoiceSummary(orderId) { 
  const order = await fetchOrder(orderId);
  const customer = await fetchCustomer(order.customerId);
  if (!customer) return buildSummary(order, 'Unknown');
  return buildSummary(order, customer.name || customer.email);
}

export async function getSummary(orderId) { 
  const order = await fetchOrder(orderId);
  const customer = await fetchCustomer(order.customerId);
  return {
    id: order.id,
    customer: customer ? customer.name || customer.email : 'Unknown',
    price: order.totalWithTax,
    date: formatDate(order.createdAt),
  };
}

This is also where I can remove useless intermediate wrappers, or even change the input signature or output format of my functions. This means updating the callers and the tests, which makes this phase the riskiest.

That is why it comes last: with the code cleaned up and simplified by the previous phases, heavy refactoring is now possible.

The skill

I packaged all this in a /refactor skill that I run regularly when I feel I've hit a big enough chunk of code.

Feel free to use my skill as an inspiration to build yours; mine contains a few assumptions about how I plan and execute my issues.

Migrating from Jekyll to Eleventy

I started this blog in 2009. At the beginning, I posted a lot, mostly as a way to document my troubles for others (and for myself). Along the year, I moved away from writing, not because I had nothing to say anymore, but because I went more and more into public speaking instead.

Recently, AI-assisted blog writing made me want to come back to sharing my prose online, and while I was at it I decided to migrate the stack from Jekyll to Eleventy, and redesign the blog while I was at it.

This post is an attempt at documenting that process that took me about 2 days, where I would have probably spent months on it before.

Picking an engine

This repo has 320 posts and ran on Jekyll 3.6, with Ruby, Grunt, Bower, Sass, and even jQuery. I wanted to move to a more modern stack, and the choices I had in mind were between Hugo, Astro and Eleventy.

I had already tried Hugo on painting.pixelastic.com, and now I wanted to compare with something else. Hugo is really as fast as people say, but I don't know how to write good Go, and I wanted to test a JavaScript contender, where I have more experience.

I decided to not try Astro this time mostly because I had wanted to try Eleventy for a long long long time. The fact that it's still relevant today, is a sure sign it's a solid stack. Next time I need to migrate a static website I'll try Astro, I promise.

Part 1: migrating the code

Here I called my custom /plan skill, which has the agent write a PRD and split it into 9 individual issues covering the skeleton, content migration, tags, archives, feeds, tests, the Tailwind layout, and various cleanups.

I kept the redesign out of scope here (see part 2), the goal was to have the default Eleventy layout, but with all my content.

Thoughout the migration, it also fixed some inconsistency I had in my posts: mix of Windows/Linux line ending, mixed case tag collision, and even duplicated posts at various dates.

Once done, no more Ruby in the codebase, only JS. Note that I didn't review the code there; my goal was having something that worked, so I could keep posting. I told myself I would review the code later.

Part 2: the redesign

For the reddesign I first made a /grill-me session with Claude, starting with a very vague setup ("I want to redesign my blog into something more modern"), then through questions we managed to nail it down more precisely.

This question/answer loop made it emerge that I wanted something dark-themed, reminiscent of the time I spend at a terminal, re-using some of the visual language of a CLI (like the ~/pixelastic header), and using my real neovim colorscheme.

Claude then transformed that into a prompt for v0, that I pasted 4 times into 4 different v0 windows:

Version 1: monospace post titles with underline, a green "$ ls posts/" label and plain-text tags

Version 2: bold post titles and tags as bordered chips, with a prompt arrow next to the site name

Version 3: a large "Notes from the command line" hero in thin monospace, with a cyan highlight

Version 4: a large "Things I learned while building" hero in bold sans-serif, with breadcrumbs

This approach worked well for me because I felt I was one of those horrible customers coming to see a designer with nothing more than "I don't know what I want, but I'll now when I'll see it". Thankfully, the AI doesn't shy from those requests, so seeing 4 different iterations made me realize what is important for me and what I don't like.

Out of the 4 versions above, version 2 was the one I liked the most. I then exported the zip from V0 and told Claude to integrate it into Eleventy.

From there, I did a lengthy chat with it, telling it one by one everything I wanted to change, waited for it to change, then move to the next item. Those were things like:

  • make the main column wider on desktop;
  • make sure titles do not overflow that column;
  • reuse the colors from my own colors.jsonc in my dotfiles;
  • move some items from the header to the footer.

Here, I didn't follow a clear /plan like in the first part. It really was an "old-school" prompting session of "do this", "no, do that instead", "actually I liked the first version better".

This is just the beginning

Now that the migration is done, I still have some work to do. For example, I still care about the generated code and I want to review it, to understand how Eleventy works, and what's the best way to organize my code in it.

I will also fix small reading issues here and there, improve the mobile experience, migrate the site to Netlify, etc.

Making my agent find my own CLI helpers

Over the years, I made myself a long list of CLI helpers. Stuff like git-worktree-create or video-volume-increase. They help me get the info I want or do the action I need without having to remember the exact underlying command and args to type.

But when I ask Claude to create a branch or get me the dimensions of a video, it would use bare git or ffmpeg commands. That inconsistency buggued me at first but I thought that given the agent was smarter than I was, it didn't need an alias, it could just remember the complete and complex command so it didn't really matter.

Well, it mattered, actually

When I ask the agent for a new worktree, what I have in mind is running my custom git-worktree-create helper. I packed a lot of complexity away behind that command:

  • It stops early when the current directory is not a git repository.
  • It starts a new branch.
  • It creates the worktree folder in a shared directory, with a naming convention.
  • It handles git submodules.
  • It installs dependencies.
  • It handles edge cases (invalid chars in branch names, etc.).
  • It also takes care of some edge cases or additional tweaks for some specific repos.

This command started as a simple git worktree add /worktree/dir branch-name wrapper but grew over time to the complex beast it is today. And its tests grew with it. That command does exactly what I want it to do, and I have tests to prove it now. I feel safe using it, knowing it will do exactly what I expect it to do.

Letting the agent know

I'd like the agent to use that helper. But the agent doesn't know about it. It still thinks "Creating a worktree" is running git worktree add /worktree/dir branch-name and that's it.

I found myself having to tell it: "Use the git-worktree-create helper instead." often. And I had to do that for all my helpers.

I started adding lines into my CLAUDE.md to tell it about my favorite helpers but I quickly realized it wouldn't scale with the 1000+ helpers I currently have.

What I did instead was writing two other helpers. helper-list would list all my helpers and helper-documentation would explain how to use on.

helper-list

helper-list takes a query and returns only the helpers that match that query.

A terminal running . It prints about 25 helpers, from  to . Each helper name sits in orange, next to a one-line description in white.

All my helpers follow a consistent naming scheme of <domain>-<object>-<action>. For example git-branch-create, docker-image-list, notion-page-read, etc.

Finding them is a matter of piping find into fzf to get some fuzzy-matching against the query and displaying them. As my helpers have explicit enough names, the agent can guess which one would be useful to it.

helper-documentation

Knowing that a helper exists is only the first step. Now the agent needs to know how to use it. helper-documentation takes a helper name as input, and returns its header documentation comments (including description, usage, etc).

A terminal running . It prints the helper description, "Create a Worktree for a branch (new or existing) and cd into it", then a usage line with the example .

By making sure all my helpers are properly documented, documentation usage can easily be resurfaced to the agent.

CLAUDE.md

To wrap this all together, here is what I added to my CLAUDE.md:

Prefer dedicated CLI helpers over raw commands.
Examples:


| Instead of…              | Use…                         |
|--------------------------|------------------------------|
| `git` complex pipelines  | `git-*` helpers              |
| `convert`, `imagemagick` | `img-*` helpers              |
| `ffmpeg` pipelines       | `audio-*`, `video-*` helpers |


Discover helpers with `helper-list <domain> [action]`.
Read the flags and usage of a helper with `helper-documentation <name>`.

The result

Now, I can just focus on adding more helpers and more helpers, following my naming convention and documenting them properly. I'm assured my agent will be able to use them without me having to update anything in my CLAUDE.md. Those helpers are useful to both me and the agent.

A helper that finds my other helpers feels a bit meta, but it works really well.

One more benefit is that I can now configure Claude to allow specific commands (like git-worktree-create) without needing to give it a blanket permission to the whole git command.

Saving my kitty workspace every two minutes

Yesterday, my Ubuntu laptop ran an update of packages, and once it was done, it let me know I needed to reboot my laptop for the change to be completed. But I didn't want to reboot my laptop right now. I had 12+ open tabs in Kitty, each split into subwindows because I was working on various topics.

So I postponed the reboot. And postponed; for days. Finally I gave up, wrote down the list of tabs, rebooted, and manually restarted all my kitty tabs.

A kitty window with ten named tabs. The current tab is split in two columns, and the right column is split again.

Doing it by hand

It was tedious, and I didn't really like doing it. And I certainly didn't want to have to do it twice. I wanted a keybinding to do it for me. Something that would save the whole layout, and bring it back.

Since Kitty 0.43, the save_as_session action dumps tabs, tab names, splits, layout geometry, and working directories to a file. And kitty --session <file> starts from that file.

I updated my Kitty to the right version and created a kitty-save script that dumps the session to a file, and bound it to Alt-F5. I also made it flash the screen green when I save (so I have some visual feedback something actually happened).

# kitty.conf
allow_remote_control socket-only
listen_on unix:/tmp/kitty-socket

map alt+f5 launch --type=background kitty-save
#!/usr/bin/env zsh
# kitty-save
sessionFile="$HOME/.local/state/kitty/session.kitty-session"

# Talk to kitty through its socket
kitty-remote() { kitty @ --to unix:/tmp/kitty-socket "$@" }

# Flash all windows green
kitty-remote set-colors --all background='#1a3a1a'

# Dump the session to a file
kitty-remote action save_as_session --save-only "$sessionFile"

# Back to the normal colors
kitty-remote set-colors --all --reset

I also created a kitty-restore script that checks for the existence of the session file, and appends --session with it if the file exists. I call that script now instead of opening Kitty directly.

#!/usr/bin/env zsh
# kitty-restore
sessionFile="$HOME/.local/state/kitty/session.kitty-session"

sessionArgs=()
[[ -f "$sessionFile" ]] && sessionArgs+=(--session "$sessionFile")

kitty "${sessionArgs[@]}"

I was happy. On the next reboot, I could just Alt+F5 to save everything, reboot, and come back where I left off.

The crash

But the next time my computer rebooted, it was because it crashed. Totally unrelated, but the computer froze and I had to manually reboot it, without being able to press Alt-F5 first.

When I restarted Kitty, it came back with the layout from my last manual save, which was days old. My tabs were not the ones I had been working on.

So, manual saving was a good start, but wasn't enough. I needed something more automated.

Every two minutes

I thought about running kitty-save automatically every two minutes. But in my mind it meant writing cron files, with their weird syntax made of * and / and that seemed too complex to me at that time.

Then I remembered we live in an age of agents and I wouldn't be the one writing those commands anyway, so why not ask the agent? Actually the agent told me that cron was outdated and systemd was apparently the new way of doing it.

A systemd user timer is made of two small files: a service to say what to run, and a timer to say when.

# kitty-save.service
[Service]
Type=oneshot
ExecStart=/path/to/kitty-save
# kitty-save.timer
[Timer]
OnStartupSec=2min
OnUnitActiveSec=2min

[Install]
WantedBy=timers.target

OnStartupSec tells it to start its first tick 2mn after login. OnUnitActiveSec tells it to run again after that every 2mn.

I had to slightly update kitty-save to work both when called manually and through the timer. I made the window green flash optional behind a --flash flag, and added some guards to return early if kitty wasn't even running.

Peace of mind

I now have this automated save running in the background every 2mn. It takes about 90ms to run, but as it runs asynchronously in the background, I don't even perceive it. And now a crash would cost me 2mn of Kitty layout at most.

Keeping the context of an Airtable Record Picker from page to page

Isometric illustration of a map pin standing on layered cards

I'm using Airtable Interfaces a lot, to pilot my automations. For a project at work I built a whole pipeline to help us organize meetups in our office. It starts with the URL of a meetup.com event, and ends with the printing of the signs to put in the building, and goes through all the phases of calculating the number of pizzas needed, booking rooms in the corporate calendar, printing badges for attendees and pre-generating internal Slack messages, etc.

Airtable interface page for the React Paris Meetup 5 with attendee numbers and food estimates

But the process is made of multiple steps, that having all the info on one page was getting overwhelming and very practical, so I started splitting it into several pages. One page would handle the attendee list, another would take care of the calendar, another of the Slack messages, etc.

Airtable interface sidebar of the Meetup Hosting app, with the Attendees and Food page selected

The issue I ran into though, was that whenever I would switch pages, I would lose the context of which meetup I'm currently editing. For example I would start on the "Metadata" page for the upcoming React Meetup; I would then click on the "Calendar Events" page and would somehow now be editing the upcoming Algolia Search Party.

This is because, from Airtable Interfaces point of view, each page is an independent context, and there is no default way to keep the currently selected Event from one page to the next.

My hackish workaround

Airtable table of meetups with a pin trigger URL column and an is pinned checkbox

Meetup picker dropdown next to a blue Pin button

The (very hackish and imperfect) way I found around this is to add a isPinnedInUI checkbox field to my table. Only one element of the table should be marked as pinned at any given time. I also configured the record picker to select the fields where isPinnedInUi is true in priority.

I also added a custom Make scenario (trigger when clicking on the Pin blue button next to the record picker) that would uncheck the previously checked element, and check the currently selected one.

That way, when I'm working on an event I can pin it in place, and keep its context even when navigating from page to page.

Of course, this is just a bandaid, and fails as soon as more than one person is working on the dashboard at the same time. If I start pinning a meetup and my coworker Lucas is working on another one and pin the other, we'll just step on each other's toes. But so far, it has been an improvement over completely losing the context.