<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Automation | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/automation/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:51 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/automation/index.xml" rel="self" type="application/rss+xml"/><item><title>We're Living in the Future: Cars Fly and We Don't Have to Write HTML/CSS Anymore</title><link>https://akitaonrails.github.io/en/2026/10/16/living-in-the-future-cars-fly-no-more-html-css/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/10/16/living-in-the-future-cars-fly-no-more-html-css/</guid><pubDate>Fri, 16 Oct 2026 02:00:00 -0300</pubDate><description>&lt;p&gt;It&amp;rsquo;s past two in the morning and I&amp;rsquo;m still awake because I just watched the new Tesla Roadster climb a vertical ramp live, stop upside down, then spin in midair and land in one piece on another run.&lt;/p&gt;
&lt;p&gt;I expected a fast car. I didn&amp;rsquo;t expect one with ten compressed-air propulsion nozzles using SpaceX-derived technology, plus an underbody suction system capable, &lt;a href="https://paultan.org/2026/10/16/tesla-roadster-revealed-flies-on-spacex-air-thrusters-0-96-km-h-in-0-81s-production-2028/"target="_blank" rel="noopener"&gt;according to Elon Musk, of pulling up to 2 G&lt;/a&gt;. During the demo, Tesla announced zero to 60 miles per hour, nearly 97 km/h, in 0.81 seconds. It did not disclose the measurement protocol.&lt;/p&gt;</description><content:encoded><![CDATA[<p>It&rsquo;s past two in the morning and I&rsquo;m still awake because I just watched the new Tesla Roadster climb a vertical ramp live, stop upside down, then spin in midair and land in one piece on another run.</p>
<p>I expected a fast car. I didn&rsquo;t expect one with ten compressed-air propulsion nozzles using SpaceX-derived technology, plus an underbody suction system capable, <a href="https://paultan.org/2026/10/16/tesla-roadster-revealed-flies-on-spacex-air-thrusters-0-96-km-h-in-0-81s-production-2028/"target="_blank" rel="noopener">according to Elon Musk, of pulling up to 2 G</a>. During the demo, Tesla announced zero to 60 miles per hour, nearly 97 km/h, in 0.81 seconds. It did not disclose the measurement protocol.</p>
<p>This is the future I wanted to see.</p>
<p>The entire demonstration is in <a href="https://livestream.tesla.com/"target="_blank" rel="noopener">Tesla&rsquo;s official livestream</a>. <a href="https://www.roadandtrack.com/news/a74151425/the-air-assisted-tesla-roadster-flies-in-demo-hits-0-60-in-08-seconds/"target="_blank" rel="noopener">Road &amp; Track measured and described the maneuvers</a>, while <a href="https://techcrunch.com/2026/10/15/tesla-reveals-spacex-powered-roadster-that-flies-and-falls-with-style/"target="_blank" rel="noopener">TechCrunch detailed the three versions</a>: Performance with active aerodynamics and suction, Thrust with rear thrusters, and Rocket with a ten-nozzle propulsion system and a single seat.</p>
<p>Of course, we watched a prototype in a controlled demonstration. Tesla expects production to begin in 2028, and the Rocket version is not approved for public-road use. Still, an actual car left the ground, rotated, and landed. I watched it live from my office. It was spectacular.</p>
<p>While Musk was landing rockets and making cars fly, I was doing something far smaller and more mundane: fiddling with my websites.</p>
<h2>Six sites in about a day<span class="hx:absolute hx:-mt-20" id="six-sites-in-about-a-day"></span>
    <a href="#six-sites-in-about-a-day" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before the Tesla event, I spent the entire day redesigning six sites:</p>
<ul>
<li><a href="https://ailair.akitaonrails.com/"target="_blank" rel="noopener">Akita&rsquo;s AI Lair</a>, now matching the look of my new book, <em>Competent Enough</em>;</li>
<li><a href="https://aimemory.sh/"target="_blank" rel="noopener">ai-memory</a>;</li>
<li><a href="https://aijail.io/"target="_blank" rel="noopener">ai-jail</a>;</li>
<li><a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a> newsletter;</li>
<li>the <a href="https://blog.themakitachronicles.com/"target="_blank" rel="noopener">newsletter archive</a>;</li>
<li>and this blog, <a href="https://akitaonrails.com/"target="_blank" rel="noopener">AkitaOnRails.com</a>.</li>
</ul>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/10/redesign-seis-sites-stitch.jpg" alt="The six redesigned sites in production: Akita’s AI Lair, ai-memory, ai-jail, The M.Akita Chronicles, the newsletter archive, and AkitaOnRails"  loading="lazy" /></p>
<p>They all started with variations of that default theme Claude makes. Dark background, purple or blue gradient, huge sans-serif type, rounded cards, a glow behind something. It works. It has also become so common that you can spot a &ldquo;site made by Claude&rdquo; before reading the title.</p>
<p>I had already tried asking Claude for different designs. It changed the color, moved the cards around, and delivered the same visual grammar. I posted <a href="https://x.com/AkitaOnRails/status/2110809199890300986"target="_blank" rel="noopener">some screens and the result of the change on Twitter</a>. I&rsquo;m no web designer, and for a while I accepted &ldquo;it works.&rdquo; Yesterday I got tired of it.</p>
<p>Someone suggested <a href="https://stitch.withgoogle.com/"target="_blank" rel="noopener">Stitch by Google</a>. It&rsquo;s an experimental Google Labs tool that turns instructions and images into editable interfaces and front-end code. The <a href="https://developers.googleblog.com/en/stitch-a-new-way-to-design-uis/"target="_blank" rel="noopener">first version launched in 2025</a> on top of Gemini; the <a href="https://blog.google/innovation-and-ai/models-and-research/google-labs/stitch-ai-ui-design/"target="_blank" rel="noopener">current version</a> has become a design canvas with an agent, prototypes, and export to a <code>DESIGN.md</code> that other agents can follow.</p>
<p><a href="https://blog.google/innovation-and-ai/models-and-research/google-labs/stitch-ai-ui-design/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/10/stitch-ai-native-canvas-google.webp" alt="The Google Stitch canvas with multiple interfaces, visual variations, and design-system controls"  loading="lazy" /></a></p>
<p>I signed up, got the API token, and went to work. Stitch generated visual directions. Claude Code and Codex brought those directions into the real repositories, adapted the components, fixed responsiveness, replaced images, adjusted SEO, tested, and deployed. When one quota ran out, I continued with the other.</p>
<p>The six sites come from five repositories. Looking at them side by side made one thing clear: Stitch responds much better when it receives a concrete metaphor, the audience, and what the product needs to communicate. ai-memory became a memory archive. ai-jail became a cinematic prison. AI Lair adopted a 1950s and 1960s world&rsquo;s fair, drawn from my book cover. The newsletter became a late-night broadcast, and this blog became an editorial archive. Five different identities instead of five variations of Claude&rsquo;s same gradient.</p>
<p>The pretty prototype is the easy part. The job only ends when the visual language reaches internal pages, the article, the book reader, email, legal pages, error states, and mobile. That&rsquo;s what the agents did inside the repositories. On ai-memory, for example, verification covered 102 pages in six languages, light and dark themes, desktop and mobile. On The M.Akita Chronicles, the same design had to work across signup, archive, newsletter detail, podcast, and the email template. A Stitch mockup solves none of that by itself.</p>
<p>Each site went through several rounds of prototypes. Some looked awful. Others had one good idea surrounded by bad decisions. I told the agents to throw them away, preserve one part, mix it with another attempt, and start over. Code is cheap now, so there is no reason to fall in love with the first version.</p>
<p>The result won&rsquo;t win a design award. A professional designer would still find hundreds of things to improve. I know I could take it further by spending a few more days. It looks much better than it did, though, and for now it is competent enough.</p>
<p>Six existing sites redesigned, responsive, with SEO, image generation and replacement, HTML, CSS, and JavaScript adjustments, testing, and production deployment. One person. About a day. A year ago, that package would have taken weeks or months.</p>
<p>Keep the scale in perspective: these are small sites, mostly landing pages and content pages. Nobody is rebuilding Amazon.com&rsquo;s entire collection of pages and systems in a day. The experiment shows how much repetitive work has already moved out of the way within a scope that one experienced person can understand and review. Large projects remain large, full of private rules, old integrations, strange states, and decisions that do not fit in a prompt.</p>
<h2>Even the first proofreading pass can be automated<span class="hx:absolute hx:-mt-20" id="even-the-first-proofreading-pass-can-be-automated"></span>
    <a href="#even-the-first-proofreading-pass-can-be-automated" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>As part of the same process, I had agents review the sites as different readers. This wasn&rsquo;t grammar correction. I wanted to know what someone would understand in the first few seconds, where they would get lost, which promise would sound suspicious, and whether they could complete a task without already knowing the product.</p>
<p>On ai-jail, I used two isolated sessions. One played a skeptical engineer who approves every prompt and had never heard of the project. The other was a non-engineer managing eight developers, trying to decide whether to approve the tool for the company. On ai-memory, I used three: an engineer learning AI tools, a manager evaluating a pilot, and a parent with no agent experience who worried about AGI and jobs. Each one read the site without seeing the others&rsquo; reports.</p>
<p>Anyone can do the same thing. Run the application locally or open a preview deployment, start fresh sessions, and give each agent a concrete task. A basic prompt can look like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Open this site on desktop and mobile: [URL].
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">You are [persona], know [context], and have never seen this product.
</span></span><span class="line"><span class="cl">Your goal is [specific decision or task].
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Navigate as this person. Do not read code or internal documentation first.
</span></span><span class="line"><span class="cl">Record:
</span></span><span class="line"><span class="cl">- what you understood in the first ten seconds;
</span></span><span class="line"><span class="cl">- what you think the product does and who it is for;
</span></span><span class="line"><span class="cl">- where you did not know the next step;
</span></span><span class="line"><span class="cl">- contradictory, absolute, or hard-to-believe promises;
</span></span><span class="line"><span class="cl">- terms the site uses before explaining them;
</span></span><span class="line"><span class="cl">- reading, keyboard, contrast, touch, and responsiveness problems;
</span></span><span class="line"><span class="cl">- what would stop you from trying, buying, or approving it.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">For every finding, show the page and visible evidence.
</span></span><span class="line"><span class="cl">Separate factual bugs, usability problems, and subjective preferences.
</span></span><span class="line"><span class="cl">Do not propose a broad redesign. Suggest the smallest change that solves the problem.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I prefer three independent sessions over an agent roundtable. Once one reads another&rsquo;s report, it starts agreeing by inertia. A separate session can then consolidate repeated points, discard matters of taste, and check every claim against the code and product documentation. A review can flag a sentence as wrong; it has no authority to invent the right one.</p>
<p>The most useful results tend to fall into four groups: positioning, information order, path to the first action, and trust. Traditional automated tests still handle broken links, contrast, accessibility, overflow, and error states. The persona tells you that a technically accessible button appeared five screens below where the person needed it.</p>
<p>During the <a href="https://github.com/akitaonrails/ai-jail-web/blob/main/docs/research/PROOFREAD-2026-10-15-BRIEF.md"target="_blank" rel="noopener">ai-jail proofreading</a>, both profiles agreed that the site took too long to show a concrete risk and started speaking like engineering documentation too early. The threat of a poisoned page moved directly below the hero. New sections explained &ldquo;What changes on day one&rdquo; and &ldquo;For the person who must approve this,&rdquo; including operating cost, limitations, and the keys a company actually exposes. The review also caught a technically incorrect claim repeated across several pages: the network does not stay entirely off by default. The agent&rsquo;s own API host and, on Linux, registries remain accessible. This kind of reading catches documentation bugs too.</p>
<p>The <a href="https://github.com/akitaonrails/ai-memory-web/blob/main/docs/research/persona-review.md"target="_blank" rel="noopener">ai-memory report</a> found permissions described in contradictory ways, privacy promises that were too absolute, and the basic explanation buried under advanced features. During the second round, the profiles noticed that remote-access instructions told users to use HTTP before requiring TLS, that an unauthenticated <code>curl</code> promised the wrong response, and that important choices appeared far too late on mobile. The collection guide moved from about 4,364 pixels from the top to 1,617; the individual and team paths moved from 5,300 to 2,489. That&rsquo;s an objective improvement born from simulated reading.</p>
<p>In the <a href="https://github.com/akitaonrails/ai-toolkit-web/pull/4"target="_blank" rel="noopener">AI Lair redesign</a>, the new-reader review reorganized the entry path. A <a href="https://github.com/akitaonrails/ai-toolkit-web/commit/c2a5237e876f662a8b53450f23f0162fdc1d4a76"target="_blank" rel="noopener">later revision</a> made it explicit that my setup with multiple agents and two monitors is a mature configuration, not a requirement for starting. One person, one project, and one agent are enough. It also produced a pilot plan for managers: a reversible task, non-sensitive data, an acceptance criterion, deadline, budget, and measurement. The next audit found invalid ARIA and an animation that hid the opening copy from assistive technologies; the <a href="https://github.com/akitaonrails/ai-toolkit-web/pull/5"target="_blank" rel="noopener">fix</a> took axe-core from four serious violations to zero.</p>
<p>Simulated personas still fall far short of human feedback. They carry the models&rsquo; same biases, may agree with the prompt, never feel the irritation of someone who lost twenty minutes, and never take the risk of entering their own credit card. The reports serve as hypotheses. A real user will always find the case that I, the agent, and the test suite failed to imagine.</p>
<p>Before the first user exists, however, the alternative is usually no review at all. Two or three independent readings already catch contradictory promises, unexplained jargon, buried information, and a flow that only makes sense to its author. These readings cost little and make the product less stupid before it reaches actual people.</p>
<h2>HTML was never programming<span class="hx:absolute hx:-mt-20" id="html-was-never-programming"></span>
    <a href="#html-was-never-programming" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For years, I watched people perform some remarkable mental gymnastics to argue that &ldquo;HTML is a programming language&rdquo; and therefore anyone who writes HTML is a programmer.</p>
<p>HTML is markup: it describes a document&rsquo;s structure. CSS describes presentation. That has value, requires knowledge, and can become terribly complex. Accidental complexity does not turn markup into programming.</p>
<p>There is no shame in that. I started building sites in the 1990s. I received a Photoshop layout and turned it into HTML. I sliced images, built tables, adjusted pixels, opened the result in multiple browsers, went back, and tried again. It was honest work and paid my bills.</p>
<p>It was also basically mechanical work: copy, paste, adjust, look, repeat. Exactly the category computers should automate.</p>
<p>Today I still chose the visual direction, rejected bad proposals, decided what each site needed to communicate, and recognized when the result was good enough. The agents handled the grunt work. This should have happened years ago.</p>
<h2>The bubble delayed automation<span class="hx:absolute hx:-mt-20" id="the-bubble-delayed-automation"></span>
    <a href="#the-bubble-delayed-automation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For much of the 2010s, <a href="https://fred.stlouisfed.org/series/EFFR"target="_blank" rel="noopener">US interest rates stayed near zero</a>. Cheap money flooded funds, startups, and large companies. Hiring more people was easy, and the market absorbed any amount of manual labor disguised as engineering.</p>
<p>Low rates explain only part of tech&rsquo;s expansion. A field breaking hiring records has little incentive to eliminate its own repetitive work. If you can put fifty more people to work assembling screens, you put fifty more people to work assembling screens.</p>
<p>Anyone who attended my talks at tech events in the 2010s may remember <strong>Constraint == Innovation</strong>. Yes, the original title used two equal signs. I gave that talk at DevConf 2019, the <a href="https://www.slideshare.net/slideshow/devconf-2019-so-carlos/143923367"target="_blank" rel="noopener">83 slides are on my SlideShare</a>, and I had already written the same argument in 2013 in <a href="https://startupi.com.br/restricoes-sao-libertadoras-menos-e-mais/"target="_blank" rel="noopener">&ldquo;Constraints are liberating: less is more&rdquo;</a>.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/V0D3PuTLOZ8"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>It&rsquo;s the old maxim: <strong>necessity is the mother of invention</strong>. Constraints force choices. Choices force priorities. When the conventional path cannot fit the money, deadline, or available capacity, someone has to find another path. That&rsquo;s where real innovation often comes from.</p>
<p>Money funds expensive research, buys equipment, and gives people time to experiment. Excess money without pressure for results does something else: it hides inefficiency with brute force. There is no need to automate, simplify, or discover a better solution while you can keep hiring and growing the spreadsheet. Too much abundance turns a technical problem into an operating expense and postpones the difficult decision.</p>
<p>I <a href="/en/2026/02/08/rant-ai-killed-programmers/">repeated this warning at the beginning of 2026</a>: the programming bubble managed to replace efficiency with volume. Constraints returned when cheap money ended. Coincidentally, agents arrived at the same time and gave companies a practical way to automate the mechanical work they had spent a decade scaling through hiring.</p>
<p>That&rsquo;s how we spent a decade recreating the same admin dashboard and calling it innovation. Every week brought the next front-end savior: React, Angular, Vue, Svelte, another <em>bundler</em>, another framework on top of the framework. They are useful tools in the right context, but none addressed the obvious question: why is a human still hand-writing the thousandth variation of the same form?</p>
<p>The hiring bubble burst in 2022 and ended the party. At the end of that year, ChatGPT brought language models to the public. GPT-2, just to keep the chronology straight, had come out in 2019. Four years after ChatGPT, agentic programming became a normal work tool.</p>
<p>Any task that consists of copying a pattern, adapting an example, running it, looking at the error, and trying again is already within these agents&rsquo; reach. Front-end is the most visible target because it accumulated an absurd amount of this work. The rest of programming has no mystical shield. Plenty of back-end work is CRUD, glue between APIs, and repetitive configuration. That will be automated too.</p>
<p>Fine by me. Programming was never about typing code. Programming is making a computer do something useful.</p>
<h2>VS Code is the new punch card<span class="hx:absolute hx:-mt-20" id="vs-code-is-the-new-punch-card"></span>
    <a href="#vs-code-is-the-new-punch-card" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I wrote about exactly this in April in <a href="/en/2026/04/11/vs-code-is-the-new-punch-card/">&ldquo;VS Code Is the New Punch Card&rdquo;</a>.</p>
<p>For decades, the interface between intention and computer kept changing. First you connected wires and switches. Then you punched cards. Then you wrote Assembly, C, high-level languages, frameworks, and configuration. Each generation looked at the previous one and automated another layer of manual work.</p>
<p>No sane person would argue today that operating a card punch was the essence of computer science. It was the available interface. Typing every tag and CSS rule into VS Code was also the available interface.</p>
<p>Now I describe the intention, show references, evaluate the result, and tell the agent to fix it. I still need to understand browsers, networks, caching, accessibility, semantics, responsiveness, and SEO. Without the fundamentals, I couldn&rsquo;t tell whether the agent delivered a solution or a pretty time bomb. The keyboard lost importance; judgment gained it.</p>
<p>Today, every Tier A model I test, from Claude to Kimi, can reliably execute the mechanical front-end work that appeared in these six projects. I can say without hesitation that any front-end task that merely turns an already-made decision into code is automatable. They needed direction to create the six designs. None required me to hand-write the flexbox, breakpoint, or component I had already explained.</p>
<p>That covers an enormous share of the work while leaving the most valuable part. Someone still has to know the framework&rsquo;s tradeoffs, understand the browser, choose the architecture, notice when a library is being forced against its design, and find out why an apparently correct solution breaks under a particular combination of device, language, state, and real data. The agent writes the code; an experienced professional provides direction, limits the damage, and owns the result.</p>
<p>Front-end still exists. The excess mechanical work and the people hired only to follow a finished recipe lose value. A great front-end professional can direct agents and deliver far more than they could alone. A mediocre professional who spent years mistaking component typing for engineering will compete for fewer openings. The market gets smaller and more selective, with its best professionals producing more. The goal remains what it always was: get close to the top, where judgment and experience still make a difference.</p>
<p>And it doesn&rsquo;t stop at the browser. <a href="/en/2026/10/11/are-there-better-languages-for-programming-with-llms/">Pick the framework</a>: Android with Flutter or Kotlin, iOS with Swift and SwiftUI, desktop with Rust/Tauri, Flutter, C#, or Electron. Modern agents generally know the components, build tools, tests, and peculiarities of these platforms. They still need direction, feedback, and verification, just as they do on the web front-end. The mechanical part is already automatable.</p>
<p>I tested this in my own small open-source projects. <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">FrankYomik</a> has a Flutter client for Android and Linux, plus the server and extension. <a href="https://github.com/akitaonrails/FrankSherlock"target="_blank" rel="noopener">Frank Sherlock</a> is a Rust/Tauri desktop app that produces an AppImage for Linux, a DMG for macOS, and a <code>.exe</code> installer for Windows. I also built <a href="https://github.com/akitaonrails/frank_karaoke"target="_blank" rel="noopener">Frank Karaoke</a> in Flutter for Android and <a href="https://github.com/akitaonrails/frank_mangaplus"target="_blank" rel="noopener">FRANK MANGA+</a> in Rust/Tauri for desktop. There are several others on <a href="https://github.com/akitaonrails?tab=repositories"target="_blank" rel="noopener">my GitHub</a>.</p>
<p>None came out perfect from the first prompt. Flutter and Tauri gave me trouble, especially on the early projects. Reliable means the agent can move forward, receive a correction, create tests, fix what broke, and reach a working application without making me take over the manual typing. After testing web, Android, and desktop across different stacks, I&rsquo;m very satisfied with the result. The pattern repeats: mechanical work became agent work.</p>
<p>At two in the morning, I had agents making the final adjustments to this blog without losing pace. That&rsquo;s what I need.</p>
<h2>When the web still didn&rsquo;t know what it was<span class="hx:absolute hx:-mt-20" id="when-the-web-still-didnt-know-what-it-was"></span>
    <a href="#when-the-web-still-didnt-know-what-it-was" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At the peak of the dot-com bubble, career designers were creating websites. The web had not yet decided how a page should work, so there was real room to experiment. It was often ugly and almost always heavy, but actual creation was happening.</p>
<p>The iPhone opened another window in 2007. Suddenly everything had to fit on a small screen, respond to touch, and work without a mouse. Years of experimentation followed before the market settled on a reasonably stable grammar for navigation, gestures, forms, and content.</p>
<p>Today nobody needs to rediscover a menu, a list, a checkout, or an article page. The patterns are documented, millions of examples are public, and models have seen more interfaces than any person could study in a lifetime.</p>
<p>The most valuable work remains: understanding the product, the person who will use it, the constraints, and what deserves to appear first. Converting a finished decision into hundreds of predictable lines has become a perfect candidate for automation.</p>
<h2>Jobs disappear. Others appear<span class="hx:absolute hx:-mt-20" id="jobs-disappear-others-appear"></span>
    <a href="#jobs-disappear-others-appear" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The world has never stopped to preserve a job function.</p>
<p>Punch-card operators entered data and programs on paper cards. Telephone operators physically connected calls. Typists kept offices running. Linotype operators cast lines of hot metal to print newspapers. Technology reduced or eliminated these occupations at scale. The Bureau of Labor Statistics&rsquo; <a href="https://www.bls.gov/emp/tables/fastest-declining-occupations.htm"target="_blank" rel="noopener">current list of fastest-declining occupations</a> still includes data entry keyers, typists, and telephone operators. Nobody proposes bringing them back to protect jobs.</p>
<p>At the same time, jobs appeared that a person in 1926 could not even name: mobile developer, SRE, experience designer, Internet video creator, cloud specialist, machine learning engineer. The web itself killed old work and opened an entire industry.</p>
<p>The real story is messier than the caricature. The <a href="https://www.ilo.org/publications/generative-ai-and-jobs-refined-global-index-occupational-exposure"target="_blank" rel="noopener">ILO shows that technology tends to automate tasks without necessarily eliminating entire occupations</a>. Some functions shrink, others change, and new jobs appear. That statistical consolation does nothing for the person who became obsolete today. The individual has to react before the economic average reacts for them.</p>
<p>Compare 2026 with 1926, three years before the crash of 1929. It looks like another planet, even though a century is very little on history&rsquo;s scale. No contract guarantees that your current function will survive the next ten years, much less the next forty.</p>
<h2>How does a junior gain experience now?<span class="hx:absolute hx:-mt-20" id="how-does-a-junior-gain-experience-now"></span>
    <a href="#how-does-a-junior-gain-experience-now" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It will get harder. The old ladder began with someone paying a beginner to assemble simple screens, fix CSS, and copy components. An agent does much of that faster and cheaper. No company has an obligation to preserve inefficient work as a training program.</p>
<p>Beginners will have to build their own ladder with real projects and problems they are responsible for. That includes reading the code the agent generated, breaking and repairing it, studying documentation and source code, writing tests, and contributing to open projects. An agent gives any student access to an amount of practice that once required a team. Anyone who uses that access only to generate code they do not understand will remain a junior for a long time.</p>
<p>The market will reward people who make that extra effort. It always has. When someone asks how a junior can gain experience without the easy tasks, the hidden question is often different: &ldquo;how do I get a better salary without doing the hard work of getting good?&rdquo; The easy position, with rising pay and little pressure, was a distortion of the bubble. Adapting includes accepting that entry has become more competitive.</p>
<h2>My career never stayed in one place<span class="hx:absolute hx:-mt-20" id="my-career-never-stayed-in-one-place"></span>
    <a href="#my-career-never-stayed-in-one-place" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="/2023/02/14/akitando-138-meu-comeco-de-carreira-durante-a-bolha-1997-a-2002-bug-do-milenio-crash/">I started with multimedia and static websites</a>. Then I worked in a digital agency, enterprise consulting, SAP integration, startups, video, education, and now AI. In my <a href="/2018/12/26/akitando-34-voce-nao-sabe-nada-de-enterprise-conhecendo-a-sap/">Akitando episode about SAP</a>, I tell part of that phase when I had to learn a world completely different from what I had done before.</p>
<p>I lived through the dot-com crash, the 2008 crisis, and the end of the zero-rate bubble. I survived because I never treated my current function as a permanent identity. I kept studying, gaining experience, and changing direction.</p>
<p>A productive career should not consist of repeating the same mechanical task for decades. If your value depends on the market continuing to pay someone to push tags and pixels by hand, the problem did not begin with AI. The problem was betting that the present would last forever.</p>
<p>Many people understand &ldquo;being prepared&rdquo; as studying the bare minimum required for today&rsquo;s open position. That&rsquo;s the fastest recipe for becoming obsolete. Do the short-term work, but study beyond it. Learn the things that seem useless today. Fundamentals, operating systems, networks, databases, business, economics, history. You don&rsquo;t know which combination will save your career five years from now.</p>
<p>I spent years talking about this on Akitando. Revisit the <a href="https://www.youtube.com/playlist?list=PLdsnXVqbHDUehzKjiRruy--gncHz9Injy"target="_blank" rel="noopener"><strong>I Warned You</strong></a> and <a href="https://www.youtube.com/playlist?list=PLdsnXVqbHDUd6wEL932R7dW9Zazt2oNWF"target="_blank" rel="noopener"><strong>Which course should I take?</strong></a> playlists. If you have to start with one video, start with the <strong>Definitive Guide to Learning How to Learn</strong>. No course can save someone who still outsources their own learning.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/oUPaJxk6TZ0"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>The world has no shortage of opportunity. Most people feel they never get one because they are always unprepared when it appears. Once the opportunity lands in front of you, it is too late to start preparing.</p>
<p>So I won&rsquo;t mourn the fact that this package of six small sites, which used to take weeks or months, fit into about a day. I&rsquo;m happy as hell. I gained time to think about what to build, experiment more, and throw away bad versions without wasting weeks of my life.</p>
<p>Today I watched a car spin in midair and land. In about a day, I put six sites with new designs into production without writing most of the HTML and CSS. The future I wanted did not arrive perfect, cheap, or evenly distributed. It arrived far enough for me to work with it.</p>
<p>Accept what ended. Study what comes next. Be ready before the next opportunity appears.</p>
]]></content:encoded><category>artificial-intelligence</category><category>frontend</category><category>automation</category><category>career</category></item><item><title>Talking a Bit About My AI Skills</title><link>https://akitaonrails.github.io/en/2026/09/17/talking-about-my-ai-skills/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/09/17/talking-about-my-ai-skills/</guid><pubDate>Thu, 17 Sep 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;Lately several readers have asked me about the &amp;ldquo;skills&amp;rdquo; I mention when I talk about my workflow with AI agents. This article explains what they are, how I use them, and what my actual recommendation is.&lt;/p&gt;
&lt;p&gt;And the recommendation comes right up front, because it&amp;rsquo;s the reason this article exists: &lt;strong&gt;don&amp;rsquo;t use other people&amp;rsquo;s skills.&lt;/strong&gt; Read that again. My skills are published on &lt;a href="https://github.com/akitaonrails/my-skills"target="_blank" rel="noopener"&gt;my my-skills repository&lt;/a&gt; and the repo description warns you: &lt;em&gt;&amp;ldquo;not tailored for general usage&amp;rdquo;&lt;/em&gt;. They&amp;rsquo;re there so you can read the reasoning, understand the structure, and go build your own. Copying somebody&amp;rsquo;s skill and running it blind is the digital equivalent of signing a contract without reading it. Worse: it&amp;rsquo;s installing someone else&amp;rsquo;s instructions to run with your permissions, on your repositories, with your tokens. That&amp;rsquo;s prompt injection you install voluntarily and say thank you for.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Lately several readers have asked me about the &ldquo;skills&rdquo; I mention when I talk about my workflow with AI agents. This article explains what they are, how I use them, and what my actual recommendation is.</p>
<p>And the recommendation comes right up front, because it&rsquo;s the reason this article exists: <strong>don&rsquo;t use other people&rsquo;s skills.</strong> Read that again. My skills are published on <a href="https://github.com/akitaonrails/my-skills"target="_blank" rel="noopener">my my-skills repository</a> and the repo description warns you: <em>&ldquo;not tailored for general usage&rdquo;</em>. They&rsquo;re there so you can read the reasoning, understand the structure, and go build your own. Copying somebody&rsquo;s skill and running it blind is the digital equivalent of signing a contract without reading it. Worse: it&rsquo;s installing someone else&rsquo;s instructions to run with your permissions, on your repositories, with your tokens. That&rsquo;s prompt injection you install voluntarily and say thank you for.</p>
<h2>What a skill really is<span class="hx:absolute hx:-mt-20" id="what-a-skill-really-is"></span>
    <a href="#what-a-skill-really-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No magic involved. A skill is a directory with a <code>SKILL.md</code> inside: a header with name and description, and a body of text that is, essentially, a well-written prompt about how to execute a task. Sometimes it ships with a <code>scripts/</code> folder of helper scripts, so the LLM doesn&rsquo;t have to recreate them from scratch every time.</p>
<blockquote>
  <p>Skills are just prompts.</p>

</blockquote>
<p>That&rsquo;s it. A prompt you versioned, refined, and kept one command away. The only difference between a skill and that giant prompt you paste into the chat every day is that the skill lives in a file, with a name, and the agent knows it exists.</p>
<p>And here&rsquo;s the answer to the question everybody asks: &ldquo;doesn&rsquo;t that fill the context with junk?&rdquo; The good news is the harnesses do this right. Claude Code uses what Anthropic calls <a href="https://code.claude.com/docs/en/skills"target="_blank" rel="noopener">progressive disclosure</a>: at session start, only each skill&rsquo;s name and description go into the system prompt. The <code>SKILL.md</code> body only loads when the agent decides to invoke the skill. <a href="https://opencode.ai/docs/skills/"target="_blank" rel="noopener">OpenCode does the same</a>: lists name and description, loads content on demand through its own tool. Anthropic itself <a href="https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills"target="_blank" rel="noopener">documents the design</a>: metadata first, body only when needed.</p>
<p>That solves the text cost, but only half the problem. Every installed skill still takes a line in the listing, and more importantly: it competes for the agent&rsquo;s attention when it decides which tool to use. Anthropic again, <a href="https://www.anthropic.com/engineering/writing-tools-for-agents"target="_blank" rel="noopener">on the engineering blog</a>: <em>&ldquo;more tools don&rsquo;t always lead to better outcomes&rdquo;</em>, and their official recommendation is a few well-designed tools for high-impact workflows. My version of that rule is more acidic:</p>
<blockquote>
  <p>An idle skill is debt.</p>

</blockquote>
<p>You change your process, the skill ages, and one day the agent executes a rule that no longer makes sense in your workflow. Fewer skills, better written, reviewed often.</p>
<h2>The only third-party skill that survived here<span class="hx:absolute hx:-mt-20" id="the-only-third-party-skill-that-survived-here"></span>
    <a href="#the-only-third-party-skill-that-survived-here" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Having said all that, it would be hypocritical to pretend I live on homemade skills alone. One survived my scrutiny and runs on every post on this blog: <a href="https://github.com/akitaonrails/my-skills/tree/master/humanizer"target="_blank" rel="noopener">Humanizer</a>.</p>
<p>It exists because the Wikipedia community maintains a public, brutally honest catalog called <a href="https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing"target="_blank" rel="noopener">Signs of AI writing</a>, kept by WikiProject AI Cleanup, documenting the &ldquo;tells&rdquo; of AI-generated text one by one: the rule of three, the &ldquo;not just X, but also Y&rdquo;, the puffery, the emoji-decorated bullet. Humanizer turns that catalog into a procedure: the agent receives the text, marks the tells from strongest to weakest, rewrites preserving every supported claim (losing a fact is an error, inventing a fact is an error), and does a final read-aloud check. Without changing what was said, only how it was said.</p>
<p>If you produce content, you know why I insist: text that smells like AI is becoming the new &ldquo;Dear Sir&rdquo;, the mark of a rushed form letter. I run Humanizer on every article before publishing, including <a href="https://akitaonrails.com/en/2026/08/16/anthropic-ai-watermark-how-to-beat-it/"target="_blank" rel="noopener">this one</a>. But note the detail: I merged version 3.0.0 into my repo, with my own adjustments. I read the source material before trusting it. That&rsquo;s the pattern.</p>
<h2>What I do with this every day<span class="hx:absolute hx:-mt-20" id="what-i-do-with-this-every-day"></span>
    <a href="#what-i-do-with-this-every-day" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the practical part. I maintain and monitor over 40 repositories on GitHub. My own projects, forks I maintain, tools that became daily dependencies. To stay sane, my second monitor runs <a href="https://github.com/akitaonrails/clock-tui"target="_blank" rel="noopener">tclock</a> (my fork of <a href="https://github.com/race604/clock-tui"target="_blank" rel="noopener">clock-tui</a>, with Evangelion themes, because why not) and next to it <a href="https://github.com/akitaonrails/ghpending"target="_blank" rel="noopener">ghpending</a>, a Rust CLI that lists open issues and PRs from all my repos at once.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260917161027_screenshot-2026-09-17_16-10-27.png" alt="My second monitor with tclock and ghpending listing the repositories with open PRs and issues"  loading="lazy" /></p>
<p>In the photo you can see: <a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener">ai-memory</a> and <a href="https://github.com/akitaonrails/ai-usagebar"target="_blank" rel="noopener">ai-usagebar</a> with open PRs and issues. Every morning I open the harness on each project with <code>ai-memory run</code> in its directory. ai-memory deserves a paragraph: it&rsquo;s my long-term memory project for agents, a persistent Markdown wiki that captures session observations, consolidates decisions and gotchas into pages, keeps workstreams across sessions and handoffs between agents. Every project of mine has its own memory, with the architectural decisions and the traps only that codebase has. Without it, every agent session starts from zero like an intern with amnesia.</p>
<p>From there the daily ritual is almost childishly simple. I write, literally:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">run pr-audit and iss-audit, then run github-resolution</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When a release is ripe, I add <code>then run the release skill</code>. On days when the only queue is Dependabot&rsquo;s automatic bumps, it&rsquo;s <code>run pr-bump and release</code> and done. One line of instruction from me. The heavy lifting lives in the skills. Let&rsquo;s get to them.</p>
<h3>pr-audit<span class="hx:absolute hx:-mt-20" id="pr-audit"></span>
    <a href="#pr-audit" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>PR audit before merge, and the central principle is: <strong>evidence over narrative</strong>. Nothing the contributor wrote is treated as truth; everything is a claim to verify, with a claims ledger (confirmed, partial, unsupported). The rest of the skill is a hostile checklist:</p>
<ul>
<li><strong>Change gate</strong>: hunk inventory looking for executable bits, symlinks, bidirectional characters, homoglyphs, <code>pull_request_target</code> workflows, unpinned actions, typosquat dependencies, lockfile drift.</li>
<li><strong>Credentials</strong>: unexplained credential access is an instant blocker. Execution happens in an isolated worktree, with no host credentials.</li>
<li><strong>Semver</strong>: the changelog category is a version gate; breaking changes go in major, never in patch. A rule that has saved me more than once.</li>
<li><strong>Prompt injection</strong>: text inside a PR trying to &ldquo;change the audit rules&rdquo; is just one more suspicious data point to investigate. It&rsquo;s an audit finding, obviously.</li>
</ul>
<h3>iss-audit<span class="hx:absolute hx:-mt-20" id="iss-audit"></span>
    <a href="#iss-audit" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The same distrust, applied to issues:</p>
<ul>
<li>Never run a command copied from an issue. Attachments only go through an isolated scanner.</li>
<li>Formal separation between what was observed, what was expected, the reporter&rsquo;s diagnosis, and the proposed fix.</li>
<li>Reproduction with synthetic data in a disposable sandbox, with resource limits for DoS claims.</li>
</ul>
<p>And two golden rules I love: <em>&ldquo;a number that moves is not a pinned number&rdquo;</em> (async lag looks like a bug) and <em>&ldquo;the obvious owner may be innocent&rdquo;</em> (query the suspect population; if it comes back empty, the cause is elsewhere).</p>
<h3>github-resolution<span class="hx:absolute hx:-mt-20" id="github-resolution"></span>
    <a href="#github-resolution" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The execution phase: turning what the audit approved into merged code. The rules:</p>
<ul>
<li>Only execute approved tickets, one at a time, with a regression test written before the fix. <em>&ldquo;Slop is a defect&rdquo;</em>: no speculative abstraction, no TODOs, no drive-by refactors, no weakened tests.</li>
<li>If I resolved more than three tickets in a session, run the post-audit over the whole range before pushing, no exceptions.</li>
<li>A ticket closes when the fix is merged with green CI on that exact SHA, never left hanging waiting for a release.</li>
<li>Every unresolved issue gets logged with the reason. Compressing it into a number is forbidden.</li>
</ul>
<h3>pr-bump<span class="hx:absolute hx:-mt-20" id="pr-bump"></span>
    <a href="#pr-bump" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The fast path for Dependabot&rsquo;s dependency bumps. The rule that saves me the most time: <strong>batch, never serialize</strong>. Instead of merging PR by PR, one consolidated <code>bundle update</code> to the latest compatible versions, a single CI pass for the whole set, fix forward on whatever breaks.</p>
<p>But there&rsquo;s a <strong>supply-chain floor</strong>:</p>
<ul>
<li>Every dependency must resolve on the public registry (rubygems, npm, pypi) with the expected name, version, and checksum.</li>
<li>Git source, path source, typosquat-adjacent name, new install hook: full stop, it goes to a complete audit.</li>
</ul>
<p><code>bundler-audit</code> will happily pass a well-formed trojanized gem. That&rsquo;s exactly the case the floor covers.</p>
<h3>release<span class="hx:absolute hx:-mt-20" id="release"></span>
    <a href="#release" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Cut a version only when I ask. The iron rules:</p>
<ul>
<li>The version comes from the changelog classification: fixes go in patch, additive changes in minor, breaking in major. A number that conflicts with the classification forces me to confirm; silent bumps are forbidden.</li>
<li>Green CI gate on the tag&rsquo;s exact SHA. Never tag with pending or skipped CI.</li>
<li>Annotated tag, updated changelog.</li>
<li>Never rewrite a published tag. Never cut a release nobody asked for.</li>
</ul>
<h2>The numbers, because numbers don&rsquo;t argue<span class="hx:absolute hx:-mt-20" id="the-numbers-because-numbers-dont-argue"></span>
    <a href="#the-numbers-because-numbers-dont-argue" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Counting right now, on the day I write this article, across just three projects (<a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener">ai-memory</a>, <a href="https://github.com/akitaonrails/ai-usagebar"target="_blank" rel="noopener">ai-usagebar</a> and <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a>):</p>
<ul>
<li>ai-memory: 430 merged PRs, 260 closed issues</li>
<li>ai-usagebar: 137 merged PRs, 34 closed issues</li>
<li>ai-jail: 42 merged PRs, 81 closed issues</li>
</ul>
<p>Total: <strong>609 merged PRs and 375 closed issues</strong>, over a thousand items. Nearly all of that was handled by these skills, with me supervising and deciding. One person, no team, keeping three active projects at this volume. That&rsquo;s the multiplier.</p>
<h2>And they weren&rsquo;t born ready<span class="hx:absolute hx:-mt-20" id="and-they-werent-born-ready"></span>
    <a href="#and-they-werent-born-ready" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Open the <a href="https://github.com/akitaonrails/my-skills/commits/master"target="_blank" rel="noopener">my-skills commit history</a> and you see skills being polished the hard way. July starts the repo with the basic audits. Late July: <em>&ldquo;harden audit skills and add security audit&rdquo;</em>. August: <em>&ldquo;consolidate agent skills from all harnesses into this repo&rdquo;</em> and, days later, github-resolution is born as the execution phase of the audits. September: <em>&ldquo;fold in release-discipline and audit lessons&rdquo;</em>.</p>
<p>And just yesterday, look at the level of fine-tuning in pr-bump: <em>&ldquo;batch all bump PRs into one consolidated verification pass, fix forward on failures&rdquo;</em> and <em>&ldquo;require deferred/declined PRs be left in a clean state&rdquo;</em>. Two rules that became skill text. Neither came from a blog post or somebody&rsquo;s opinion: they came from me face-planting into the exact error the rule now prevents.</p>
<blockquote>
  <p>Every line in these skills is a scar.</p>

</blockquote>
<p>That&rsquo;s how you write a good prompt: watch the result, decide to adjust, adjust again.</p>
<h2>Skills are little. Knowledge is a lot.<span class="hx:absolute hx:-mt-20" id="skills-are-little-knowledge-is-a-lot"></span>
    <a href="#skills-are-little-knowledge-is-a-lot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the counterpoint, because I don&rsquo;t want you leaving here thinking skills are the answer to everything. I depend on them very little. What actually carries my workflow is something else.</p>
<p>Every project of mine has its own <code>AGENTS.md</code>, instructions specific to that code, and its ai-memory memory with decisions and gotchas. And the pattern I use most day to day isn&rsquo;t even a skill: it&rsquo;s pointing at a living example. When I wanted automated AUR packaging for clock-tui, I didn&rsquo;t write a &ldquo;how to make an AUR package&rdquo; skill. I opened the harness and said:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">make a similar github action to build and publish AUR packages
</span></span><span class="line"><span class="cl">as we did in ~/Projects/ai-memory</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The agent went there, read the workflow that works, and reproduced the pattern in the new project. A skill for that would be worse: AUR changes, GitHub Actions changes, the example gets stale. A living example in a repo I maintain is always up to date by definition, because when it breaks, I fix it.</p>
<blockquote>
  <p>Every project becomes its own knowledge base.</p>

</blockquote>
<p>And this generalizes, because harnesses can consume any text base. My entire Linux PC is documented and reinstallable from a config repo of mine, and my homeserver is fully described in runbooks. Those two are not on GitHub: they live on a private Gitea server I maintain, because that&rsquo;s the kind of thing I don&rsquo;t want leaking in public. When I want to change something at home, I say:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">add integration to my Plex server as documented in my homelab runbooks</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That&rsquo;s not a skill. It&rsquo;s organized, versioned, searchable knowledge. Skills are for <em>procedures</em> that repeat the same way. Knowledge is for everything else.</p>
<h2>The recommendation worth more than any skill<span class="hx:absolute hx:-mt-20" id="the-recommendation-worth-more-than-any-skill"></span>
    <a href="#the-recommendation-worth-more-than-any-skill" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you take a single practice from this article, take this one: <strong>have your agent write down everything that didn&rsquo;t become code.</strong> The research it did, the decision you made and why, the comparison with the rejected alternative. Without that, the knowledge evaporates at the end of the session.</p>
<p>My ai-memory, for instance, has <a href="https://github.com/akitaonrails/ai-memory/tree/main/docs"target="_blank" rel="noopener">a docs folder</a> with competitor research I commissioned before every big decision:</p>
<ul>
<li><a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-karpathy-llm-wiki.md"target="_blank" rel="noopener">The Karpathy wiki pattern</a>, the project&rsquo;s intellectual origin.</li>
<li><a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-2026-landscape.md"target="_blank" rel="noopener">A 2026 landscape survey</a>, covering Zep/Graphiti, Letta, Mem0, and Google&rsquo;s Open Knowledge Format.</li>
<li>Deep dives on each competitor: <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-hindsight.md"target="_blank" rel="noopener">Hindsight</a> (the one with a paper and funding, which ended up validating my design bets), <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-cognee.md"target="_blank" rel="noopener">cognee</a> (pretty graph pipeline, ugly gateway bug), <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-agentmemory.md"target="_blank" rel="noopener">agentmemory</a> (I kept the ideas, threw out the substrate), <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-basic-memory.md"target="_blank" rel="noopener">basic-memory</a>.</li>
<li>And my favorite, the <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/issues-mempalace.md"target="_blank" rel="noopener">MemPalace</a> autopsy: 47k stars, viral, and a tracker clogged with index corruption and lost data. The fairy tale in reverse that confirmed for me why compiling memory beats stockpiling verbatim.</li>
</ul>
<p>Dozens of documents like that. Every hour of research an agent did for me became a permanent asset, reusable in any new project. The more you produce and document, the more intelligence you accumulate.</p>
<blockquote>
  <p>It&rsquo;s compound interest for knowledge.</p>

</blockquote>
<h2>Nobody knows what you need<span class="hx:absolute hx:-mt-20" id="nobody-knows-what-you-need"></span>
    <a href="#nobody-knows-what-you-need" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And there the circle closes, back to where we started: why I don&rsquo;t recommend copying anybody&rsquo;s skills, mine included.</p>
<p>Because my PR audit skill is molded around <em>my</em> projects, <em>my</em> languages, <em>my</em> risks, <em>my</em> level of supply-chain paranoia. It reflects what I, Fabio, maintaining the repositories I maintain, on the infra I have, with the goals I have, needed to write into it. Your project has another stack, other risks, another tolerance level. You may need checks I don&rsquo;t, and vice versa. Experience is only relevant to whoever lived through its constraints. That changes from person to person, and that&rsquo;s exactly why a third-party skill used blind is a gamble.</p>
<p>That&rsquo;s the thesis of my 2019 video, <a href="/2019/10/09/akitando-63-nao-terceirize-suas-decisoes-a-licao-mais-importante-da-sua-vida/">Don&rsquo;t Outsource Your Decisions: the most important lesson of your life</a> (in Portuguese): nobody but you knows what you need.</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/D3L8IOncLkg"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>I don&rsquo;t ask anybody. I research, I build mine, and the skills are the versioned record of my decisions. The beauty of LLMs is they speed this process up tenfold: the research that took a week now takes an afternoon, and the cost of building your own tool fell through the floor.</p>
<p>A good skill is your own. Mine are on <a href="https://github.com/akitaonrails/my-skills"target="_blank" rel="noopener">my-skills</a> so you can read the reasoning. Then close the tab and write yours. I don&rsquo;t waste time asking for other people&rsquo;s opinions. Neither should you.</p>
]]></content:encoded><category>coding-agents</category><category>automation</category><category>llms</category></item><item><title>Backing Up Gmail to Maildir on Linux</title><link>https://akitaonrails.github.io/en/2026/05/28/backing-up-gmail-to-maildir-on-linux/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/28/backing-up-gmail-to-maildir-on-linux/</guid><pubDate>Thu, 28 May 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;Most people just trust Google and leave everything there. Anyone who follows this blog knows I don&amp;rsquo;t trust anyone: if it&amp;rsquo;s in the cloud, it&amp;rsquo;s not mine. That&amp;rsquo;s why I keep a NAS at home to back up everything that matters. I don&amp;rsquo;t trust Google Drive, OneDrive, Dropbox, or Gmail.&lt;/p&gt;
&lt;p&gt;Email has always been the most annoying piece of this puzzle. My old workflow was to open Thunderbird, let it pull everything from Gmail over IMAP, and back up the &lt;code&gt;~/.thunderbird&lt;/code&gt; directory. Works, but it&amp;rsquo;s manual, tedious (remembering to open Thunderbird every so often), and locked to Thunderbird itself: if I want to read those emails in another client, they&amp;rsquo;re sitting in some proprietary internal format.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Most people just trust Google and leave everything there. Anyone who follows this blog knows I don&rsquo;t trust anyone: if it&rsquo;s in the cloud, it&rsquo;s not mine. That&rsquo;s why I keep a NAS at home to back up everything that matters. I don&rsquo;t trust Google Drive, OneDrive, Dropbox, or Gmail.</p>
<p>Email has always been the most annoying piece of this puzzle. My old workflow was to open Thunderbird, let it pull everything from Gmail over IMAP, and back up the <code>~/.thunderbird</code> directory. Works, but it&rsquo;s manual, tedious (remembering to open Thunderbird every so often), and locked to Thunderbird itself: if I want to read those emails in another client, they&rsquo;re sitting in some proprietary internal format.</p>
<p>After a while it clicked: Linux has had native email support forever. The modern protocols (IMAP, SMTP) are open standards. And the local storage format, <strong>Maildir</strong>, has been around and working well since the 90s. Thunderbird isn&rsquo;t needed for any of this.</p>
<h2>What is Maildir<span class="hx:absolute hx:-mt-20" id="what-is-maildir"></span>
    <a href="#what-is-maildir" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Maildir was created by Daniel J. Bernstein (djb) as part of qmail in 1995. The idea was to fix the file-locking pain of the mbox format, where the entire mailbox is a single file and two processes writing at the same time could corrupt the whole thing. In Maildir, <strong>each message becomes a separate file</strong> inside a three-subdirectory structure:</p>
<ul>
<li><code>tmp/</code> — where the email is written while being delivered</li>
<li><code>new/</code> — newly arrived messages, not yet seen</li>
<li><code>cur/</code> — messages the client has already seen, with flags encoded in the filename (<code>:2,S</code> = seen, <code>:2,F</code> = flagged, etc.)</li>
</ul>
<p>Delivery is atomic: the MTA writes the message into <code>tmp/</code>, then does a <code>rename()</code> into <code>new/</code>. <code>rename()</code> on a POSIX filesystem is atomic, so there&rsquo;s no corruption window. No locking, no corruption, and every email is just a plain-text file you can read with <code>cat</code>, index with <code>grep</code>, or version with git if you feel like it.</p>
<p>Thirty years later, it&rsquo;s still the default local format for most Linux email clients. And it&rsquo;s the format I&rsquo;ll use to mirror Gmail.</p>
<h2>The tool: mbsync (isync)<span class="hx:absolute hx:-mt-20" id="the-tool-mbsync-isync"></span>
    <a href="#the-tool-mbsync-isync" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The bridge between Gmail&rsquo;s IMAP and the local Maildir is <a href="https://isync.sourceforge.io/"target="_blank" rel="noopener"><code>mbsync</code></a>, the binary in the <code>isync</code> package. It reads a simple config (<code>~/.config/isyncrc</code>), connects to Gmail&rsquo;s IMAP, and copies the messages to disk. What makes it different from <code>offlineimap</code> (the better-known alternative) is that <code>mbsync</code> is written in C, it&rsquo;s significantly faster on large mailboxes, and it has fewer weird edge cases with Gmail.</p>
<p>The config is straightforward. Here&rsquo;s a sketch of what my <code>~/.config/isyncrc</code> looks like (no password, obviously):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">IMAPAccount my-account</span>
</span></span><span class="line"><span class="cl"><span class="na">Host imap.gmail.com</span>
</span></span><span class="line"><span class="cl"><span class="na">User you@example.com</span>
</span></span><span class="line"><span class="cl"><span class="na">PassCmd &#34;/home/akitaonrails/.config/mbsync/bin/mbsync-password you@example.com&#34;</span>
</span></span><span class="line"><span class="cl"><span class="na">TLSType IMAPS</span>
</span></span><span class="line"><span class="cl"><span class="na">CertificateFile /etc/ssl/certs/ca-certificates.crt</span>
</span></span><span class="line"><span class="cl"><span class="na">AuthMechs LOGIN</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="na">IMAPStore my-remote</span>
</span></span><span class="line"><span class="cl"><span class="na">Account my-account</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="na">MaildirStore my-local</span>
</span></span><span class="line"><span class="cl"><span class="na">SubFolders Verbatim</span>
</span></span><span class="line"><span class="cl"><span class="na">Path ~/.local/share/mail/mbsync/you.example.com/</span>
</span></span><span class="line"><span class="cl"><span class="na">Inbox ~/.local/share/mail/mbsync/you.example.com/INBOX</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="na">Channel my-backup</span>
</span></span><span class="line"><span class="cl"><span class="na">Far :my-remote:</span>
</span></span><span class="line"><span class="cl"><span class="na">Near :my-local:</span>
</span></span><span class="line"><span class="cl"><span class="na">Patterns * !&#34;[Gmail]/Trash&#34; !&#34;[Gmail]/Spam&#34; !&#34;[Gmail]/Drafts&#34;</span>
</span></span><span class="line"><span class="cl"><span class="na">Create Near</span>
</span></span><span class="line"><span class="cl"><span class="na">Sync Pull New Flags</span>
</span></span><span class="line"><span class="cl"><span class="na">Expunge None</span>
</span></span><span class="line"><span class="cl"><span class="na">Remove None</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Three things worth flagging in that config:</p>
<ol>
<li><strong><code>Sync Pull</code></strong>: only pulls from the server to local. Nothing I do in the Maildir propagates back to Gmail. That&rsquo;s accident protection (if I delete a file locally, it doesn&rsquo;t disappear from Gmail).</li>
<li><strong><code>Expunge None</code> + <code>Remove None</code></strong>: if a message disappears from Gmail (someone deleted it or Google decided to move it nowhere), it <strong>stays</strong> in my Maildir. The backup accumulates history, it doesn&rsquo;t mirror the current state.</li>
<li><strong><code>PassCmd</code> instead of a plaintext password</strong>: the password goes through a script that does <code>secret-tool lookup</code> against the desktop keyring. No password sitting in a config file on disk.</li>
</ol>
<h3>The password block<span class="hx:absolute hx:-mt-20" id="the-password-block"></span>
    <a href="#the-password-block" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>mbsync-password</code> script is a tiny wrapper that isolates the secret:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">account</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="p">:?usage: mbsync-password ACCOUNT</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">password</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>secret-tool lookup service mbsync account <span class="s2">&#34;</span><span class="nv">$account</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> -z <span class="s2">&#34;</span><span class="nv">$password</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;missing keyring secret for </span><span class="nv">$account</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$password</span><span class="s2">&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The password gets stored once with <code>secret-tool store --label=&quot;mbsync gmail&quot; service mbsync account you@example.com</code>. Modern Gmail requires a specific <strong>app password</strong> (16 characters, generated at <a href="https://myaccount.google.com/apppasswords"target="_blank" rel="noopener">myaccount.google.com/apppasswords</a>) because it no longer accepts plain basic auth — you don&rsquo;t paste your Google password there, you paste a key generated exclusively for the app, revocable any time without touching the rest of your account.</p>
<h3>Automation with a systemd user timer<span class="hx:absolute hx:-mt-20" id="automation-with-a-systemd-user-timer"></span>
    <a href="#automation-with-a-systemd-user-timer" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>To avoid running mbsync by hand, I set up two systemd user units. First the service, which is just a call to the wrapper script:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># ~/.config/systemd/user/mbsync-backup.service</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Mirror Gmail accounts locally with mbsync</span>
</span></span><span class="line"><span class="cl"><span class="na">Wants</span><span class="o">=</span><span class="s">network-online.target</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network-online.target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">%h/.config/mbsync/bin/mbsync-run</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the timer that fires the service periodically:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># ~/.config/systemd/user/mbsync-backup.timer</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Run mbsync periodically</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Timer]</span>
</span></span><span class="line"><span class="cl"><span class="na">OnBootSec</span><span class="o">=</span><span class="s">10m</span>
</span></span><span class="line"><span class="cl"><span class="na">OnUnitActiveSec</span><span class="o">=</span><span class="s">30m</span>
</span></span><span class="line"><span class="cl"><span class="na">Persistent</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="cl"><span class="na">Unit</span><span class="o">=</span><span class="s">mbsync-backup.service</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">timers.target</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>OnBootSec=10m</code> waits 10 minutes after boot before the first run (avoids fighting with <code>network-online</code> early in the session). <code>OnUnitActiveSec=30m</code> refires 30 minutes after each run completes. <code>Persistent=true</code> makes the timer catch up: if the machine was off when it would&rsquo;ve fired, it runs at the next boot instead of skipping. The wrapper itself grabs <code>flock</code> so two mbsync runs never overlap, so if a sync ever takes longer than 30 minutes the next one just waits. Everything logs to <code>~/.local/state/mbsync/mbsync.log</code>.</p>
<p>To manage the timer, the basic commands:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Re-read the unit files after creating/editing</span>
</span></span><span class="line"><span class="cl">systemctl --user daemon-reload
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Start now + enable for the next boot</span>
</span></span><span class="line"><span class="cl">systemctl --user <span class="nb">enable</span> --now mbsync-backup.timer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Check whether the timer is active and when it fires next</span>
</span></span><span class="line"><span class="cl">systemctl --user status mbsync-backup.timer
</span></span><span class="line"><span class="cl">systemctl --user list-timers mbsync-backup.timer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Trigger an immediate sync without waiting for the interval</span>
</span></span><span class="line"><span class="cl">systemctl --user start mbsync-backup.service
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Tail the logs in real time</span>
</span></span><span class="line"><span class="cl">journalctl --user -u mbsync-backup.service -f
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Inspect the status of the last service run</span>
</span></span><span class="line"><span class="cl">systemctl --user status mbsync-backup.service
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># To shut the whole thing down</span>
</span></span><span class="line"><span class="cl">systemctl --user disable --now mbsync-backup.timer</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For the timer to keep running when you&rsquo;re not logged in (handy on a desktop that stays up 24/7), enable linger once: <code>sudo loginctl enable-linger $USER</code>. Without that, user units only run while you have an active session.</p>
<p>Enable it, check the status once to make sure it&rsquo;s green, and forget it exists.</p>
<h2>Backing up the Maildir with restic<span class="hx:absolute hx:-mt-20" id="backing-up-the-maildir-with-restic"></span>
    <a href="#backing-up-the-maildir-with-restic" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now that Gmail is mirrored under <code>~/.local/share/mail/</code>, it rides into the same backup pipeline as the rest of my home directory. I use <a href="https://restic.net/"target="_blank" rel="noopener">restic</a> to push encrypted snapshots to the NAS. Restic is incremental via content-addressable storage, so adding 100MB of email to today&rsquo;s snapshot costs basically the delta, not the full 100MB every time. For the off-site copy, my Synology NAS already has the Glacier Backup package built in, which handles pushing encrypted snapshots to AWS Glacier without me having to configure anything beyond the bucket and the key. The Maildir rides along on the same plan, so the off-site backup is essentially free in terms of extra configuration.</p>
<p>Maildir is friendly to incremental backup by construction: every email is an immutable file (the filename doesn&rsquo;t even change after download, except for the seen/flagged flag bit). Restic dedups perfectly. A 5GB mailbox snapshot has near-zero marginal cost after the first one.</p>
<h2>The open-format upside: many clients<span class="hx:absolute hx:-mt-20" id="the-open-format-upside-many-clients"></span>
    <a href="#the-open-format-upside-many-clients" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the part that sealed it for me. Maildir is an open standard, so <strong>any client that speaks Maildir reads the same backup</strong>. I can:</p>
<ul>
<li>Open it in <a href="http://www.mutt.org/"target="_blank" rel="noopener">mutt</a> or <a href="https://neomutt.org/"target="_blank" rel="noopener">neomutt</a> when I want something quick in the terminal.</li>
<li>Open it in <a href="https://aerc-mail.org/"target="_blank" rel="noopener">aerc</a> when I want a more modern TUI.</li>
<li>Point Thunderbird or <a href="https://www.claws-mail.org/"target="_blank" rel="noopener">Claws Mail</a> at <code>~/.local/share/mail/...</code> when I want a GUI.</li>
<li>Migrate to a different client down the road without an export/import dance.</li>
</ul>
<p>The emails are mine. They live on my disk. In a plain text format, readable by anything.</p>
<h2>Bonus: scripts and bots read directly<span class="hx:absolute hx:-mt-20" id="bonus-scripts-and-bots-read-directly"></span>
    <a href="#bonus-scripts-and-bots-read-directly" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A less obvious consequence: if I want to build a filter, a bot, a classifier, or anything that touches the emails, I <strong>don&rsquo;t need to go through Gmail OAuth, create a Google Cloud project, manage refresh tokens, deal with quotas</strong> — none of it. I just read the Maildir.</p>
<p>Python has native Maildir support in the standard library&rsquo;s <code>mailbox</code> module. A trivial example to list subjects from the inbox:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">mailbox</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">email.header</span> <span class="kn">import</span> <span class="n">decode_header</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">inbox</span> <span class="o">=</span> <span class="n">mailbox</span><span class="o">.</span><span class="n">Maildir</span><span class="p">(</span><span class="s2">&#34;/home/akitaonrails/.local/share/mail/mbsync/you.example.com/INBOX&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">msg</span> <span class="ow">in</span> <span class="n">inbox</span><span class="o">.</span><span class="n">iteritems</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">subject</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Subject&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">sender</span> <span class="o">=</span> <span class="n">msg</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;From&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># decode_header handles Subject in UTF-8 / latin-1 / etc</span>
</span></span><span class="line"><span class="cl">    <span class="n">decoded</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">part</span><span class="o">.</span><span class="n">decode</span><span class="p">(</span><span class="n">enc</span> <span class="ow">or</span> <span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">part</span><span class="p">,</span> <span class="nb">bytes</span><span class="p">)</span> <span class="k">else</span> <span class="n">part</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">part</span><span class="p">,</span> <span class="n">enc</span> <span class="ow">in</span> <span class="n">decode_header</span><span class="p">(</span><span class="n">subject</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">sender</span><span class="p">[:</span><span class="mi">40</span><span class="p">]</span><span class="si">:</span><span class="s2">&lt;40</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">decoded</span><span class="p">[:</span><span class="mi">60</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Runs in milliseconds, consumes zero API quota, has no rate limit, never needs a credential refresh. It&rsquo;s just file reads. If you want to build something more serious (attachment parsing, LLM-based classification, automated rules), the same principle holds.</p>
<h2>Summary<span class="hx:absolute hx:-mt-20" id="summary"></span>
    <a href="#summary" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The combination that finally let me relax about this:</p>
<ul>
<li><strong>mbsync</strong> (isync) pulls from Gmail&rsquo;s IMAP into a local Maildir, automatically via a systemd timer.</li>
<li><strong>Maildir</strong> keeps the open format, one file per email, pull-only protects against accidental deletion.</li>
<li><strong>restic</strong> snapshots the Maildir to the NAS with ridiculous deduplication, and Synology&rsquo;s Glacier Backup carries that off-site as an encrypted copy.</li>
<li><strong>Free choice of mail client</strong> — mutt, neomutt, aerc, Thunderbird, Claws, anything reads it.</li>
<li><strong>Light automation</strong> — bot or script reads straight off disk, no OAuth, no API.</li>
</ul>
<p>Gmail becomes just a convenient web interface for reading and replying. The real ownership of the emails sits on my disk, replicated to the NAS, replicated to an encrypted off-site backup. If Google shuts my account down tomorrow (it&rsquo;s happened to people), I lose the interface but keep the entire history. For me, this is the definitive way to handle it. Recommended.</p>
]]></content:encoded><category>storage-and-backup</category><category>linux</category><category>automation</category></item><item><title>I Built a CLI to Check My GitHub Pending Stuff: ghpending</title><link>https://akitaonrails.github.io/en/2026/05/23/i-built-a-cli-to-check-my-github-pending-stuff-ghpending/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/23/i-built-a-cli-to-check-my-github-pending-stuff-ghpending/</guid><pubDate>Sat, 23 May 2026 22:00:00 -0300</pubDate><description>&lt;p&gt;After my &lt;a href="https://akitaonrails.github.io/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/"&gt;AI marathon&lt;/a&gt;, I&amp;rsquo;m sitting on dozens of public GitHub repos to look after. A bunch of them have contributors filing issues and sending pull requests. The problem is, opening a browser, walking into each repo, checking whether anything new is waiting, becomes a chore I end up dodging. And when I dodge it, I go weeks without responding to collaborators.&lt;/p&gt;
&lt;p&gt;I know there are plenty of tools that solve this. But I wanted something dead simple and fast to run in the terminal so I can see pending work across all my projects at once. So I built it: &lt;strong&gt;&lt;a href="https://github.com/akitaonrails/ghpending"target="_blank" rel="noopener"&gt;ghpending&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;</description><content:encoded><![CDATA[<p>After my <a href="/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/">AI marathon</a>, I&rsquo;m sitting on dozens of public GitHub repos to look after. A bunch of them have contributors filing issues and sending pull requests. The problem is, opening a browser, walking into each repo, checking whether anything new is waiting, becomes a chore I end up dodging. And when I dodge it, I go weeks without responding to collaborators.</p>
<p>I know there are plenty of tools that solve this. But I wanted something dead simple and fast to run in the terminal so I can see pending work across all my projects at once. So I built it: <strong><a href="https://github.com/akitaonrails/ghpending"target="_blank" rel="noopener">ghpending</a></strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/23/ghpending/ghpending-screenshot.png" alt="ghpending output in the terminal: compact table listing each tracked repo with counts of open issues and pull requests, latest activity author, and how long ago."  loading="lazy" /></p>
<p>Runs on Linux and macOS. I tested on Linux (Arch + Omarchy) but haven&rsquo;t run it on macOS yet. If you try it there and hit a bug, file an issue at <a href="https://github.com/akitaonrails/ghpending/issues"target="_blank" rel="noopener">github.com/akitaonrails/ghpending/issues</a>.</p>
<h2>Install<span class="hx:absolute hx:-mt-20" id="install"></span>
    <a href="#install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Three paths, pick whichever fits your setup.</p>
<p>Via Homebrew (macOS or Linux):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ghpending</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Via Cargo:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">cargo install ghpending</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Via mise:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">mise use -g github:akitaonrails/ghpending</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Upgrading<span class="hx:absolute hx:-mt-20" id="upgrading"></span>
    <a href="#upgrading" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># Homebrew</span>
</span></span><span class="line"><span class="cl">brew upgrade ghpending
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Cargo</span>
</span></span><span class="line"><span class="cl">cargo install ghpending --force
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># mise</span>
</span></span><span class="line"><span class="cl">mise upgrade ghpending
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># From source</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ghpending <span class="o">&amp;&amp;</span> git pull <span class="o">&amp;&amp;</span> cargo install --path .</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Usage<span class="hx:absolute hx:-mt-20" id="usage"></span>
    <a href="#usage" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Four commands. The normal flow is to run <code>ghpending add</code> once to set up which repos you want to track, then just <code>ghpending</code> whenever you want to see the digest.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">ghpending add    <span class="c1"># pick repos from a GitHub user or org to track</span>
</span></span><span class="line"><span class="cl">ghpending        <span class="c1"># print the digest of open issues and PRs</span>
</span></span><span class="line"><span class="cl">ghpending list   <span class="c1"># show tracked repos</span>
</span></span><span class="line"><span class="cl">ghpending rm     <span class="c1"># remove repos from tracking via interactive menu</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>add</code> asks for a user or organization, lists their public repos, and lets you check off the ones you want to follow. The username gets saved, so re-running <code>add</code> skips that prompt.</p>
<p>The main command (<code>ghpending</code> with no args) fetches every tracked repo concurrently and prints the table with open issues and PRs.</p>
<h2>GitHub token (optional)<span class="hx:absolute hx:-mt-20" id="github-token-optional"></span>
    <a href="#github-token-optional" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It works without auth, but GitHub caps anonymous traffic at 60 requests per hour. To raise that ceiling to 5,000 req/h, export a token:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="nv">GITHUB_TOKEN</span><span class="o">=</span><span class="k">$(</span>gh auth token<span class="k">)</span> ghpending</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It&rsquo;s read silently at startup, no config needed.</p>
<h2>Where the config lives<span class="hx:absolute hx:-mt-20" id="where-the-config-lives"></span>
    <a href="#where-the-config-lives" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li>Linux: <code>~/.config/ghpending/config.toml</code></li>
<li>macOS: <code>~/Library/Application Support/ghpending/config.toml</code></li>
</ul>
<p>You can edit it directly if you want to reorder repos or change the <code>user</code>. Shape:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="nx">user</span> <span class="p">=</span> <span class="s2">&#34;akitaonrails&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">repos</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;akitaonrails/ai-memory&#34;</span><span class="p">,</span> <span class="s2">&#34;akitaonrails/ai-jail&#34;</span><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Why this CLI exists<span class="hx:absolute hx:-mt-20" id="why-this-cli-exists"></span>
    <a href="#why-this-cli-exists" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The honest answer: tools I build for myself, I use. Tools written by other people always come with some friction (web login, a different architectural opinion, a feature I wanted missing or one I didn&rsquo;t want included). Since the goal was to reduce the friction of checking 30 repos a week, 200 lines of Rust on a Saturday afternoon beat learning a new stack.</p>
<p>Now I&rsquo;m hoping I can respond to contributors faster. If you find it useful, it&rsquo;s MIT licensed, PRs are welcome.</p>
]]></content:encoded><category>ghpending</category><category>automation</category><category>developer-tools</category><category>version-control</category></item><item><title>An Emulation Distrobox with Claude Code</title><link>https://akitaonrails.github.io/en/2026/04/11/emulation-distrobox-with-claude-code/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/11/emulation-distrobox-with-claude-code/</guid><pubDate>Sat, 11 Apr 2026 18:00:00 -0300</pubDate><description>&lt;p&gt;I&amp;rsquo;ve loved old videogames since before many of you were born. My first contact was back in the Atari era, in the early 80s. Then came the 8-bit micros, the 90s arcades, the 16, 32 and 64-bit consoles, and I kept following all of it. For me, nostalgia is not a cute Instagram folder. It&amp;rsquo;s an archive. ROMs, BIOS files, dumps, patches, DLC, firmware, saves. Over the years I kept everything on my NAS. At this point I have terabytes of it under &lt;code&gt;/mnt/terachad/Emulators&lt;/code&gt;.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve loved old videogames since before many of you were born. My first contact was back in the Atari era, in the early 80s. Then came the 8-bit micros, the 90s arcades, the 16, 32 and 64-bit consoles, and I kept following all of it. For me, nostalgia is not a cute Instagram folder. It&rsquo;s an archive. ROMs, BIOS files, dumps, patches, DLC, firmware, saves. Over the years I kept everything on my NAS. At this point I have terabytes of it under <code>/mnt/terachad/Emulators</code>.</p>
<p>On my YouTube channel I even used old games to teach computer science fundamentals. In <a href="https://www.youtube.com/watch?v=hYJ3dvHjeOE&amp;pp=ygUUYWtpdGFuZG8gc3VwZXIgbWFyaW8%3D"target="_blank" rel="noopener">this Akitando episode about Super Mario and old computers</a>, I explain the 6502, memory maps, the PPU, hardware constraints, and why those games were programmed the way they were. If you never watched it, start there:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/hYJ3dvHjeOE"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>The problem is always the same: every few years I decide to set up a new emulation machine, download the main emulators again, and there I go repeating the same masochistic ritual. <code>PCSX2</code>, <code>RPCS3</code>, <code>Eden</code>, <code>Azahar</code>, <code>Dolphin</code>, <code>RetroArch</code>, <code>Flycast</code>, <code>shadPS4</code>, and so on. Each one with its quirks. Each one with its own particular way of wasting hours of your life.</p>
<p>In theory, setting all this up should be fun. In practice, it takes days. And that&rsquo;s not an exaggeration. <code>Dolphin</code> still manages to be one of the worst because GameCube and Wii controller setups have no standard. <code>RPCS3</code> requires per-game tuning. <code>Eden</code> needs DLC, updates and cheats in the right place. <code>shadPS4</code> is a festival of trial and error. When I&rsquo;d finally finish configuring everything, I didn&rsquo;t feel like playing anything anymore. I just wanted to close the menus and go do something else.</p>
<p>I&rsquo;ve been known to say that maybe the fun was the tuning itself. I don&rsquo;t think that anymore. After repeating this process too many times, I&rsquo;m done. I want to play the games, not fill out forms hidden inside emulator GUIs.</p>
<h2>The problem was never Linux<span class="hx:absolute hx:-mt-20" id="the-problem-was-never-linux"></span>
    <a href="#the-problem-was-never-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve solved this kind of thing in different ways in the past. Years ago I ran Linux on the host and a virtualized Windows with GPU passthrough to play inside the VM. At the time that made sense. I made a whole video about it:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/IDnabc3DjYY"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>But that was before Valve, Proton, modern Wine and the whole open source community pushed the Linux desktop to a much better place. Today you can avoid Windows in most cases. In the other video below I explain the evolution of CPU, GPU, OpenGL, Vulkan, and why we ended up here:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/JEp7ozWqIps"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>My gaming mini-PC with the RTX 4090 is still my main Steam machine, especially now that I&rsquo;ve built a <a href="/en/2026/04/01/my-sim-racing-cockpit-formula-fx1/">proper sim racing cockpit</a>. On it I use <a href="https://www.emudeck.com/"target="_blank" rel="noopener">EmuDeck</a>, which was born to automate installing and configuring emulators on the Steam Deck, then grew Windows support, and helps cut the complexity of setting up this kind of environment. But my main desktop is also way too powerful to leave underused. It has an RTX 5090, and today I use that GPU mostly to test LLMs and for benchmarking, as I wrote in <a href="/en/2026/04/05/testing-llms-open-source-and-commercial-can-anyone-beat-claude-opus/">Testing Open Source and Commercial LLMs</a>. Leaving it idle for gaming would be a waste.</p>
<p>Only on Linux I wanted something else. I didn&rsquo;t want a black box doing everything for me without me knowing exactly what was being changed, especially on my main PC, which is my work machine. I wanted my own setup, handmade in the right sense: not clicking GUI by GUI, but understanding what&rsquo;s being configured, keeping the files under my control, and being able to rebuild everything the way I like. I know the NixOS folks will jump in here to say &ldquo;just use Nix&rdquo; — I&rsquo;ll explain why I ruled that out below. I also didn&rsquo;t want to turn my work machine into a carnival of emulator packages, GTK themes, wrappers, launchers, weird runtimes and configs buried in <code>~/.config</code>. Nor did I want dual boot or a VM. In 2026, the best answer for this, in my opinion, is <a href="https://distrobox.it/"target="_blank" rel="noopener">Distrobox</a>.</p>
<p>From what the project itself explains, Distrobox is a wrapper on top of <code>podman</code>, <code>docker</code> or <code>lilipod</code> to create containers tightly integrated with the host, with access to <code>HOME</code>, Wayland/X11, audio, USB devices, external storage and GPU. In other words: exactly the kind of pragmatic isolation I wanted. It&rsquo;s not a security boundary, and the project site makes that clear. Don&rsquo;t use it thinking about high-security sandboxing. Use it thinking about separating environments without paying the price of a VM.</p>
<p>And before anyone repeats the usual confusion: a container is not a virtual machine. I explain this calmly in this other episode:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/85k8se4Zo70"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<h2>The setup<span class="hx:absolute hx:-mt-20" id="the-setup"></span>
    <a href="#the-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The idea is simple: a vanilla Arch Linux inside a Distrobox called <code>gaming</code>, with NAS paths mounted read-only, Steam library accessible where it makes sense, and all emulators installed inside. Since the container has access to the NVIDIA GPU, audio, USB and other peripherals, I can separate work and gaming on the same machine without practical performance penalty.</p>
<p>The initial bootstrap looks roughly like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">distrobox create --name gaming --image archlinux:latest --nvidia <span class="se">\
</span></span></span><span class="line"><span class="cl">  --home /mnt/data/distrobox/gaming <span class="se">\
</span></span></span><span class="line"><span class="cl">  --volume /mnt/data/steam:/mnt/data/steam <span class="se">\
</span></span></span><span class="line"><span class="cl">  --volume /mnt/terachad/Emulators:/mnt/terachad/Emulators:ro</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Even that first step has a catch. <code>archlinux:latest</code> inside Distrobox comes without <code>[multilib]</code> in <code>pacman.conf</code>, with a misconfigured <code>sudoers</code>, and with the old <code>--nvidia</code> problem: the host driver libs get bind-mounted read-only, so any install that depends on <code>nvidia-utils</code> breaks with file conflicts. Just that alone is the kind of headache that, a few years ago, would make me open half a dozen tabs, a bunch of terminals, and spend a whole night in trial and error.</p>
<p>This time I did it differently.</p>
<h2>Claude Code as an infrastructure assistant<span class="hx:absolute hx:-mt-20" id="claude-code-as-an-infrastructure-assistant"></span>
    <a href="#claude-code-as-an-infrastructure-assistant" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ve been getting more and more comfortable using Claude Code as my infrastructure assistant on my personal machines. I wouldn&rsquo;t do this blindly on a client server. But on my own machine, in an environment I can destroy and rebuild however many times I want, it makes all the sense in the world. I wrote about this in <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">Migrating my Home Server with Claude Code</a>, when I used Claude to install and configure openSUSE MicroOS, Docker, NFS, firewall, hardening, and all the rest without me having to remember annoying shell commands.</p>
<p>So I thought: why not do the same thing here?</p>
<p>That&rsquo;s exactly what I did. I started with a sequence of objective prompts, always asking for two things at once: do the work, and document enough for me to rebuild everything later. The most important history ended up in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/distrobox-gaming-prompts.md"target="_blank" rel="noopener"><code>docs/distrobox-gaming-prompts.md</code></a>.</p>
<p>A clarification worth making: those prompts on GitHub aren&rsquo;t my raw messages, the way they came out in real time. After everything worked, I asked Claude itself to rewrite the prompts in a much more organized and detailed way, purely for documentation. The original prompts were much simpler and much less specific. I described the goal and let Claude figure out paths, config files, formats and commands on its own.</p>
<p>The first prompt created the box with <code>--nvidia</code>, a separate <code>--home</code>, and correct mounts. The second solved the three classic problems of Arch inside Distrobox: <code>sudo</code>, <code>multilib</code>, and the <code>nvidia-utils</code> dummy package. The third installed the gaming base, including a detail I would have easily forgotten if I was doing it by hand: <code>pipewire-pulse</code>, needed so several emulators don&rsquo;t end up mute or out of sync.</p>
<p>The point isn&rsquo;t the text itself. The point is the method. I don&rsquo;t need to remember the exact order, the precise options, or where the docs for each detail are. I describe the goal and let the agent carry the piano. I stay in the tech-lead role: watching, reviewing, course-correcting when needed, and telling it to keep going.</p>
<h2>The GitHub project<span class="hx:absolute hx:-mt-20" id="the-github-project"></span>
    <a href="#the-github-project" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The setup lives in <a href="https://github.com/akitaonrails/distrobox-gaming"target="_blank" rel="noopener">akitaonrails/distrobox-gaming</a>, structured as an Ansible project. 17 roles cover everything from box creation, package bootstrap, config seeding, DLC installation, controller setup, to post-setup verification. The main playbooks are:</p>
<ul>
<li><code>site.yml</code>: full setup from scratch</li>
<li><code>reset-configs.yml</code>: reset configs only (useful when you want to wipe tuning and redeploy)</li>
<li><code>backup.yml</code> / <code>restore.yml</code>: container snapshot and restore via <code>podman commit</code></li>
<li><code>refresh-shadps4.yml</code>: standalone shadPS4 update</li>
<li><code>install-xenia.yml</code>: optional Xenia Manager install (Wine prefix)</li>
</ul>
<p>Full walkthrough is in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/rebuild-runbook.md"target="_blank" rel="noopener"><code>docs/rebuild-runbook.md</code></a>. Package decisions (why AUR instead of Flatpak, for example) are in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/distrobox-gaming-packages.md"target="_blank" rel="noopener"><code>docs/distrobox-gaming-packages.md</code></a>.</p>
<h3>Why not Nix?<span class="hx:absolute hx:-mt-20" id="why-not-nix"></span>
    <a href="#why-not-nix" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Someone will ask. &ldquo;You&rsquo;re recreating a reproducible environment. Why not use NixOS, or at least <code>nix-shell</code>/flakes?&rdquo;</p>
<p>I considered and ruled it out. The emulator ecosystem I need lives on the AUR. AUR <code>-bin</code> packages are wrappers around AppImages that ship the official upstream binary without compiling anything. <code>rpcs3-bin</code>, <code>pcsx2-latest-bin</code>, <code>eden-bin</code>, <code>duckstation-qt-bin</code>, <code>shadps4-bin</code> — these binaries track upstream practically on release day. On Nixpkgs, many of these emulators are weeks or months behind release, and some don&rsquo;t exist. Creating a Nix overlay for every emulator I need, maintaining it, and dealing with compatibility patches when upstream changes is work I don&rsquo;t want to have.</p>
<p>There&rsquo;s also the mental model. I don&rsquo;t want to learn the Nix language, the derivation system and the evaluation model just to set up a personal gaming environment. I already know Ansible, roles are folders with YAML tasks, and debugging is <code>ansible-playbook -vvv</code>. When something goes wrong, I read the error, open the task that failed, and know exactly where to fix it. If I needed the same level of reproducibility at scale, across a cluster, with atomic rollback, then Nix would have an argument. For a distrobox with emulators on my personal machine, Ansible solves it with much less friction.</p>
<p>Flatpak was also tested and ruled out. Flatpak&rsquo;s <code>bwrap</code> (bubblewrap) doesn&rsquo;t nest properly inside Docker&rsquo;s mount namespaces, so Flatpak apps simply don&rsquo;t run inside distrobox. The alternative would be installing Flatpak on the host, but then you&rsquo;re back to polluting the work machine with gaming packages. Plus, Flatpak versions of emulators lag well behind AUR — the AUR&rsquo;s <code>pcsx2-latest-bin</code> tracks the 2.7.x AppImage, while Flathub still ships v2.6.3.</p>
<h2>Platforms covered<span class="hx:absolute hx:-mt-20" id="platforms-covered"></span>
    <a href="#platforms-covered" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The setup covers 12 platforms today:</p>
<table>
  <thead>
      <tr>
          <th>Platform</th>
          <th>Emulator</th>
          <th>Renderer</th>
          <th>Highlight</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PS1</td>
          <td>DuckStation</td>
          <td>Vulkan, 8x upscale</td>
          <td>JINC2 filter, PGXP, widescreen hack</td>
      </tr>
      <tr>
          <td>PS2</td>
          <td>PCSX2 2.7.x</td>
          <td>Vulkan, 4x SSAA</td>
          <td>FXAA + PCRTC antiblur, Xbox bindings</td>
      </tr>
      <tr>
          <td>PS3</td>
          <td>RPCS3</td>
          <td>Vulkan</td>
          <td>Curated per-game configs via API</td>
      </tr>
      <tr>
          <td>PS4</td>
          <td>shadPS4</td>
          <td>Vulkan</td>
          <td>Driveclub-focused (experimental)</td>
      </tr>
      <tr>
          <td>PS Vita</td>
          <td>Vita3K</td>
          <td>—</td>
          <td>Default seeding</td>
      </tr>
      <tr>
          <td>GameCube</td>
          <td>Dolphin</td>
          <td>Vulkan</td>
          <td>8BitDo Ultimate 2 profiles</td>
      </tr>
      <tr>
          <td>Wii</td>
          <td>Dolphin</td>
          <td>Vulkan</td>
          <td>Classic Controller + Nunchuk</td>
      </tr>
      <tr>
          <td>Wii U</td>
          <td>Cemu</td>
          <td>Vulkan</td>
          <td>Baseline seed only-if-missing</td>
      </tr>
      <tr>
          <td>Switch</td>
          <td>Eden</td>
          <td>Vulkan</td>
          <td>Atmosphere DLC + cheats</td>
      </tr>
      <tr>
          <td>Dreamcast</td>
          <td>Flycast</td>
          <td>Vulkan, 2880p</td>
          <td>Hi-res wrapper, widescreen</td>
      </tr>
      <tr>
          <td>OG Xbox</td>
          <td>xemu</td>
          <td>—</td>
          <td>BIOS/HDD via symlink</td>
      </tr>
      <tr>
          <td>Xbox 360</td>
          <td>Xenia (Wine)</td>
          <td>Vulkan</td>
          <td>Project Forza Plus, PGR3/4</td>
      </tr>
  </tbody>
</table>
<p>RetroArch comes on top to cover the rest (NES, SNES, Genesis, N64, arcade, etc.), with 21 buildbot cores and 8 asset packs (databases, shaders, cheats, overlays, autoconfig).</p>
<p>Frontend is <a href="https://es-de.org/"target="_blank" rel="noopener">ES-DE</a>. Custom system definitions live in <code>ansible/group_vars/all/esde.yml</code>, and each emulator comes with a wrapper that sets the right GPU environment variables (more on that in a sec).</p>
<h2>GPU hardforce for NVIDIA<span class="hx:absolute hx:-mt-20" id="gpu-hardforce-for-nvidia"></span>
    <a href="#gpu-hardforce-for-nvidia" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On a hybrid system with NVIDIA dGPU + AMD iGPU (my case on the desktop), Vulkan sometimes picks the iGPU on its own and you end up with an emulator running at 15 fps while the 5090 sits idle. The fix is to force NVIDIA&rsquo;s Vulkan ICD in every launcher.</p>
<p>Each desktop entry rendered by Ansible exports <code>VK_ICD_FILENAMES</code> pointing to NVIDIA&rsquo;s ICD, and the ES-DE wrapper does the same before launching any core. Combined with the <code>--nvidia</code> flag at box creation, which bind-mounts host drivers read-only, the result is predictable: Vulkan always on the NVIDIA dGPU, regardless of system state at runtime.</p>
<p>This mostly matters for hybrid setups. On a desktop with a single GPU, the ICD filtering is placebo, but it doesn&rsquo;t hurt.</p>
<h2>PS2: PCSX2 with per-game tuning<span class="hx:absolute hx:-mt-20" id="ps2-pcsx2-with-per-game-tuning"></span>
    <a href="#ps2-pcsx2-with-per-game-tuning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Gran Turismo line is my declared addiction. Each version needs its own specific tweak. The PCSX2 setup today has sane global configs (Vulkan, 4x SSAA, widescreen 16:9, Xbox-style bindings, FXAA + PCRTC antiblur, 16x anisotropic, Automatic deinterlace) and per-game INIs for the titles I actually play:</p>
<ul>
<li><strong>Gran Turismo 3 A-spec</strong> (SCUS-97102 + bundle PBPX-95503): widescreen pnach, optional retexture pack (disabled by default because NFS streaming causes flicker in car-showcase cutscenes)</li>
<li><strong>Gran Turismo 4 USA</strong> (SCUS-97328): Silentwarior112 HD HUD, full retexture pack, Silent&rsquo;s trigger/cam patches, widescreen pnach</li>
<li><strong>Gran Turismo 4 Spec II</strong> (SCUS-97436, CRC <code>4CE521F2</code>): special case — the vanilla GT4 HD packs are linked via symlink because Spec II has the same structure but a different CRC. Widescreen pnach renamed to match the CRC. Deinterlace mode 8 (Adaptive TFF), ShadeBoost tuning (+10 saturation, +3 brightness, +2 contrast) to fix pause-interlace ghosting and the anemic upscale saturation</li>
<li><strong>Enthusia Professional Racing</strong> (SLUS-20967): HD textures + widescreen</li>
<li><strong>Ridge Racer V</strong> (SLUS-20002): widescreen + no-interlace pnach</li>
</ul>
<p>Silent&rsquo;s and community pnach files are downloaded automatically by the <code>pcsx2_textures</code> role, which also deploys the cheats pack from the NAS and installs the Spec II HD packs via symlink. Widescreen is enabled per-CRC via <code>gamesettings</code> (PCSX2 identifies games by CRC before applying pnach).</p>
<p>On PCSX2 versioning: the AUR&rsquo;s <code>pcsx2-latest-bin</code> tracks the 2.7.x line, which is where all the modern features live (texture replacement, FXAA, PCRTC antiblur). The default <code>pcsx2</code> in Arch repos is still on 2.6.3. The gap is over 250 commits of fixes and new features.</p>
<h2>PS3: RPCS3 with curated per-game configs<span class="hx:absolute hx:-mt-20" id="ps3-rpcs3-with-curated-per-game-configs"></span>
    <a href="#ps3-rpcs3-with-curated-per-game-configs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>RPCS3 has an annoying detail: per-game configs live in <code>~/.config/rpcs3/custom_configs/config_&lt;SERIAL&gt;.yml</code>. The file format has a version, and if you hand-write YAML with the wrong version, RPCS3 silently rejects it and falls back to defaults. That cost me time to figure out.</p>
<p>The <code>rpcs3_per_game_configs</code> role handles it like this:</p>
<ol>
<li>Queries the <a href="https://rpcs3.net/compatibility"target="_blank" rel="noopener">RPCS3 community compatibility DB</a> for recommended presets per title</li>
<li>Applies curated overrides on top for titles I know need specific tuning (typically GT5 and GT6)</li>
<li>Saves the YAML with the exact filename convention (<code>config_&lt;SERIAL&gt;.yml</code>, <code>config_</code> prefix mandatory)</li>
</ol>
<p>For the Gran Turismo line:</p>
<ul>
<li><strong>GT5</strong> (BCUS98114, BCES00569): Resolution Scale 300%, Shader Precision Ultra, Force High Precision Z, SPU XFloat Accurate, Multithreaded RSX. The combo kills the typical RSX dithering without breaking the GT-series safety config (WCB/RCB off).</li>
<li><strong>GT6</strong> (BCES01893 + regional variants): special case, <strong>version pinned to v1.05</strong>. Patches 1.06+ regress with black surfaces on cars in the garage menu. The <code>extract_ps3_dlc.py</code> script has a per-title PATCH ceiling specifically to pin GT6 at 1.05 even when PSN serves newer patches. Also, Force CPU Blit is mandatory (without it, full-screen flicker). The trade-off is that the rear-view mirror stays permanently black — turning WCB on would restore the mirror but downgrade to native 720p. I&rsquo;ll take no mirror.</li>
</ul>
<p>More details on those two in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/gt5-rpcs3.md"target="_blank" rel="noopener"><code>docs/gt5-rpcs3.md</code></a> and <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/gt6-rpcs3.md"target="_blank" rel="noopener"><code>docs/gt6-rpcs3.md</code></a>.</p>
<h3>PS3 update checker<span class="hx:absolute hx:-mt-20" id="ps3-update-checker"></span>
    <a href="#ps3-update-checker" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>To avoid opening RPCS3&rsquo;s GUI and clicking <code>Download Game Updates</code> game by game, I wrote a Python script: <code>check_ps3_updates.py</code>. It walks the games installed in <code>dev_hdd0/game</code>, parses <code>PARAM.SFO</code> to get the current version per title, queries the PSN update server (<code>a0.ww.np.dl.playstation.net</code>), and compares against the local patch cache. <code>--list</code> shows the diff; <code>--download</code> fetches what&rsquo;s missing. <code>--max-version</code> respects per-title ceilings (e.g., 1.05 for GT6).</p>
<p>The first scan of my library found 51 of 72 games with updates available, 69 patches missing locally, ~24 GB in total. GT6 alone had 16 missing patches from 1.07 to 1.22, which I skipped for the reasons above.</p>
<p>Patches arrive as PSN packages type 0x0001 without encryption, so <code>extract_ps3_dlc.py</code> handles the install. Standard call:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">distrobox enter gaming -- python3 scripts/extract_ps3_dlc.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s2">&#34;/mnt/terachad/Emulators/EmuDeck/roms_heavy/ps3-DLC&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dest <span class="s2">&#34;/mnt/data/distrobox/gaming/.config/rpcs3/dev_hdd0/game&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Tracking PS3 DLC versions manually, game by game through RPCS3, is impractical. It becomes an infinite to-do list, and you only find out there was a 1.22 patch when the game crashes.</p>
<h2>PS1: DuckStation per-game<span class="hx:absolute hx:-mt-20" id="ps1-duckstation-per-game"></span>
    <a href="#ps1-duckstation-per-game" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Gran Turismo trilogy starts on PS1, and quality there only goes up if you enable the right things:</p>
<ul>
<li><strong>Gran Turismo</strong> (SCUS-94949): DuckStation&rsquo;s built-in WidescreenHack (no cheat exists for this game), PGXP enabled</li>
<li><strong>Gran Turismo 2 Simulation</strong> (SCUS-94455): widescreen cheat + 8 MB RAM cheat (fixes audio issues on crowded tracks), JINC2 filter</li>
<li><strong>Gran Turismo 2 Arcade</strong> (SCUS-94488): same treatment as Simulation</li>
</ul>
<p>Global defaults: Vulkan, 8x upscale, JINC2 as the default filter.</p>
<p>GT2 cheats come from a widescreen-fixes repo and are linked automatically into DuckStation&rsquo;s cheats folder.</p>
<h2>Switch: Eden with DLC, update and cheats<span class="hx:absolute hx:-mt-20" id="switch-eden-with-dlc-update-and-cheats"></span>
    <a href="#switch-eden-with-dlc-update-and-cheats" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Switch, <code>eden-bin</code> is the emulator. The integration automates three things I used to do by hand:</p>
<ol>
<li>
<p><strong>DLC and update install</strong>: the <code>install_dlcs</code> role points to the dumps directory on the NAS, identifies <code>.nsp</code>/<code>.nsz</code>/<code>.xci</code>/<code>.xcz</code> files, and installs everything into <code>~/.local/share/eden/nand/user/Contents/registered/</code>. If the dump is messy (patches and DLC mixed in a flat folder), the <code>reorganize_switch_nsps.py</code> script sorts everything by Title ID first.</p>
</li>
<li>
<p><strong>Atmosphere cheats</strong>: Atmosphere-format cheats get symlinked into Eden&rsquo;s load path (<code>~/.local/share/eden/load/&lt;TITLE_ID&gt;/cheats/</code>). The <code>switch_cheats</code> role handles this for every title covered by the NAS pack.</p>
</li>
<li>
<p><strong>Update checker</strong>: <code>check_switch_updates.py</code> lists titles with updates available against what&rsquo;s installed locally. Useful so I don&rsquo;t miss important patches for games still getting updates.</p>
</li>
</ol>
<p>A detail that burned me: <code>QT_STYLE_OVERRIDE</code> needs to be unset before launching <code>eden-bin</code>, otherwise it conflicts with Kvantum and breaks the UI. The wrapper does the <code>unset</code> before <code>exec</code>.</p>
<h2>Xbox 360: Xenia Manager via Wine<span class="hx:absolute hx:-mt-20" id="xbox-360-xenia-manager-via-wine"></span>
    <a href="#xbox-360-xenia-manager-via-wine" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Xbox 360 is still rough territory on Linux. There&rsquo;s no decent native emulator. The best route today is <a href="https://xenia.jp/"target="_blank" rel="noopener">Xenia</a> running through Wine, managed by <a href="https://github.com/xenia-manager/xenia-manager"target="_blank" rel="noopener">Xenia Manager</a>.</p>
<p>This is opt-in in my setup. The <code>install-xenia.yml</code> playbook creates a dedicated Wine prefix, downloads Xenia Manager, and registers launchers. It&rsquo;s not part of the main <code>site.yml</code> because not everyone wants Wine in the mix.</p>
<h3>Project Forza Plus (Forza Motorsport 2/3/4)<span class="hx:absolute hx:-mt-20" id="project-forza-plus-forza-motorsport-234"></span>
    <a href="#project-forza-plus-forza-motorsport-234" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Old Forza is still the king of previous-gen simcade. Running FM2/3/4 on Xenia requires the Project Forza Plus community mods: compatibility patches, performance mods, specific title-update installs. I documented the process in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/project-forza.md"target="_blank" rel="noopener"><code>docs/project-forza.md</code></a>.</p>
<h3>Project Gotham Racing 3 and 4<span class="hx:absolute hx:-mt-20" id="project-gotham-racing-3-and-4"></span>
    <a href="#project-gotham-racing-3-and-4" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>PGR3 runs reasonably well on Xenia Canary. PGR4 needs a specific tweak: <code>render_target_path_vulkan = &quot;fsi&quot;</code> in the config, otherwise some races break with visual artifacts. Setup documented in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/xbox360-pgr.md"target="_blank" rel="noopener"><code>docs/xbox360-pgr.md</code></a>. PGR4 audio on NVIDIA still has an XMA decoding issue that produces intermittent garbage. There&rsquo;s an open xenia-canary issue, no resolution so far.</p>
<h3>Batch Title Updates<span class="hx:absolute hx:-mt-20" id="batch-title-updates"></span>
    <a href="#batch-title-updates" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Title Updates for Xbox 360 games were distributed via Xbox Live, which has been shut down. The alternative is archive.org, which has the complete catalog preserved. I wrote <code>scripts/download-xbox360-tus.py</code> to automate:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">distrobox enter gaming -- python3 scripts/download-xbox360-tus.py <span class="se">\
</span></span></span><span class="line"><span class="cl">  --src /mnt/terachad/Emulators/EmuDeck/roms_heavy/xbox360 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dest /mnt/terachad/Emulators/EmuDeck/roms_heavy/xbox360-updates <span class="se">\
</span></span></span><span class="line"><span class="cl">  --dry-run</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The script needs the <code>internetarchive</code> CLI authenticated (<code>ia configure</code> once, stores the token). It scans the games folder, matches against the archive.org manifest (cached locally), prioritizes by region (USA &gt; World &gt; Europe &gt; Japan), and downloads via <code>ia download --checksum</code> with automatic retry. The resulting .zip files go to Xenia Manager via <code>Manage → Install Content</code>, which extracts into the correct directory (<code>000B0000/</code>).</p>
<p>Full setup in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/xbox360-title-updates.md"target="_blank" rel="noopener"><code>docs/xbox360-title-updates.md</code></a>.</p>
<h2>Steam in the distrobox<span class="hx:absolute hx:-mt-20" id="steam-in-the-distrobox"></span>
    <a href="#steam-in-the-distrobox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since Proton became a first-class citizen on Linux, it makes sense to have Steam in the gaming box too. I added the <code>steam</code> package via Ansible (which pulls in multilib and 32-bit deps), mounted <code>/mnt/data/steam</code> read-write so the library persists outside the container, and created a host-side launcher.</p>
<p>In practice, this lets me run Steam games via Proton side-by-side with emulation, without switching environments. The mini-PC with the RTX 4090 is still the main Steam machine in the sim racing cockpit. The desktop is fallback and a test environment.</p>
<h2>Shell and controllers<span class="hx:absolute hx:-mt-20" id="shell-and-controllers"></span>
    <a href="#shell-and-controllers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Minimal in-box shell<span class="hx:absolute hx:-mt-20" id="minimal-in-box-shell"></span>
    <a href="#minimal-in-box-shell" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When I <code>distrobox enter gaming</code>, I want a decent prompt even for quick troubleshooting. I installed <code>zsh</code> + <code>starship</code> with a minimal config, zero exotic plugins. It&rsquo;s not my full dev setup, it&rsquo;s just &ldquo;a prompt that shows path and git branch without embarrassing me&rdquo;.</p>
<h3>Dolphin and the modern-controller hell<span class="hx:absolute hx:-mt-20" id="dolphin-and-the-modern-controller-hell"></span>
    <a href="#dolphin-and-the-modern-controller-hell" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>Dolphin</code> has always been the king of manual friction. If you use a modern controller, like my 8BitDo Ultimate 2, you have to remember how I like the GameCube mapping, how I adapt the Wii Remote, when to use a Nunchuk profile, when to switch to Classic Controller. I don&rsquo;t have the patience to rebuild that by hand every time. Profiles are pre-built in <code>config/emulator-overrides/dolphin/Profiles/</code> and copied by the <code>seed_configs</code> role. Details in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/controller-hotkeys.md"target="_blank" rel="noopener"><code>docs/controller-hotkeys.md</code></a>.</p>
<h2>A few stumbles that stuck around<span class="hx:absolute hx:-mt-20" id="a-few-stumbles-that-stuck-around"></span>
    <a href="#a-few-stumbles-that-stuck-around" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Not everything became magic. Emulation always has banana peels.</p>
<p><code>Flycast</code>, for example, has an irritating trap. The <code>emu.cfg</code> file uses <code>rend.*</code> keys inside the <code>[config]</code> section. If you create a separate <code>[rend]</code> section, it looks right, but the emulator just ignores it and later rewrites everything with mediocre defaults. The fix became a dedicated wrapper at <code>$DG_BOX_HOME/bin/flycast-hires</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">$DG_BOX_HOME</span>/bin/flycast-hires <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:pvr.rend<span class="o">=</span><span class="m">4</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:rend.Resolution<span class="o">=</span><span class="m">2880</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:rend.EmulateFramebuffer<span class="o">=</span>no <span class="se">\
</span></span></span><span class="line"><span class="cl">  -config config:rend.WideScreen<span class="o">=</span>yes</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Details in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/flycast-resolution.md"target="_blank" rel="noopener"><code>docs/flycast-resolution.md</code></a>.</p>
<p><code>shadPS4</code> is still far from a closed case. The current setup is focused on <code>Driveclub</code>, with a mirrored config, XML patch and sys_modules links from firmware 11.00. And that wasn&rsquo;t random: I really like <code>Driveclub</code>. It&rsquo;s practically the only game still stuck on PS4 that I really wanted to get running properly. I&rsquo;ve seen several YouTube videos showing the game working, but so far I haven&rsquo;t been able to get it running satisfactorily on Linux myself. Documented in <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/driveclub-shadps4.md"target="_blank" rel="noopener"><code>docs/driveclub-shadps4.md</code></a>. The good part is that I no longer depend on muscle memory to remember how to launch it, with which patch, in which folder, with which modules. The bad part is that PS4 emulation is still PS4 emulation. No script makes upstream mature faster. If anyone knows how to close this setup on Linux, check the project on GitHub and send a PR.</p>
<p>Ridge Racer V has car-texture flickering documented in PCSX2 issues (#3639, #13729). The Hardware renderer is acceptable for a racing game where you&rsquo;re not standing still admiring the model. Software renderer fixes it but kills performance. I chose to live with the flicker.</p>
<h2>The real gain isn&rsquo;t just automation<span class="hx:absolute hx:-mt-20" id="the-real-gain-isnt-just-automation"></span>
    <a href="#the-real-gain-isnt-just-automation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;d be easy to sum this up as &ldquo;look how cool, I used AI to automate shell scripts&rdquo;. That&rsquo;s not it.</p>
<p>The real gain is more mundane and more important: I stopped spending mental energy on manual work. I didn&rsquo;t have to remember rare commands. I didn&rsquo;t have to keep dozens of tabs of wiki, issue, forum, README. I didn&rsquo;t have to leave half a dozen terminals scattered tailing logs while I try to remember which hidden GUI option the emulator insists on overwriting on the first boot. I started working in conversation.</p>
<p>I tell the agent the goal. It looks up the files, reads the logs, finds the right config format, compares defaults, proposes fixes, writes scripts, validates paths, checks UID/GID, verifies broken symlinks, generates wrappers, exports launchers. I&rsquo;m still responsible for decisions, of course. But I stopped being a typist of rare commands.</p>
<p>That&rsquo;s the point that interests me most about using coding agents on Linux. They dramatically reduce the entry friction. A lot of people bounce off the Linux desktop not because the system is incapable, but because the tuning curve has historically been too irritating. Having an assistant capable of reading docs, cross-referencing configs, proposing automation and executing under supervision changes that game.</p>
<h2>If you want to reproduce<span class="hx:absolute hx:-mt-20" id="if-you-want-to-reproduce"></span>
    <a href="#if-you-want-to-reproduce" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The repo was published for this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/distrobox-gaming.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> distrobox-gaming/ansible
</span></span><span class="line"><span class="cl">ansible-galaxy collection install -r collections/requirements.yml
</span></span><span class="line"><span class="cl">cp host_vars/localhost.yml.example host_vars/localhost.yml
</span></span><span class="line"><span class="cl"><span class="nv">$EDITOR</span> host_vars/localhost.yml  <span class="c1"># adjust paths for your machine</span>
</span></span><span class="line"><span class="cl">ansible-playbook site.yml</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Xenia is opt-in:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ansible-playbook install-xenia.yml</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Read the <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/README.md"target="_blank" rel="noopener"><code>README.md</code></a> and <a href="https://github.com/akitaonrails/distrobox-gaming/blob/master/docs/rebuild-runbook.md"target="_blank" rel="noopener"><code>docs/rebuild-runbook.md</code></a> first. I don&rsquo;t distribute ROMs, BIOS, firmware or keys. The repo only detects, links and configures what you already have on your own machine.</p>
<h2>If you prefer the Claude Code route<span class="hx:absolute hx:-mt-20" id="if-you-prefer-the-claude-code-route"></span>
    <a href="#if-you-prefer-the-claude-code-route" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>My recommendation is simple:</p>
<ol>
<li>Start with the goal, not the command. &ldquo;I want an Arch distrobox with NVIDIA GPU, separate home, ROMs read-only and Steam rw&rdquo; beats dumping a half shell command without context.</li>
<li>Always ask it to document what it&rsquo;s doing. If it works, promote the result to a script or role. If you don&rsquo;t document, you just manufactured a throwaway hack.</li>
<li>Work in phases. Box creation. Bootstrap. Emulator config. Launcher export. Verification. That&rsquo;s exactly how I broke down the problem.</li>
<li>Ask for objective checks. <code>command -v</code>, file existence, broken symlinks, UID/GID, ROM paths, audio, GPU. The best automation is the one that fails early.</li>
<li>Don&rsquo;t use the agent as a command parrot. Use it as a technical assistant. You still review the decisions and tell it to adjust course.</li>
</ol>
<p>For me, that was the gain. I went from zero to a much more complete emulation machine without having to customize everything by hand, GUI by GUI, one window at a time. And this time, I finished with energy left to do what I wanted from the start.</p>
<p>Play.</p>
]]></content:encoded><category>emulation</category><category>containers</category><category>automation</category></item><item><title>Discord as an Admin Panel | Behind The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/</guid><pubDate>Fri, 20 Feb 2026 03:18:24 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along via the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. This is part 6.&lt;/p&gt;
&lt;p&gt;And don&amp;rsquo;t forget to subscribe to my new newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Every Rails project starts the same way: you build an admin panel. Active Admin, Administrate, or some homegrown CRUD. Tables, forms, buttons. And every week, you end up using 5% of the screens you built.&lt;/p&gt;
&lt;p&gt;Now picture your admin panel as a conversation. You paste a link; the system processes it. You type &lt;code&gt;/week&lt;/code&gt; and see the summary. &lt;code&gt;/approve&lt;/code&gt; and the newsletter is scheduled. &lt;code&gt;/ask akita how does the podcast pipeline work?&lt;/code&gt; and an AI explains the architecture of your own system to you.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along via the tag <a href="/en/tags/the-m-akita-chronicles/">/themakitachronicles</a>. This is part 6.</p>
<p>And don&rsquo;t forget to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Every Rails project starts the same way: you build an admin panel. Active Admin, Administrate, or some homegrown CRUD. Tables, forms, buttons. And every week, you end up using 5% of the screens you built.</p>
<p>Now picture your admin panel as a conversation. You paste a link; the system processes it. You type <code>/week</code> and see the summary. <code>/approve</code> and the newsletter is scheduled. <code>/ask akita how does the podcast pipeline work?</code> and an AI explains the architecture of your own system to you.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-40-53.jpg" alt="/ask akita"  loading="lazy" /></p>
<p>That&rsquo;s Discord as an operational interface. It&rsquo;s not a hack — it&rsquo;s <strong>the best CRUD interface there is for a team of one</strong>.</p>
<h2>Why Discord Instead of a Web Admin?<span class="hx:absolute hx:-mt-20" id="why-discord-instead-of-a-web-admin"></span>
    <a href="#why-discord-instead-of-a-web-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s be honest about what an admin panel demands:</p>
<ol>
<li><strong>Authentication</strong>: login, session, CSRF, password reset</li>
<li><strong>Authorization</strong>: roles, permissions, who can do what</li>
<li><strong>UI</strong>: tables, pagination, forms, client-side validation</li>
<li><strong>Deployment</strong>: another route, another controller, another set of tests</li>
<li><strong>Mobile</strong>: responsive? App? PWA?</li>
</ol>
<p>And what Discord already gives you for free:</p>
<ol>
<li><strong>Auth</strong>: Discord handles it. You just check the <code>author_id</code></li>
<li><strong>Authorization</strong>: a list of admin IDs and you&rsquo;re done</li>
<li><strong>UI</strong>: embeds with colors, fields, thumbnails, reactions as status</li>
<li><strong>Deployment</strong>: zero. Uses what&rsquo;s already deployed</li>
<li><strong>Mobile</strong>: native iOS and Android app, push notifications</li>
</ol>
<p>The trade-off? It doesn&rsquo;t fit everything. A complex CRUD with 20-field forms? Better off with a web admin. But for command/response operations — which are 90% of what you do day to day — Discord is unbeatable.</p>
<h2>discordrb: A Gem That Works<span class="hx:absolute hx:-mt-20" id="discordrb-a-gem-that-works"></span>
    <a href="#discordrb-a-gem-that-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="https://github.com/shardlab/discordrb"target="_blank" rel="noopener"><code>discordrb</code></a> gem (shardlab fork, version 3.7.2) is the most mature one for Ruby. It&rsquo;s not perfect — it has its quirks. But it genuinely works in production.</p>
<p>The minimal setup:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">intents</span><span class="p">:</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:server_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="no">Discordrb</span><span class="o">::</span><span class="no">INTENTS</span><span class="o">[</span><span class="ss">:direct_messages</span><span class="o">]</span> <span class="o">|</span>
</span></span><span class="line"><span class="cl">           <span class="p">(</span><span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">15</span><span class="p">)</span>  <span class="c1"># message_content intent</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Every message flows through here</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">run</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That <code>(1 &lt;&lt; 15)</code> is the <em>Message Content Intent</em>. Without it, your bot receives events but <code>event.content</code> comes in empty. You need to enable it in the Discord Developer Portal &gt; Bot &gt; Privileged Gateway Intents. That&rsquo;s the first trap that catches everyone.</p>
<p><a href="https://discord.com/developers/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-58-46.jpg" alt="discord dashboard"  loading="lazy" /></a></p>
<p>I won&rsquo;t walk through how to set up a Discord bot, there are <a href="https://www.ionos.com/digitalguide/server/know-how/creating-discord-bot/"target="_blank" rel="noopener">plenty of tutorials</a> for that already, easy to find online. I&rsquo;ll focus only on my use case as an example.</p>
<h2>Pattern 1: Parser + Dispatcher<span class="hx:absolute hx:-mt-20" id="pattern-1-parser--dispatcher"></span>
    <a href="#pattern-1-parser--dispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Mixing command parsing with business logic is a recipe for spaghetti. Split it into three layers:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Discord Event → Parser → Dispatcher → Job/Service</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The runner (the thing that talks to the Discord API) is deliberately dumb:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">message</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="k">next</span> <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">author</span><span class="o">.</span><span class="n">bot_account?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">parsed</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">event</span><span class="o">.</span><span class="n">content</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">urls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">parsed</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span>
</span></span><span class="line"><span class="cl">    <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">parsed</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{2705}&#34;</span><span class="p">)</span>  <span class="c1"># checkmark</span>
</span></span><span class="line"><span class="cl">  <span class="k">when</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Submission</span>
</span></span><span class="line"><span class="cl">    <span class="no">ProcessMessageJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span><span class="ss">url</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">url</span><span class="p">,</span> <span class="ss">tags</span><span class="p">:</span> <span class="n">parsed</span><span class="o">.</span><span class="n">tags</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{1F916}&#34;</span><span class="p">)</span>  <span class="c1"># robot</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">react</span><span class="p">(</span><span class="s2">&#34;\u{274C}&#34;</span><span class="p">)</span>  <span class="c1"># red X</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Three instant visual results: checkmark (command accepted), robot (URL being processed), red X (error). The user knows what happened without waiting for any text response.</p>
<p>The parser returns typed objects, not strings. The dispatcher runs a giant <code>case</code> but each handler is an isolated method. Each handler calls a job or service. <strong>No business logic in the runner.</strong></p>
<h3>Evolution: Discord Application Commands<span class="hx:absolute hx:-mt-20" id="evolution-discord-application-commands"></span>
    <a href="#evolution-discord-application-commands" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The initial version parsed raw text — the user typed <code>/week</code> and the parser interpreted it. It works, but it has limits: no autocomplete, no argument validation, no inline documentation.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-56-15.jpg" alt="help"  loading="lazy" /></p>
<p>Discord has a native <strong>Application Commands</strong> system — slash commands registered with the API that show up with autocomplete, description, and typed parameters. When the user types <code>/</code>, Discord displays every available command with its description.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;List this week&#39;s stories&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:approve</span><span class="p">,</span> <span class="s2">&#34;Approve newsletter for sending&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:ask</span><span class="p">,</span> <span class="s2">&#34;Ask AI a question&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">guild_id</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;question&#34;</span><span class="p">,</span> <span class="s2">&#34;Your question&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="s2">&#34;personality&#34;</span><span class="p">,</span> <span class="s2">&#34;AI personality&#34;</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span><span class="p">,</span> <span class="s2">&#34;Marvin&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The handler uses <code>bot.application_command</code> instead of parsing text:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch_week</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Listing stories...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Both systems coexist: Application Commands for those who prefer autocomplete, raw text for those who prefer typing fast. The dispatcher is the same — the only difference is how the command arrives.</p>
<h2>Pattern 2: Reactions as Status<span class="hx:absolute hx:-mt-20" id="pattern-2-reactions-as-status"></span>
    <a href="#pattern-2-reactions-as-status" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-55.jpg" alt="reactions"  loading="lazy" /></p>
<p>The most underrated feature of Discord for bots is reactions. They&rsquo;re instant visual feedback that doesn&rsquo;t pollute the channel with messages:</p>
<ul>
<li><strong>Checkmark</strong> = command received and queued</li>
<li><strong>Robot</strong> = async processing started</li>
<li><strong>Red X</strong> = error (details in the embed that follows)</li>
</ul>
<p>The user pastes a link. Sees the robot appear. 30 seconds later, a rich embed shows up with title, summary, image and AI commentary. If something fails, a red embed appears with the error message.</p>
<p>That beats any loading spinner in a web UI.</p>
<h2>Pattern 3: Rich Embeds as Dashboard<span class="hx:absolute hx:-mt-20" id="pattern-3-rich-embeds-as-dashboard"></span>
    <a href="#pattern-3-rich-embeds-as-dashboard" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-44-36.jpg" alt="new story"  loading="lazy" /></p>
<p>Discord embeds are surprisingly powerful:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;Story Preview&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">url</span><span class="p">:</span> <span class="n">source_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFF4500</span><span class="p">,</span>           <span class="c1"># orange-red for high priority</span>
</span></span><span class="line"><span class="cl">  <span class="ss">description</span><span class="p">:</span> <span class="n">summary</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">thumbnail</span><span class="p">:</span> <span class="p">{</span> <span class="ss">url</span><span class="p">:</span> <span class="n">image</span> <span class="p">},</span>  <span class="c1"># article image</span>
</span></span><span class="line"><span class="cl">  <span class="ss">fields</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Akita diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">akita_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;M.Arvin diz&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="n">marvin_comment</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;File&#34;</span><span class="p">,</span> <span class="ss">value</span><span class="p">:</span> <span class="s2">&#34;`stories/2026-02-16-titulo.md`&#34;</span><span class="p">,</span> <span class="ss">inline</span><span class="p">:</span> <span class="kp">false</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="ss">footer</span><span class="p">:</span> <span class="p">{</span> <span class="ss">text</span><span class="p">:</span> <span class="s2">&#34;fire high | tech_news | verified&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="no">Discordrb</span><span class="o">::</span><span class="no">API</span><span class="o">::</span><span class="no">Channel</span><span class="o">.</span><span class="n">create_message</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">,</span> <span class="kp">false</span><span class="p">,</span> <span class="o">[</span><span class="n">embed</span><span class="o">]</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Colors signal priority (red = high, amber = medium, green = low). The footer shows score, section and fact-check status all on a single line. You glance at it and know the state of things.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-45-11.jpg" alt="week list"  loading="lazy" /></p>
<p>For reports, multiple embeds side by side (Discord accepts up to 10 per message) work as a mini-dashboard. The <code>/week</code> command lists every story with inline actions — <code>/load @a3f2b1</code> to see details, <code>/delete @a3f2b1</code> to remove it. The hash is stable (based on the file path), so it doesn&rsquo;t shift when you delete another story.</p>
<h2>Pattern 4: Context Per Channel<span class="hx:absolute hx:-mt-20" id="pattern-4-context-per-channel"></span>
    <a href="#pattern-4-context-per-channel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-46-29.jpg" alt="load"  loading="lazy" /></p>
<p>When the user types <code>/load #3</code>, the #3 refers to the most recent listing. But what if two channels are being used at the same time?</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ChannelContext</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="no">EXPIRY_DURATION</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">for_channel</span><span class="p">(</span><span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">find_or_create_by!</span><span class="p">(</span><span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">set_week_stories!</span><span class="p">(</span><span class="nb">hash</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update!</span><span class="p">(</span><span class="ss">week_stories_json</span><span class="p">:</span> <span class="nb">hash</span><span class="o">.</span><span class="n">to_json</span><span class="p">,</span> <span class="ss">expires_at</span><span class="p">:</span> <span class="no">EXPIRY_DURATION</span><span class="o">.</span><span class="n">from_now</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">resolve_story_reference</span><span class="p">(</span><span class="n">ref</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A@[a-f0-9]+\z/i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># Hash reference (stable across re-listings)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">hash</span> <span class="o">=</span> <span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;@&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">downcase</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">path</span><span class="o">|</span> <span class="n">md5_prefix</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="o">==</span> <span class="nb">hash</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">elsif</span> <span class="n">ref</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A#?\d+\z/</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">week_stories</span><span class="o">[</span><span class="n">ref</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="s2">&#34;#&#34;</span><span class="p">)</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each channel has its own context with a 2-hour expiration. <code>/week</code> stores the number→file mapping. Then <code>/load #3</code> queries that context. If it expired, the bot says &ldquo;run <code>/week</code> first&rdquo;.</p>
<p>And the most useful detail: hash-based references (<code>@a3f2b1</code>) instead of numbers. When you delete story #2, #3 becomes #2 — but the hash <code>@a3f2b1</code> still points to the same file. That avoids the classic &ldquo;I deleted the wrong one because the list shifted&rdquo;.</p>
<h2>Pattern 5: Simple Admin Check<span class="hx:absolute hx:-mt-20" id="pattern-5-simple-admin-check"></span>
    <a href="#pattern-5-simple-admin-check" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No complex RBAC. A list of IDs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">ADMIN_COMMANDS</span> <span class="o">=</span> <span class="o">%</span><span class="n">i</span><span class="o">[</span><span class="n">delete</span> <span class="n">update_score</span> <span class="n">approve</span> <span class="n">newsletter</span> <span class="n">publish_blog</span><span class="o">].</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">author_id</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">ADMIN_COMMANDS</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">command</span><span class="o">.</span><span class="n">type</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">send_error</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="s2">&#34;Permission denied. Admin privileges required.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">admin?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_ADMIN_USER_IDS&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;,&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">admin_ids</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># dev: no IDs = everything allowed</span>
</span></span><span class="line"><span class="cl">  <span class="n">admin_ids</span><span class="o">.</span><span class="n">include?</span><span class="p">(</span><span class="n">author_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In dev, without <code>DISCORD_ADMIN_USER_IDS</code> set, everything is allowed. In production, it&rsquo;s a list of Discord user IDs. No database, no roles table, no middleware. Ten lines handle authorization.</p>
<h2>Pattern 6: Async Operations with Feedback<span class="hx:absolute hx:-mt-20" id="pattern-6-async-operations-with-feedback"></span>
    <a href="#pattern-6-async-operations-with-feedback" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Slow operations (generating an image, processing an article, calling an AI) go to background jobs. Visual feedback comes in three beats:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">handle_ask</span><span class="p">(</span><span class="n">personality</span><span class="p">,</span> <span class="n">question</span><span class="p">,</span> <span class="n">attachments</span><span class="p">,</span> <span class="n">channel_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 1. Immediate feedback: &#34;I&#39;m thinking&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">thinking_id</span> <span class="o">=</span> <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Thinking...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 2. Job does the heavy lifting in the background</span>
</span></span><span class="line"><span class="cl">  <span class="no">AskJob</span><span class="o">.</span><span class="n">perform_later</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">question</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">channel_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="n">thinking_id</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In AskJob:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">channel_id</span><span class="p">:,</span> <span class="ss">thinking_message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">result</span> <span class="o">=</span> <span class="no">AiChat</span><span class="o">.</span><span class="n">ask_multimodal</span><span class="p">(</span><span class="n">question</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="o">[</span><span class="no">GenerateImageTool</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 3. Remove &#34;Thinking...&#34; and show result</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">delete_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">thinking_message_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;**Marvin:** </span><span class="si">#{</span><span class="n">result</span><span class="o">[</span><span class="ss">:text</span><span class="o">]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-47-26.jpg" alt="thinking"  loading="lazy" /></p>
<p>The &ldquo;Thinking&hellip;&rdquo; shows up instantly, the job runs in the background, and when it finishes the temporary message disappears and the real result appears. If the job fails, &ldquo;Thinking&hellip;&rdquo; disappears and an error embed shows up.</p>
<p>Without this, the user sends a question and stares at the screen not knowing if the bot heard them. Terrible UX.</p>
<h2>Pattern 7: RubyLLM + Tool Calling on Discord<span class="hx:absolute hx:-mt-20" id="pattern-7-rubyllm--tool-calling-on-discord"></span>
    <a href="#pattern-7-rubyllm--tool-calling-on-discord" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The most powerful part: giving the LLM tools. <code>/ask</code> has three tools available: <code>GenerateImageTool</code> (generates images via Gemini), <code>WebSearchTool</code> (searches the web via Brave Search), and <code>WebFetchTool</code> (fetches and summarizes web pages). The LLM decides on its own which one to use based on the question. When the user asks &ldquo;generate an image of a cat programming&rdquo;, the LLM picks the right tool:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">GenerateImageTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
</span></span><span class="line"><span class="cl">  <span class="n">description</span> <span class="s2">&#34;Generate an image using Gemini. Use when the user asks to create or draw.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:prompt</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;Image description&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">param</span> <span class="ss">:aspect_ratio</span><span class="p">,</span> <span class="ss">desc</span><span class="p">:</span> <span class="s2">&#34;1:1 or 16:9&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:,</span> <span class="ss">aspect_ratio</span><span class="p">:</span> <span class="s2">&#34;1:1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">image_data</span> <span class="o">=</span> <span class="no">ImageGenerator</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span><span class="ss">prompt</span><span class="p">:</span> <span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;Failed to generate image&#34;</span> <span class="k">unless</span> <span class="n">image_data</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">save_temp_file</span><span class="p">(</span><span class="n">image_data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Thread</span><span class="o">.</span><span class="n">current</span><span class="o">[</span><span class="ss">:generated_images</span><span class="o">]</span> <span class="o">&lt;&lt;</span> <span class="n">path</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Image generated successfully. Tell the user it&#39;s ready.&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>Thread.current[:generated_images]</code> is the trick to pass data back from the tool to the job. The LLM calls the tool during <code>chat.ask</code>, the tool saves the file and registers the path. After <code>chat.ask</code> returns, the job grabs the paths and sends them to Discord as file uploads.</p>
<p>Why thread-local and not a direct return? Because the LLM can call multiple tools within a single response, and the tool&rsquo;s return value goes back to the LLM (not to your code) — the LLM decides what to say to the user. Thread-local is the side channel for your code to know what happened.</p>
<h2>Pattern 8: Production Status Notifications<span class="hx:absolute hx:-mt-20" id="pattern-8-production-status-notifications"></span>
    <a href="#pattern-8-production-status-notifications" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every content generation job notifies a dedicated channel:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordStatus</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_start</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;Starting </span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2">...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_done</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> complete.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">notify_error</span><span class="p">(</span><span class="n">label</span><span class="p">,</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_to_status_channel</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="n">label</span><span class="si">}</span><span class="s2"> FAILED: </span><span class="si">#{</span><span class="n">error</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The status channel is separate from the operations channel. When the 8 content generation jobs run on Sunday at 5pm, the status channel turns into a real-time log:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>Starting Anime Ranking...
Starting Hacker News...
Starting YouTube Digest...
Anime Ranking complete.
YouTube Digest complete.
Hacker News complete.
Starting Market Recap...  (wave 2, after world events)
...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In the morning, if something went wrong, you open Discord on your phone and you know exactly what — no SSH into the server, no grep in logs.</p>
<p>And when the bot restarts after a deploy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">status_channel</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">[</span><span class="s2">&#34;DISCORD_STATUS_CHANNEL_ID&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;Bot reconnected and ready.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-48-20.jpg" alt="log"  loading="lazy" /></p>
<p>Simple, but you know the deploy worked without checking anything.</p>
<h2>Pattern 9: Cross-App Communication<span class="hx:absolute hx:-mt-20" id="pattern-9-cross-app-communication"></span>
    <a href="#pattern-9-cross-app-communication" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The system has two Rails apps that need to talk to each other. The newsletter needs to notify Discord when it finishes assembling an edition. But the newsletter has no Discord connection — marvin-bot is the one that does.</p>
<p>Solution: HTTP relay.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># In the newsletter — send it to marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DiscordRelayClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">send_embed</span><span class="p">(</span><span class="ss">title</span><span class="p">:,</span> <span class="ss">description</span><span class="p">:,</span> <span class="ss">color</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/discord_notify&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;embed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">payload</span><span class="p">:</span> <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span> <span class="ss">description</span><span class="p">:</span> <span class="n">description</span><span class="p">,</span> <span class="ss">color</span><span class="p">:</span> <span class="n">color</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In marvin-bot — receive it and forward to Discord</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Api</span><span class="o">::</span><span class="no">DiscordNotifyController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">create</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="n">params</span><span class="o">[</span><span class="ss">:type</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;embed&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">when</span> <span class="s2">&#34;message&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_message</span><span class="p">(</span><span class="n">status_channel</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">params</span><span class="o">[</span><span class="ss">:payload</span><span class="o">][</span><span class="ss">:content</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The newsletter speaks HTTP. marvin-bot translates it to Discord. A shared <code>ADMIN_TOKEN</code> authenticates. Every method rescues errors and returns nil — if Discord is down, the newsletter doesn&rsquo;t lock up. Fire and forget.</p>
<p>But there&rsquo;s a second, more interesting cross-app flow: the podcast trigger. When the newsletter is approved, it needs to tell marvin-bot to generate the podcast:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># In the newsletter — trigger podcast generation on marvin-bot</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MarvinBotClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">trigger_podcast</span><span class="p">(</span><span class="ss">date</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">post</span><span class="p">(</span><span class="s2">&#34;/api/trigger/podcast&#34;</span><span class="p">,</span> <span class="ss">date</span><span class="p">:</span> <span class="n">date</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That kicks off an entire pipeline on marvin-bot: two-pass LLM script → per-turn TTS on the GPU → assembly with loudnorm → S3 upload. And the newsletter&rsquo;s <code>PublishAndSendJob</code> waits (via <code>retry_on PodcastNotReady</code>) until the podcast is ready. Two apps, two backend languages (Ruby and Python), connected by fire-and-forget HTTP with result polling. It&rsquo;s not elegant — but it works every single week with no intervention.</p>
<h2>Pattern 10: Integrated Cost Tracking<span class="hx:absolute hx:-mt-20" id="pattern-10-integrated-cost-tracking"></span>
    <a href="#pattern-10-integrated-cost-tracking" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every AI call is tracked with tokens and estimated cost:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">create_chat</span><span class="p">(</span><span class="nb">caller</span><span class="p">:,</span> <span class="ss">model</span><span class="p">:,</span> <span class="ss">provider</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span><span class="ss">model</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="ss">provider</span><span class="p">:</span> <span class="n">provider</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span><span class="o">.</span><span class="n">on_end_message</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="no">ApiUsage</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">caller_name</span><span class="p">:</span> <span class="nb">caller</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">input_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">input_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">output_tokens</span><span class="p">:</span> <span class="n">message</span><span class="o">.</span><span class="n">output_tokens</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">cost_usd</span><span class="p">:</span> <span class="n">estimate_cost</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">chat</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And on Discord: <code>/cost</code> shows spending by month, year, and by provider. No separate dashboard, no Grafana, nothing. One command and the information that matters.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-49-05.jpg" alt="cost"  loading="lazy" /></p>
<h2>Pattern 11: Never Fail Silently<span class="hx:absolute hx:-mt-20" id="pattern-11-never-fail-silently"></span>
    <a href="#pattern-11-never-fail-silently" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the pattern I should have implemented from the start, but I only learned it the hard way — in production.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-46.jpg" alt="daily"  loading="lazy" /></p>
<p>The project has ~10 daily jobs that fetch data from external sources (Hacker News, GitHub Trending, Morningstar, etc.) and post embeds to Discord. Each client follows a &ldquo;defensive&rdquo; pattern:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># The pattern that LOOKS right:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDevClient</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="no">API_URL</span><span class="p">,</span> <span class="ss">headers</span><span class="p">:</span> <span class="p">{</span> <span class="s2">&#34;Authorization&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;Bearer </span><span class="si">#{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">})</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</span>
</span></span><span class="line"><span class="cl">    <span class="n">parse</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;DailyDevClient failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># And in the job:</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DailyDailyDevJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>  <span class="c1"># ← THE PROBLEM</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... posts embed to Discord</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This code never crashes. Never raises an exception. Never fills the log with errors. It&rsquo;s the most &ldquo;robust&rdquo; code possible.</p>
<p>It&rsquo;s also completely <strong>useless for diagnosis</strong>. When I deployed and ran the daily jobs manually, only one of ten worked. But there was <strong>no</strong> error in the logs. None. <code>return [] unless response.success?</code> swallowed the HTTP 401 without logging. <code>return if posts.empty?</code> discarded the result without telling anyone. All green, all silent, zero data.</p>
<p>The fix has two parts:</p>
<p><strong>Part 1: Clients log the HTTP code:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">unless</span> <span class="n">response</span><span class="o">.</span><span class="n">success?</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;DailyDevClient: API returned HTTP </span><span class="si">#{</span><span class="n">response</span><span class="o">.</span><span class="n">code</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It sounds obvious, but it&rsquo;s easy to forget when you write <code>return []</code> as a defensive reflex. <code>response.code</code> is the information that distinguishes &ldquo;expired token&rdquo; (401) from &ldquo;rate limited&rdquo; (429) from &ldquo;API down&rdquo; (503). Without it, everything looks the same: empty array.</p>
<p><strong>Part 2: Jobs warn on Discord when they have no data:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DailyDiscordEmbed</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">report_empty</span><span class="p">(</span><span class="n">title</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">: </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2"> returned no results&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">channel_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">channel_ids</span><span class="o">.</span><span class="n">first</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">channel_id</span><span class="o">.</span><span class="n">present?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">embed</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">title</span><span class="p">:</span> <span class="s2">&#34;⚠️ </span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> returned no results. Check logs.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">color</span><span class="p">:</span> <span class="mh">0xFFA500</span><span class="p">,</span>  <span class="c1"># orange = warning, not red = error</span>
</span></span><span class="line"><span class="cl">      <span class="ss">timestamp</span><span class="p">:</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="o">.</span><span class="n">iso8601</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordNotifier</span><span class="o">.</span><span class="n">send_embed</span><span class="p">(</span><span class="n">channel_id</span><span class="p">,</span> <span class="n">embed</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">#{</span><span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2"> empty report failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now when a daily job brings back no data:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">  <span class="n">posts</span> <span class="o">=</span> <span class="no">DailyDevClient</span><span class="o">.</span><span class="n">fetch_popular</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">posts</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">    <span class="n">report_empty</span><span class="p">(</span><span class="s2">&#34;daily.dev Popular&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-50-02.jpg" alt="no results"  loading="lazy" /></p>
<p>An orange embed appears on Discord. In the morning, I open the channel and see: &ldquo;⚠️ daily.dev Popular returned no results&rdquo;. I check the logs and find: &ldquo;DailyDevClient: API returned HTTP 401&rdquo;. Expired token. Fixed in 2 minutes.</p>
<p>Without this pattern, I would have discovered it weeks later — when someone asked &ldquo;why are there never any daily.dev posts?&rdquo;.</p>
<p>The lesson: <strong><code>rescue =&gt; []</code> is not error handling. It&rsquo;s error hiding.</strong> Every failure point needs two signals: a log with the technical detail (HTTP code, exception message) and a visual notification in the channel where you&rsquo;re already looking (Discord). If the bot IS your operational interface, bot failures need to show up ON the interface.</p>
<h2>Pattern 12: Conversations With Memory (Multi-Turn <code>/ask</code>)<span class="hx:absolute hx:-mt-20" id="pattern-12-conversations-with-memory-multi-turn-ask"></span>
    <a href="#pattern-12-conversations-with-memory-multi-turn-ask" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first version of <code>/ask</code> created a brand new <code>RubyLLM::Chat</code> on every invocation. It worked, but it was like talking to someone with amnesia:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask what is Ruby?
→ Ruby is a programming language created by Matz in 1995...

/ask compare it with Python
→ Compare what with Python? Could you be more specific?</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>ruby_llm already keeps history internally — every <code>chat.ask()</code> appends to the message list and sends the full context to the model. What was missing was persisting the <code>Chat</code> object between invocations.</p>
<p>The solution is an in-memory store with three safeguards:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">AskSessionStore</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_SESSIONS</span> <span class="o">=</span> <span class="mi">100</span>       <span class="c1"># total across all users</span>
</span></span><span class="line"><span class="cl">  <span class="no">SESSION_TTL</span> <span class="o">=</span> <span class="mi">2</span><span class="o">.</span><span class="n">hours</span>    <span class="c1"># inactivity timeout</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_MESSAGES</span> <span class="o">=</span> <span class="mi">50</span>        <span class="c1"># ~25 exchanges, then auto-reset</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="no">Session</span> <span class="o">=</span> <span class="no">Struct</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">:chat</span><span class="p">,</span> <span class="ss">:last_used</span><span class="p">,</span> <span class="ss">keyword_init</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">get_or_create</span><span class="p">(</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:,</span> <span class="ss">tools</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">    <span class="n">synchronize</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">cleanup_expired</span>
</span></span><span class="line"><span class="cl">      <span class="n">key</span> <span class="o">=</span> <span class="o">[</span><span class="n">author_id</span><span class="p">,</span> <span class="n">personality</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="n">session</span> <span class="o">=</span> <span class="vi">@sessions</span><span class="o">[</span><span class="n">key</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">last_used</span> <span class="o">=</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">session</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">messages</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;</span> <span class="no">MAX_MESSAGES</span>
</span></span><span class="line"><span class="cl">          <span class="vi">@sessions</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">          <span class="k">return</span> <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">end</span>
</span></span><span class="line"><span class="cl">        <span class="n">session</span><span class="o">.</span><span class="n">chat</span>
</span></span><span class="line"><span class="cl">      <span class="k">else</span>
</span></span><span class="line"><span class="cl">        <span class="n">evict_oldest</span> <span class="k">if</span> <span class="vi">@sessions</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;=</span> <span class="no">MAX_SESSIONS</span>
</span></span><span class="line"><span class="cl">        <span class="n">create_session</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="ss">system_prompt</span><span class="p">:</span> <span class="n">system_prompt</span><span class="p">,</span> <span class="ss">tools</span><span class="p">:</span> <span class="n">tools</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-54-32.jpg" alt="ask memory"  loading="lazy" /></p>
<p>The key is <code>(author_id, personality)</code> — each user has separate sessions for Marvin and for Akita. Volatile on purpose: restart the server, lose the history. No database, no serialization, no complexity. It&rsquo;s working memory, not persistence.</p>
<p><code>/ask clear</code> resets the session. Without it, there&rsquo;s no way to &ldquo;start over&rdquo; when the conversation gets tangled.</p>
<p>Now:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/ask what is Ruby?
→ Ruby is a programming language created by Matz in 1995...

/ask compare it with Python
→ Ruby and Python have similar philosophies — both prioritize programmer
  happiness — but Ruby embraces blocks and metaprogramming while Python
  prefers explicitness...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The LLM knows &ldquo;compare&rdquo; refers to Ruby because the session history includes the previous question. That&rsquo;s the difference between a chatbot and an assistant.</p>
<h2>Pattern 13: Registering Application Commands (Native Autocomplete)<span class="hx:absolute hx:-mt-20" id="pattern-13-registering-application-commands-native-autocomplete"></span>
    <a href="#pattern-13-registering-application-commands-native-autocomplete" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The Application Commands section above showed the simplified concept. In practice, registering 18 commands with subcommands, typed parameters and choices demands a dedicated structure. Here&rsquo;s how the project handles it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-43-18.jpg" alt="auto complete"  loading="lazy" /></p>
<h3>The Problem With Raw Text<span class="hx:absolute hx:-mt-20" id="the-problem-with-raw-text"></span>
    <a href="#the-problem-with-raw-text" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When the bot only parses text, the user has to memorize the syntax:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/update score #1 #2 #3 high       ← works
/update score #1 #2 #3 alta       ← fails silently
/update scoree #1 high             ← ignored</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No autocomplete, no parameter validation, no inline documentation. The user types <code>/</code> and has no idea what&rsquo;s available.</p>
<h3>The <code>DiscordCommands</code> Module<span class="hx:absolute hx:-mt-20" id="the-discordcommands-module"></span>
    <a href="#the-discordcommands-module" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>All command definitions live in a separate module with two public methods: <code>register!</code> (registers the commands with the Discord API) and <code>setup_handlers!</code> (wires the handlers to the bot):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">DiscordCommands</span>
</span></span><span class="line"><span class="cl">  <span class="no">SCORE_CHOICES</span>  <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">  <span class="no">SECTION_CHOICES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Tech&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;tech&#34;</span><span class="p">,</span> <span class="s2">&#34;Global&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;global&#34;</span><span class="p">,</span> <span class="s2">&#34;Financial&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;financial&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Q&amp;A&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;qa&#34;</span><span class="p">,</span> <span class="s2">&#34;Entertainment&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;entertainment&#34;</span><span class="p">,</span> <span class="s2">&#34;Open Source&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;opensource&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Product&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;product&#34;</span><span class="p">,</span> <span class="s2">&#34;Book&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;book&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">  <span class="no">PERSONALITY_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;Marvin (default)&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;marvin&#34;</span><span class="p">,</span> <span class="s2">&#34;Akita&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;akita&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">server_id</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">discord</span><span class="o">.</span><span class="n">server_id</span>
</span></span><span class="line"><span class="cl">    <span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">register_week</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">register_ask</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... 14 more commands</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_week_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_add_handler</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_update_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">setup_ask_handlers</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... 14 more handlers</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Guild vs Global<span class="hx:absolute hx:-mt-20" id="guild-vs-global"></span>
    <a href="#guild-vs-global" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>server_id</code> (or <code>sid</code>) controls the registration scope. With <code>server_id</code>, the command shows up instantly on the specified server. Without it, the command is global — available on every server, but can take up to an hour to propagate.</p>
<p>For a private bot doing internal operations, always go guild-scoped. The <code>DISCORD_SERVER_ID</code> env var is set at deploy time:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">sid</span> <span class="o">=</span> <span class="n">server_id</span><span class="o">.</span><span class="n">present?</span> <span class="p">?</span> <span class="n">server_id</span><span class="o">.</span><span class="n">to_i</span> <span class="p">:</span> <span class="kp">nil</span>
</span></span><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:week</span><span class="p">,</span> <span class="s2">&#34;Story list and weekly report&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If <code>sid</code> is nil, discordrb registers globally. In dev it&rsquo;s convenient; in production, guild-scoped is faster and lets you have different commands per server.</p>
<h3>Anatomy of a Command<span class="hx:absolute hx:-mt-20" id="anatomy-of-a-command"></span>
    <a href="#anatomy-of-a-command" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The simplest method — no parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_count</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:count</span><span class="p">,</span> <span class="s2">&#34;Show story count for this week&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With typed parameters:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_add</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:add</span><span class="p">,</span> <span class="s2">&#34;Submit a URL for the newsletter&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:url</span><span class="p">,</span> <span class="s2">&#34;The URL to submit&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:tags</span><span class="p">,</span> <span class="s2">&#34;Tags (e.g. #tech #high)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:comment</span><span class="p">,</span> <span class="s2">&#34;Optional comment about this link&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Discord renders this as an inline form: required <code>url</code> field, optional <code>tags</code> and <code>comment</code>. The user sees each parameter&rsquo;s description as they fill it in.</p>
<h3>Subcommands: Grouping Actions<span class="hx:absolute hx:-mt-20" id="subcommands-grouping-actions"></span>
    <a href="#subcommands-grouping-actions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Complex commands use subcommands. <code>/update</code> on its own does nothing — the user has to pick what to update:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">register_update</span><span class="p">(</span><span class="n">bot</span><span class="p">,</span> <span class="n">sid</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">bot</span><span class="o">.</span><span class="n">register_application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">,</span> <span class="s2">&#34;Update story properties&#34;</span><span class="p">,</span> <span class="ss">server_id</span><span class="p">:</span> <span class="n">sid</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">cmd</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">,</span> <span class="s2">&#34;Update story priority score&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:tag</span><span class="p">,</span> <span class="s2">&#34;Update story section tag&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:section</span><span class="p">,</span> <span class="s2">&#34;Section tag&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SECTION_CHOICES</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... more subcommands: title, description, comment, source</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When the user types <code>/update</code>, Discord shows a list of subcommands: score, tag, title, description, comment, image, source. Pick <code>score</code> and the <code>stories</code> field (free text) and <code>level</code> field (dropdown with High/Medium/Low) appear. Syntax errors become impossible.</p>
<h3>Choices: Native Dropdowns<span class="hx:absolute hx:-mt-20" id="choices-native-dropdowns"></span>
    <a href="#choices-native-dropdowns" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>SCORE_CHOICES</code> hash becomes a dropdown on Discord. The key is the visible label (&ldquo;High&rdquo;), the value is what arrives in the handler (&ldquo;high&rdquo;):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">SCORE_CHOICES</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">&#34;High&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;high&#34;</span><span class="p">,</span> <span class="s2">&#34;Medium&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;medium&#34;</span><span class="p">,</span> <span class="s2">&#34;Low&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;low&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the registration:</span>
</span></span><span class="line"><span class="cl"><span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:level</span><span class="p">,</span> <span class="s2">&#34;Priority level&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">,</span> <span class="ss">choices</span><span class="p">:</span> <span class="no">SCORE_CHOICES</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The user doesn&rsquo;t have to remember whether it&rsquo;s &ldquo;high&rdquo;, &ldquo;HIGH&rdquo;, &ldquo;alta&rdquo;, or &ldquo;3&rdquo;. Pick it from the dropdown and done. That eliminates an entire category of input errors.</p>
<h3>Attachments: File Uploads<span class="hx:absolute hx:-mt-20" id="attachments-file-uploads"></span>
    <a href="#attachments-file-uploads" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Discord supports attachment-type parameters — the user can drag a file straight into the command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">cmd</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:image</span><span class="p">,</span> <span class="s2">&#34;Update story image&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="nb">sub</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">string</span><span class="p">(</span><span class="ss">:stories</span><span class="p">,</span> <span class="s2">&#34;Story numbers (e.g. #1 #2 #3 or 1,2,3)&#34;</span><span class="p">,</span> <span class="ss">required</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="nb">sub</span><span class="o">.</span><span class="n">attachment</span><span class="p">(</span><span class="ss">:file</span><span class="p">,</span> <span class="s2">&#34;Image file (omit for AI generation)&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In the handler, the resolved attachments come through as Discord CDN URLs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">resolve_attachments</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">&amp;.</span><span class="n">attachments</span><span class="o">&amp;.</span><span class="n">any?</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">resolved</span><span class="o">.</span><span class="n">attachments</span><span class="o">.</span><span class="n">values</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Lifecycle: Where to Register, Where to Wire Handlers<span class="hx:absolute hx:-mt-20" id="lifecycle-where-to-register-where-to-wire-handlers"></span>
    <a href="#lifecycle-where-to-register-where-to-wire-handlers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the point that confuses people most. The <strong>handlers</strong> have to be registered <strong>before</strong> <code>bot.run</code>. The <strong>registration with the Discord API</strong> can happen inside <code>bot.ready</code> (which fires after the WebSocket connection):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">MarvinBotRunner</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">run!</span>
</span></span><span class="line"><span class="cl">    <span class="n">bot</span> <span class="o">=</span> <span class="no">Discordrb</span><span class="o">::</span><span class="no">Bot</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">token</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span> <span class="ss">intents</span><span class="p">:</span> <span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># 1. Handlers BEFORE bot.run (otherwise the bot doesn&#39;t know how to respond)</span>
</span></span><span class="line"><span class="cl">    <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">setup_handlers!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">ready</span> <span class="k">do</span> <span class="o">|</span><span class="n">_event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="c1"># 2. API registration AFTER connection (idempotent, safe on reconnect)</span>
</span></span><span class="line"><span class="cl">      <span class="no">DiscordCommands</span><span class="o">.</span><span class="n">register!</span><span class="p">(</span><span class="n">bot</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">bot</span><span class="o">.</span><span class="n">run</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Why inside <code>bot.ready</code>? Because the bot can reconnect (Discord closes WebSockets periodically). <code>register!</code> is idempotent — registering the same command twice doesn&rsquo;t duplicate it, it just updates. Putting it in <code>ready</code> makes sure the commands exist even after a reconnect.</p>
<h3>Bridge: Application Command → CommandDispatcher<span class="hx:absolute hx:-mt-20" id="bridge-application-command--commanddispatcher"></span>
    <a href="#bridge-application-command--commanddispatcher" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The most elegant part of the architecture: the Application Command handler has no business logic. It translates Discord&rsquo;s parameters into the format the <code>CommandDispatcher</code> already expects:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">type</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="o">]</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="o">[]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">event</span><span class="o">.</span><span class="n">respond</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="s2">&#34;\u{2705}&#34;</span><span class="p">,</span> <span class="ss">ephemeral</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span>  <span class="c1"># checkmark visible only to the author</span>
</span></span><span class="line"><span class="cl">  <span class="n">command</span> <span class="o">=</span> <span class="no">SlashCommandParser</span><span class="o">::</span><span class="no">Command</span><span class="o">.</span><span class="n">new</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="ss">type</span><span class="p">:</span> <span class="n">type</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">args</span><span class="p">:</span> <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="n">match</span><span class="p">,</span> <span class="ss">attachments</span><span class="p">:</span> <span class="n">attachments</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="no">CommandDispatcher</span><span class="o">.</span><span class="n">dispatch</span><span class="p">(</span><span class="n">command</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">channel_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">channel</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">message_id</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">author_id</span><span class="p">:</span> <span class="n">event</span><span class="o">.</span><span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">  <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">error</span><span class="p">(</span><span class="s2">&#34;Discord interaction error (</span><span class="si">#{</span><span class="n">type</span><span class="si">}</span><span class="s2">): </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each subcommand handler is a one-liner that assembles the args and calls <code>dispatch_interaction</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">bot</span><span class="o">.</span><span class="n">application_command</span><span class="p">(</span><span class="ss">:update</span><span class="p">)</span><span class="o">.</span><span class="n">subcommand</span><span class="p">(</span><span class="ss">:score</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">stories</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;stories&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">level</span> <span class="o">=</span> <span class="n">event</span><span class="o">.</span><span class="n">options</span><span class="o">[</span><span class="s2">&#34;level&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="n">dispatch_interaction</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="ss">:update_score</span><span class="p">,</span> <span class="ss">match</span><span class="p">:</span> <span class="o">[</span><span class="kp">nil</span><span class="p">,</span> <span class="n">stories</span><span class="p">,</span> <span class="kp">nil</span><span class="p">,</span> <span class="n">level</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>CommandDispatcher</code> doesn&rsquo;t know if the command came from raw text or from an Application Command. Same logic, same authorization, same jobs. No duplicated code.</p>
<h3>The Result<span class="hx:absolute hx:-mt-20" id="the-result"></span>
    <a href="#the-result" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The project registers 18 Application Commands with combinations of subcommands, typed parameters, dropdowns and file uploads. The user types <code>/</code> and sees everything available with descriptions. Pick a command and Discord guides you field by field. Syntax errors are impossible — Discord validates before sending.</p>
<p>And both systems coexist: people who prefer autocomplete use Application Commands, people who prefer to type fast use raw text. <code>CommandDispatcher</code> receives both the same way. Adding a new command means adding a <code>register_*</code>, a <code>setup_*_handler</code>, and a dispatcher method — without touching the runner.</p>
<h2>The discordrb Pitfalls<span class="hx:absolute hx:-mt-20" id="the-discordrb-pitfalls"></span>
    <a href="#the-discordrb-pitfalls" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s not all roses. Some real pain points:</p>
<p><strong>Gateway disconnects</strong>: The WebSocket to Discord drops every once in a while. discordrb reconnects automatically, but your <code>bot.ready</code> and <code>bot.message</code> handlers need to be resilient to reconnects.</p>
<p><strong>Message Content Intent</strong>: Since 2022, bots in more than 75 servers need approval to read message content. For private bots (which is the case for internal operations), that&rsquo;s a non-issue. But if you plan to distribute the bot, get ready for paperwork.</p>
<p><strong>API Rate Limits</strong>: Discord rate-limits per route. Sending 10 embeds at once to the same channel? You&rsquo;ll hit the limit. discordrb handles rate limits internally, but calls that are too fast cause visible delays.</p>
<p><strong>Embed Limits</strong>: Title max 256 chars. Description 4096. Field value 1024. Exceeded them? Silent error. Always truncate before sending.</p>
<p><strong>File Uploads</strong>: 25MB limit on the free plan, 50MB with Nitro. For AI-generated images that&rsquo;s never an issue, but if you&rsquo;re shipping PDFs or audio, be careful.</p>
<h2>When NOT to Use Discord as Admin<span class="hx:absolute hx:-mt-20" id="when-not-to-use-discord-as-admin"></span>
    <a href="#when-not-to-use-discord-as-admin" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><ul>
<li><strong>Complex forms</strong>: editing 15 fields on a record? Web screen</li>
<li><strong>Bulk operations</strong>: importing a CSV with 1000 records? Script</li>
<li><strong>Data visualization</strong>: charts, analytical dashboards? Grafana/Metabase</li>
<li><strong>Multi-step approval</strong>: workflows with 5 approval stages? You need persisted state</li>
</ul>
<p>But for day-to-day operations — curating content, approving newsletters, monitoring jobs, checking costs, talking to AI — Discord is the most productive interface I&rsquo;ve ever used. And the best part: the subscribers will never know their weekly newsletter was curated by a guy typing commands into Discord on his phone while drinking coffee.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-20_00-57-11.jpg" alt="hello"  loading="lazy" /></p>
<p>Using Discord as an admin panel isn&rsquo;t laziness — it&rsquo;s pragmatism. The best interface is the one you already use all day long. Auth, push notifications, mobile, rich formatting, file sharing — all free. You only build the business logic.</p>
<p>The <a href="https://rubyllm.com/"target="_blank" rel="noopener"><code>ruby_llm</code></a> gem with tool calling takes the bot from &ldquo;command executor&rdquo; to &ldquo;assistant that thinks&rdquo;. Add session memory and it thinks <strong>and remembers</strong>. When the LLM can generate images, search the web, keep context across questions, and answer questions about the architecture of its own system — that&rsquo;s when it stops being CRUD and becomes a co-pilot.</p>
<p>And the Parser → Dispatcher → Job pattern keeps everything testable. The parser knows nothing about Discord. The dispatcher knows nothing about AI. The jobs know nothing about the interface. Swap any one of those layers and the others don&rsquo;t even notice.</p>
<p>In the end, the best admin UI is the one that doesn&rsquo;t need a separate deploy. And the worst bug is the one that fails in silence — if the bot is your operational interface, the bot&rsquo;s failures need to show up <strong>on</strong> the interface, not hidden away in a log nobody reads.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>automation</category><category>backend</category></item><item><title>Web Scraping in 2026 | Behind the Scenes of The M.Akita Chronicles</title><link>https://akitaonrails.github.io/en/2026/02/18/web-scraping-in-2026-behind-the-m-akita-chronicles/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/18/web-scraping-in-2026-behind-the-m-akita-chronicles/</guid><pubDate>Wed, 18 Feb 2026 14:57:22 +0000</pubDate><description>&lt;p&gt;This post is part of a series; follow along via the tag &lt;a href="https://akitaonrails.github.io/en/tags/the-m-akita-chronicles/"&gt;/themakitachronicles&lt;/a&gt;. This is part 2.&lt;/p&gt;
&lt;p&gt;And don&amp;rsquo;t forget to subscribe to my new newsletter &lt;a href="https://themakitachronicles.com/"target="_blank" rel="noopener"&gt;The M.Akita Chronicles&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;Ten years ago, fetching data from a website was trivial. Four lines of Ruby: &lt;code&gt;Net::HTTP.get&lt;/code&gt;, Nokogiri parse, CSS selector, done. The whole world was open to any script with a decent User-Agent.&lt;/p&gt;
&lt;p&gt;Today? Half the sites I need to read to put together my newsletter return &amp;ldquo;Are you a robot?&amp;rdquo; for any conventional HTTP request. The other half renders the content entirely in JavaScript — there&amp;rsquo;s no HTML at all in the server response. And the most paranoid ones combine both.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is part of a series; follow along via the tag <a href="/en/tags/the-m-akita-chronicles/">/themakitachronicles</a>. This is part 2.</p>
<p>And don&rsquo;t forget to subscribe to my new newsletter <a href="https://themakitachronicles.com/"target="_blank" rel="noopener">The M.Akita Chronicles</a>!</p>
<p>&ndash;</p>
<p>Ten years ago, fetching data from a website was trivial. Four lines of Ruby: <code>Net::HTTP.get</code>, Nokogiri parse, CSS selector, done. The whole world was open to any script with a decent User-Agent.</p>
<p>Today? Half the sites I need to read to put together my newsletter return &ldquo;Are you a robot?&rdquo; for any conventional HTTP request. The other half renders the content entirely in JavaScript — there&rsquo;s no HTML at all in the server response. And the most paranoid ones combine both.</p>
<p>My goal was to create a daily routine so that M.arvin would deliver the top news to me in the morning on my Discord. That way I wouldn&rsquo;t have to go hunting for the news of the day. Sounded simple&hellip; at first.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-18_11-59-42.jpg" alt="daily routine"  loading="lazy" /></p>
<p>This post is about how I went from &ldquo;one HTTP call&rdquo; to &ldquo;packing an entire Chromium into Docker&rdquo; — and why this is the reality of any project that needs to read the web programmatically in 2026.</p>
<h2>The Golden Age: Simple HTTP<span class="hx:absolute hx:-mt-20" id="the-golden-age-simple-http"></span>
    <a href="#the-golden-age-simple-http" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s start with what still works. Some services offer structured data with zero resistance:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># RSS feeds: the most resilient format on the web</span>
</span></span><span class="line"><span class="cl"><span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="p">(</span><span class="s2">&#34;https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com/world&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">xml</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">uri</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span> <span class="o">=</span> <span class="no">REXML</span><span class="o">::</span><span class="no">Document</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">xml</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span><span class="o">.</span><span class="n">get_elements</span><span class="p">(</span><span class="s2">&#34;//item&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">title</span> <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl">  <span class="n">url</span>   <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;link&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">to_s</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Public APIs: clean JSON, documented, stable</span>
</span></span><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;https://api.jikan.moe/v4/top/anime?filter=airing&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">animes</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">parsed_response</span><span class="o">[</span><span class="s2">&#34;data&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># GitHub trending: server-rendered HTML with no protection</span>
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="no">HTTParty</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;https://github.com/trending&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">body</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">HTML</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">repos</span> <span class="o">=</span> <span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="s2">&#34;article.Box-row&#34;</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Hacker News, GitHub Trending, anime APIs, RSS feeds, Project Gutenberg — they all work with a <code>GET</code> and a parser. No JavaScript, no implicit authentication, no fingerprinting. Just like the web was supposed to be.</p>
<p>The problem is that these are the exception. Sites with financial data, exclusive news, or premium content — exactly the data that matters most — are increasingly armored.</p>
<h2>The First Obstacle: SPAs and JavaScript Rendering<span class="hx:absolute hx:-mt-20" id="the-first-obstacle-spas-and-javascript-rendering"></span>
    <a href="#the-first-obstacle-spas-and-javascript-rendering" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When the server returns something like:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">html</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;root&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/app-3fa8c2.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">html</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Your <code>Net::HTTP.get</code> returns exactly that: an empty div and a link to a 2 MB bundle. The real content — articles, prices, lists — is assembled in the browser by React, Vue, Angular, or the framework of the week.</p>
<p>For a script, this page is useless. To extract the content, you need to execute the JavaScript. That means running a real browser.</p>
<p>In the Ruby world, the answer is the <code>ferrum</code> gem — a CDP (Chrome DevTools Protocol) client that controls Chromium programmatically:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">browser</span> <span class="o">=</span> <span class="no">Ferrum</span><span class="o">::</span><span class="no">Browser</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">headless</span><span class="p">:</span> <span class="s2">&#34;new&#34;</span><span class="p">,</span> <span class="ss">timeout</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="s2">&#34;https://www.morningstar.com/markets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">network</span><span class="o">.</span><span class="n">wait_for_idle</span>  <span class="c1"># wait for JS to finish loading</span>
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="n">browser</span><span class="o">.</span><span class="n">body</span>  <span class="c1"># now the DOM is populated</span>
</span></span><span class="line"><span class="cl"><span class="n">browser</span><span class="o">.</span><span class="n">quit</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That <code>browser.body</code> returns the HTML <strong>after</strong> the JavaScript has executed. The same data you see in the browser, now accessible to your parser.</p>
<p>But here&rsquo;s where the headache begins: you need a Chromium binary available on the system. In Docker, that means adding ~300MB to the image. On the server, that&rsquo;s one more dependency to manage. And the latency of opening a browser, navigating, waiting for JS to render, and extracting the HTML — we&rsquo;re talking 3 to 10 seconds per page, instead of the 200ms of an HTTP GET.</p>
<p>The project uses this as the main strategy in <code>ArticleFetcher</code>: first it tries with a browser (to catch SPAs), then falls back to simple HTTP (to grab OpenGraph metadata when the browser fails). Most of the links that readers send in Discord are from sites that render with JavaScript, so browser-first pays off.</p>
<h2>The Second Obstacle: Bot Detection<span class="hx:absolute hx:-mt-20" id="the-second-obstacle-bot-detection"></span>
    <a href="#the-second-obstacle-bot-detection" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Opening a page with headless Chromium solves the JavaScript problem. But it solves 50% of the sites — not 100%. Because there&rsquo;s an entire industry dedicated to distinguishing real browsers from automated browsers.</p>
<h3>What Headless Chrome Reveals<span class="hx:absolute hx:-mt-20" id="what-headless-chrome-reveals"></span>
    <a href="#what-headless-chrome-reveals" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Chromium in headless mode is almost identical to regular Chrome. Almost. The differences are subtle, but enough for detection:</p>
<p><strong>1. <code>navigator.webdriver</code></strong> — In any Chrome controlled by automation, this property returns <code>true</code>. In normal browsers, <code>undefined</code>. It&rsquo;s the most obvious flag.</p>
<p><strong>2. <code>navigator.plugins</code></strong> — Normal Chrome has 3-5 plugins (PDF viewer, etc.). Headless returns an empty array. Bot detected.</p>
<p><strong>3. <code>navigator.userAgentData</code></strong> — The Client Hints API. Real Chrome returns brands, platform, mobile flag. Headless may not have that.</p>
<p><strong>4. WebGL fingerprint</strong> — The WebGL renderer in a headless Chrome returns &ldquo;SwiftShader&rdquo; (software renderer). Real Chrome returns &ldquo;NVIDIA GeForce&rdquo; or &ldquo;Intel Iris&rdquo; or whatever. Bot detected.</p>
<p><strong>5. Timing and behavior</strong> — Real browsers take time to load. They scroll. They move the mouse. They have focus events. A script that does <code>go_to</code> → <code>body</code> in 2 seconds doesn&rsquo;t look like a human.</p>
<p>The solution is what I call StealthBrowser — a wrapper that injects JavaScript patches before any script on the page runs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">StealthBrowser</span>
</span></span><span class="line"><span class="cl">  <span class="no">STEALTH_JS</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">JS</span>
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Remove</span> <span class="n">the</span> <span class="n">automation</span> <span class="n">flag</span>
</span></span><span class="line"><span class="cl">    <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;webdriver&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kp">false</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Simulate</span> <span class="n">real</span> <span class="n">plugins</span>
</span></span><span class="line"><span class="cl">    <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;plugins&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Chrome PDF Plugin&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;internal-pdf-viewer&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Chrome PDF Viewer&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;mhjfbmdgcfjbbpaeojofohoefgiehjai&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span> <span class="nb">name</span><span class="p">:</span> <span class="s1">&#39;Native Client&#39;</span><span class="p">,</span> <span class="ss">filename</span><span class="p">:</span> <span class="s1">&#39;internal-nacl-plugin&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Simulate</span> <span class="n">a</span> <span class="n">real</span> <span class="no">WebGL</span> <span class="n">renderer</span>
</span></span><span class="line"><span class="cl">    <span class="n">const</span> <span class="n">getParameter</span> <span class="o">=</span> <span class="no">WebGLRenderingContext</span><span class="o">.</span><span class="n">prototype</span><span class="o">.</span><span class="n">getParameter</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="no">WebGLRenderingContext</span><span class="o">.</span><span class="n">prototype</span><span class="o">.</span><span class="n">getParameter</span> <span class="o">=</span> <span class="n">function</span><span class="p">(</span><span class="nb">p</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nb">p</span> <span class="o">===</span> <span class="mi">37445</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;Google Inc. (NVIDIA)&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nb">p</span> <span class="o">===</span> <span class="mi">37446</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;ANGLE (NVIDIA, GeForce GTX 1080 ...)&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="n">getParameter</span><span class="o">.</span><span class="n">call</span><span class="p">(</span><span class="n">this</span><span class="p">,</span> <span class="nb">p</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="sr">//</span> <span class="no">Client</span> <span class="no">Hints</span> <span class="no">API</span> <span class="p">(</span><span class="n">absent</span> <span class="k">in</span> <span class="n">headless</span> <span class="o">=</span> <span class="n">bot</span> <span class="n">signal</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">navigator</span><span class="o">.</span><span class="n">userAgentData</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="no">Object</span><span class="o">.</span><span class="n">defineProperty</span><span class="p">(</span><span class="n">navigator</span><span class="p">,</span> <span class="s1">&#39;userAgentData&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">get</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="cl">          <span class="ss">brands</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span> <span class="ss">brand</span><span class="p">:</span> <span class="s1">&#39;Chromium&#39;</span><span class="p">,</span> <span class="ss">version</span><span class="p">:</span> <span class="s1">&#39;131&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span> <span class="ss">brand</span><span class="p">:</span> <span class="s1">&#39;Google Chrome&#39;</span><span class="p">,</span> <span class="ss">version</span><span class="p">:</span> <span class="s1">&#39;131&#39;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">          <span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">mobile</span><span class="p">:</span> <span class="kp">false</span><span class="p">,</span> <span class="ss">platform</span><span class="p">:</span> <span class="s1">&#39;Linux&#39;</span>
</span></span><span class="line"><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="no">JS</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="nb">name</span><span class="p">:,</span> <span class="ss">wait_for</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span> <span class="o">=</span> <span class="no">Ferrum</span><span class="o">::</span><span class="no">Browser</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">headless</span><span class="p">:</span> <span class="s2">&#34;new&#34;</span><span class="p">,</span> <span class="ss">timeout</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">browser_options</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;disable-blink-features&#34;</span> <span class="o">=&gt;</span> <span class="s2">&#34;AutomationControlled&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;user-agent&#34;</span> <span class="o">=&gt;</span> <span class="no">REAL_USER_AGENT</span>
</span></span><span class="line"><span class="cl">      <span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Inject BEFORE any script on the page runs</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">.</span><span class="n">page</span><span class="o">.</span><span class="n">command</span><span class="p">(</span><span class="s2">&#34;Page.addScriptToEvaluateOnNewDocument&#34;</span><span class="p">,</span> <span class="ss">source</span><span class="p">:</span> <span class="no">STEALTH_JS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">yield</span> <span class="n">browser</span>
</span></span><span class="line"><span class="cl">  <span class="k">ensure</span>
</span></span><span class="line"><span class="cl">    <span class="n">browser</span><span class="o">&amp;.</span><span class="n">quit</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The key trick is <code>Page.addScriptToEvaluateOnNewDocument</code> — a CDP command that registers JavaScript to be executed <strong>before</strong> any script on the page. That means when the bot detector runs, it sees the fake values we injected, not the real ones. And <code>disable-blink-features=AutomationControlled</code> removes the <code>navigator.webdriver</code> flag at the browser level, even before JavaScript.</p>
<p>This works for most sites. Morningstar, Hindenburg Research, generic news sites — they pass with the stealth. Yahoo Finance needs more — TLS fingerprinting that blocks even Chromium without crumb authentication. But the stealth browser handles the bulk.</p>
<h2>The Third Obstacle: DataDome and Anti-Bot CDPs<span class="hx:absolute hx:-mt-20" id="the-third-obstacle-datadome-and-anti-bot-cdps"></span>
    <a href="#the-third-obstacle-datadome-and-anti-bot-cdps" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And now we get to the pros. <strong>DataDome</strong>, Cloudflare Turnstile, PerimeterX, Akamai Bot Manager — services specialized in detecting automation. And they play at a completely different level.</p>
<p>Reuters, for example, uses DataDome. When I tried to hit <code>reuters.com/world/</code> with StealthBrowser, I got back a 1,500-byte page with a JavaScript captcha. All the <code>navigator.webdriver</code>, WebGL, plugin patches — useless.</p>
<p>Why? Because DataDome doesn&rsquo;t just check JavaScript properties. It detects the <strong>automation protocol itself</strong>.</p>
<h3>The Fundamental Problem with CDP<span class="hx:absolute hx:-mt-20" id="the-fundamental-problem-with-cdp"></span>
    <a href="#the-fundamental-problem-with-cdp" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>When <strong>Ferrum</strong> (or Puppeteer, or Playwright) controls a Chrome, it does so via the Chrome DevTools Protocol — CDP. And the first thing Ferrum does when connecting is send <code>Runtime.enable</code> — a command that enables runtime evaluation in the browser.</p>
<p>The problem is that <code>Runtime.enable</code> changes the observable behavior of the browser. Specifically, it activates internal interceptors that can be detected via <code>console.debug</code>. DataDome uses this: it does a <code>console.debug</code> with a getter that watches whether the runtime is intercepting. If it is — and with CDP it always is — bot detected.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// What DataDome does (simplified):
</span></span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">detected</span> <span class="o">=</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nb">Object</span><span class="p">.</span><span class="nx">defineProperty</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="s1">&#39;id&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">get</span><span class="o">:</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">detected</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span> <span class="k">return</span> <span class="s1">&#39;test&#39;</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">debug</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// If Runtime.enable is active, the getter is triggered
</span></span></span><span class="line"><span class="cl"><span class="c1">// In a normal browser, it isn&#39;t
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is not something you can patch with JavaScript. <code>Runtime.enable</code> is sent by Ferrum <strong>before</strong> any script runs — it&rsquo;s part of the automation protocol. To fix this, you&rsquo;d have to modify Ferrum to not send that command, which would break most of the gem&rsquo;s functionality.</p>
<h3>The Alternatives (and Why None Are Ideal)<span class="hx:absolute hx:-mt-20" id="the-alternatives-and-why-none-are-ideal"></span>
    <a href="#the-alternatives-and-why-none-are-ideal" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I researched extensively. The options:</p>
<ol>
<li>
<p><strong>nodriver (Python)</strong>: Uses Chrome via CDP without <code>Runtime.enable</code>. No equivalent in Ruby. And even so, it&rsquo;s in a cat-and-mouse race with DataDome.</p>
</li>
<li>
<p><strong>curl-impersonate</strong>: Does TLS fingerprinting identical to Chrome without opening a browser. Good for sites with TLS detection, useless for SPAs.</p>
</li>
<li>
<p><strong>Scraping APIs</strong> (ScraperAPI, ZenRows): Outsource the problem. They work, but they cost money and add latency and dependency.</p>
</li>
<li>
<p><strong>Fork Ferrum</strong>: Modify the gem to optionally not send <code>Runtime.enable</code>. Technically viable, but endless maintenance.</p>
</li>
<li>
<p><strong>Google News RSS</strong>: Google has already read Reuters. The result is in a public RSS feed. No JavaScript, no DataDome, no browser.</p>
</li>
</ol>
<p>Option 5 won. For Reuters, I traded all the headless browser complexity for:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ReutersClient</span>
</span></span><span class="line"><span class="cl">  <span class="no">RSS_URL</span> <span class="o">=</span> <span class="s2">&#34;https://news.google.com/rss/search?q=when:24h+allinurl:reuters.com/world&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch_headlines</span><span class="p">(</span><span class="ss">limit</span><span class="p">:</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">xml</span> <span class="o">=</span> <span class="no">Net</span><span class="o">::</span><span class="no">HTTP</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="no">URI</span><span class="p">(</span><span class="no">RSS_URL</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="no">REXML</span><span class="o">::</span><span class="no">Document</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">xml</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span><span class="o">.</span><span class="n">get_elements</span><span class="p">(</span><span class="s2">&#34;//item&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span><span class="o">.</span><span class="n">filter_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">title</span> <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;title&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">      <span class="n">url</span>   <span class="o">=</span> <span class="n">item</span><span class="o">.</span><span class="n">get_text</span><span class="p">(</span><span class="s2">&#34;link&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">      <span class="k">next</span> <span class="k">if</span> <span class="n">title</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">url</span><span class="o">.</span><span class="n">blank?</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\s*-\s*Reuters\s*\z/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">),</span> <span class="ss">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="ss">category</span><span class="p">:</span> <span class="kp">nil</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">StandardError</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">logger</span><span class="o">.</span><span class="n">warn</span><span class="p">(</span><span class="s2">&#34;ReutersClient failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Simple. Reliable. Zero browser dependencies. The Google News link redirects to the original article when the reader clicks — works perfectly for a newsletter.</p>
<p>Not every scraping problem needs a scraping solution. Sometimes the data is already available in an accessible format — you just have to find it.</p>
<h2>The Fourth Obstacle: Sites That Change Structure (Daily)<span class="hx:absolute hx:-mt-20" id="the-fourth-obstacle-sites-that-change-structure-daily"></span>
    <a href="#the-fourth-obstacle-sites-that-change-structure-daily" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Even when scraping works, maintenance never stops. Morningstar is a textbook case — because it made me get it wrong <strong>twice</strong> before reaching a resilient solution.</p>
<h3>Attempt 1: Select by DOM Hierarchy<span class="hx:absolute hx:-mt-20" id="attempt-1-select-by-dom-hierarchy"></span>
    <a href="#attempt-1-select-by-dom-hierarchy" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Originally, the article links were inside <code>&lt;h2&gt;</code> and <code>&lt;h3&gt;</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">h2</span><span class="p">&gt;&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/markets/article-slug&#34;</span><span class="p">&gt;</span>Article Title<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;&lt;/</span><span class="nt">h2</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>One day later: zero results. Morningstar changed the structure to flat links with specific CSS classes — no heading elements:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">a</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;mdc-basic-feed-item__mdc&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/markets/article-slug&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">header</span><span class="p">&gt;&lt;</span><span class="nt">h3</span><span class="p">&gt;</span>Article Title<span class="p">&lt;/</span><span class="nt">h3</span><span class="p">&gt;&lt;/</span><span class="nt">header</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;mdc-metadata__mdc&#34;</span><span class="p">&gt;</span>Author · Date<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Attempt 2: Select by CSS Class<span class="hx:absolute hx:-mt-20" id="attempt-2-select-by-css-class"></span>
    <a href="#attempt-2-select-by-css-class" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The obvious fix: swap DOM hierarchy for CSS selectors based on the component class:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">ARTICLE_CSS</span> <span class="o">=</span> <span class="s2">&#34;a.mdc-basic-feed-item__mdc, a.mdc-card--link__mdc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="no">ARTICLE_CSS</span><span class="p">)</span><span class="o">.</span><span class="n">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">link</span><span class="o">|</span> <span class="o">...</span> <span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It worked. For exactly one day. Then: zero results again. Morningstar — like many modern sites with CSS-in-JS frameworks — generates class names dynamically. What was <code>mdc-basic-feed-item__mdc</code> became something else. And it may become something else tomorrow.</p>
<p>This is increasingly common. Sites using CSS Modules, styled-components, or Tailwind with JIT generate random hashes in class names as part of the build. Every time the site deploys, the classes change. Your hardcoded selector is a time bomb.</p>
<h3>Attempt 3: Don&rsquo;t Rely on Any Class Name<span class="hx:absolute hx:-mt-20" id="attempt-3-dont-rely-on-any-class-name"></span>
    <a href="#attempt-3-dont-rely-on-any-class-name" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The final solution is to flip the logic: instead of telling the parser <strong>which</strong> classes to look for, let the parser <strong>discover</strong> which links are articles based on structural properties that don&rsquo;t change:</p>
<ol>
<li><strong>Article links have long URLs</strong>: <code>/news/why-stock-market-is-up-today-2026-02-17</code> vs. navigation links like <code>/markets</code> or <code>/tools</code></li>
<li><strong>Article links are grouped</strong>: they appear in lists, feeds, grids — multiple links with the same CSS class</li>
<li><strong>Article links have descriptive titles</strong>: long text, not short labels like &ldquo;Markets&rdquo; or &ldquo;Tools&rdquo;</li>
</ol>
<p>The algorithm:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">parse_html</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">doc</span> <span class="o">=</span> <span class="no">Nokogiri</span><span class="o">::</span><span class="no">HTML</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 1. Collect all internal &lt;a&gt; with href, class, and enough text</span>
</span></span><span class="line"><span class="cl">  <span class="n">candidates</span> <span class="o">=</span> <span class="n">doc</span><span class="o">.</span><span class="n">css</span><span class="p">(</span><span class="s2">&#34;a[href]&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">link</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">href</span> <span class="o">=</span> <span class="n">link</span><span class="o">[</span><span class="s2">&#34;href&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">    <span class="n">css_class</span> <span class="o">=</span> <span class="n">link</span><span class="o">[</span><span class="s2">&#34;class&#34;</span><span class="o">].</span><span class="n">to_s</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">href</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">css_class</span><span class="o">.</span><span class="n">blank?</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">unless</span> <span class="n">internal_href?</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">extract_path</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">segments</span> <span class="o">=</span> <span class="n">path</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">&#34;/&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:empty?</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">segments</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;</span> <span class="mi">2</span>  <span class="c1"># nav links have 1 segment: /markets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">title</span> <span class="o">=</span> <span class="n">extract_title</span><span class="p">(</span><span class="n">link</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">next</span> <span class="k">if</span> <span class="n">title</span><span class="o">.</span><span class="n">blank?</span> <span class="o">||</span> <span class="n">title</span><span class="o">.</span><span class="n">length</span> <span class="o">&lt;</span> <span class="mi">10</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">title</span><span class="p">,</span> <span class="ss">url</span><span class="p">:</span> <span class="n">absolutize</span><span class="p">(</span><span class="n">href</span><span class="p">),</span> <span class="ss">css_class</span><span class="p">:</span> <span class="n">css_class</span><span class="p">,</span> <span class="ss">slug_length</span><span class="p">:</span> <span class="n">path</span><span class="o">.</span><span class="n">length</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 2. Group by CSS class</span>
</span></span><span class="line"><span class="cl">  <span class="n">groups</span> <span class="o">=</span> <span class="n">candidates</span><span class="o">.</span><span class="n">group_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">c</span><span class="o">[</span><span class="ss">:css_class</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 3. Filter: minimum 3 links in the group (article clusters, not stray links)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 4. Pick the group with the highest average slug length</span>
</span></span><span class="line"><span class="cl">  <span class="n">best</span> <span class="o">=</span> <span class="n">groups</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">_cls</span><span class="p">,</span> <span class="n">items</span><span class="o">|</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="o">&gt;=</span> <span class="mi">3</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">max_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">_cls</span><span class="p">,</span> <span class="n">items</span><span class="o">|</span> <span class="n">items</span><span class="o">.</span><span class="n">sum</span> <span class="p">{</span> <span class="o">|</span><span class="n">i</span><span class="o">|</span> <span class="n">i</span><span class="o">[</span><span class="ss">:slug_length</span><span class="o">]</span> <span class="p">}</span><span class="o">.</span><span class="n">to_f</span> <span class="o">/</span> <span class="n">items</span><span class="o">.</span><span class="n">length</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="o">[]</span> <span class="k">unless</span> <span class="n">best</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">best</span><span class="o">[</span><span class="mi">1</span><span class="o">].</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">c</span><span class="o">|</span> <span class="n">c</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="ss">:title</span><span class="p">,</span> <span class="ss">:url</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="n">uniq</span> <span class="p">{</span> <span class="o">|</span><span class="n">a</span><span class="o">|</span> <span class="n">a</span><span class="o">[</span><span class="ss">:url</span><span class="o">]</span> <span class="p">}</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>What saves this approach: <strong>no class name is hardcoded</strong>. Morningstar can rename every class tomorrow and the parser will keep working — because it discovers the article pattern through structure, not through names.</p>
<p>What&rsquo;s stable on a site:</p>
<ul>
<li><strong>Article URLs are always longer</strong> than navigation URLs (that&rsquo;s architectural, not cosmetic)</li>
<li><strong>Articles are always grouped</strong> under the same component (that&rsquo;s UX, not implementation)</li>
<li><strong>Article titles are descriptive</strong> (that&rsquo;s editorial, not technical)</li>
</ul>
<p>What&rsquo;s <strong>not</strong> stable:</p>
<ul>
<li>CSS class names (change on every build)</li>
<li>DOM hierarchy (changes on every redesign)</li>
<li><code>data-*</code> attributes (change as the frontend team decides)</li>
</ul>
<p>Bottom line: don&rsquo;t try to identify elements by name — identify them by behavior. Article links behave differently from navigation links in measurable ways (URL length, clustering, title), and those properties are inherent to what the element IS, not to how it was implemented.</p>
<h2>The Reliability Hierarchy<span class="hx:absolute hx:-mt-20" id="the-reliability-hierarchy"></span>
    <a href="#the-reliability-hierarchy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After hours of banging my head, a hierarchy of data sources became obvious:</p>
<table>
  <thead>
      <tr>
          <th>Source</th>
          <th>Reliability</th>
          <th>Latency</th>
          <th>Maintenance</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Official API (JSON)</td>
          <td>High</td>
          <td>~200ms</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>RSS/Atom feed</td>
          <td>High</td>
          <td>~300ms</td>
          <td>Low</td>
      </tr>
      <tr>
          <td>Server-rendered HTML</td>
          <td>Medium</td>
          <td>~500ms</td>
          <td>Medium</td>
      </tr>
      <tr>
          <td>SPA with headless browser</td>
          <td>Low-Medium</td>
          <td>~5s</td>
          <td>High</td>
      </tr>
      <tr>
          <td>Site with active anti-bot</td>
          <td>Low</td>
          <td>~8s+</td>
          <td>Very high</td>
      </tr>
  </tbody>
</table>
<p>The rule: <strong>always prefer the simplest source that works</strong>. If the site has RSS, use RSS. If it has an API, use the API. Only go to the headless browser when there&rsquo;s no alternative.</p>
<p>In the project, the distribution ended up like this:</p>
<p><strong>Simple HTTP</strong> (Net::HTTP or HTTParty):</p>
<ul>
<li>Google News RSS (Reuters world headlines)</li>
<li>Hacker News RSS (top stories)</li>
<li>HackerNoon RSS (top stories)</li>
<li>dev.to API (trending articles)</li>
<li>Jikan API (anime rankings)</li>
<li>YouTube Data API (videos)</li>
<li>Yahoo Finance news RSS</li>
</ul>
<p><strong>Headless browser</strong> (StealthBrowser + Ferrum):</p>
<ul>
<li>Morningstar (SPA + dynamic components)</li>
<li>Yahoo Finance charts (TLS fingerprinting + crumb auth)</li>
<li>FinViz market map (canvas rendering)</li>
<li>Generic reader articles (any URL could be an SPA)</li>
<li>Hindenburg Research (JavaScript-rendered)</li>
</ul>
<p>In other words: <strong>most data sources work without a browser</strong>. The browser is needed for a handful of problem sites — but those sites are the ones that cause the most trouble.</p>
<h2>The Real Cost<span class="hx:absolute hx:-mt-20" id="the-real-cost"></span>
    <a href="#the-real-cost" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Let&rsquo;s be practical about what &ldquo;packing a Chrome&rdquo; means:</p>
<p><strong>Docker image</strong>: +300MB. Chromium and its dependencies (fonts, graphics libs, sandbox) are huge. In an optimized Dockerfile with multi-stage build:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Install Chromium and minimal dependencies</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y chromium fonts-liberation libnss3 libatk-bridge2.0-0 <span class="se">\
</span></span></span><span class="line"><span class="cl">    libdrm2 libxkbcommon0 libgbm1</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>Memory</strong>: Each Chromium instance consumes 200-400MB of RAM. If you have 3 parallel workers, that&rsquo;s 1.2GB just for browsers. On a 2GB VPS, that&rsquo;s 60% of the memory.</p>
<p><strong>Latency</strong>: A page that takes 200ms via HTTP takes 3-8 seconds via headless browser (process boot, navigation, JS execution, network idle). For 10 data sources, that&rsquo;s 30-80 seconds instead of 2.</p>
<p><strong>Stability</strong>: Chromium can crash, hang, or leak memory. A 30-second timeout with cleanup in <code>ensure</code> is mandatory:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="nb">name</span><span class="p">:)</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span> <span class="o">=</span> <span class="n">create</span><span class="p">(</span><span class="nb">name</span><span class="p">:</span> <span class="nb">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span><span class="o">.</span><span class="n">go_to</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">yield</span> <span class="n">browser</span>
</span></span><span class="line"><span class="cl"><span class="k">ensure</span>
</span></span><span class="line"><span class="cl">  <span class="n">browser</span><span class="o">&amp;.</span><span class="n">quit</span>  <span class="c1"># ALWAYS quit, even on exception</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you don&rsquo;t <code>quit</code>, the Chromium process stays hanging around burning CPU and memory. I&rsquo;ve had a server with 15 zombie Chrome processes eating 3GB of RAM because an <code>ensure</code> was missing.</p>
<h2>The Future (Or: It&rsquo;s Going to Get Worse)<span class="hx:absolute hx:-mt-20" id="the-future-or-its-going-to-get-worse"></span>
    <a href="#the-future-or-its-going-to-get-worse" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The trend is clear: sites are getting harder to access programmatically. Every quarter, the DataDomes and Cloudflares of the world add new detection techniques. Headless Chrome gets more similar to real Chrome — but the detectors keep up.</p>
<p>The web that was born open and machine-readable is becoming a walled garden. Even RSS, which is trivial to implement and useful to everyone, is being abandoned by many sites — because they don&rsquo;t want their data read outside the context (and the ads) of the original site.</p>
<p>For anyone building systems that aggregate information, the playbook is:</p>
<ol>
<li><strong>Use APIs and feeds whenever they exist</strong>. More stable, faster, more respectful</li>
<li><strong>Keep a stealth browser for SPAs</strong> that have no alternative</li>
<li><strong>Never hardcode CSS selectors</strong>. Sites with CSS-in-JS change class names on every deploy. Identify elements by structural properties (URL length, clustering, title size) that survive redesigns</li>
<li><strong>Detect blocks and fail gracefully</strong> — <code>og:description</code> as fallback, title extracted from the URL as a last resort</li>
<li><strong>Never depend on a single access method</strong>. The site that works today may block tomorrow</li>
<li><strong>Accept that some sites are inaccessible</strong>. If DataDome won&rsquo;t allow it, the data will have to come from another source</li>
</ol>
<p>The web in 2026 is a battlefield between those who want to read and those who want to control the reading. But in the long run, the creativity of those who want to access data wins — but it costs more effort every year. And, contrary to what I thought at the beginning: it&rsquo;s never simple.</p>
]]></content:encoded><category>the-m-akita-chronicles</category><category>web-development</category><category>automation</category></item><item><title>AI Agents: Installing LSPs for Crush</title><link>https://akitaonrails.github.io/en/2026/01/21/ai-agents-installing-lsps-for-crush/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/01/21/ai-agents-installing-lsps-for-crush/</guid><pubDate>Wed, 21 Jan 2026 13:14:07 -0300</pubDate><description>&lt;p&gt;This post is mostly for me to document things and not forget. As I said in the last few posts, I&amp;rsquo;ve been using &lt;a href="https://github.com/charmbracelet/crush"target="_blank" rel="noopener"&gt;Charm&amp;rsquo;s Crush&lt;/a&gt; as my favorite AI coding agent. Repeating myself: it doesn&amp;rsquo;t matter if you use Claude Code or OpenCode, they&amp;rsquo;re all equally good. My personal preference is still Crush because I think it looks nicer, that&amp;rsquo;s all.&lt;/p&gt;
&lt;p&gt;I still don&amp;rsquo;t fully understand how much of a difference it makes, but both OpenCode and Crush support Language Servers (LSPs), so I thought it&amp;rsquo;d be cool to have it configured. Here&amp;rsquo;s my &lt;code&gt;~/.config/crush/crush.json&lt;/code&gt;:&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is mostly for me to document things and not forget. As I said in the last few posts, I&rsquo;ve been using <a href="https://github.com/charmbracelet/crush"target="_blank" rel="noopener">Charm&rsquo;s Crush</a> as my favorite AI coding agent. Repeating myself: it doesn&rsquo;t matter if you use Claude Code or OpenCode, they&rsquo;re all equally good. My personal preference is still Crush because I think it looks nicer, that&rsquo;s all.</p>
<p>I still don&rsquo;t fully understand how much of a difference it makes, but both OpenCode and Crush support Language Servers (LSPs), so I thought it&rsquo;d be cool to have it configured. Here&rsquo;s my <code>~/.config/crush/crush.json</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;$schema&#34;</span><span class="p">:</span> <span class="s2">&#34;https://charm.land/crush.json&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;default_provider&#34;</span><span class="p">:</span> <span class="s2">&#34;openrouter&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;providers&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;openrouter&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;api_key&#34;</span><span class="p">:</span> <span class="s2">&#34;sk-...&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;lmstudio&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;LM Studio&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;base_url&#34;</span><span class="p">:</span> <span class="s2">&#34;http://localhost:1234/v1/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;openai&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;models&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;gpt-oss-120b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;gpt-oss:120b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">100000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;gpt-oss-20b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;gpt-oss:20b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">130000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;zai-org/glm-4.6v-flash&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;glm-4.6v-flash&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">130000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek-coder-v2-lite-instruct&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek-coder-v2-lite-instruct&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">64000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen/qwen3-coder-30b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen3-coder-30b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">120000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;ollama&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;openai-compat&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;base_url&#34;</span><span class="p">:</span> <span class="s2">&#34;http://127.0.0.1:11434/v1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;api_key&#34;</span><span class="p">:</span> <span class="s2">&#34;ollama&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;models&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;glm-4.7-flash&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;glm-4.7-flash&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">65536</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;gpt-oss-20b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;gpt-oss:20b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">64000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen3-nuclear&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen3-nuclear&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">120000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen3-coder:30b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen3-coder:30b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">120000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen2.5-coder:32b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;qwen2.5-coder:32b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">32768</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek-coder-v2:16b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;deepseek-coder-v2:16b&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">128000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;glm-4.7:cloud&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;glm-4.7:cloud&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;context_window&#34;</span><span class="p">:</span> <span class="mi">128000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;default_max_tokens&#34;</span><span class="p">:</span> <span class="mi">-1</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;supports_tools&#34;</span><span class="p">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;lsp&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;gopls&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;gopls&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;rust-analyzer&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;rust-analyzer&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;ruby-lsp&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;ruby-lsp&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;solargraph&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;solargraph&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;pylsp&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;pylsp&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;pyright&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;pyright-langserver&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;elixir-ls&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;elixir-ls&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;docker-langserver&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;docker-langserver&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;languages&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;dockerfile&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;bash-language-server&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;bash-language-server&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;start&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;marksman&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;marksman&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;server&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;typescript-language-server&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;typescript-language-server&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;vscode-html-language-server&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;vscode-html-language-server&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;vscode-css-language-server&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;vscode-css-language-server&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;vscode-json-language-server&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;vscode-json-language-server&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;yaml-language-server&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;yaml-language-server&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;--stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;taplo&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;taplo&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;lsp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;stdio&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;mcp&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;blender&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;stdio&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;uvx&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;args&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;blender-mcp&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Note that I primarily use Claude Opus 4.5 via OpenRouter and I have LM Studio and Ollama configured as providers for testing other open source models.</p>
<p>I also use <a href="/en/2025/09/07/omarchy-2-0-mise-for-organizing-dev-environments/">Mise</a> to organize the programming languages I use.</p>
<p>But now you need to install the LSPs for each language. I don&rsquo;t know of a better way to manage this the same way Mason does in Neovim. So I made a script to install everything for me (on my Arch Linux). For that I created this script <code>~/.local/bin/install-lsps.sh</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># LSP Installer for Arch Linux with Mise</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Installs major LSPs using native package managers or yay</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Only installs LSPs for languages that are already installed</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">GREEN</span><span class="o">=</span><span class="s1">&#39;\033[0;32m&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">BLUE</span><span class="o">=</span><span class="s1">&#39;\033[0;34m&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">YELLOW</span><span class="o">=</span><span class="s1">&#39;\033[1;33m&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">RED</span><span class="o">=</span><span class="s1">&#39;\033[0;31m&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nv">NC</span><span class="o">=</span><span class="s1">&#39;\033[0m&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">info<span class="o">()</span> <span class="o">{</span> <span class="nb">echo</span> -e <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BLUE</span><span class="si">}</span><span class="s2">[INFO]</span><span class="si">${</span><span class="nv">NC</span><span class="si">}</span><span class="s2"> </span><span class="nv">$1</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">success<span class="o">()</span> <span class="o">{</span> <span class="nb">echo</span> -e <span class="s2">&#34;</span><span class="si">${</span><span class="nv">GREEN</span><span class="si">}</span><span class="s2">[OK]</span><span class="si">${</span><span class="nv">NC</span><span class="si">}</span><span class="s2"> </span><span class="nv">$1</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">warn<span class="o">()</span> <span class="o">{</span> <span class="nb">echo</span> -e <span class="s2">&#34;</span><span class="si">${</span><span class="nv">YELLOW</span><span class="si">}</span><span class="s2">[SKIP]</span><span class="si">${</span><span class="nv">NC</span><span class="si">}</span><span class="s2"> </span><span class="nv">$1</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">skip_lang<span class="o">()</span> <span class="o">{</span> <span class="nb">echo</span> -e <span class="s2">&#34;</span><span class="si">${</span><span class="nv">RED</span><span class="si">}</span><span class="s2">[LANG]</span><span class="si">${</span><span class="nv">NC</span><span class="si">}</span><span class="s2"> </span><span class="nv">$1</span><span class="s2"> not installed, skipping LSPs&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">command_exists<span class="o">()</span> <span class="o">{</span> <span class="nb">command</span> -v <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="p">&amp;</span>&gt;/dev/null<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Language detection helpers</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">has_c<span class="o">()</span>       <span class="o">{</span> command_exists gcc <span class="o">||</span> command_exists clang<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_rust<span class="o">()</span>    <span class="o">{</span> command_exists rustc <span class="o">||</span> command_exists cargo<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_go<span class="o">()</span>      <span class="o">{</span> command_exists go<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_python<span class="o">()</span>  <span class="o">{</span> command_exists python <span class="o">||</span> command_exists python3<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_node<span class="o">()</span>    <span class="o">{</span> command_exists node <span class="o">||</span> command_exists npm<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_java<span class="o">()</span>    <span class="o">{</span> command_exists java <span class="o">||</span> command_exists javac<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_kotlin<span class="o">()</span>  <span class="o">{</span> command_exists kotlin <span class="o">||</span> command_exists kotlinc<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_ruby<span class="o">()</span>    <span class="o">{</span> command_exists ruby<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_php<span class="o">()</span>     <span class="o">{</span> command_exists php<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_lua<span class="o">()</span>     <span class="o">{</span> command_exists lua <span class="o">||</span> command_exists luajit<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_zig<span class="o">()</span>     <span class="o">{</span> command_exists zig<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_haskell<span class="o">()</span> <span class="o">{</span> command_exists ghc <span class="o">||</span> command_exists stack <span class="o">||</span> command_exists cabal<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_elixir<span class="o">()</span>  <span class="o">{</span> command_exists elixir <span class="o">||</span> command_exists iex<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_scala<span class="o">()</span>   <span class="o">{</span> command_exists scala <span class="o">||</span> command_exists sbt<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_dotnet<span class="o">()</span>  <span class="o">{</span> command_exists dotnet<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_ocaml<span class="o">()</span>   <span class="o">{</span> command_exists ocaml <span class="o">||</span> command_exists opam<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_clojure<span class="o">()</span> <span class="o">{</span> command_exists clojure <span class="o">||</span> command_exists clj <span class="o">||</span> command_exists lein<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_nix<span class="o">()</span>     <span class="o">{</span> command_exists nix<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_terraform<span class="o">()</span> <span class="o">{</span> command_exists terraform <span class="o">||</span> command_exists tofu<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_latex<span class="o">()</span>   <span class="o">{</span> command_exists latex <span class="o">||</span> command_exists pdflatex <span class="o">||</span> command_exists xelatex<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_docker<span class="o">()</span>  <span class="o">{</span> command_exists docker <span class="o">||</span> command_exists podman<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_ansible<span class="o">()</span> <span class="o">{</span> command_exists ansible<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_sql<span class="o">()</span>     <span class="o">{</span> command_exists psql <span class="o">||</span> command_exists mysql <span class="o">||</span> command_exists sqlite3<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">has_bash<span class="o">()</span>    <span class="o">{</span> command_exists bash<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Package installation helpers</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_pacman<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">pkg</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> pacman -Qi <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="p">&amp;</span>&gt;/dev/null<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2"> already installed&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        sudo pacman -S --noconfirm --needed <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> success <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed: </span><span class="nv">$pkg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">install_yay<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">pkg</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! command_exists yay<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;yay not found, skipping </span><span class="nv">$pkg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> yay -Qi <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="p">&amp;</span>&gt;/dev/null<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2"> already installed&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        yay -S --noconfirm --needed <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> success <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed: </span><span class="nv">$pkg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">install_npm<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">pkg</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> npm list -g <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="p">&amp;</span>&gt;/dev/null 2&gt;<span class="p">&amp;</span>1<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2"> already installed&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        npm install -g <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> success <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed: </span><span class="nv">$pkg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">install_pipx<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">pkg</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! command_exists pipx<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        pip install --user pipx 2&gt;/dev/null <span class="o">||</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">            pip install --user <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> success <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed: </span><span class="nv">$pkg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> pipx list 2&gt;/dev/null <span class="p">|</span> grep -q <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2"> already installed&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        pipx install <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> success <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed: </span><span class="nv">$pkg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">install_go<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">pkg</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">name</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">pkg</span><span class="p">##*/</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">name</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">name</span><span class="p">%%@*</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="c1"># Check if binary already exists in GOPATH/bin</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">gobin</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">GOBIN</span><span class="k">:-</span><span class="si">${</span><span class="nv">GOPATH</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/go</span><span class="si">}</span><span class="p">/bin</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[[</span> -x <span class="s2">&#34;</span><span class="nv">$gobin</span><span class="s2">/</span><span class="nv">$name</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;</span><span class="nv">$name</span><span class="s2"> already installed&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    go install <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> success <span class="s2">&#34;</span><span class="nv">$name</span><span class="s2">&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed: </span><span class="nv">$name</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">install_gem<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">pkg</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> gem list -i <span class="s2">&#34;^</span><span class="si">${</span><span class="nv">pkg</span><span class="si">}</span>$<span class="s2">&#34;</span> <span class="p">&amp;</span>&gt;/dev/null<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2"> already installed&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        gem install <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> success <span class="s2">&#34;</span><span class="nv">$pkg</span><span class="s2">&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed: </span><span class="nv">$pkg</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># C/C++ LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_c_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_c<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;C/C++ (gcc/clang)&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing C/C++ LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman clang  <span class="c1"># includes clangd</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Rust LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_rust_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_rust<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Rust&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Rust LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="k">if</span> command_exists rustup<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        rustup component add rust-analyzer 2&gt;/dev/null <span class="o">&amp;&amp;</span> success <span class="s2">&#34;rust-analyzer (rustup)&#34;</span> <span class="o">||</span> install_pacman rust-analyzer
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        install_pacman rust-analyzer
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Go LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_go_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_go<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Go&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Go LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    install_go golang.org/x/tools/gopls@latest
</span></span><span class="line"><span class="cl">    install_go github.com/go-delve/delve/cmd/dlv@latest
</span></span><span class="line"><span class="cl">    install_go github.com/nametake/golangci-lint-langserver@latest
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Python LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_python_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_python<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Python&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Python LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="c1"># Prefer npm pyright (faster updates) if node available</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> has_node<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        install_npm pyright
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        install_pipx pyright
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    install_pipx python-lsp-server
</span></span><span class="line"><span class="cl">    install_pipx ruff-lsp
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Node.js/TypeScript LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_node_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_node<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Node.js&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Node.js/TypeScript LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    install_npm typescript
</span></span><span class="line"><span class="cl">    install_npm typescript-language-server
</span></span><span class="line"><span class="cl">    install_npm vscode-langservers-extracted  <span class="c1"># HTML, CSS, JSON, ESLint</span>
</span></span><span class="line"><span class="cl">    install_npm <span class="s2">&#34;@tailwindcss/language-server&#34;</span>
</span></span><span class="line"><span class="cl">    install_npm <span class="s2">&#34;@vue/language-server&#34;</span>
</span></span><span class="line"><span class="cl">    install_npm svelte-language-server
</span></span><span class="line"><span class="cl">    install_npm graphql-language-service-cli
</span></span><span class="line"><span class="cl">    install_npm <span class="s2">&#34;@prisma/language-server&#34;</span>
</span></span><span class="line"><span class="cl">    install_npm emmet-ls
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Java LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_java_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_java<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Java&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Java LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay jdtls
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kotlin LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_kotlin_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_kotlin<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Kotlin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Kotlin LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay kotlin-language-server
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Ruby LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_ruby_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_ruby<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Ruby&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Ruby LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_gem solargraph
</span></span><span class="line"><span class="cl">    install_gem ruby-lsp
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># PHP LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_php_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_php<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;PHP&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_node<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;PHP LSP (intelephense) requires npm, skipping&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing PHP LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_npm intelephense
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Lua LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_lua_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_lua<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Lua&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Lua LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman lua-language-server
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Zig LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_zig_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_zig<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Zig&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Zig LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman zls
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Haskell LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_haskell_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_haskell<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Haskell&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Haskell LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay haskell-language-server
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Elixir LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_elixir_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_elixir<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Elixir&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Elixir LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay elixir-ls
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Scala LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_scala_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_scala<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Scala&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Scala LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay metals
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># C# / F# (.NET) LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_dotnet_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_dotnet<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;.NET (C#/F#)&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing .NET LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay omnisharp-roslyn
</span></span><span class="line"><span class="cl">    install_yay fsautocomplete
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># OCaml LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_ocaml_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_ocaml<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;OCaml&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing OCaml LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> command_exists opam<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        opam install ocaml-lsp-server -y <span class="o">&amp;&amp;</span> success <span class="s2">&#34;ocaml-lsp-server&#34;</span> <span class="o">||</span> warn <span class="s2">&#34;Failed&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">        install_yay ocaml-lsp-server
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Clojure LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_clojure_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_clojure<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Clojure&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Clojure LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay clojure-lsp-bin
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Nix LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_nix_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_nix<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Nix&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Nix LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman nil
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Bash LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_bash_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_bash<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Bash&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Bash LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman bash-language-server
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Terraform LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_terraform_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_terraform<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Terraform&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Terraform LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman terraform-ls
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># LaTeX LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_latex_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_latex<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;LaTeX&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing LaTeX LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman texlab
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Docker LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_docker_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_docker<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Docker&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Docker LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_yay dockerfile-language-server
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Ansible LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_ansible_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_ansible<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;Ansible&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_node<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        warn <span class="s2">&#34;Ansible LSP requires npm, skipping&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing Ansible LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_npm <span class="s2">&#34;@ansible/ansible-language-server&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># SQL LSPs</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_sql_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> ! has_sql<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        skip_lang <span class="s2">&#34;SQL (psql/mysql/sqlite)&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing SQL LSPs...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman sqls
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Generic config file LSPs (always install)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">install_generic_lsps<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    info <span class="s2">&#34;Installing generic config LSPs (YAML, TOML, JSON, Markdown, XML)...&#34;</span>
</span></span><span class="line"><span class="cl">    install_pacman yaml-language-server
</span></span><span class="line"><span class="cl">    install_pacman taplo               <span class="c1"># TOML</span>
</span></span><span class="line"><span class="cl">    install_pacman marksman            <span class="c1"># Markdown</span>
</span></span><span class="line"><span class="cl">    install_yay lemminx                <span class="c1"># XML</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Main</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ─────────────────────────────────────────────────────────────</span>
</span></span><span class="line"><span class="cl">main<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;═══════════════════════════════════════════════════════════&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;         LSP Installer for Arch Linux + Mise&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    (Only installs LSPs for detected languages)&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;═══════════════════════════════════════════════════════════&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="c1"># Language-specific LSPs</span>
</span></span><span class="line"><span class="cl">    install_c_lsps
</span></span><span class="line"><span class="cl">    install_rust_lsps
</span></span><span class="line"><span class="cl">    install_go_lsps
</span></span><span class="line"><span class="cl">    install_python_lsps
</span></span><span class="line"><span class="cl">    install_node_lsps
</span></span><span class="line"><span class="cl">    install_java_lsps
</span></span><span class="line"><span class="cl">    install_kotlin_lsps
</span></span><span class="line"><span class="cl">    install_ruby_lsps
</span></span><span class="line"><span class="cl">    install_php_lsps
</span></span><span class="line"><span class="cl">    install_lua_lsps
</span></span><span class="line"><span class="cl">    install_zig_lsps
</span></span><span class="line"><span class="cl">    install_haskell_lsps
</span></span><span class="line"><span class="cl">    install_elixir_lsps
</span></span><span class="line"><span class="cl">    install_scala_lsps
</span></span><span class="line"><span class="cl">    install_dotnet_lsps
</span></span><span class="line"><span class="cl">    install_ocaml_lsps
</span></span><span class="line"><span class="cl">    install_clojure_lsps
</span></span><span class="line"><span class="cl">    install_nix_lsps
</span></span><span class="line"><span class="cl">    install_bash_lsps
</span></span><span class="line"><span class="cl">    install_terraform_lsps
</span></span><span class="line"><span class="cl">    install_latex_lsps
</span></span><span class="line"><span class="cl">    install_docker_lsps
</span></span><span class="line"><span class="cl">    install_ansible_lsps
</span></span><span class="line"><span class="cl">    install_sql_lsps
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Always-useful LSPs for config files</span>
</span></span><span class="line"><span class="cl">    install_generic_lsps
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;═══════════════════════════════════════════════════════════&#34;</span>
</span></span><span class="line"><span class="cl">    success <span class="s2">&#34;LSP installation complete!&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;═══════════════════════════════════════════════════════════&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">main <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In theory that&rsquo;s all there is to it. If anyone knows a better way, drop it in the comments below.</p>
]]></content:encoded><category>coding-agents</category><category>automation</category><category>developer-tools</category></item><item><title>Omarchy 2.0 - ZSH Configs</title><link>https://akitaonrails.github.io/en/2025/09/07/omarchy-2-0-zsh-configs/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/09/07/omarchy-2-0-zsh-configs/</guid><pubDate>Sun, 07 Sep 2025 15:00:00 -0300</pubDate><description>&lt;p&gt;I&amp;rsquo;ve been really enjoying &lt;a href="https://omarchy.org/"target="_blank" rel="noopener"&gt;Omarchy&lt;/a&gt; as my desktop manager. Read &lt;a href="https://akitaonrails.com/en/2025/08/29/new-omarchy-2-0-install/"target="_blank" rel="noopener"&gt;my previous post&lt;/a&gt; to see how I installed it and tweaked it for my setup. Following that thread, Omarchy ships with Bash as the default shell. I prefer ZSH. Bash is perfectly usable, this is just personal preference. Some people prefer Fish or others. But Omarchy only ships with Bash for now, so I decided to modify the setup to support my ZSH.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve been really enjoying <a href="https://omarchy.org/"target="_blank" rel="noopener">Omarchy</a> as my desktop manager. Read <a href="https://akitaonrails.com/en/2025/08/29/new-omarchy-2-0-install/"target="_blank" rel="noopener">my previous post</a> to see how I installed it and tweaked it for my setup. Following that thread, Omarchy ships with Bash as the default shell. I prefer ZSH. Bash is perfectly usable, this is just personal preference. Some people prefer Fish or others. But Omarchy only ships with Bash for now, so I decided to modify the setup to support my ZSH.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-07_15-16-57.png" alt="My ZSH"  loading="lazy" /></p>
<p>If you want to switch to ZSH, it&rsquo;s easy, just do this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">yay -S zsh zoxide
</span></span><span class="line"><span class="cl">chsh -s <span class="k">$(</span>which zsh<span class="k">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Logout, login, and done. But now your shell will be empty. To grab my config do this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/omarchy-zsh.git ~/.config/zsh
</span></span><span class="line"><span class="cl">ln -s ~/.config/zsh/.zshrc ~/.zshrc </span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>There are two things you need to change. In <code>.zshrc</code> remove this line:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>source ~/.config/zsh/mounts</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That&rsquo;s just for me to check my NFS mounts to my NAS. It&rsquo;s useless to you so you can remove it.</p>
<p>The second thing is to create a new file for secrets:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>touch ~/.config/zsh/secrets
chmod 600 ~/.config/zsh/secrets</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is where you put stuff like your OpenAI or Google Cloud keys or anything like that. For example:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># ~/.config/zsh/secrets
export OPENAI_API_KEY=&#34;sk-xx-v1-xxx...xxxxx&#34;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That way your secrets stay separated from your configs and we can <code>git push</code> to a repository without worrying that something might leak in the future. Always keep secrets, passwords, and keys separated locally. This is already in <code>.gitignore</code> so you&rsquo;re safe.</p>
<p>As a bonus, in <code>~/.config/init</code> I added support for <a href="https://atuin.sh/"target="_blank" rel="noopener">Atuin</a>, a tool that syncs all the command history you ran in your shell to a &ldquo;cloud&rdquo; repository (read the docs, you can create your own local server too). The repository is encrypted, so it&rsquo;s safe even if your history contains secret keys in the commands.</p>
<p>Install it like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>yay -S atuin
atuin register -u &lt;your username&gt; -e &lt;your email&gt;
atuin import auto
atuin sync</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>As soon as you register, Atuin will generate a random encryption key. Make sure you store this key somewhere safe like 1Password, Bitwarden or another secure vault!! Without it you won&rsquo;t be able to decrypt on a new installation. If you forget, you can still view it before reinstalling, like:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>atuin key</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you reinstall your machine or set up a new one and want your history back, just install Atuin and run:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>atuin login -u &lt;your username&gt;
atuin sync</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It will ask for your password and encryption key and bring all your history back.</p>
<p>With my config, just hit <code>Ctrl+R</code> to bring up the history search (which from now on will live in a sqlite on your machine). Read the <a href="https://docs.atuin.sh/"target="_blank" rel="noopener">documentation</a> for more features.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-07_15-17-20.png" alt="Ctrl&#43;R"  loading="lazy" /></p>
<p>DHH set up the <strong>Starship</strong> prompt using the old <code>PS1</code> variable style. But Starship already has several nice prompts that are easy to use as &ldquo;presets&rdquo;. I picked Pure Prompt, which I like, but there are plenty of others. If you want something flashier and more colorful, there&rsquo;s this <a href="https://starship.rs/presets/pastel-powerline"target="_blank" rel="noopener">Pastel Powerline</a>:</p>
<p><img src="https://starship.rs/presets/img/pastel-powerline.png" alt="Pastel Powerline"  loading="lazy" />
Pick your favorite on the <a href="https://starship.rs/presets/"target="_blank" rel="noopener">Presets page</a> and change <code>~/.config/zsh/prompt</code> on this line:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>starship preset bracketed-segments -o ~/.config/starship.toml</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Finally, I left some ENVs in <code>~/.config/zsh/envs</code> that work for my Ollama tests and things like that, but it&rsquo;s good for you to know they&rsquo;re there so you don&rsquo;t find it weird if your Ollama is behaving strangely. Edit the <code>~/.config/zsh/envs</code> file to set the envs you prefer:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># envs
export OPENAI_API_BASE=&#34;https://openrouter.ai/api/v1&#34;
export DEFAULT_MODEL=&#34;o1-mini&#34;;
export OLLAMA_API_BASE=http://127.0.0.1:11434
export OLLAMA_HOME=http://127.0.0.1:11434
export OLLAMA_MODELS=/mnt/gigachad/ollama/models</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Also modify the <code>~/.config/zsh/aliases</code> file to include aliases you prefer:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># aliases
alias monhd=&#34;sudo ddcutil -d 1 setvcp 60 0x12&#34;
alias mondp=&#34;sudo ddcutil -d 1 setvcp 60 0x0F&#34;

alias sgpt=&#39;docker run --rm -e OPENAI_API_BASE=${OPENAI_API_BASE} -e OPENAI_API_KEY=${OPENROUTER_API_KEY} ghcr.io/tbckr/sgpt:latest txt &#39;

alias git=&#39;nocorrect git&#39;
git config --global alias.a &#39;add&#39;
git config --global alias.ps &#39;push&#39;
git config --global alias.pl &#39;pull&#39;
git config --global alias.l &#39;log&#39;
git config --global alias.c &#39;commit -m&#39;
git config --global alias.s &#39;status&#39;
git config --global alias.co &#39;checkout&#39;
git config --global alias.b &#39;branch&#39;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The first two aliases are for my dual monitor setup where one of the monitors receives signal from both the HDMI input and the DisplayPort input (I have 2 GPUs). If you didn&rsquo;t know, you can control that from the shell. Read <a href="https://jeancharles.quillet.org/posts/2021-08-20-How-to-use-ddcutil-to-switch-input-of-a-Dell-screen.html"target="_blank" rel="noopener">this post about ddcutil</a></p>
<p>Next you get the <code>sgpt</code> command. Omarchy already ships with Docker installed. Now from the shell you can ask ChatGPT without having to open a web browser for it:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sgpt &#34;what is the rsync syntax to sync two directories?&#34;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Read the <a href="https://github.com/tbckr/sgpt"target="_blank" rel="noopener">SGPT documentation</a> to learn about other features or which LLMs you can choose.</p>
<p>At the end we have aliases for Git. Omarchy already has an alias making &ldquo;g&rdquo; mean &ldquo;git&rdquo; and with this one I added you can do:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>git add . # old
g a . # new

git commit -m &#34;initial commit&#34; # old
g c &#34;initial commit&#34; # new

git commit --amend -m &#34;initial commit&#34;
g ca &#34;initial commit&#34;

git push origin master
g ps origin master

git pull origin master
g pl origin master

git log
g l

# and so on</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>More practical and less verbose. Add more aliases as your workflow demands in <code>~/.config/aliases</code>.</p>
<p>And I think that&rsquo;s it, now my Omarchy is organized the way I like. Make your own tweaks so everything fits your style too!</p>
]]></content:encoded><category>omarchy</category><category>developer-tools</category><category>automation</category></item><item><title>Replacing RVM/Rbenv/Nvm/etc for ASDF</title><link>https://akitaonrails.github.io/en/2017/10/24/replacing-rvm-rbenv-nvm-etc-for-asdf/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2017/10/24/replacing-rvm-rbenv-nvm-etc-for-asdf/</guid><pubDate>Tue, 24 Oct 2017 14:19:00 -0200</pubDate><description>&lt;p&gt;Many people are using Docker as the means to have different Ruby versions or for any other language. I still think the added overhead both in resources usage and usability friction is simply not worth it. I highly recommend against it. Docker is great as the basis for automated infrastructures, but I prefer to have them in servers only.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve been using &lt;a href="https://github.com/asdf-vm/asdf"target="_blank" rel="noopener"&gt;asdf&lt;/a&gt; as my main Ruby version manager for a long while now and I am confident that I can recommend it in place of the more well recognized RVM or Rbenv.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Many people are using Docker as the means to have different Ruby versions or for any other language. I still think the added overhead both in resources usage and usability friction is simply not worth it. I highly recommend against it. Docker is great as the basis for automated infrastructures, but I prefer to have them in servers only.</p>
<p>I&rsquo;ve been using <a href="https://github.com/asdf-vm/asdf"target="_blank" rel="noopener">asdf</a> as my main Ruby version manager for a long while now and I am confident that I can recommend it in place of the more well recognized RVM or Rbenv.</p>
<p>Moreover, it not only can manage Ruby versions, but it can manage almost all languages you might want. With just one command set. So you don&rsquo;t even need virtualenv for Python or NVM for Node.js. Just use ASDF.</p>
<p>Installing it couldn&rsquo;t be easier. Just follow the <a href="https://github.com/asdf-vm/asdf/blob/master/README.md"target="_blank" rel="noopener">README</a> from the project page.</p>
<p>Don&rsquo;t forget to install the base development tools for your environment. You can follow <a href="https://github.com/rbenv/ruby-build/wiki"target="_blank" rel="noopener">ruby-build&rsquo;s wiki page</a> for example:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>## For OS X
# optional, but recommended:
brew install openssl libyaml libffi

# required for building Ruby &lt;= 1.9.3-p0:
brew tap homebrew/dupes &amp;&amp; brew install apple-gcc42

## For Ubuntu
sudo apt-get install gcc-6 autoconf bison build-essential libssl-dev libyaml-dev libreadline6-dev zlib1g-dev libncurses5-dev libffi-dev libgdbm3 libgdbm-dev

## For Arch
sudo pacman -S --needed gcc5 base-devel libffi libyaml openssl zlib</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then you can install ASDF itself:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.4.0</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then add the proper environment configuration for Path and auto-completion.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># For Ubuntu or other linux distros
echo -e &#39;\n. $HOME/.asdf/asdf.sh&#39; &gt;&gt; ~/.bashrc
echo -e &#39;\n. $HOME/.asdf/completions/asdf.bash&#39; &gt;&gt; ~/.bashrc

# OR for Mac OSX
echo -e &#39;\n. $HOME/.asdf/asdf.sh&#39; &gt;&gt; ~/.bash_profile
echo -e &#39;\n. $HOME/.asdf/completions/asdf.bash&#39; &gt;&gt; ~/.bash_profile</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Finally, you must install one of the dozens of <a href="https://github.com/asdf-vm/asdf-plugins"target="_blank" rel="noopener">plugins</a>. In my case, I have these installed:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf plugin-add clojure https://github.com/vic/asdf-clojure.git
asdf plugin-add elixir https://github.com/asdf-vm/asdf-elixir.git
asdf plugin-add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin-add golang https://github.com/kennyp/asdf-golang.git
asdf plugin-add python https://github.com/tuvistavie/asdf-python.git
asdf plugin-add ruby https://github.com/asdf-vm/asdf-ruby.git

# Imports Node.js release team&#39;s OpenPGP keys to main keyring
bash ~/.asdf/plugins/nodejs/bin/import-release-team-keyring

asdf plugin-add nodejs https://github.com/asdf-vm/asdf-nodejs.git</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>You can update the plugins all at once with this simple command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf plugin-update --all</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>You can see what versions are available for a particular language like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf list-all ruby
asdf list-all clojure
asdf list-all python</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then you can install any version you need like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf install ruby 2.4.2
asdf install nodejs 8.7.0
asdf install erlang 20.1</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>After you install a particular language version, I always set one as the system default like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf global ruby 2.4.2
asdf global elixir 1.5.2</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And in a particular project directory, I can set it to use any other version, just for that project:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf local ruby 2.3.4</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The command above will write a <code>.tool-versions</code> file to the directory you&rsquo;re at when you ran it. It will contain the language and version you chose, so whenever you go back to that directory ASDF will set the correct version for the language you need. The previous <code>asdf global &lt;language&gt;</code> command is actually writing a <code>.tool-versions</code> file to your home directory. The local config overrides the home directory version.</p>
<p>Another important thing to remember, whenever you install libraries which have executable scripts that need to be in the PATH, you must <strong>reshim</strong> them. For example:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>npm install -g phantomjs # will install phantomjs
asdf reshim nodejs # will put the shim for the phantomjs executable in the PATH
phantomjs # will properly execute it</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you try to install Ruby versions prior to 2.4 you will find <a href="https://github.com/asdf-vm/asdf-ruby/wiki/Ruby-Installation-Problems"target="_blank" rel="noopener">compilation problems</a> as it depends on gcc5 and openssl-1.0. So you should use the following command (assuming you have already installed the obsolete openssl-1.0 and gcc5):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>CC=gcc-5 PKG_CONFIG_PATH=/usr/lib/openssl-1.0/pkgconfig \
RUBY_EXTRA_CONFIGURE_OPTIONS=&#34;--with-openssl-dir=/usr/lib/openssl-1.0&#34; \
asdf install ruby 2.3.4</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Sometimes, if a dependency is missing and an install fails, you must manually remove it before attempting to reinstall, so you have to do:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf remove &lt;language&gt; &lt;version&gt;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Finally, if you&rsquo;re used to add something to your bash or zsh files to show the current language version in the command line, you&rsquo;re probably using something like <code>rvm-prompt</code>. In the case of ASDF you will need something a bit longer like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>asdf current ruby | awk -F&#39; &#39; &#39;{print $1}&#39;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This will get only the version (the <code>asdf current</code> command states which <code>.tool-versions</code> file it is using, so this is a longer result).</p>
<p>Other than that, you&rsquo;re all set. One version manager to rule them all. No more RVM, no more Virtualenv, no more NVM, etc. You can live happily ever after!</p>
]]></content:encoded><category>developer-tools</category><category>automation</category></item><item><title>Hacking Mattermost Team Edition</title><link>https://akitaonrails.github.io/en/2016/08/12/hacking-mattermost-team-edition/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2016/08/12/hacking-mattermost-team-edition/</guid><pubDate>Fri, 12 Aug 2016 14:43:00 -0300</pubDate><description>&lt;p&gt;In &lt;a href="http://www.akitaonrails.com/en/2016/08/09/moving-away-from-slack-into-rocket-chat-good-enough"target="_blank" rel="noopener"&gt;my previous post&lt;/a&gt; I was reporting on my move from Slack to Rocket.chat. But I also mentioned that before Rocket.chat I would rather use Mattermost. First and foremost because it&amp;rsquo;s written in Go (lightweight, highly concurrent, super stable), and because the code base shows much more quality than Rocket.chat (which feels super fragile, with almost no automated tests at all).&lt;/p&gt;
&lt;p&gt;But my major complaint with Mattermost is that the free, open source, Team Edition lacks a super important feature: not allowing users to delete private groups.&lt;/p&gt;</description><content:encoded><![CDATA[<p>In <a href="http://www.akitaonrails.com/en/2016/08/09/moving-away-from-slack-into-rocket-chat-good-enough"target="_blank" rel="noopener">my previous post</a> I was reporting on my move from Slack to Rocket.chat. But I also mentioned that before Rocket.chat I would rather use Mattermost. First and foremost because it&rsquo;s written in Go (lightweight, highly concurrent, super stable), and because the code base shows much more quality than Rocket.chat (which feels super fragile, with almost no automated tests at all).</p>
<p>But my major complaint with Mattermost is that the free, open source, Team Edition lacks a super important feature: not allowing users to delete private groups.</p>
<p><a href="http://www.akitaonrails.com/en/2016/08/09/moving-away-from-slack-into-rocket-chat-good-enough#comment-2832915684"target="_blank" rel="noopener">@iantien commented</a> that the private groups are never actually &ldquo;deleted&rdquo;, they are just marked as deleted, audited, but all data is still in the database. Just the UI has no way to hide the &ldquo;delete&rdquo; option from users and there is no Administration UI to unarchive the deleted groups.</p>
<p>In fact, you can open a <code>psql</code> session in your PostgreSQL database and just do:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>update channels set deleteat = 0;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This will unarchive and restore all deleted channels. But you can see how this is a hassle.</p>
<p>I strongly disagree when he says that only &ldquo;10,000-user enterprises&rdquo; would need such a feature. Even in a small team, any grumpy user can just archive a channel out of the blue and disrupt the entire team communication in private groups. Sure, the &ldquo;Town Square&rdquo; and other public channels will still function, but if you have just one external user participating in projects, for example, you want to use private groups to isolate your internal communication from external users.</p>
<p>So, not having the option for very basic permissions (such as disallowing members to delete channels or private groups) is a very big show stopper even for small teams. And sure, the USD 20/user/year fee is not expensive, but as Mattermost still has fewer features than Rocket.chat, it becomes a very hard sell.</p>
<p>Also, hacking the code itself and adding a flag to disallow this option, in Go, is actually quite easy, but you would have to maintain your own fork (as I think Mattermost would not accept a pull request for a feature that already is in their paid, Enterprise offering).</p>
<p>But after @iantien commented that nothing is deleted and it&rsquo;s all audited, I quickly realized that I could use the audit metadata and devise a way to automatically restore the channels (unless it&rsquo;s the system admin doing it). All without altering the source code.</p>
<p>One can use the many tools available in PostgreSQL itself, namely: <strong>TRIGGERS</strong>. So, without further ado, just run this in your Mattermost database:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="k">REPLACE</span><span class="w"> </span><span class="k">FUNCTION</span><span class="w"> </span><span class="n">undelete_channel</span><span class="p">()</span><span class="w"> </span><span class="k">RETURNS</span><span class="w"> </span><span class="k">trigger</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">$$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">DECLARE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">user_counter</span><span class="w"> </span><span class="nb">integer</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">channel_id</span><span class="w"> </span><span class="nb">character</span><span class="w"> </span><span class="nb">varying</span><span class="p">(</span><span class="mi">26</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">BEGIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">-- Only for channel delete operations
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">IF</span><span class="w"> </span><span class="k">NEW</span><span class="p">.</span><span class="n">action</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;%/channels/%/delete&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="k">RETURN</span><span class="w"> </span><span class="k">NEW</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">END</span><span class="w"> </span><span class="k">IF</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">-- Check if it is the system_admin
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">user_counter</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">FROM</span><span class="w"> </span><span class="n">users</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">NEW</span><span class="p">.</span><span class="n">userid</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">AND</span><span class="w"> </span><span class="n">roles</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;system_admin&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">IF</span><span class="w"> </span><span class="n">user_counter</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">THEN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="k">RETURN</span><span class="w"> </span><span class="k">NEW</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">END</span><span class="w"> </span><span class="k">IF</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">channel_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">split_part</span><span class="p">(</span><span class="k">NEW</span><span class="p">.</span><span class="n">action</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">7</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">UPDATE</span><span class="w"> </span><span class="n">channels</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">SET</span><span class="w"> </span><span class="n">deleteat</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">WHERE</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">channel_id</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">RETURN</span><span class="w"> </span><span class="k">NEW</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">END</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="err">$$</span><span class="w"> </span><span class="k">LANGUAGE</span><span class="w"> </span><span class="n">plpgsql</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">DROP</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">EXISTS</span><span class="w"> </span><span class="n">undelete_channel</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">audits</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TRIGGER</span><span class="w"> </span><span class="n">undelete_channel</span><span class="w"> </span><span class="k">AFTER</span><span class="w"> </span><span class="k">INSERT</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">audits</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">FOR</span><span class="w"> </span><span class="k">EACH</span><span class="w"> </span><span class="k">ROW</span><span class="w"> </span><span class="k">EXECUTE</span><span class="w"> </span><span class="k">PROCEDURE</span><span class="w"> </span><span class="n">undelete_channel</span><span class="p">();</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That&rsquo;s it, it will listen for new audit inserts, check if it is a &ldquo;channel delete&rdquo; action, check that it is not a &lsquo;system_admin&rsquo;, and if so it will automatically grab the channel id from the action REST URL and do the proper UPDATE to bring it back.</p>
<p>I tested it already and in my UI users don&rsquo;t even realize something happened. Not even the offending user sees the channel go away, it instantly comes back.</p>
<p>So, if this was the only thing stopping you from using the free-of-charge, on-premise Team Edition, there you go. And with this you can derive functions to also prevent channels from being renamed, but I&rsquo;ll leave it as an exercise for you (please share in the comments section below if you do it).</p>
<p>Happy Hacking!</p>
]]></content:encoded><category>databases</category><category>automation</category></item><item><title>Ex Manga Downloadr - Part 3: Mangafox Support!</title><link>https://akitaonrails.github.io/en/2015/12/02/ex-manga-downloadr-part-3-mangafox-support/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2015/12/02/ex-manga-downloadr-part-3-mangafox-support/</guid><pubDate>Wed, 02 Dec 2015 16:27:00 -0200</pubDate><description>&lt;p&gt;I thought &lt;a href="http://www.akitaonrails.com/en/2015/11/19/ex-manga-downloadr-parte-2-poolboy-ao-resgate"target="_blank" rel="noopener"&gt;Part 2&lt;/a&gt; would be my last article about this tool, but turns out it&amp;rsquo;s just too much fun to let it go easily. As usual, all the source code is on my &lt;a href="https://github.com/akitaonrails/ex_manga_downloadr"target="_blank" rel="noopener"&gt;Github repository&lt;/a&gt;. And the gist of the post is that now you can do this:&lt;/p&gt;
&lt;div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code"&gt;
&lt;div&gt;&lt;pre&gt;&lt;code&gt;git pull
mix escript.build
./ex_manga_downloadr -n onepunch -u http://mangafox.me/manga/onepunch_man/ -d /tmp/onepunch -s mangafox&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0"&gt;
&lt;button
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
title="Copy code"
&gt;
&lt;div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"&gt;&lt;/div&gt;
&lt;/button&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;And there you go: download from Mangafox built-in! \o/&lt;/p&gt;</description><content:encoded><![CDATA[<p>I thought <a href="http://www.akitaonrails.com/en/2015/11/19/ex-manga-downloadr-parte-2-poolboy-ao-resgate"target="_blank" rel="noopener">Part 2</a> would be my last article about this tool, but turns out it&rsquo;s just too much fun to let it go easily. As usual, all the source code is on my <a href="https://github.com/akitaonrails/ex_manga_downloadr"target="_blank" rel="noopener">Github repository</a>. And the gist of the post is that now you can do this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>git pull
mix escript.build
./ex_manga_downloadr -n onepunch -u http://mangafox.me/manga/onepunch_man/ -d /tmp/onepunch -s mangafox</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And there you go: download from Mangafox built-in! \o/</p>
<p>It starts when I wanted to download manga that is not available at MangaReader but exists under Mangafox.</p>
<p>So, the initial endeavor was to copy the MangaReader parser modules (IndexPage, ChapterPage, and Page) and paste them at a specific &ldquo;lib/ex_manga_downloadr/mangafox&rdquo; folder. Same thing done to the unit tests folder. Just a matter of copying and pasting the files and change the &ldquo;MangaReader&rdquo; module name to &ldquo;Mangafox&rdquo;.</p>
<p>Of course the URL formats are different, the Floki CSS selectors are a bit different, so that&rsquo;s what had to change in the parser. For example, this is how I parse the chapter links from the main page at MangaReader:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defp</span> <span class="n">fetch_chapters</span><span class="p">(</span><span class="n">html</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">Floki</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="s2">&#34;#listing a&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span> <span class="n">fn</span> <span class="p">{</span><span class="s2">&#34;a&#34;</span><span class="p">,</span> <span class="o">[</span><span class="p">{</span><span class="s2">&#34;href&#34;</span><span class="p">,</span> <span class="n">url</span><span class="p">}</span><span class="o">]</span><span class="p">,</span> <span class="n">_</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">url</span> <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And this is the same thing but for Mangafox:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defp</span> <span class="n">fetch_chapters</span><span class="p">(</span><span class="n">html</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">html</span>
</span></span><span class="line"><span class="cl">  <span class="o">|&gt;</span> <span class="no">Floki</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="s2">&#34;.chlist a[class=&#39;tips&#39;]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span> <span class="n">fn</span> <span class="p">{</span><span class="s2">&#34;a&#34;</span><span class="p">,</span> <span class="o">[</span><span class="p">{</span><span class="s2">&#34;href&#34;</span><span class="p">,</span> <span class="n">url</span><span class="p">},</span> <span class="p">{</span><span class="s2">&#34;title&#34;</span><span class="p">,</span> <span class="n">_</span><span class="p">},</span> <span class="p">{</span><span class="s2">&#34;class&#34;</span><span class="p">,</span> <span class="s2">&#34;tips&#34;</span><span class="p">}</span><span class="o">]</span><span class="p">,</span> <span class="n">_</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">url</span> <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Exactly the same logic but the pattern matching structure is different because the returning HTML DOM nodes are different.</p>
<p>Another difference is that MangaReader returns everything in plain text by default, but Mangafox returns everything Gzipped regardless of whether I send the &ldquo;Accept-Encoding&rdquo; HTTP header (curiously, if I retry several times it changes behavior and sometimes sends plain text).</p>
<p>What I did differently was to check if the returned <tt>%HTTPotion.Response{}</tt> structure had a &ldquo;Content-Encoding&rdquo; header set to &ldquo;gzip&rdquo; and if so, gunzip it using the built-in Erlang &ldquo;zlib&rdquo; package (nothing to import!):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">gunzip</span><span class="p">(</span><span class="n">body</span><span class="p">,</span> <span class="n">headers</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">headers</span><span class="o">[</span><span class="ss">:&#34;Content-Encoding&#34;</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;gzip&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="ss">:zlib</span><span class="o">.</span><span class="n">gunzip</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="n">body</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I would&rsquo;ve preferred if HTTPotion did that out of the box for me (#OpportunityToContribute!), but this was easy enough.</p>
<p>Once the unit tests were passing correctly after tuning the scrapper (HTTPotion requests) and parser (Floki selectors) it was time to make my Worker aware of the existence of this new set of modules.</p>
<p>The Workflow module just calls the Worker, which in turn does the heavy lifting of fetching pages and downloading images. The Worker called the MangaReader module directly, like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defmodule</span> <span class="no">PoolManagement</span><span class="o">.</span><span class="n">Worker</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">use</span> <span class="no">GenServer</span>
</span></span><span class="line"><span class="cl">  <span class="n">use</span> <span class="no">ExMangaDownloadr</span><span class="o">.</span><span class="n">MangaReader</span>
</span></span><span class="line"><span class="cl">  <span class="nb">require</span> <span class="no">Logger</span>
</span></span><span class="line"><span class="cl">  <span class="o">...</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">chapter_page</span><span class="p">(</span><span class="n">chapter_link</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="no">Task</span><span class="o">.</span><span class="n">async</span> <span class="n">fn</span> <span class="o">-&gt;</span> 
</span></span><span class="line"><span class="cl">      <span class="ss">:poolboy</span><span class="o">.</span><span class="n">transaction</span> <span class="ss">:worker_pool</span><span class="p">,</span> <span class="n">fn</span><span class="p">(</span><span class="n">server</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="no">GenServer</span><span class="o">.</span><span class="n">call</span><span class="p">(</span><span class="n">server</span><span class="p">,</span> <span class="p">{</span><span class="ss">:chapter_page</span><span class="p">,</span> <span class="n">chapter_link</span><span class="p">},</span> <span class="vi">@timeout_ms</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span><span class="p">,</span> <span class="vi">@transaction_timeout_ms</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="o">...</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">handle_call</span><span class="p">({</span><span class="ss">:chapter_page</span><span class="p">,</span> <span class="n">chapter_link</span><span class="p">},</span> <span class="n">_from</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="ss">:reply</span><span class="p">,</span> <span class="no">ChapterPages</span><span class="o">.</span><span class="n">pages</span><span class="p">(</span><span class="n">chapter_link</span><span class="p">),</span> <span class="n">state</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That &ldquo;<tt>use ExMangaDownloadr.MangaReader</tt>&rdquo; statement up above is just a macro that will alias the corresponding modules:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defmodule</span> <span class="no">ExMangaDownloadr</span><span class="o">.</span><span class="n">MangaReader</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">defmacro</span> <span class="n">__using__</span><span class="p">(</span><span class="n">_opts</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">quote</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="k">alias</span> <span class="no">ExMangaDownloadr</span><span class="o">.</span><span class="n">MangaReader</span><span class="o">.</span><span class="n">IndexPage</span>
</span></span><span class="line"><span class="cl">      <span class="k">alias</span> <span class="no">ExMangaDownloadr</span><span class="o">.</span><span class="n">MangaReader</span><span class="o">.</span><span class="n">ChapterPage</span>
</span></span><span class="line"><span class="cl">      <span class="k">alias</span> <span class="no">ExMangaDownloadr</span><span class="o">.</span><span class="n">MangaReader</span><span class="o">.</span><span class="n">Page</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>So when I call &ldquo;<tt>ChapterPages.pages(chapter_link)</tt>&rdquo; it&rsquo;s a shortcut to use the fully qualified module name like this: &ldquo;<tt>ExMangaDownloadr.MangaReader.ChapterPages.pages(chapter_link)</tt>&rdquo;.</p>
<p>An Elixir module namespace is just an Atom. Nested module names have the full, dot-separated name, prefixed with its parent. For example:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defmodule</span> <span class="no">Foo</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">defmodule</span> <span class="no">Bar</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">defmodule</span> <span class="no">Xyz</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">       <span class="k">def</span> <span class="nf">teste</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">       <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>You can just call &ldquo;<tt>Foo.Bar.Xyz.teste()</tt>&rdquo; and that&rsquo;s it. But there is a small trick. Elixir also transparently prefixes the full module name with &ldquo;Elixir&rdquo;. So in reality, the full module name is &ldquo;Elixir.Foo.Bar.Xyz&rdquo;, in order to make sure no Elixir module ever conflicts with an existing Erlang module.</p>
<p>This is important because of this new function I added to the Worker module first:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">manga_source</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">module</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">source</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;mangareader&#34;</span> <span class="o">-&gt;</span> <span class="nb">String</span><span class="o">.</span><span class="n">to_atom</span><span class="p">(</span><span class="s2">&#34;Elixir.ExMangaDownloadr.MangaReader.</span><span class="si">#{</span><span class="n">module</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;mangafox&#34;</span>    <span class="o">-&gt;</span> <span class="nb">String</span><span class="o">.</span><span class="n">to_atom</span><span class="p">(</span><span class="s2">&#34;Elixir.ExMangaDownloadr.Mangafox.</span><span class="si">#{</span><span class="n">module</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is how I map from &ldquo;mangafox&rdquo; to the new &ldquo;ExMangaDownloadr.Mangafox.&rdquo; namespace. And because of the dynamic, message passing nature of Elixir, I can replace this code:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">handle_call</span><span class="p">({</span><span class="ss">:chapter_page</span><span class="p">,</span> <span class="n">chapter_link</span><span class="p">},</span> <span class="n">_from</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:reply</span><span class="p">,</span> <span class="no">ChapterPages</span><span class="o">.</span><span class="n">pages</span><span class="p">(</span><span class="n">chapter_link</span><span class="p">),</span> <span class="n">state</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">handle_call</span><span class="p">({</span><span class="ss">:chapter_page</span><span class="p">,</span> <span class="n">chapter_link</span><span class="p">,</span> <span class="n">source</span><span class="p">},</span> <span class="n">_from</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">links</span> <span class="o">=</span> <span class="n">source</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="n">manga_source</span><span class="p">(</span><span class="s2">&#34;ChapterPage&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="n">apply</span><span class="p">(</span><span class="ss">:pages</span><span class="p">,</span> <span class="o">[</span><span class="n">chapter_link</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:reply</span><span class="p">,</span> <span class="n">links</span><span class="p">,</span> <span class="n">state</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I can now choose between the &ldquo;Elixir.ExMangaDownloadr.Mangafox.ChapterPage&rdquo; or &ldquo;Elixir.ExMangaDownloadr.MangaReader.ChapterPage&rdquo; modules, call the <tt>pages/1</tt> function and send the same argument as before. I just have to make sure I can receive a &ldquo;source&rdquo; string from the command line now, so I change the CLI module like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defp</span> <span class="n">parse_args</span><span class="p">(</span><span class="n">args</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">parse</span> <span class="o">=</span> <span class="no">OptionParser</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">args</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">switches</span><span class="p">:</span> <span class="o">[</span><span class="nb">name</span><span class="p">:</span> <span class="ss">:string</span><span class="p">,</span> <span class="ss">url</span><span class="p">:</span> <span class="ss">:string</span><span class="p">,</span> <span class="ss">directory</span><span class="p">:</span> <span class="ss">:string</span><span class="p">,</span> <span class="ss">source</span><span class="p">:</span> <span class="ss">:string</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">aliases</span><span class="p">:</span> <span class="o">[</span><span class="ss">n</span><span class="p">:</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">u</span><span class="p">:</span> <span class="ss">:url</span><span class="p">,</span> <span class="ss">d</span><span class="p">:</span> <span class="ss">:directory</span><span class="p">,</span> <span class="ss">s</span><span class="p">:</span> <span class="ss">:source</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="n">parse</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="o">[</span><span class="nb">name</span><span class="p">:</span> <span class="n">manga_name</span><span class="p">,</span> <span class="ss">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="ss">directory</span><span class="p">:</span> <span class="n">directory</span><span class="p">,</span> <span class="ss">source</span><span class="p">:</span> <span class="n">source</span><span class="o">]</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">process</span><span class="p">(</span><span class="n">manga_name</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">directory</span><span class="p">,</span> <span class="n">source</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="o">[</span><span class="nb">name</span><span class="p">:</span> <span class="n">manga_name</span><span class="p">,</span> <span class="ss">directory</span><span class="p">:</span> <span class="n">directory</span><span class="o">]</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">process</span><span class="p">(</span><span class="n">manga_name</span><span class="p">,</span> <span class="n">directory</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="p">}</span> <span class="o">-&gt;</span> <span class="n">process</span><span class="p">(</span><span class="ss">:help</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Compared to the previous version I just added the &ldquo;<tt>:source</tt>&rdquo; string argument to the OptionParser and passed the captured value to <tt>process/4</tt>. I should add some validation here to avoid strings different than &ldquo;mangareader&rdquo; or &ldquo;mangafox&rdquo;, but I will leave that to another time.</p>
<p>And in the Workflow module, instead of starting from just the manga URL, now I have to start with both the URL and the manga source:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="o">[</span><span class="n">url</span><span class="p">,</span> <span class="n">source</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">chapters</span>
</span></span><span class="line"><span class="cl">  <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">pages</span>
</span></span><span class="line"><span class="cl">  <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">images_sources</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Which means that each of the above functions has to not only return the new URL lists but also pass through the source:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">chapters</span><span class="p">(</span><span class="o">[</span><span class="n">url</span><span class="p">,</span> <span class="n">source</span><span class="o">]</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">_manga_title</span><span class="p">,</span> <span class="n">chapter_list</span><span class="p">}</span> <span class="o">=</span> <span class="n">source</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Worker</span><span class="o">.</span><span class="n">manga_source</span><span class="p">(</span><span class="s2">&#34;IndexPage&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="n">apply</span><span class="p">(</span><span class="ss">:chapters</span><span class="p">,</span> <span class="o">[</span><span class="n">url</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span><span class="n">chapter_list</span><span class="p">,</span> <span class="n">source</span><span class="o">]</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This was the only function in the Workflow module hardcoded to MangaReader so I also made it dynamic using the same <tt>manga_source/2</tt> function from the Worker, and notice the return value being &ldquo;<tt>[chapter_list, source]</tt>&rdquo; instead of just &ldquo;<tt>chapter_list</tt>&rdquo;.</p>
<p>And now, I can finally test with &ldquo;<tt>mix test</tt>&rdquo; and create the new executable command line binary with &ldquo;<tt>mix escript.build</tt>&rdquo; and run the new version like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>./ex_manga_downloadr -n onepunch -u http://mangafox.me/manga/onepunch_man/ -d /tmp/onepunch -s mangafox</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The Mangafox site is very unreliable with several concurrent connections and it quickly times out sometimes, dumping ugly errors like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>15:58:46.637 [error] Task #PID&lt;0.2367.0&gt; started from #PID&lt;0.124.0&gt; terminating
** (stop) exited in: GenServer.call(#PID&lt;0.90.0&gt;, {:page_download_image, {&#34;http://z.mfcdn.net/store/manga/11362/TBD-053.2/compressed/h006.jpg&#34;, &#34;Onepunch-Man 53.2: 53rd Punch [Fighting Spirit] (2) at MangaFox.me-h006.jpg&#34;}, &#34;/tmp/onepunch&#34;}, 1000000)
    ** (EXIT) an exception was raised:
        ** (HTTPotion.HTTPError) connection_closing
            (httpotion) lib/httpotion.ex:209: HTTPotion.handle_response/1</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I haven&rsquo;t figured out how to retry HTTPotion requests properly yet. But one small thing I did was add an availability check in the Worker module. So you can just re-run the same command line and it will resume downloading only the remaining files:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defp</span> <span class="n">download_image</span><span class="p">({</span><span class="n">image_src</span><span class="p">,</span> <span class="n">image_filename</span><span class="p">},</span> <span class="n">directory</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">filename</span> <span class="o">=</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">directory</span><span class="si">}</span><span class="s2">/</span><span class="si">#{</span><span class="n">image_filename</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">File</span><span class="o">.</span><span class="n">exists?</span><span class="p">(</span><span class="n">filename</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="no">Logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">&#34;Image </span><span class="si">#{</span><span class="n">filename</span><span class="si">}</span><span class="s2"> already downloaded, skipping.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">image_src</span><span class="p">,</span> <span class="n">filename</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="no">Logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">&#34;Downloading image </span><span class="si">#{</span><span class="n">image_src</span><span class="si">}</span><span class="s2"> to </span><span class="si">#{</span><span class="n">filename</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="no">HTTPotion</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">image_src</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="o">[</span><span class="ss">headers</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;User-Agent&#34;</span><span class="p">:</span> <span class="vi">@user_agent</span><span class="o">]</span><span class="p">,</span> <span class="ss">timeout</span><span class="p">:</span> <span class="vi">@http_timeout</span><span class="o">]</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="o">%</span><span class="no">HTTPotion</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span> <span class="ss">body</span><span class="p">:</span> <span class="n">body</span><span class="p">,</span> <span class="ss">headers</span><span class="p">:</span> <span class="n">_headers</span><span class="p">,</span> <span class="ss">status_code</span><span class="p">:</span> <span class="mi">200</span> <span class="p">}</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="no">File</span><span class="o">.</span><span class="n">write!</span><span class="p">(</span><span class="n">filename</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">image_src</span><span class="p">,</span> <span class="n">filename</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="n">_</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="ss">:err</span><span class="p">,</span> <span class="n">image_src</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This should at least reduce rework. Another thing I am still working on is this other bit at the main &ldquo;CLI.process&rdquo; function:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defp</span> <span class="n">process</span><span class="p">(</span><span class="n">manga_name</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">directory</span><span class="p">,</span> <span class="n">source</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">File</span><span class="o">.</span><span class="n">mkdir_p!</span><span class="p">(</span><span class="n">directory</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">dump_file</span> <span class="o">=</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">directory</span><span class="si">}</span><span class="s2">/images_list.dump&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">images_list</span> <span class="o">=</span> <span class="k">if</span> <span class="no">File</span><span class="o">.</span><span class="n">exists?</span><span class="p">(</span><span class="n">dump_file</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">                  <span class="ss">:erlang</span><span class="o">.</span><span class="n">binary_to_term</span><span class="p">(</span><span class="no">File</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">dump_file</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">                <span class="k">else</span>
</span></span><span class="line"><span class="cl">                  <span class="n">list</span> <span class="o">=</span> <span class="o">[</span><span class="n">url</span><span class="p">,</span> <span class="n">source</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">                    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">chapters</span>
</span></span><span class="line"><span class="cl">                    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">pages</span>
</span></span><span class="line"><span class="cl">                    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">images_sources</span>
</span></span><span class="line"><span class="cl">                  <span class="no">File</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">dump_file</span><span class="p">,</span> <span class="ss">:erlang</span><span class="o">.</span><span class="n">term_to_binary</span><span class="p">(</span><span class="n">list</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">                  <span class="n">list</span>
</span></span><span class="line"><span class="cl">                <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="n">images_list</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">process_downloads</span><span class="p">(</span><span class="n">directory</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">optimize_images</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">compile_pdfs</span><span class="p">(</span><span class="n">manga_name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="n">finish_process</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>As you can see the idea is to serialize the final images URL links to a file using the built-in serializer &ldquo;<tt>:erlang.binary_to_term/1</tt>&rdquo; and check if that dump file exists, and deserialize with &ldquo;<tt>:erlang.term_to_binary/1</tt>&rdquo; before fetching all pages all over again. Now the process can resume directly from the <tt>process_downloads/2</tt> function after.</p>
<p>Mangafox is terribly unreliable and I will need to figure out a better way to retry timed out connections without having to crash and manually restart from the command line. It&rsquo;s either a bad site or a clever one that shuts down scrapers like me, although I am guessing it&rsquo;s just bad infrastructure on their side.</p>
<p>If I downgrade from 50 processes to 5 in the pool, it seems to be able to handle it better (but the process slows down, of course):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl">    <span class="n">pool_options</span> <span class="o">=</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="nb">name</span><span class="p">:</span> <span class="p">{</span><span class="ss">:local</span><span class="p">,</span> <span class="ss">:worker_pool</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="ss">worker_module</span><span class="p">:</span> <span class="no">PoolManagement</span><span class="o">.</span><span class="n">Worker</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">size</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">max_overflow</span><span class="p">:</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you see time out errors, change this parameter. MangaReader still supports 50 or more for concurrency.</p>
<p>And now you know how to add support for more manga sources. Feel free to send me a Pull Request! :-)</p>
]]></content:encoded><category>elixir-and-erlang</category><category>anime-and-manga</category><category>automation</category></item><item><title>Ex Manga Downloader, an exercise with Elixir</title><link>https://akitaonrails.github.io/en/2015/11/18/ex-manga-downloader-an-exercise-with-elixir/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2015/11/18/ex-manga-downloader-an-exercise-with-elixir/</guid><pubDate>Wed, 18 Nov 2015 17:26:00 -0200</pubDate><description>&lt;p&gt;&lt;strong&gt;Update 11/19/15:&lt;/strong&gt; In this article I mention a few doubts I had, so read this and then follow through &lt;a href="http://www.akitaonrails.com/en/2015/11/19/ex-manga-downloadr-parte-2-poolboy-ao-resgate"target="_blank" rel="noopener"&gt;Part 2&lt;/a&gt; to see how I solved it.&lt;/p&gt;
&lt;p&gt;As an exercise (and also because I&amp;rsquo;m obviously an &lt;a href="https://en.wikipedia.org/wiki/Otaku"target="_blank" rel="noopener"&gt;Otaku&lt;/a&gt;) I &lt;a href="https://github.com/akitaonrails/ex_manga_downloadr/blob/master/lib/ex_manga_downloadr/mangareader/index_page.ex"target="_blank" rel="noopener"&gt;implemented&lt;/a&gt; a simple Elixir-based scraper for the great &lt;a href="http://www.mangareader.net/"target="_blank" rel="noopener"&gt;MangaReader&lt;/a&gt; website. One can argue whether it&amp;rsquo;s OK to scrape their website, and one might also argue whether their providing those mangas is OK in the first place, so let&amp;rsquo;s not go down this path.&lt;/p&gt;</description><content:encoded><![CDATA[<p><strong>Update 11/19/15:</strong> In this article I mention a few doubts I had, so read this and then follow through <a href="http://www.akitaonrails.com/en/2015/11/19/ex-manga-downloadr-parte-2-poolboy-ao-resgate"target="_blank" rel="noopener">Part 2</a> to see how I solved it.</p>
<p>As an exercise (and also because I&rsquo;m obviously an <a href="https://en.wikipedia.org/wiki/Otaku"target="_blank" rel="noopener">Otaku</a>) I <a href="https://github.com/akitaonrails/ex_manga_downloadr/blob/master/lib/ex_manga_downloadr/mangareader/index_page.ex"target="_blank" rel="noopener">implemented</a> a simple Elixir-based scraper for the great <a href="http://www.mangareader.net/"target="_blank" rel="noopener">MangaReader</a> website. One can argue whether it&rsquo;s OK to scrape their website, and one might also argue whether their providing those mangas is OK in the first place, so let&rsquo;s not go down this path.</p>
<p>I had an older version <a href="https://github.com/akitaonrails/manga-downloadr"target="_blank" rel="noopener">written in Ruby</a>. It still works but it&rsquo;s in sore need of a good refactoring (sorry about that). The purpose of that version was to see if I could actually do parallel fetching and retry connections using Typhoeus.</p>
<p><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/525/big_ex_manga_downloadr.png" alt="OnePunch Man downloaded"  loading="lazy" /></p>
<p>As I&rsquo;ve been progressing in my studies of Elixir, that tool felt like a great candidate to test my current knowledge of the platform. It would make me test:</p>
<ol>
<li>Fetch and parse ad hoc content through HTTP (<a href="https://github.com/myfreeweb/httpotion"target="_blank" rel="noopener">HTTPotion</a> and <a href="https://github.com/philss/floki"target="_blank" rel="noopener">Floki</a>).</li>
<li>Test parallel/asynchronous downloads (Elixir&rsquo;s built-in <a href="http://elixir-lang.org/docs/stable/elixir/Task.html"target="_blank" rel="noopener">Task</a> module).</li>
<li>The built-in command line and <a href="http://elixir-lang.org/docs/stable/elixir/OptionParser.html"target="_blank" rel="noopener">option parser</a> support.</li>
<li>Basic testing through ExUnit and mocking the workflow (<a href="https://github.com/jjh42/mock"target="_blank" rel="noopener">Mock</a>).</li>
</ol>
<p>The exercise was very interesting, and a scraper is also an ideal candidate for TDD. The initial steps had to go like this:</p>
<ol>
<li>Parse the main manga page to fetch all the chapter links.</li>
<li>Parse each chapter page to fetch all the individual pages.</li>
<li>Parse each page to parse the main embedded image (the actual manga page).</li>
</ol>
<p>For each of those initial steps I did a simple unit test and the <tt>IndexPage</tt>, <tt>ChapterPage</tt> and <tt>Page</tt> modules. They have roughly the same structure, this is one example:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defmodule</span> <span class="no">ExMangaDownloadr</span><span class="o">.</span><span class="n">MangaReader</span><span class="o">.</span><span class="n">IndexPage</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">chapters</span><span class="p">(</span><span class="n">manga_root_url</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="no">HTTPotion</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">manga_root_url</span><span class="p">,</span> <span class="o">[</span><span class="ss">timeout</span><span class="p">:</span> <span class="mi">30_000</span><span class="o">]</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="o">%</span><span class="no">HTTPotion</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span> <span class="ss">body</span><span class="p">:</span> <span class="n">body</span><span class="p">,</span> <span class="ss">headers</span><span class="p">:</span> <span class="n">_headers</span><span class="p">,</span> <span class="ss">status_code</span><span class="p">:</span> <span class="mi">200</span> <span class="p">}</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">fetch_manga_title</span><span class="p">(</span><span class="n">body</span><span class="p">),</span> <span class="n">fetch_chapters</span><span class="p">(</span><span class="n">body</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="n">_</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="p">{</span><span class="ss">:err</span><span class="p">,</span> <span class="s2">&#34;not found&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">defp</span> <span class="n">fetch_manga_title</span><span class="p">(</span><span class="n">html</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="no">Floki</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="s2">&#34;#mangaproperties h1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">fn</span> <span class="p">{</span><span class="s2">&#34;h1&#34;</span><span class="p">,</span> <span class="o">[]</span><span class="p">,</span> <span class="o">[</span><span class="n">title</span><span class="o">]</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">title</span> <span class="k">end</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">defp</span> <span class="n">fetch_chapters</span><span class="p">(</span><span class="n">html</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="no">Floki</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="s2">&#34;#listing a&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span> <span class="n">fn</span> <span class="p">{</span><span class="s2">&#34;a&#34;</span><span class="p">,</span> <span class="o">[</span><span class="p">{</span><span class="s2">&#34;href&#34;</span><span class="p">,</span> <span class="n">url</span><span class="p">}</span><span class="o">]</span><span class="p">,</span> <span class="n">_</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">url</span> <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Here I am already exercising some of Elixir&rsquo;s awesome features such as pattern matching the result of the <tt>HTTPotion.get/2</tt> function to extract the body from the returning record.</p>
<p>Then I pass the HTML body to 2 different functions: <tt>fetch_manga_title</tt> and <tt>fetch_chapters</tt>. They both use the Floki package which can use CSS selectors to return a List. Then I need to walk through the list (using <tt>Enum.map/2</tt> for example) and pattern match on it to extract the values I need.</p>
<p><a href="http://elixir-lang.org/getting-started/pattern-matching.html"target="_blank" rel="noopener">Pattern Matching</a> is one of the most important concepts to learn about Elixir/Erlang. It&rsquo;s different from simply assigning a value to a variable, it can be used to destructure a value into its components and get their individual parts.</p>
<p>Then I just went through building the skeleton for the command line interface. This is already explained in other tutorials such as <a href="http://asquera.de/blog/2015-04-10/writing-a-commandline-app-in-elixir/"target="_blank" rel="noopener">this</a> and <a href="https://speakerdeck.com/st23am/writing-command-line-applications-in-elixir"target="_blank" rel="noopener">this</a>, so I won&rsquo;t waste time explaining it again. At the core I needed to have the following workflow:</p>
<ol>
<li>Starting from the Manga main URL at MangaReader, extract the chapters</li>
<li>Then loop through the chapters and fetch all the pages</li>
<li>Then loop through the pages and fetch all the image sources</li>
<li>Then loop through the images and download them all to a temporary directory</li>
<li>Then sort through files in the directory and move them to sub-directories of 250 images each (I think it is a good size for each volume)</li>
<li>Finally, resize and convert all the images of each sub-directory into a PDF file for my Kindle to consume.</li>
</ol>
<p>This workflow is defined like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defp</span> <span class="n">process</span><span class="p">(</span><span class="n">manga_name</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">directory</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">File</span><span class="o">.</span><span class="n">mkdir_p!</span><span class="p">(</span><span class="n">directory</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">url</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">chapters</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">pages</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">images_sources</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">process_downloads</span><span class="p">(</span><span class="n">directory</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">optimize_images</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">compile_pdfs</span><span class="p">(</span><span class="n">manga_name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="n">finish_process</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is one place where the pipeline notation from Elixir really shines. It&rsquo;s much better than having to write this equivalent:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">Workflow</span><span class="o">.</span><span class="n">compile_pdfs</span><span class="p">(</span><span class="no">Workflow</span><span class="o">.</span><span class="n">optimize_images</span><span class="p">(</span><span class="n">directory</span><span class="p">))</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This notation is just syntactic sugar where the returning value of the previous statement is used as the first argument of the following function. Combine that with other syntactic sugars such as parentheses being optional (just like beloved Ruby) and you have a clear exposure of &ldquo;transforming from a URL into compiled PDFs&rdquo;.</p>
<p>I separated the Workflow into its own module and each step is very similar, each taking a list and walking through it. This is the simplest of them:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">pages</span><span class="p">(</span><span class="n">chapter_list</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">chapter_list</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="no">Task</span><span class="o">.</span><span class="n">async</span><span class="p">(</span><span class="n">fn</span> <span class="o">-&gt;</span> <span class="no">ChapterPage</span><span class="o">.</span><span class="n">pages</span><span class="p">(</span><span class="o">&amp;</span><span class="mi">1</span><span class="p">)</span> <span class="k">end</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="no">Task</span><span class="o">.</span><span class="n">await</span><span class="p">(</span><span class="o">&amp;</span><span class="mi">1</span><span class="p">,</span> <span class="vi">@http_timeout</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">reduce</span><span class="p">(</span><span class="o">[]</span><span class="p">,</span> <span class="n">fn</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">list</span><span class="p">},</span> <span class="n">acc</span> <span class="o">-&gt;</span> <span class="n">acc</span> <span class="o">++</span> <span class="n">list</span> <span class="k">end</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If you&rsquo;re new to Elixir here you will find another oddity, this <tt>&quot;&amp;(x(&amp;1))&quot;</tt>, this is just a shortcut macro to this other similar statement:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">fn</span> <span class="p">(</span><span class="n">list</span><span class="p">)</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="no">Task</span><span class="o">.</span><span class="n">async</span><span class="p">(</span><span class="n">fn</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="no">ChapterPage</span><span class="o">.</span><span class="n">pages</span><span class="p">(</span><span class="n">list</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><a href="http://elixir-lang.org/docs/stable/elixir/Enum.html"target="_blank" rel="noopener">Enum</a> is one of the most useful modules you need to master. If you come from Ruby it feels like home, you must learn all of its functions. You&rsquo;re usually having to transform one collection into another, so it&rsquo;s important to study it thoroughly.</p>
<h3>A few problems understanding parallel HTTP processing (W.I.P.)<span class="hx:absolute hx:-mt-20" id="a-few-problems-understanding-parallel-http-processing-wip"></span>
    <a href="#a-few-problems-understanding-parallel-http-processing-wip" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Then there is this <tt>Task.async/await</tt> deal. If you&rsquo;re from a language that has Threads, it&rsquo;s quite similar: you start several different Threads and await all of them returning before continuing. But a Task in Elixir is not a real thread, it&rsquo;s a <a href="https://en.wikipedia.org/wiki/Green_threads#Green_threads_in_other_languages"target="_blank" rel="noopener">&ldquo;green thread&rdquo;</a> or, in Erlang lingo, a very lightweight &ldquo;process&rdquo;. Erlang uses processes for everything, and so does Elixir. Under the hood, the &ldquo;Task&rdquo; module encapsulates the entire OTP framework for supervisors/workers. But instead of having to deal right now with <a href="http://elixir-lang.org/getting-started/mix-otp/genserver.html"target="_blank" rel="noopener">OTP GenServer</a> I decided to go the simpler route for now, and the &ldquo;Task&rdquo; module accomplishes just that.</p>
<p>Then I ended up with a problem. If I just kept going like this, spawning hundreds of async HTTP calls, I quickly ended up with this exception:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>17:10:55.882 [error] Task #PID&lt;0.2217.0&gt; started from #PID&lt;0.69.0&gt; terminating
** (HTTPotion.HTTPError) req_timedout
    (httpotion) lib/httpotion.ex:209: HTTPotion.handle_response/1
    (ex_manga_downloadr) lib/ex_manga_downloadr/mangareader/page.ex:6: ExMangaDownloadr.MangaReader.Page.image/1
    (elixir) lib/task/supervised.ex:74: Task.Supervised.do_apply/2
    (elixir) lib/task/supervised.ex:19: Task.Supervised.async/3
    (stdlib) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
Function: #Function&lt;12.106612505/0 in ExMangaDownloadr.Workflow.images_sources/1&gt;
    Args: []</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That&rsquo;s why there is <tt>@maximum_fetches 80</tt> at the top of the <tt>Workflow</tt> module, together with this other odd construction:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">images_sources</span><span class="p">(</span><span class="n">pages_list</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">pages_list</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="n">chunk</span><span class="p">(</span><span class="vi">@maximum_fetches</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">reduce</span><span class="p">(</span><span class="o">[]</span><span class="p">,</span> <span class="n">fn</span> <span class="n">pages_chunk</span><span class="p">,</span> <span class="n">acc</span> <span class="o">-&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="n">result</span> <span class="o">=</span> <span class="n">pages_chunk</span>
</span></span><span class="line"><span class="cl">        <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="no">Task</span><span class="o">.</span><span class="n">async</span><span class="p">(</span><span class="n">fn</span> <span class="o">-&gt;</span> <span class="no">Page</span><span class="o">.</span><span class="n">image</span><span class="p">(</span><span class="o">&amp;</span><span class="mi">1</span><span class="p">)</span> <span class="k">end</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">        <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="no">Task</span><span class="o">.</span><span class="n">await</span><span class="p">(</span><span class="o">&amp;</span><span class="mi">1</span><span class="p">,</span> <span class="vi">@http_timeout</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">        <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">fn</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">image</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">image</span> <span class="k">end</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">acc</span> <span class="o">++</span> <span class="n">result</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This gets a huge list (such as all the pages of a very long manga like Naruto), breaks it down into smaller lists of 80 elements and then proceeds to fire up the asynchronous Tasks, reducing the results back to a plain List. The <tt>chunk/2</tt> private function just gets the smaller size between the list length and the maximum fetches value.</p>
<p>Sometimes it breaks down if the maximum is larger, sometimes it doesn&rsquo;t, so my guess is that my code isn&rsquo;t dealing with network instabilities (with some retry logic) or that the MangaReader site is queueing up above my designated timeout (which I set to 30 seconds). Either way, keeping the maximum value lower than 100 seems to be a good balance without crashing the workflow.</p>
<p>This is one part where I&rsquo;m not entirely sure what to do to deal with uncertainties like the external website not responding or the network going down for a little while. HTTPotion has support for asynchronous calls, but I don&rsquo;t know what the difference is between using that and just making synchronous calls within parallel processes with Task, the way I&rsquo;m doing. And in either case, they are supervised workers, how do I handle the exceptions, how should I implement logic to retry the call once it fails? If anyone has more knowledge about this, a comment below will be really appreciated.</p>
<p>Finally, there is one dirty trick behind why I like to use MangaReader: it&rsquo;s very friendly to scrapers because on each page of the manga the image is annotated with an &ldquo;alt&rdquo; attribute with the format &ldquo;[manga name] [chapter number] - [page number]&rdquo;. So I just had to reformat it a bit, adding a pad of zeroes before the chapter and page number so a simple sort of the downloaded files gives me the correct order. MangaFox is not so friendly. This is how to reformat it:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">defp</span> <span class="n">normalize_metadata</span><span class="p">(</span><span class="n">image_src</span><span class="p">,</span> <span class="n">image_alt</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">extension</span>      <span class="o">=</span> <span class="nb">String</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">image_src</span><span class="p">,</span> <span class="s2">&#34;.&#34;</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">list</span>           <span class="o">=</span> <span class="nb">String</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">image_alt</span><span class="p">)</span>      <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">reverse</span>
</span></span><span class="line"><span class="cl">  <span class="n">title_name</span>     <span class="o">=</span> <span class="no">Enum</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="n">list</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="no">Enum</span><span class="o">.</span><span class="n">count</span><span class="p">(</span><span class="n">list</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34; &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">chapter_number</span> <span class="o">=</span> <span class="no">Enum</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">list</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="nb">String</span><span class="o">.</span><span class="n">rjust</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="sc">?0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">page_number</span>    <span class="o">=</span> <span class="no">Enum</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">list</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="nb">String</span><span class="o">.</span><span class="n">rjust</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="sc">?0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="n">image_src</span><span class="p">,</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">title_name</span><span class="si">}</span><span class="s2"> </span><span class="si">#{</span><span class="n">chapter_number</span><span class="si">}</span><span class="s2"> - Page </span><span class="si">#{</span><span class="n">page_number</span><span class="si">}</span><span class="s2">.</span><span class="si">#{</span><span class="n">extension</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Once I have all the images, I spawn another external process using <a href="https://github.com/alco/porcelain"target="_blank" rel="noopener">Porcelain</a> to deal with shelling out to run ImageMagick&rsquo;s Mogrify and Convert tools to resize all the images down to 600x800 pixels (Kindle Voyage resolution) and pack them together into a PDF file. This results in PDF files with 250 pages and around 20Mb in size. Now it is just a matter of copying the files to my Kindle through USB.</p>
<p>The ImageMagick code is quite boring, I just generate the commands in the following format for Mogrify:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>&#34;mogrify -resize #{@image_dimensions} #{directory}/*.jpg&#34;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And compile the PDFs with this other command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>&#34;convert #{volume_directory}/*.jpg #{volume_file}&#34;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>(By the way, notice the Ruby-like String interpolation we&rsquo;re used to.)</p>
<p><img src="https://akitaonrails.s3.amazonaws.com/assets/image_asset/image/526/big_11249911_10153742264949837_7817568172948440418_n.jpg" alt="OnePunch Man in Kindle"  loading="lazy" /></p>
<p>Technically I could copy the MangaReader module files into a new MangaFox module and repurpose the same Workflow logic once I tweak the parsers to deal with MangaFox page format. But I leave that as an exercise to the reader.</p>
<p>The MangaReader module tests do real calls to their website. I left it that way on purpose because if the test fails it means that they changed the website format and the parser needs tweaking to conform. But after a few years, I never saw them change enough to break my old Ruby parser.</p>
<p>Just as a final exercise I imported the Mock package, to control what some inner pieces of the Workflow implementation return. It&rsquo;s called Mock but it&rsquo;s more like stubbing particular functions of a module. I can declare a block where I override the <tt>File.rename/1</tt> so it doesn&rsquo;t actually try to move a file that doesn&rsquo;t exist in the test environment. This makes the test more brittle because it depends on a particular implementation, which is never good, but when we are dealing with external I/O, this might be the only way to isolate it. This is how the Workflow test was done. Again, if there is a better way I am eager to learn it, please comment down below.</p>
<p>This is how a unit test with Mock looks like, stubbing both the HTTPotion and File modules:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="nb">test</span> <span class="s2">&#34;workflow tries to download the images&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">with_mock</span> <span class="no">HTTPotion</span><span class="p">,</span> <span class="o">[</span><span class="ss">get</span><span class="p">:</span> <span class="n">fn</span><span class="p">(</span><span class="n">_url</span><span class="p">,</span> <span class="n">_options</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="o">%</span><span class="no">HTTPotion</span><span class="o">.</span><span class="n">Response</span><span class="p">{</span> <span class="ss">body</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">headers</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">status_code</span><span class="p">:</span> <span class="mi">200</span> <span class="p">}</span> <span class="k">end</span><span class="o">]</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">with_mock</span> <span class="no">File</span><span class="p">,</span> <span class="o">[</span><span class="n">write!</span><span class="p">:</span> <span class="n">fn</span><span class="p">(</span><span class="n">_filename</span><span class="p">,</span> <span class="n">_body</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kp">nil</span> <span class="k">end</span><span class="o">]</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">assert</span> <span class="no">Workflow</span><span class="o">.</span><span class="n">process_downloads</span><span class="p">(</span><span class="o">[</span><span class="p">{</span><span class="s2">&#34;http://src_foo&#34;</span><span class="p">,</span> <span class="s2">&#34;filename_foo&#34;</span><span class="p">}</span><span class="o">]</span><span class="p">,</span> <span class="s2">&#34;/tmp&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="o">[</span><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="s2">&#34;http://src_foo&#34;</span><span class="p">,</span> <span class="s2">&#34;/tmp/filename_foo&#34;</span><span class="p">}</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">      <span class="n">assert</span> <span class="n">called</span> <span class="no">HTTPotion</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;http://src_foo&#34;</span><span class="p">,</span> <span class="o">[</span><span class="ss">timeout</span><span class="p">:</span> <span class="mi">30_000</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">assert</span> <span class="n">called</span> <span class="no">File</span><span class="o">.</span><span class="n">write!</span><span class="p">(</span><span class="s2">&#34;/tmp/filename_foo&#34;</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This has been a very fun experience, albeit very short, and good enough to iron out what I have learned so far. Code like this makes me smile:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="o">[</span><span class="n">destination_file</span><span class="o">|</span><span class="n">_rest</span><span class="o">]</span> <span class="o">=</span> <span class="nb">String</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="n">file</span><span class="p">,</span> <span class="s2">&#34;/&#34;</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">reverse</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The way I can pattern match to extract the head of a list is a different way of thinking. Then there is the other most important way of thinking: everything is a transformation chain, an application is a way to start from some input argument (such as a URL) and go step by step to &ldquo;transform&rdquo; it into a collection of PDF files, for example.</p>
<p>Instead of thinking about how to architect classes and objects, we start thinking about what the initial arguments are and what result we want to achieve, and go from there, one small transformation function at a time.</p>
<p>The Workflow module is an example. I actually started writing everything in a single large function in the CLI module. Then I refactored into smaller functions and chained them together to create the Workflow. Finally, I just moved all the functions into the Workflow module and called that from the CLI module.</p>
<p>Because of no global state, and thinking in smaller, isolated functions, both refactoring and test-driven development are much smoother than in OOP languages. This way of thinking is admittedly slow to get a grip on, but then it starts to feel very natural and it quickly steers your way of programming into leaner code.</p>
<p>And the dynamic aspects of both Erlang and Elixir make me feel right at home, just like having an &ldquo;improved Ruby&rdquo;.</p>
<p>The code of the downloader is all on <a href="https://github.com/akitaonrails/ex_manga_downloadr/blob/master/lib/ex_manga_downloadr/mangareader/index_page.ex"target="_blank" rel="noopener">Github, please fork it</a>.</p>
<p>I am eager to exercise more. I hope this motivates you to learn Elixir. And if you&rsquo;re already an advanced programmer in Elixir or Erlang, don&rsquo;t forget to comment below and even send me a Pull Request to improve this small exercise. I am still a beginner and there is a lot of room to learn more. All contributions are greatly appreciated.</p>
]]></content:encoded><category>elixir-and-erlang</category><category>anime-and-manga</category><category>automation</category></item><item><title>Chatting with Adam Jacob</title><link>https://akitaonrails.github.io/en/2009/11/18/chatting-with-adam-jacob/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2009/11/18/chatting-with-adam-jacob/</guid><pubDate>Wed, 18 Nov 2009 11:46:00 -0200</pubDate><description>&lt;p&gt;&lt;img src="http://s3.amazonaws.com/akitaonrails/assets/2009/11/18/Opscode_logo_final_full_aspect_medium_original.png" alt="" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Configuration Management is a tricky subject. For non-starters, when you&amp;rsquo;re a developer and you have few boxes to take care of, you can usually get away with just managing them manually. People are probably just used to pop in a CD, double-click the &amp;ldquo;install&amp;rdquo; program and click &amp;ldquo;next&amp;rdquo;, &amp;ldquo;next&amp;rdquo; until the end, then you manually log in to backup (when you remember it), and sometimes you do apply some security updates when you remember about them.&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="http://s3.amazonaws.com/akitaonrails/assets/2009/11/18/Opscode_logo_final_full_aspect_medium_original.png" alt=""  loading="lazy" /></p>
<p>Configuration Management is a tricky subject. For non-starters, when you&rsquo;re a developer and you have few boxes to take care of, you can usually get away with just managing them manually. People are probably just used to pop in a CD, double-click the &ldquo;install&rdquo; program and click &ldquo;next&rdquo;, &ldquo;next&rdquo; until the end, then you manually log in to backup (when you remember it), and sometimes you do apply some security updates when you remember about them.</p>
<p>But then you have more than a dozen machines, things start to get uglier, you end up making more mistakes, forgetting important steps, and all of a sudden managing machines becomes a nightmare. You end up being woken up in the middle of the night because you forgot to install some crucial component, and so on and so forth.</p>
<p>The same way you need testing, continuous integration tools when you&rsquo;re a developer, you also need automated, reliable and flexible tools for the system administrator role. That&rsquo;s where tools such as <strong>Chef</strong> kick in to help.</p>
<p>From <a href="http://opscode.com/"target="_blank" rel="noopener">Opscode Inc.</a>, we have <a href="http://twitter.com/adamhjk"target="_blank" rel="noopener">Adam Jacob</a> (CTO) and <a href="http://twitter.com/jesserobbins"target="_blank" rel="noopener">Jesse Robbins</a> (CEO) to talk about the new contender in the automated system administration field, <a href="http://www.opscode.com/chef/"target="_blank" rel="noopener">Chef</a>, already in use by many companies which are pushing the cutting edge to maintain their datacenters.</p>
<p><strong>AkitaOnRails:</strong> To kick start this interview, it would be great to have more background info about you guys. So, how did you end up in the configuration management space?</p>
<p><img src="http://s3.amazonaws.com/akitaonrails/assets/2009/11/18/adam_jacob_original_original.png" alt=""  loading="lazy" /></p>
<p><strong>Adam:</strong> I&rsquo;ve been a systems administrator for 13 years, and for a majority of that time I was working for companies who did a lot of mergers and acquisition work. Every couple of months we would acquire a new company, and it was my job to help figure out how to absorb those companies into the whole. I got really good at looking at an application I had nothing to do with creating, and figuring out what needed to be done to make it scale (or at least make it run.)</p>
<p>One thing we couldn&rsquo;t do was tell the people who had built the applications that they needed to be radically altered (in many cases, they weren&rsquo;t even around anymore.) What that meant in practice was we needed to have a very flexible, modular underlying architecture – everything we did had to be in service of the application, not the other way around. By necessity that meant becoming a tools developer – if we didn&rsquo;t have the tools we needed, we would never be able to do the job in front of us.</p>
<p>Eventually I co-founded a consulting firm called HJK Solutions. We built fully automated infrastructure for startups – everything from OS installation to application deployment, all fully automated (including Identity Management, Monitoring and Trending, etc.) Over the course of the next two years we built infrastructure for 12 different startups, whose products ranged from electrical car fleet management to online dating.</p>
<p>So I come to configuration management through the trenches – as a line-level systems administrator trying to make my life easier, as a consultant helping others to reap the benefits, and now as a tool builder trying to move the state of the art forward.</p>
<p><img src="http://s3.amazonaws.com/akitaonrails/assets/2009/11/18/Jesse_Robbins_original_original.png" alt=""  loading="lazy" /></p>
<p><strong>Jesse:</strong> Jesse Robbins is CEO of Opscode (makers of Chef) and a recognized expert in Infrastructure, Web Operations, and Emergency Management. He serves as co-chair of the Velocity Web Performance &amp; Operations Conference and contributes to the O&rsquo;Reilly Radar. Prior to co-founding Opscode, he worked at Amazon.com with a title of &ldquo;Master of Disaster&rdquo; where he was responsible for Website Availability for every property bearing the Amazon brand. Jesse is a volunteer Firefighter/EMT and Emergency Manager, and led a task force deployed in Operation Hurricane Katrina. His experiences in the fire service profoundly influence his efforts in technology, and he strives to distill his knowledge from these two worlds and apply it in service of both.</p>
<p><strong>AkitaOnRails:</strong> What&rsquo;s the story behind Opscode, what&rsquo;s its mission, and what&rsquo;s the story for Chef&rsquo;s creation?</p>
<p><strong>Opscode:</strong> The story behind Opscode really starts when we met Jesse. He had written an article for O&rsquo;Reilly Radar about <a href="http://radar.oreilly.com/2007/10/operations-is-a-competitive-ad.html"target="_blank" rel="noopener">Operations being the new secret sauce</a> for startups. He and I met for coffee, became friends, and stayed in touch. Jesse understands operations culture at a visceral level, and he&rsquo;s very well connected to a huge community of like-minded people that I didn&rsquo;t even know existed.</p>
<p>As our consulting company grew, we reached a crossroads – we clearly had a nice business going, and it wasn&rsquo;t difficult for us to find more work. What was difficult was finding people who had the skills to actually deploy a new infrastructure, and to adapt the stack we had developed for a new application. We couldn&rsquo;t avoid the fact that there was a couple of weeks of very high-touch work that was required to get the entire infrastructure up and running for a new client.</p>
<p>We tried to recruit Jesse during this period, since he was one of those rare people who could do that initial high-touch engagement. He turned us down – primarily because he saw what we did: we could build a consulting company that was huge, but it would still be down to us in the trenches every day. Unless we could get over that hump, there were probably less stressful ways to make a living. :)</p>
<p>So we started looking at what was stopping us from being able to get that initial part of the engagement done as quickly as possible. What was stopping us from literally having a customer fill out a questionnaire, and letting that data drive 95% of the decisions about the infrastructure?</p>
<p>It turned out the answer was, in a word, &ldquo;everything&rdquo;. The entire stack of open source tools we were using had been built in a different era, and they saw the world through a very different lens. We needed everything to have an API, we needed everything to be more open with its data, and we needed everything to be flexible enough to handle the next evolution of application architectures (whatever that may be.)</p>
<p>Once we had that revelation, the next step was figuring out what a &rsquo;new stack&rsquo; would really look like. If we could start from scratch, what would we take with us, and what would we leave behind? Amazon had done such a great job showing us what an API over the bootstrapping process could look like, and the kind of benefits that could be had from the approach. So assuming something like that existed for bootstrapping, what about the next layer of the stack (configuration)?</p>
<p>We started experimenting with Chef during this period of questioning. We started working on Chef with the goal of putting ourselves out of business – making the barrier to entry to having a fully automated infrastructure so low that any developer or systems administrator could just do it. We built a prototype, showed it to Jesse, and he agreed to come on board.</p>
<p>Opscode was born then, and our mission came out of those experiments: we are bringing Infrastructure Automation to the Masses. We want to tear down the barriers to entry that stop people from having really great, repeatable, automated infrastructure. Our role is to bring developers and systems administrators the best tools possible, so that they can build the systems they have always wanted.</p>
<p>We raised 2.5 million from DFJ in January of 2009 on that vision, and have been at it ever since.</p>
<p><strong>AkitaOnRails:</strong> I&rsquo;d like to say that only amateur sysadmins do everything manually, but I think most small to medium corporations at least still do everything manually or with random scripts spread all over the place. The notion of &ldquo;configuration management&rdquo; is still new to a lot of people. Could you briefly explain what it is, and why it is important?</p>
<p><strong>Opscode:</strong> To me, &ldquo;Configuration Management&rdquo;, at its core, is all the stuff you have to do to take a system from &ldquo;running an operating system&rdquo; to &ldquo;doing its job&rdquo;. When a systems administrator configures a system by hand, and posts her notes on a Wiki, she&rsquo;s practicing the most primitive form of Configuration Management.</p>
<p>Now, having those notes is better than nothing – when she needs to do that task again, she can at least go back and read them to remember what she did last time. She still has to do it over again, though – and the repetition gets tiresome. So she starts writing scripts that encapsulate that knowledge in code, so that now she only has to run a series of smaller commands.</p>
<p>Time passes, though, and entropy sets in. The systems begin to drift from where they were when the systems administrator wrote the scripts. Next thing you know, the scripts don&rsquo;t run anymore, or if they did, the configuration they build is wrong. Our intrepid admin then starts editing the scripts, or making new ones, to deal with the system when it&rsquo;s in this new state. This is the stage we call &ldquo;tool sprawl&rdquo; – you have a tool for each different phase of a system&rsquo;s observable life-cycle.</p>
<p>Modern configuration management tools solve this problem by providing a framework for describing the final state a system should be in, rather than the discrete steps we should take to get there at any given time. Rather than writing a script that lists the commands to install Apache, write the configuration file, and start the service, you would describe that &ldquo;apache should be installed&rdquo;, the configuration file &ldquo;should look like this&rdquo;, and the service &ldquo;should be running&rdquo;.</p>
<p>When the configuration management system runs it looks at each of these descriptions individually, and makes sure that they are in the proper state. We no longer care what the initial state of the system is – the configuration management system will only take action at each step in the process if the system is not in the state you described. If you run it again, and nothing has changed, the system takes no action. (Configuration management geeks call this property <a href="http://en.wikipedia.org/wiki/Idempotence"target="_blank" rel="noopener">idempotence</a>)</p>
<p>This means that you can describe your systems once in code, and as they change over time, simply update that code to reflect what you want the state of those systems to be. The impact of this model on the daily life a systems administrator cannot be overstated – it makes everything easier.</p>
<p>It also has huge impacts outside the systems administrators world. You now have a living document that describes how all your servers are configured – you can share it, you can put it in revision control, you can print it out for an auditor. Business processes that previously required manual intervention frequently can be boiled down to discrete changes. The more you work in this way, the more the impacts spread throughout the organization.</p>
<p><strong>AkitaOnRails:</strong> Even though the Ruby community probably compares Chef to Puppet, I think one of the most widely used systems is CFEngine2. How does Chef compare with CFEngine2?</p>
<p><strong>Opscode:</strong> Cfengine 2 is where almost everyone in the configuration management world cut their teeth. Mark Burgess is responsible for the academic papers that outlined the idea that each part of the system should be idempotent, and his work in studying how real-world systems can be managed at scale has done more to impact the evolution of configuration management than any other individual.</p>
<p>In addition to the idea of idempotent resources, Burgess introduced the larger concept of &ldquo;convergence&rdquo;. The basic idea here is that if you have the description of how every finite resource should be configured, given enough time, those resources will bring the system into a compliant state. The order in which the resources run fundamentally does not matter – eventually, they will all wind up in the right place.</p>
<p>While this model works at a fundamental level, it has some pretty dramatic inefficiencies. Cfengine applies resources based on their type – all the files are managed at once, then all the services, then all the packages. You can control the order they are run with the &lsquo;actionsequence&rsquo;, but each system gets only one order. So if you need to have a file copied, a package installed, and a service restarted, it&rsquo;s easy enough to model. When you start getting into more complex configurations, however, it becomes more and more difficult to get an actionsequence assembled that allows you to configure your entire system in a single run.</p>
<p>This is not a bug in Cfengine – it&rsquo;s by design. Convergence is the answer – it&rsquo;s okay that it has to run more than once, it will get there eventually. If you are thinking too much about the order things should happen in, you&rsquo;re probably not thinking about idempotent descriptions.</p>
<p>In my experience, though, this was frustrating at scale. It meant that the amount of time it took to configure a system increased as the configuration became more complicated, and your ability to model complicated interactions in the system became increasingly opaque.</p>
<p>For the era in which it was written, this problem mattered a lot less. If it takes you 6 to 8 weeks to get a server even ordered, then another week to get it racked, stacked, and the OS installed, the fact that Cfengine needs to run 3 times to configure it doesn&rsquo;t matter very much. (You have lots of bigger problems!) In a world where many of us can go from bare-metal to running operating system in 5 minutes, either via our own install systems or an API call to AWS, it starts to matter a whole lot.</p>
<p>Chef takes a different approach. We start with the idea of an idempotent &ldquo;resource&rdquo; – a package, a service, etc. We add the idea of &ldquo;actions&rdquo; – these are all the different states you might want to request a resource to be in. Then you put these resources into &ldquo;recipes&rdquo;, which are evaluated in the order they are written. You can then have recipes rely on other recipes having completed before they are run, giving you the ability to say &ldquo;make sure Apache is installed before you configure my Web Application&rdquo;.</p>
<p>The goal is that, with Chef, you can always bring the system into the proper state with a single Chef run. It sees convergence as a response to a bug – a system that is 1/2 configured is, in fact, broken. If the cause of the bug is environmental – a network service is not available, for example – then running Chef again will likely fix things. If the issue is that you haven&rsquo;t specified the order that you want resources to be configured in, though, then it&rsquo;s a bug in your recipes. If you can&rsquo;t write a recipe to configure your system in a single Chef run, it&rsquo;s a bug in Chef.</p>
<p>This also has side-effects for reasoning about the system at scale. Given the same configuration and attributes, Chef will always behave the same way, on every system. As you add more and more things to the system, it&rsquo;s still easy to reason about when, and how, they will be configured.</p>
<p>Interestingly enough, Cfengine 3 implements a system that is very similar to Chef. Rather than a single global actionsequence, it uses a &lsquo;bundlesequence&rsquo;, where bundles are roughly analogous to Chef&rsquo;s concept of a recipe.</p>
<p>Another major difference is that Chef allows you to &rsquo;look up&rsquo; at your entire infrastructure when configuring a system. This ability comes in handy when you want to do things like configure a monitoring system, or a load balancer. You can ask Chef questions like &ldquo;what are all the servers running my application in production&rdquo;, and use the response to configure your system. (You can see an example of this on <a href="http://www.opscode.com/blog/2009/10/07/preview-chef-0-8-and-the-opscode-platform/"target="_blank" rel="noopener">our blog</a>)</p>
<p>From a systems architecture point of view, Chef and Cfengine are actually fairly similar. They both push the majority of the hard work of configuring a system out to the systems themselves. This is highly advantageous at scale – the Chef and Cfengine servers are really glorified file transfer systems.</p>
<p>We differ pretty deeply on our approach to what a language for configuration management should look like, but I&rsquo;ll talk more about that in a later question.</p>
<p>Cfengine is at work in some of the largest data centers in the industry, and it fundamentally altered the landscape of systems management. While it&rsquo;s not the tool I want anymore, you can&rsquo;t understate the impact it&rsquo;s had on the design of every configuration management tool that came after it – including Chef.</p>
<p><strong>AkitaOnRails:</strong> Chef has a lot of components to it. Could you briefly describe all the main components that work together? The client side, the server side, cookbooks?</p>
<p><strong>Opscode:</strong> Sure! The most important part of Chef is the cookbooks – they are where you actually describe your configuration. They collect recipes, and all the assets needed for the recipe to run (files, templates, etc.). <a href="http://cookbooks.opscode.com"target="_blank" rel="noopener">Cookbooks</a> are very often share-able, and lots of cookbooks already exist.</p>
<p>Chef can run your cookbooks in two modes – a client/server mode (chef-client), or a stand-alone mode (chef-solo). When you run chef-solo, you pass a URL to a tarball full of the cookbooks you want to have run. There is no more infrastructure than that – put your tarballs someplace you can download them, and go nuts.</p>
<p>In client/server mode, each Chef client is configured to talk to a Chef Server. The Server stores information about each client (a whole lot of it – things like IP addresses, loaded kernel modules, and more), and distributes the cookbooks they need to configure themselves. It also provides a REST API and an interactive Web UI, so you can easily alter the configuration of your systems centrally. Finally, all the data the server collects is indexed and searchable – you can then use this in your recipes to configure services that require complex, dynamic configuration. (Some examples would be dynamically discovering a master MySQL server, or finding all the memcached servers in a cluster)</p>
<p><strong>AkitaOnRails:</strong> What would you say about Chef&rsquo;s maturity? CFEngine has more than a decade of usage, which is difficult to beat. Would you say that it&rsquo;s &ldquo;mature enough&rdquo;? Meaning, it&rsquo;s already in production in companies of many sizes, its APIs don&rsquo;t change too much and my cookbooks will probably work if I upgrade to newer version of Chef?</p>
<p><strong>Opscode:</strong> Chef is a little over a year old – it was first released to the public on January 15th, 2009. Since then, 42 different developers have committed to the <a href="https://www.ohloh.net/p/opscode-chef/factoids/2025809"target="_blank" rel="noopener">project</a>, around 5 of whom work for Opscode. It&rsquo;s been in production use in the Engine Yard Cloud from the day it was released. Since then, it&rsquo;s seen adoption by companies and universities of all different sizes, from small startups to huge enterprises with very heavy compliance requirements.</p>
<p>So it&rsquo;s definitely &ldquo;mature enough&rdquo; for real world use – lots of people are using it, and relying on it, every day. Balancing that knowledge with the reality that the project is fast moving and evolving is important, and we try and do it in a number of ways:</p>
<ol>
<li>The recipe syntax is considered basically &ldquo;complete&rdquo;. If we do make changes, they are backwards compatible to previous Chef releases. Short of something truly amazing coming along that radically alters the shape of the universe, this will remain true.</li>
<li>We test a lot. Chef has over 2000 unit tests, and functional tests that cover the entirety of the REST API, and much of the individual resources (we&rsquo;re aiming for 100% feature test coverage.) There are more lines of code testing chef than there are lines of code in Chef proper.</li>
<li>If we do break backwards compatibility, we try and make it happen only when we bump the minor revision. If you have an 0.7.x version of Chef installed, it should always work with other versions of Chef in the same release cycle.</li>
</ol>
<p>Most of the work in Chef currently is around adding more functionality, not in changing the way existing functionality works. It&rsquo;s safe to use Chef today – in the future, you&rsquo;ll just keep getting more good sauce to add to the mix, rather than having to deeply re-factor the way you do things.</p>
<p>Finally, the Chef Community is truly epically great – we have lots of people who are spending significant amounts of their time with Chef. Even if they aren&rsquo;t contributing code, they are answering questions, they are writing documentation, they are hanging out on IRC and offering you cupcakes. It&rsquo;s a group of people that are focused on solving real world problems, and helping each other to do the same. It&rsquo;s by far what I&rsquo;m proudest of, and I think it has a significant impact on whether Chef is ready for prime time. The bench is really, really deep.</p>
<p><strong>AkitaOnRails:</strong> Chef uses Ruby directly, which some would say it&rsquo;s both a blessing and a curse. It&rsquo;s probably perfect for Rubists, but I feel that most sysadmins are used to Bash, Python and are not very flexible on change. Why did you choose to use Ruby instead of a simpler language?</p>
<p><strong>Opscode:</strong> The answer to the question of why we use Ruby directly for the configuration language comes in two parts: why we extended a 3GL rather than build a declarative DSL or a complete modeling language, and why we chose Ruby as that 3GL.</p>
<p>First, why we extended a 3GL. Tools like Cfengine and Puppet build a declarative DSL for configuration management – a custom language, which provides a model within which systems administrators or software developers can work to automate their system. Other tools like Elastra&rsquo;s EDML and ECML, OpsWare&rsquo;s DCML, or Bcfg2 give you an XML schema to describe how the system should behave.</p>
<p>The issue with these approaches is that, by definition, they must build an abstraction for every task the end-user may want to perform: an impossible feat. The level of complexity inherent in automation, coupled with the inability to break out to 3GL language constructs when necessary, result in a system that can only target a subset of the total complexity, rather than enable users to find innovative solutions to their specific problems. By leveraging Ruby, adding support for other use cases is a matter of adding new sets of base classes while maintaining consistency and approachability in their interface design, because the full scope of the language is available.</p>
<p>Our goal with Chef was to keep the simplicity that comes from having the focus be on idempotent resource declaration, while giving you the flexibility of a full 3GL. In practical terms, anything you can do with Ruby you can do with Chef – and since Ruby is a 3GL, that amounts to essentially anything.</p>
<p>Larry Wall, the creator of Perl, has a quote that I love:</p>
<blockquote>
  <p>&ldquo;For most people the perceived usefulness of a computer language is inversely proportional to the number of theoretical axes the language intends to grind.&rdquo;</p>

</blockquote>
<p>Since we wanted Chef to have the maximum amount of usefulness, we have actively tried to remove as many theoretical axes as possible – a belief that we can imagine the total breadth of the problem space being one of them. There is more than one way to do it with Chef, and the only valid criteria for rating the success of your automation project is whether it solves your problems in a reliable way.</p>
<p>In practice, you need to know very little Ruby to use Chef. Here is an example of installing the program &ldquo;screen&rdquo;:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">package</span> <span class="s2">&#34;screen&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl"> <span class="n">action</span> <span class="ss">:install</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The same thing in Puppet:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">package</span> <span class="p">{</span> <span class="s2">&#34;screen&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl"> <span class="k">ensure</span> <span class="o">=&gt;</span> <span class="n">present</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And in Cfengine 2:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="ss">packages</span><span class="p">:</span>
</span></span><span class="line"><span class="cl"> <span class="n">screen</span> <span class="n">action</span><span class="o">=</span><span class="n">install</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>While all of these systems require learning the syntax, at a base level, there isn&rsquo;t much difference between them in terms of raw learning required. The difference is that when you hit a limitation in Chef, you have the ability to innovate easily, and when you hit those same limitations in other tools, you do not.</p>
<p>We chose Ruby because of its fairly unique ability to create new syntax easily. Tools like Rspec are fine examples of ways you can manipulate Ruby for fun and profit that are very difficult to duplicate in other tools. We wanted to make sure that, even though you were &ldquo;in&rdquo; a 3GL, you didn&rsquo;t have to go through any extra hoops to make the simple things work. Ruby was the language that I was comfortable enough in, that I knew had the ability, to make that a reality.</p>
<p>That said, one of our goals is to extend the ability to write Chef recipes into other 3GL&rsquo;s. We have an example of doing this already with Perl – and we made no changes to the Chef source to accomplish it. You can see the <a href="http://search.cpan.org/~holoway/Chef-0.01/lib/Chef.pm"target="_blank" rel="noopener">demo here on CPAN</a>. It works by using Chef&rsquo;s JSON API to ship the compiled resources over to the Ruby library for execution, and over time we&rsquo;ll be extending those interfaces. You&rsquo;ll be able to have recipes written in Python interoperating with recipes written in Ruby and Perl.</p>
<p><strong>AkitaOnRails:</strong> Does Chef have (or for that matter, need) something like Augeas, which Puppet is trying to support?</p>
<p><strong>Opscode:</strong> Augeas is neat. Chef doesn&rsquo;t have Augeas support today, and the reason is that nobody has needed it badly enough to write the integration. One reason is that, with Chef, it is quite easy to dynamically add to a resource (like, say, a template) or search for particular systems that match a criteria. This means that the use-case for Augeas (which edits files in place) is less necessary – you can often get the data you need to render a template, rather than needing to build it up over time with incremental edits.</p>
<p>We think this is a better practice in general, as it ensures that all the systems you are managing can always be restored to a working state from nothing but the cookbook repository. If you use Augeas to allow idempotent changes of individual lines of a configuration file, it encourages the behavior of individual administrators editing files in place, which is a configuration management anti-pattern.</p>
<p><strong>AkitaOnRails:</strong> Sysadmins used to CFEngine complain about Ruby&rsquo;s dependencies and overall weight. Because for Chef to run you need Ruby installed. Not all distros have Ruby in the same version (although most already migrated to 1.8.7). Then you have the problem of weight. I am not familiar with Chef, but Puppet can grow to hundreds of megabytes. What they don&rsquo;t want is to have clusters of Chef machines (which, by themselves, also need maintenance, adding to the overall complexity). How do you deal with datacenters with thousands of servers? I know it&rsquo;s difficult to measure precisely, but what would be a reasonable ratio between Chef servers x managed servers?</p>
<p><strong>Opscode:</strong> When you are evaluating the scalability of configuration management systems, you want to look at two different axes. The horizontal one, which is the number of systems that can be managed by a single configuration management server at a particular rate of change, and the vertical, which is how much of your infrastructure can be automated by the tool.</p>
<p>On the vertical axis, I think Chef is the clear winner, for reasons I think are pretty well summed up by my answer to question 7. I would put Puppet second, and Cfengine last.</p>
<p>On the horizontal axis, Cfengine is the clear winner. It&rsquo;s written in C, and it has the thinnest possible server component – it does nothing but authenticate clients and serve files, essentially. I know first hand of data-centers that are running huge numbers of servers off a single cfengine server.</p>
<p>One important metric to keep in mind when discussing the horizontal scalability of a configuration management solution is that the most important metric is the <strong>rate of change</strong>. All the tools we&rsquo;ve been talking about are &lsquo;pull&rsquo; based – the clients check in at an &lsquo;interval&rsquo; with the server, and apply a <em>&lsquo;splay&rsquo;</em> to ensure that not all the systems check in at once. A common out of the box configuration is an interval of 30 minutes, with a splay of 15 minutes. (This means a server checks in every 30-45 minutes, depending.) If you are comfortable increasing that interval, you will get more scalability out of fewer resources (by lowering the amount of concurrency.)</p>
<p>So when anyone says &ldquo;I have 10 thousand servers on a single configuration management server&rdquo;, ask them &ldquo;at what interval?&rdquo;.</p>
<p>Chef scales like a web application. The server itself is quite thin, and is responsible for authenticating clients, transferring files, storing node state, and providing a search interface. It scales horizontally by adding new Chef Servers as necessary. The API is RESTful, and there is no session state between the clients and the server. (At least not in Chef 0.8+) When you encounter scalability problems with Chef, the tools you apply are the same ones you apply to any well designed web application.</p>
<p>You asked specifically about memory utilization – Chef does quite well in this regard. Individual server processes usually are between 14-50MB resident. The client itself, running in daemon mode, is usually around 28MB resident.</p>
<p>In our testing, the current bottleneck in a Chef server is CPU. Chef is smart about how it handles file transfers – we only transfer files that have changed on the server. To support this we calculate a checksum for each file requested, and we currently don&rsquo;t cache the results. We&rsquo;re planning on fixing this for the next major release of Chef (0.8.0) which should shift the bottleneck over to RAM.</p>
<p>With Chef 0.7, you should be able to support thousands of clients at a half-hour interval and fifteen minute splay on reasonable commodity hardware. The changes in Chef 0.8 should bring that number up dramatically – I&rsquo;ll get back to you with some benchmarks once the patches are in. :)</p>
<p><strong>AkitaOnRails:</strong> Probably related to the previous question, seems like specially after Sarbanes Oxley there&rsquo;s been an increasing interest in stuff such as ITIL, CoBit. Have you ever seen successful implementations of those in the Web-style infrastructure? I mean, I can see them succeeding in Banks, Aerospace and Defense, etc but I fail to see them working as advertised in a very dynamic environment such as Web services hosting. What are your experiences regarding this issue?</p>
<p><strong>Opscode:</strong> Well, I think you can think of ITIL in the same way you think about the classic &lsquo;Waterfall&rsquo; model of software development. For some kinds of projects and companies, it is essential – it&rsquo;s hard to imagine working a different way. Most often these are companies with huge manufacturing or quality control concerns – medical health, aerospace, banking and finance, etc.</p>
<p>The same thing applies to ITIL – the larger the concern, and the more stringent the requirements, and the longer the lead time, the more the processes they describe start to make sense. Like all large process, though, they tend to de-emphasize the human element – people have roles to play, and forms to file. :)</p>
<p>In the Web Ops culture, things are different. I&rsquo;ve never seen a successful marriage of ITIL and Web Ops, and the reason is that the domains are so very different. If there is a bug on the website, it&rsquo;s better to ship the fix now than wait for a release management process to ensure that the site won&rsquo;t have any more issues based on your fix, for example. It&rsquo;s also a bad cultural fit – in the best Web Ops teams, the focus is heavy on communication, agility, and respect, rather than process, formalism, and tooling. The shift you start too see in the really great Web Ops companies is that their operations personnel become enablers of the organization, rather than end-line blockers of change (to keep stability high.)</p>
<p>In general, I think the 4 steps outlined in visible ops for emergency management are not bad ones, but the devil is always in the details. The guy you should really ask about this is Jesse – he&rsquo;s largely responsible for Amazon&rsquo;s operational culture, and knows what it means to start hacking on that sort of thing from within huge organizations.</p>
<p><strong>AkitaOnRails:</strong> Awesome, I think this is a wrap. Thank you very much for this interview!</p>
]]></content:encoded><category>interviews</category><category>devops</category><category>automation</category></item><item><title>Chatting with Luke Kanies</title><link>https://akitaonrails.github.io/en/2009/11/18/chatting-with-luke-kaines/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2009/11/18/chatting-with-luke-kaines/</guid><pubDate>Wed, 18 Nov 2009 11:45:00 -0200</pubDate><description>&lt;p&gt;Configuration Management is a tricky subject. For non-starters, when you&amp;rsquo;re a developer and you have few boxes to take care of, you can usually get away with just managing them manually. People are probably just used to pop in a CD, double-click the &amp;ldquo;install&amp;rdquo; program and click &amp;ldquo;next&amp;rdquo;, &amp;ldquo;next&amp;rdquo; until the end, then you manually log in to backup (when you remember it), and sometimes you do apply some security updates when you remember about them.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Configuration Management is a tricky subject. For non-starters, when you&rsquo;re a developer and you have few boxes to take care of, you can usually get away with just managing them manually. People are probably just used to pop in a CD, double-click the &ldquo;install&rdquo; program and click &ldquo;next&rdquo;, &ldquo;next&rdquo; until the end, then you manually log in to backup (when you remember it), and sometimes you do apply some security updates when you remember about them.</p>
<p>But then you have more than a dozen machines, things start to get uglier, you end up making more mistakes, forgetting important steps, and all of a sudden managing machines becomes a nightmare. You end up being woken up in the middle of the night because you forgot to install some crucial component, and so on and so forth.</p>
<p><img src="http://s3.amazonaws.com/akitaonrails/assets/2009/11/18/pic2_original.jpg" alt=""  loading="lazy" /></p>
<p>The same way you need testing, continuous integration tools when you&rsquo;re a developer, you also need automated, reliable and flexible tools for the system administrator role. That&rsquo;s where tools such as <strong>Puppet</strong> kick in to help.</p>
<p>This time I&rsquo;ve interviewed <a href="http://twitter.com/puppetmasterd"target="_blank" rel="noopener">Luke Kanies</a>, from <a href="http://reductivelabs.com/"target="_blank" rel="noopener">Reductive Labs</a>, former contributor to the famous CFEngine tool and creator of <a href="http://github.com/reductivelabs/puppet"target="_blank" rel="noopener">Puppet</a>, one of the most acclaimed configuration management tools for 21st century datacenters.</p>
<p><strong>AkitaOnRails:</strong> To kick start this interview, it would be great to have more background info about you. So, how did you end up in the configuration management field? I understand that you have a long history with CFEngine development, right?</p>
<p><img src="http://s3.amazonaws.com/akitaonrails/assets/2009/11/18/luke_kanies_portrait_original.jpg" alt=""  loading="lazy" /></p>
<p><strong>Luke:</strong> I was a Unix admin going back to 1997, always writing scripts and tools to save myself time, and around 2001 I realized that I shouldn&rsquo;t have to write everything myself – that someone somewhere should be able to save me time. After a lot of research and experimentation, I settled on Cfengine, and had enough success with it that I started consulting, publishing, and contributing to the project.</p>
<p><strong>AkitaOnRails:</strong> What&rsquo;s the story behind Reductive Labs, what&rsquo;s its mission, and what&rsquo;s the story for Puppet&rsquo;s creation?</p>
<p><strong>Luke:</strong> After a couple of years with Cfengine, I had a lot more insight but was frustrated because it still seemed to be too hard – no one was sharing Cfengine code, and there were some problems it made you work really hard to solve. The biggest issue, though, was that its development was very closed – it was difficult to contribute much more than just bug fixes.</p>
<p>I got frustrated enough that I stopped consulting and looked for other options. I worked briefly at BladeLogic, a commercial software company in this space, but in the end I decided that the insight I had and the lack of a great solution were a good enough start for a business that I decided to morph my consulting company into a software company and write a new tool.</p>
<p><strong>AkitaOnRails:</strong> I&rsquo;d like to say that only amateur sysadmins do everything manually, but I think most small to medium corporations at least still do everything manually or with random scripts spread all over the place. The notion of &ldquo;configuration management&rdquo; is still new to a lot of people. Could you briefly explain what it is, and why it is important?</p>
<p><strong>Luke:</strong> It&rsquo;s surprisingly difficult to describe it succinctly, but for me, there are two key rules: You shouldn&rsquo;t need to connect directly to a given machine to change its configuration, and you should be able to redeploy any machine on your network very quickly.</p>
<p>These two rules combine to require comprehensive automation and/or centralization for everything that goes into making a machine work. Annoyingly, they also immediately introduce dependency cycles, because your automation server needs to be able to build itself, which is always a bit of a challenge.</p>
<p><strong>AkitaOnRails:</strong> I think one of the most widely used systems is CFEngine2. How does Puppet compare with it? Meaning, what do I have as value added when switching to Puppet, and what are the known caveats?</p>
<p><strong>Luke:</strong> There are multiple important functional differences. The biggest is Puppet&rsquo;s <strong>Resource Abstraction Layer</strong> , which allows Puppet users to avoid a lot of the detail they don&rsquo;t really care about, like how rpm, adduser, or init scripts work – they talk about users, groups, and packages, and Puppet figures out the details.</p>
<p>We also have explicit dependency support, which makes a huge difference – it&rsquo;s easy to order related resources and restart services when their configuration files change, for instance.</p>
<p>The language is also a bit more powerful. Like Cfengine, we have a simple custom language, but Puppet&rsquo;s language provides better support for heterogeneity, along with a composite resource construct that allows you to easily build in-language resource types like Apache virtual hosts that model more complex resources consisting of multiple simple resources.</p>
<p><strong>AkitaOnRails:</strong> Puppet has a lot of components. Could you briefly describe some of the main ones that work together? The client side, the server side, recipes?</p>
<p><strong>Luke:</strong> Most people use Puppet in client/server mode, where the central server is the only machine that has access to all of the code, and it runs a process capable of compiling that code into host-specific configurations. Then each machine runs a client (including the server), which then retrieves and applies that host-specific configuration. This has nice security implications because you&rsquo;ve not shipped your code to every machine on your network.</p>
<p>If this model doesn&rsquo;t work for you, though, it&rsquo;s also easy to run Puppet stand-alone, where each machine has all of the code and compiles it separately. Multiple Puppet users do this for various reasons. This stand-alone &lsquo;puppet&rsquo; executable is a standard interpreter – it can be used to run 1 line scripts or thousands of lines in a complete configuration.</p>
<p>Beyond this, we&rsquo;ve got a few other interesting executables, such as to access our certificate authority functionality, and an interesting executable called &lsquo;ralsh&rsquo; that provides a simple way to directly manage resources from the Unix shell, without having to write a separate script.</p>
<p><strong>AkitaOnRails:</strong> What would you say about Puppet&rsquo;s maturity? CFEngine has more than a decade of usage, which is difficult to beat. Would you say that it&rsquo;s &ldquo;mature enough&rdquo;? Meaning, it&rsquo;s already in production in companies of many sizes, its APIs don&rsquo;t change too much and my recipes will probably work if I upgrade to a newer version of Puppet? I think the 0.x version makes some people nervous :-)</p>
<p><strong>Luke:</strong> Really we should have called a version from 2007 1.0, but it&rsquo;s hard to know how stable a release is going to be until it&rsquo;s been out for a while. :)</p>
<p>It&rsquo;s obviously tough to match Cfengine&rsquo;s long life, although they&rsquo;re somewhat forcibly migrating to Cfengine 3, which is a complete rewrite, so that maturity isn&rsquo;t worth quite as much right now.</p>
<p>However, Puppet&rsquo;s been in production usage around the world since 2006, and it&rsquo;s currently used by more large companies than I could reasonably name – Twitter, Digg, Google, Sun, Red Hat, and lots more – and our community and customer base consider it to be mature. For the line came some time early last year, when I found that the vast majority of issues people had were user issues on their part, rather than some flaw or shortcoming in Puppet.</p>
<p>In general the APIs are quite stable, and we&rsquo;ve done quite well, I think, at maintaining backward compatibility when the APIs have had to change. The point about API stability in a 1.0 release isn&rsquo;t so much to differentiate it from previous efforts as to make a promise for the future. This especially matters for companies like Canonical, who want a release that they can support on Ubuntu for five years.</p>
<p><strong>AkitaOnRails:</strong> Puppet has its own language and you can use Ruby for the advanced cases. It&rsquo;s probably perfect for Rubists, but I feel that most sysadmins are used to Bash, Python and are not very flexible on change. Why did you choose to use Ruby instead of a more widespread language? What do sysadmins need to realize to shift paradigms?</p>
<p><strong>Luke:</strong> Part of it is that most people don&rsquo;t really need to know any ruby to be effective with Puppet. Sure, you can get some more power if you do, but if you&rsquo;re not a language person, you&rsquo;re perfectly functional with just Puppet.</p>
<p>Another nice thing is that we have a pretty smooth scale in terms of Ruby knowledge – you can start out writing ERB templates or five line extensions to <a href="http://github.com/reductivelabs/facter"target="_blank" rel="noopener">Facter</a>, which is our client-side querying system, and grow smoothly through to writing custom resource types.</p>
<p>In the end, I chose Ruby because I was most productive in it. I likely should have chosen Python, given its speed benefit and popularity at Red Hat and other places, but I found I just couldn&rsquo;t write code in it. I started thinking in Ruby after only a few hours of usage, so it was impossible for me to turn away from it.</p>
<p><strong>AkitaOnRails:</strong> Sysadmins used to CFEngine complain about Ruby&rsquo;s dependencies and overall weight. Because for Puppet to run you need Ruby installed. Not all distros have Ruby in the same version (although most already migrated to 1.8.7). Then you have the problem of weight. Puppet can grow to hundreds of megabytes. What they don&rsquo;t want is to have clusters of Puppet machines (which, by themselves, also need maintenance, adding to the overall complexity). How do you deal with datacenters with thousands of servers? I know it&rsquo;s difficult to measure precisely, but what would be a reasonable ratio between Puppet servers x managed servers?</p>
<p><strong>Luke:</strong> It&rsquo;s as impossible to tell you how many clients a Puppet server can handle as it is to tell you how many clients a Rails server can handle – it all depends on the complexity of the tasks. Google scaled to 4500 client machines on a single server, but most people tend to add another server at around 500-1000 clients.</p>
<p>It&rsquo;s true that it&rsquo;s hard to keep memory usage down in a Ruby process, but we&rsquo;ve made great strides in our recent releases by doing things like deduplicating strings in memory and being more efficient in our code paths. Really, though, we&rsquo;ve spent a lot more time on features and bug fixing and less time on optimizing – until recently, we&rsquo;ve been a small development team, and we just didn&rsquo;t have the bandwidth for it.</p>
<p>Now that my company, Reductive Labs, has some investment, we&rsquo;ve been able to add three full time developers, which is going to really help in this area.</p>
<p>As to dependencies, this is one area we break strongly from the Ruby community – we don&rsquo;t require a single gem, other than our own Facter tool (and it&rsquo;s usually not shipped as a gem). Rubyists tend not to worry too much about package dependencies – they just put it in vendor, as I&rsquo;m fond of saying – but that doesn&rsquo;t work when you have to deploy thousands of copies. So yes, you might have to install Ruby, but there won&rsquo;t be any other dependencies you have to deal with, which greatly simplifies it.</p>
<p>It&rsquo;s generally as tough to know how you&rsquo;ll need to size your puppetmaster as it would be to size a web server – it depends on how complicated the workload is. In general, somewhere between 500 and 5000 clients, you&rsquo;ll need to have a second server, but most people probably find it closer to 500. Really, though, if you&rsquo;ve got 3000 clients hitting a service, you probably want to make it horizontally scalable for stability in addition to performance.</p>
<p><strong>AkitaOnRails:</strong> Security is a big concern nowadays, Puppet was worried from the beginning on the handshake procedure between clients and server, can you describe it a little bit? Also, is there any built-in recipes for hardening machines, for example? Or at least any desires to add such tools in the future?</p>
<p><strong>Luke:</strong> Puppet uses standard SSL certificates for authentication and encryption, including a certificate signing phase. By default, the client generates a key and a certificate request (CSR) and then uploads the CSR to its server. This upload, along with the later certificate download, are the only unauthenticated connections that are allowed by default.</p>
<p>From there, a human normally has to trigger the client&rsquo;s certificate to be signed, but many organizations, including Google, automatically sign client certificates because they trust their internal network.</p>
<p>As to automatic hardening, there aren&rsquo;t any recipes that I&rsquo;m aware of right now, but it&rsquo;s something that I&rsquo;m definitely interested in. Years ago I was a big fan of TITAN, which is a hardening package for various *NIX platforms, and it was part of the inspiration to write Puppet – I&rsquo;ve always wanted a portable, executable security policy.</p>
<p><strong>AkitaOnRails:</strong> The puppetmaster uses Webrick by default, but the documentation also describes using Mongrel or Passenger. Are there any real gains in using those? Is it more for convenience or do we have performance/robustness improvements?</p>
<p><strong>Luke:</strong> Holy cow Webrick is slow. It&rsquo;s really fantastic for proof of concepts – get up and running in minutes. Once you get beyond that proof of concept, though, you really need to switch to Mongrel or Passenger. If you get more than one concurrent connection in webrick, your clients start to suffer, but you can scale to far more with the other solutions out there.</p>
<p><strong>AkitaOnRails:</strong> Are there any client cases you are allowed to talk about? Meaning, more details on the kind of infrastructure, difficulties, caveats, best practices?</p>
<p><strong>Luke:</strong> The possibilities here are pretty open ended. Google uses Puppet to maintain their corporate IT, which means they&rsquo;re running it on thousands of laptops and desktops which is pretty different. MessageOne, a division of Dell, is really interesting in that their developers have to ship Puppet code to manage the applications that they ship, so if an app isn&rsquo;t trimming its logs or backing itself up, it&rsquo;s a bug that the app developer has to fix rather than the sysadmin. This really helps to bridge the divide between dev and ops, which has worked out really well for them.</p>
<p>Otherwise, there are lots of stories and best practices, but I&rsquo;m afraid that would be a whole second article. :)</p>
<p><strong>AkitaOnRails:</strong> I&rsquo;ve seen Andrew Shaffer talk about <a href="http://www.slideshare.net/littleidea/agile-infra-agileroots-2009"target="_blank" rel="noopener">Agile Infrastructure</a> for a couple of years now, but I still think most IT organizations are unaware of this concept. Can you elaborate on what it means to be Agile outside of the development field?</p>
<!-- dead Flash embed removed (service offline): flash -->
<p><strong>Luke:</strong> I think Agile Infrastructure has even less adoption than Agile Development. The vast majority of IT shops haven&rsquo;t changed practices significantly in years and are largely unprepared for the growth in server count that they&rsquo;re experiencing. They mostly try to scale by adding more people, which we call the <em>meatcloud</em>, rather than scaling their tools and practice.</p>
<p><strong>AkitaOnRails:</strong> Probably related to the previous question, seems like specially after Sarbanes Oxley there&rsquo;s been an increasing interest in stuff such as ITIL, CoBit. Have you ever seen successful implementations of those in the Web-style infrastructure? I mean, I can see them succeeding in Banks, Aerospace and Defense, etc but I fail to see them working as advertised in a very dynamic environment such as Web services hosting. What are your experiences regarding this issue?</p>
<p><strong>Luke:</strong> In general, I think these kinds of high-level policies are great for filings but aren&rsquo;t so great for actually solving problems. The bigger a company is and the more public they are, the more likely they are to care about ITIL et al, but it doesn&rsquo;t really help them solve problems outside of PR in my experience. You can be ITIL compliant and dysfunctional, or completely out of compliance but in fantastic shape. Considering that the best standards are derived from implementation and best practice, which few of these are, I don&rsquo;t have a lot of hope for these being adopted by the best shops out there.</p>
<p>My personal experience is that very few companies ask or care about these standards, and the ones that do usually do so in a kind of checkbox way, in that they want to make sure they can check off things like CMDB but they aren&rsquo;t really that concerned with the specifics.</p>
<p><strong>AkitaOnRails:</strong> I think this is it! Thank you very much for this conversation!</p>
]]></content:encoded><category>interviews</category><category>devops</category><category>automation</category></item></channel></rss>