<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged shell</title>
<link>https://coderlegion.com/tag/shell</link>
<description>Powered by Question2Answer</description>
<item>
<title>I Tried Needle2 for Local Tool Calling. I Ended Up With llama.cpp + Granite</title>
<link>https://coderlegion.com/28584/i-tried-needle2-for-local-tool-calling-i-ended-up-with-llama-cpp-granite</link>
<description>&lt;p&gt;I’ve been working on a small experiment around a semantic shell.&lt;/p&gt;
&lt;p&gt;The basic idea is that the user types something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;copy report.pdf to backup
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and a small local model maps that to a known tool:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;filesystem.copy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shell then takes over. It validates arguments, asks for missing ones, shows confirmation UI when needed, and finally executes the operation.&lt;/p&gt;
&lt;p&gt;The model is not supposed to generate shell commands. It only needs to understand intent.&lt;/p&gt;
&lt;p&gt;That sounded like a very good fit for Needle2.&lt;/p&gt;
&lt;p&gt;Needle2 is tiny, focused on tool calling and structured extraction, and designed to run locally. On paper it looked almost purpose-built for what I needed.&lt;/p&gt;
&lt;p&gt;So I tried it.&lt;/p&gt;
&lt;p&gt;The first version worked. Then I started adding more realistic cases.&lt;/p&gt;
&lt;p&gt;And that’s where things became more interesting.&lt;/p&gt;
&lt;h3 id=&quot;five-tools-is-very-different-from-fifty&quot;&gt;Five tools is very different from fifty&lt;/h3&gt;
&lt;p&gt;Most examples of tool-calling models are some variation of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;turn on the kitchen light
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with two or three tools available.&lt;/p&gt;
&lt;p&gt;That’s fine as an API example, but it doesn’t tell you much about how the model behaves in a real application.&lt;/p&gt;
&lt;p&gt;Even my first filesystem package already had things like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;filesystem.copy
filesystem.move
filesystem.delete
filesystem.create_file
filesystem.create_directory
filesystem.list_directory
filesystem.navigate
filesystem.current_directory
filesystem.find
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Needle2 uses retrieval when the number of tools gets larger. It selects a small candidate set and then resolves the tool from there.&lt;/p&gt;
&lt;p&gt;That makes sense.&lt;/p&gt;
&lt;p&gt;But it also means there are really two decisions now:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Which tools should be considered?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and then:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Which of those tools is the right one?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the correct tool does not make it into the shortlist, the second stage never gets a chance.&lt;/p&gt;
&lt;p&gt;For many applications that may be perfectly acceptable. In my case I wasn’t comfortable relying on it without knowing more about how often it happens.&lt;/p&gt;
&lt;p&gt;I started thinking about bypassing retrieval entirely by dividing tools into groups of five and running all groups.&lt;/p&gt;
&lt;p&gt;Needle is small enough that this is not completely ridiculous.&lt;/p&gt;
&lt;p&gt;Something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;group 1 -&amp;gt; result
group 2 -&amp;gt; result
group 3 -&amp;gt; result
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;then take the strongest candidate.&lt;/p&gt;
&lt;p&gt;But immediately another question appears.&lt;/p&gt;
&lt;p&gt;Are confidence scores from different candidate groups actually comparable?&lt;/p&gt;
&lt;p&gt;Maybe they are. Maybe they are not.&lt;/p&gt;
&lt;p&gt;I couldn’t find enough information to feel confident about treating them as globally calibrated scores.&lt;/p&gt;
&lt;p&gt;That does not make Needle bad. It just means that once you move away from the demo path, you start needing answers that are not obvious from the examples.&lt;/p&gt;
&lt;h3 id=&quot;tool-descriptions-matter-a-lot&quot;&gt;Tool descriptions matter a lot&lt;/h3&gt;
&lt;p&gt;Another thing I learned quickly was how sensitive a small model is to tool descriptions.&lt;/p&gt;
&lt;p&gt;A description like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Moves files from one place to another.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is not enough if the neighboring tools are:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;filesystem.copy
filesystem.navigate
filesystem.rename
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You need to be explicit.&lt;/p&gt;
&lt;p&gt;For example, “move” should probably say that the original item no longer remains at the source, and that this is not the same thing as changing the shell’s current directory.&lt;/p&gt;
&lt;p&gt;The descriptions start looking less like documentation and more like a tiny classification dataset written in English.&lt;/p&gt;
&lt;p&gt;That was workable, but it changed how I thought about the model.&lt;/p&gt;
&lt;p&gt;It is less “small agent” and more “probabilistic semantic parser with a constrained vocabulary.”&lt;/p&gt;
&lt;p&gt;Which is actually fine for this use case.&lt;/p&gt;
&lt;h3 id=&quot;then-i-tried-the-c-runtime&quot;&gt;Then I tried the C++ runtime&lt;/h3&gt;
&lt;p&gt;For the shell itself I wanted native integration.&lt;/p&gt;
&lt;p&gt;No Python process on the side, no extra service, and no awkward lifetime management. The C++ library looked like the right way to do it.&lt;/p&gt;
&lt;p&gt;This is where I hit the biggest practical problem.&lt;/p&gt;
&lt;p&gt;I was developing on an Intel Mac.&lt;/p&gt;
&lt;p&gt;The Cactus Engine code contains ARM NEON includes and intrinsics in many places. Not just one isolated backend file, but spread through engine and kernel code.&lt;/p&gt;
&lt;p&gt;That surprised me.&lt;/p&gt;
&lt;p&gt;If ARM is the main target, this is understandable. iOS, Android, and Apple Silicon are all important platforms.&lt;/p&gt;
&lt;p&gt;But from my side it meant x86 support was not just a matter of enabling another optimized backend.&lt;/p&gt;
&lt;p&gt;I ended up spending time patching things simply to answer a much more basic question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Is Needle good enough for my use case to justify carrying this dependency?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At that point I decided to try a different route before spending more time on the port.&lt;/p&gt;
&lt;h3 id=&quot;llama-cpp-and-granite&quot;&gt;llama.cpp and Granite&lt;/h3&gt;
&lt;p&gt;So I integrated llama.cpp.&lt;/p&gt;
&lt;p&gt;Then I tried Granite 4 350M for the same tool-calling task.&lt;/p&gt;
&lt;p&gt;I expected this to be more of a baseline than a solution.&lt;/p&gt;
&lt;p&gt;It turned out to work better than I expected.&lt;/p&gt;
&lt;p&gt;For the normal cases it selected the tools I wanted.&lt;/p&gt;
&lt;p&gt;More importantly, I tried a few deliberately awkward inputs where no good tool really existed, and instead of forcing a call it simply failed to find one.&lt;/p&gt;
&lt;p&gt;For my application, that is good behavior.&lt;/p&gt;
&lt;p&gt;I would much rather see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;No suitable tool found.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;than a confident guess.&lt;/p&gt;
&lt;p&gt;The shell can recover from that.&lt;/p&gt;
&lt;p&gt;It can ask the user to rephrase, narrow the scope, or choose between two possible interpretations.&lt;/p&gt;
&lt;p&gt;A wrong tool call is much harder to recover from once you start dealing with destructive operations.&lt;/p&gt;
&lt;h3 id=&quot;this-changed-the-architecture-less-than-i-expected&quot;&gt;This changed the architecture less than I expected&lt;/h3&gt;
&lt;p&gt;The funny part is that switching models did not really affect the shell design.&lt;/p&gt;
&lt;p&gt;The important part was already outside the model.&lt;/p&gt;
&lt;p&gt;The flow is roughly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;user input
    ↓
intent resolution
    ↓
known semantic capability
    ↓
argument validation
    ↓
ask for missing arguments
    ↓
policy / confirmation
    ↓
execution
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the user says:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;copy file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;the model only needs to resolve:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;filesystem.copy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shell can notice that source and destination are missing and show proper selectors.&lt;/p&gt;
&lt;p&gt;If two tools are plausible, for example copy versus move, the shell can show two buttons.&lt;/p&gt;
&lt;p&gt;There is no need to force the model to solve every ambiguity.&lt;/p&gt;
&lt;p&gt;That ended up being one of the more useful conclusions from the experiment.&lt;/p&gt;
&lt;p&gt;A small local model does not need to be brilliant if the surrounding system is designed to handle uncertainty.&lt;/p&gt;
&lt;h3 id=&quot;the-part-i-wish-documentation-focused-on-more&quot;&gt;The part I wish documentation focused on more&lt;/h3&gt;
&lt;p&gt;I still think Needle2 is interesting.&lt;/p&gt;
&lt;p&gt;What I missed was more information about the edges.&lt;/p&gt;
&lt;p&gt;Not another:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;turn on the light
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;example.&lt;/p&gt;
&lt;p&gt;I wanted answers to things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;how accuracy changes as the tool catalogue grows;&lt;/li&gt;
&lt;li&gt;how often retrieval drops the correct tool;&lt;/li&gt;
&lt;li&gt;whether confidence remains comparable across different candidate sets;&lt;/li&gt;
&lt;li&gt;how similar tools should be described;&lt;/li&gt;
&lt;li&gt;what happens on no-match input;&lt;/li&gt;
&lt;li&gt;how confidence behaves on incorrect calls;&lt;/li&gt;
&lt;li&gt;how much fine-tuning helps with ambiguous tools;&lt;/li&gt;
&lt;li&gt;what the realistic native-platform support is.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those details are not as flashy, but they are what decides whether something works in a real system.&lt;/p&gt;
&lt;p&gt;The same applies to structured extraction.&lt;/p&gt;
&lt;p&gt;Showing that a model can extract an invoice total is useful.&lt;/p&gt;
&lt;p&gt;Showing how it behaves when there are subtotal, tax, total, previous balance, OCR errors, and two dates on the same page is much more useful.&lt;/p&gt;
&lt;h3 id=&quot;where-i-am-now&quot;&gt;Where I am now&lt;/h3&gt;
&lt;p&gt;For the moment I’m continuing with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;llama.cpp
Granite 4 350M
typed semantic tools
deterministic argument resolution
small contextual UI
policy-controlled execution
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That does not mean Needle2 is a bad model.&lt;/p&gt;
&lt;p&gt;It may be an excellent fit for the platforms and use cases Cactus is targeting.&lt;/p&gt;
&lt;p&gt;It just stopped being the obvious choice for mine.&lt;/p&gt;
&lt;p&gt;And that was probably the main thing I got from the experiment.&lt;/p&gt;
&lt;p&gt;A component can look almost perfect from the feature list and still be the wrong foundation once you start testing the boring parts: portability, failure modes, ambiguity, scaling, and integration cost.&lt;/p&gt;
&lt;p&gt;For this kind of software, I’m becoming much more interested in how a model fails than in how impressive its best demo looks.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28584/i-tried-needle2-for-local-tool-calling-i-ended-up-with-llama-cpp-granite</guid>
<pubDate>Fri, 25 Sep 2026 07:28:13 +0000</pubDate>
</item>
<item>
<title>From Game Cheats to C Shell: Matheus’s Journey Into Programming</title>
<link>https://coderlegion.com/26826/from-game-cheats-to-c-shell-matheuss-journey-into-programming</link>
<description>&lt;h2 id=&quot;from-game-cheats-to-c-shell-matheus-s-journey-into-programming&quot;&gt;From Game Cheats to C Shell: Matheus’s Journey Into Programming&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A CoderLegion Developer Story featuring &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/MProG10&quot;&gt;Matheus&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Interviewed and edited by Mehadi Hasan, Community &amp;amp; Editorial Team at CoderLegion&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Matheus has been interested in computers since childhood. What started with playing games, experimenting with cheats, and carefully watching his father repair a computer eventually turned into a deep interest in programming, Unix, Linux, shell scripting, and low-level software.&lt;/p&gt;
&lt;p&gt;Today, his interests span languages such as C, Objective-C, Smalltalk, Java, C#, JavaScript, Python, and Shell. More recently, he has been working directly on C shell, forking &lt;code&gt;tcsh&lt;/code&gt; and experimenting with new features and changes to the shell's parsing and execution behavior.&lt;/p&gt;
&lt;p&gt;His journey isn't really about following one technology.&lt;/p&gt;
&lt;p&gt;It is about curiosity.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;it-started-with-a-computer-and-game-cheats&quot;&gt;It Started With a Computer and Game Cheats&lt;/h3&gt;
&lt;p&gt;Matheus's interest in computers began before he was thinking seriously about programming.&lt;/p&gt;
&lt;p&gt;One of his earliest memories involves playing games with cheats on a computer equipped with an Intel MMX processor.&lt;/p&gt;
&lt;p&gt;Whenever something went wrong with the computer, he would ask his father to fix it. But he wasn't simply waiting for the computer to work again.&lt;/p&gt;
&lt;p&gt;He watched closely.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;I used to pay close attention to the process, so I could reproduce myself.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That habit of watching how things worked would eventually become an important part of his approach to programming.&lt;/p&gt;
&lt;p&gt;Instead of simply using technology, he wanted to understand how it worked and reproduce what he had seen.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;discovering-programming-through-game-servers&quot;&gt;Discovering Programming Through Game Servers&lt;/h3&gt;
&lt;p&gt;Matheus's programming journey properly began around 2013 or 2014.&lt;/p&gt;
&lt;p&gt;At the time, he was hosting servers for games.&lt;/p&gt;
&lt;p&gt;Those games included scripting languages that allowed players to write their own programs and modify the behavior of the game.&lt;/p&gt;
&lt;p&gt;That was one of the first opportunities for Matheus to discover that software could be something he could actually change and build himself.&lt;/p&gt;
&lt;p&gt;Around the same time, his interest in Linux and Unix began to develop.&lt;/p&gt;
&lt;p&gt;The two interests gradually came together.&lt;/p&gt;
&lt;p&gt;He started experimenting with shell scripting and quickly became fascinated by what could be accomplished from the command line.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;I played with shell scripting for a long time, until I met the C programming language, around 2021 and 2022.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;His exploration of different technologies wasn't driven by a particular career plan.&lt;/p&gt;
&lt;p&gt;It was driven mostly by curiosity.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;The reason I've explored many different technologies is mainly due to curiosity. I think my baggage is never enough, so I tend to learn every technology I can.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That curiosity has remained one of the defining characteristics of his programming journey.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-unix-linux-and-the-shell-&quot;&gt;Why Unix, Linux, and the Shell?&lt;/h3&gt;
&lt;p&gt;Matheus describes himself as a Unix and Linux enthusiast, but the part of the ecosystem that attracted him most was the shell.&lt;/p&gt;
&lt;p&gt;For him, the shell isn't simply a way to launch programs.&lt;/p&gt;
&lt;p&gt;It's a powerful programming environment.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;I think what mostly attracted and kept me interested in Linux and Unix is the shell.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Through shell scripting, he learned many different techniques and ways of solving problems.&lt;/p&gt;
&lt;p&gt;The simplicity of the environment also gave him room to experiment.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;I like to think the shell is a great tool.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That interest eventually led him much deeper into understanding how shells work and, eventually, into modifying one himself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;from-shell-scripting-to-c&quot;&gt;From Shell Scripting to C&lt;/h3&gt;
&lt;p&gt;Shell scripting became an important part of how Matheus learned and practiced programming.&lt;/p&gt;
&lt;p&gt;But learning C introduced a different way of thinking.&lt;/p&gt;
&lt;p&gt;One of the interesting differences for him was the presence of pointers in C.&lt;/p&gt;
&lt;p&gt;Coming from shell scripting, pointers were initially somewhat counter-intuitive.&lt;/p&gt;
&lt;p&gt;Shell scripting doesn't have pointers in the same way C does.&lt;/p&gt;
&lt;p&gt;But Matheus eventually came to appreciate how powerful they can be.&lt;/p&gt;
&lt;p&gt;In fact, his curiosity went far enough that he began thinking about whether pointers could even be implemented on top of a shell.&lt;/p&gt;
&lt;p&gt;That kind of experimentation reflects a broader pattern throughout his journey: rather than simply accepting how a technology works, he likes to ask what else might be possible.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rethinking-the-c-shell&quot;&gt;Rethinking the C Shell&lt;/h3&gt;
&lt;p&gt;Today, Matheus is experimenting directly with C shell.&lt;/p&gt;
&lt;p&gt;He is working on a fork of &lt;code&gt;tcsh&lt;/code&gt;, exploring fixes and new features while rethinking parts of the shell's behavior.&lt;/p&gt;
&lt;p&gt;One of his current projects focuses on &lt;strong&gt;one-line control structure parsing&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The goal is to make certain control structures work on a single line in a way similar to Bourne-style shells.&lt;/p&gt;
&lt;p&gt;For example, C shell traditionally parses an entire command line before taking further action, which creates limitations around using semicolons and line feeds interchangeably. Matheus's implementation attempts to address that behavior. :contentReference[oaicite:1]{index=1}&lt;/p&gt;
&lt;p&gt;He has also worked on other changes to the shell, including function declarations, variable assignment from pipes and redirections, comments in interactive sessions, and other fixes and features. :contentReference[oaicite:2]{index=2}&lt;/p&gt;
&lt;p&gt;For Matheus, this isn't simply about using an existing shell.&lt;/p&gt;
&lt;p&gt;He wants to understand its internals well enough to change them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-challenge-of-parsing-a-control-structure&quot;&gt;The Challenge of Parsing a Control Structure&lt;/h3&gt;
&lt;p&gt;One of the most challenging parts of his current work has been implementing one-line control structure parsing.&lt;/p&gt;
&lt;p&gt;His first approach was to implement the mechanism through built-ins.&lt;/p&gt;
&lt;p&gt;But he quickly realized that this would be dangerous and ineffective.&lt;/p&gt;
&lt;p&gt;So he changed his approach.&lt;/p&gt;
&lt;p&gt;Instead of handling the feature through built-ins, he moved the implementation into the post-parsing execution stage, where the command line could be reparsed and executed.&lt;/p&gt;
&lt;p&gt;The implementation involves recursively analyzing the structure of commands and determining the opening and enclosing blocks of control structures.&lt;/p&gt;
&lt;p&gt;Matheus describes the process as a set of recursive functions that analyze the parsed structure.&lt;/p&gt;
&lt;p&gt;His recent CoderLegion article describes the implementation in more detail, including the use of new parsing and execution modes and a circular linked list for relevant command nodes. :contentReference[oaicite:3]{index=3}&lt;/p&gt;
&lt;p&gt;It's a good example of how his curiosity has evolved.&lt;/p&gt;
&lt;p&gt;What started with using software has become an interest in understanding and modifying the machinery underneath it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;testing-you-can-t-always-test-your-own-software&quot;&gt;Testing: You Can't Always Test Your Own Software&lt;/h3&gt;
&lt;p&gt;For Matheus, testing is an essential part of software development.&lt;/p&gt;
&lt;p&gt;But he has also learned an important limitation:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The person who writes the software may not be the best person to test it.&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;A developer by themselves may not be able to play the role of a tester.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A developer knows what they intended to build.&lt;/p&gt;
&lt;p&gt;That can make it difficult to see all the unexpected ways another person might use it.&lt;/p&gt;
&lt;p&gt;Matheus doesn't consider himself a tester.&lt;/p&gt;
&lt;p&gt;He considers himself a developer whose goal is to produce a program.&lt;/p&gt;
&lt;p&gt;Of course, he tests everything he develops himself.&lt;/p&gt;
&lt;p&gt;But he doesn't assume that his own tests are enough.&lt;/p&gt;
&lt;p&gt;Instead, he often asks other people to test his software because they can approach it from a different perspective.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;I'm unaware of the many possibilities of use.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That lesson is especially relevant to the kind of software he is currently building.&lt;/p&gt;
&lt;p&gt;When you change something as fundamental as a shell, there can be many behaviors and edge cases that the original developer simply didn't anticipate.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;programming-isn-t-as-hard-as-it-looks&quot;&gt;Programming Isn't as Hard as It Looks&lt;/h3&gt;
&lt;p&gt;After spending years learning different technologies, Matheus has arrived at a surprisingly simple conclusion:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Programming isn't as hard as thought.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Even subjects that initially appear intimidating can become manageable once you spend enough time with them.&lt;/p&gt;
&lt;p&gt;That doesn't mean programming requires no effort.&lt;/p&gt;
&lt;p&gt;For Matheus, the difficult part isn't necessarily that programming is impossibly complicated.&lt;/p&gt;
&lt;p&gt;It's that understanding something requires time, experimentation, and persistence.&lt;/p&gt;
&lt;p&gt;The intimidating subject becomes less intimidating once you start working with it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-hardest-part-deciding-how-to-solve-a-problem&quot;&gt;The Hardest Part: Deciding How to Solve a Problem&lt;/h3&gt;
&lt;p&gt;One of Matheus's biggest challenges isn't learning a programming language.&lt;/p&gt;
&lt;p&gt;It's deciding how to solve a problem.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;To solve a problem. This has always been challenging for me.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When planning an implementation, he isn't always sure that the solution he's considering is the best one.&lt;/p&gt;
&lt;p&gt;There may be another approach that he hasn't thought of yet.&lt;/p&gt;
&lt;p&gt;That uncertainty can make it difficult to begin.&lt;/p&gt;
&lt;p&gt;Over time, however, he has developed a practical way of dealing with it.&lt;/p&gt;
&lt;p&gt;If he has a reasonable solution, he believes it is often better to implement it rather than remain stuck trying to find the perfect approach.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;If you have a solution, put in practice, even if this isn't the best.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And if a better solution becomes apparent later, the software can be reimplemented.&lt;/p&gt;
&lt;p&gt;For Matheus, moving forward can be more valuable than remaining paralyzed by uncertainty.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;learning-from-other-developers&quot;&gt;Learning From Other Developers&lt;/h3&gt;
&lt;p&gt;One of the things Matheus enjoys most about software development is the opportunity to learn from other people.&lt;/p&gt;
&lt;p&gt;Building software alongside other developers exposes you to different approaches and different ways of thinking.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;They might have better solutions, and you can aggregate their knowledge.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Even though he doesn't currently work as part of a team, he still enjoys sharing software and ideas with other developers.&lt;/p&gt;
&lt;p&gt;For him, open source and developer communities aren't just places to show what you've built.&lt;/p&gt;
&lt;p&gt;They're places where knowledge can move in both directions.&lt;/p&gt;
&lt;p&gt;You contribute something.&lt;/p&gt;
&lt;p&gt;Someone else contributes an idea.&lt;/p&gt;
&lt;p&gt;And your understanding becomes a little larger than it was before.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;finding-a-community-on-coderlegion&quot;&gt;Finding a Community on CoderLegion&lt;/h3&gt;
&lt;p&gt;Matheus joined CoderLegion in January 2026 and has since shared articles and projects around programming, shell development, testing, Pascal, C, and open source. His recent posts include work on C shell and one-line control structure parsing. :contentReference[oaicite:4]{index=4}&lt;/p&gt;
&lt;p&gt;His experience so far has been relatively quiet in terms of comments and engagement.&lt;/p&gt;
&lt;p&gt;But that hasn't changed what he wants from the community.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;I'm not only seeking for engagement, but also for contributions.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For Matheus, the value of a developer community isn't simply the number of reactions a post receives.&lt;/p&gt;
&lt;p&gt;He wants to expand his network, exchange knowledge, and contribute to other developers.&lt;/p&gt;
&lt;p&gt;That's also why feedback matters to him.&lt;/p&gt;
&lt;p&gt;A developer can build something alone, but other people can see possibilities, problems, and solutions that the original developer may have missed.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-advice-he-would-give-to-new-developers&quot;&gt;The Advice He Would Give to New Developers&lt;/h3&gt;
&lt;p&gt;For someone just beginning their programming journey, Matheus's advice is straightforward:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't give up.&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;My best advice is to never look down and give up.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Programming is a journey, and according to Matheus, it requires dedication and discipline.&lt;/p&gt;
&lt;p&gt;There will be moments when a problem seems impossible.&lt;/p&gt;
&lt;p&gt;There will be technologies that look intimidating.&lt;/p&gt;
&lt;p&gt;There will be times when you aren't sure how to implement something.&lt;/p&gt;
&lt;p&gt;His advice is to keep moving.&lt;/p&gt;
&lt;p&gt;And when you're stuck, don't be afraid to look for help.&lt;/p&gt;
&lt;p&gt;One resource he particularly recommends is books.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;In my experience, I found books to be excellent guides when stuck.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For Matheus, learning isn't about reaching a point where there is nothing left to discover.&lt;/p&gt;
&lt;p&gt;His own journey demonstrates almost the opposite.&lt;/p&gt;
&lt;p&gt;From game scripting to shell scripting, from Unix and Linux to C, from programming languages to open-source shell development, curiosity keeps pushing him toward the next thing he wants to understand.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;curiosity-is-the-common-thread&quot;&gt;Curiosity Is the Common Thread&lt;/h3&gt;
&lt;p&gt;Looking at Matheus's journey, there isn't one programming language or framework that defines it.&lt;/p&gt;
&lt;p&gt;The common thread is curiosity.&lt;/p&gt;
&lt;p&gt;It started with watching his father repair a computer.&lt;/p&gt;
&lt;p&gt;Then came game servers and scripting.&lt;/p&gt;
&lt;p&gt;Then Linux and Unix.&lt;/p&gt;
&lt;p&gt;Then shell scripting.&lt;/p&gt;
&lt;p&gt;Then C.&lt;/p&gt;
&lt;p&gt;And now, instead of simply using a shell, Matheus is working on the shell itself.&lt;/p&gt;
&lt;p&gt;His journey shows that learning doesn't always follow a straight path.&lt;/p&gt;
&lt;p&gt;Sometimes the best reason to learn a technology is simply because you want to understand it.&lt;/p&gt;
&lt;p&gt;And sometimes that curiosity eventually leads you much deeper than you expected.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;about-matheus&quot;&gt;About Matheus&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/MProG10&quot;&gt;Matheus&lt;/a&gt; is a passionate computer programmer based in Brazil.&lt;/p&gt;
&lt;p&gt;His programming interests include C, Objective-C, Smalltalk, Java, C#, JavaScript, Python, and Shell. He has a strong interest in Unix and Linux and is particularly interested in shell scripting and developer tools.&lt;/p&gt;
&lt;p&gt;On CoderLegion, Matheus has shared work around C shell, testing, Pascal, C programming, and open source. His current work includes experimenting with and extending &lt;code&gt;tcsh&lt;/code&gt;, including one-line control structure parsing and other shell improvements.&lt;/p&gt;
&lt;p&gt;You can follow his work on &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/MProG10&quot;&gt;his CoderLegion profile&lt;/a&gt; and his &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Krush206&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;about-this-developer-story&quot;&gt;About This Developer Story&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Developer:&lt;/strong&gt; Matheus&lt;br&gt;
&lt;strong&gt;Series:&lt;/strong&gt; CoderLegion Developer Stories&lt;br&gt;
&lt;strong&gt;Interview format:&lt;/strong&gt; Q&amp;amp;A&lt;br&gt;
&lt;strong&gt;Interviewed and edited by:&lt;/strong&gt; Mehadi Hasan, Community &amp;amp; Editorial Team at CoderLegion&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This story was created from Matheus's own answers and experiences shared during the CoderLegion Developer Stories Q&amp;amp;A.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26826/from-game-cheats-to-c-shell-matheuss-journey-into-programming</guid>
<pubDate>Fri, 11 Sep 2026 09:52:03 +0000</pubDate>
</item>
<item>
<title>C shell - Rethought and reimplementation on functions</title>
<link>https://coderlegion.com/26272/c-shell-rethought-and-reimplementation-on-functions</link>
<description>&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/3nlbnailt76s4m5xallg.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
Hello. Recently, I've added a new feature to the C shell. This feature is for one-line control structure parsing. Now, after this work, I've reimplemented functions. I disliked the work on functions based on pipes. Now, because one-line control structures are a feature, I based functions on aliases.&lt;/p&gt;
&lt;p&gt;Should we have &lt;code&gt;goto&lt;/code&gt; as one-line? What's your take?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26272/c-shell-rethought-and-reimplementation-on-functions</guid>
<pubDate>Sat, 05 Sep 2026 22:12:55 +0000</pubDate>
</item>
<item>
<title>One-line control structure parsing</title>
<link>https://coderlegion.com/26223/one-line-control-structure-parsing</link>
<description>&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/mofrtzuk654jozzv3zhu.jpeg&quot; alt=&quot;&quot;&gt;&lt;br&gt;
Hello. I'm writing this post as a matter of contribution. I forked Tcsh, and added some features. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tcsh-org/tcsh/pull/125&quot;&gt;My most-recent contribution is on one-line control structure parsing.&lt;/a&gt; I'm currently working on an implementation to parse control structures in a single line, as in Bourne shells.&lt;/p&gt;
&lt;p&gt;It's well known that, e.g., &lt;code&gt;foreach n ( * ) ; echo $n ; end&lt;/code&gt; doesn't work on any C shell. The line is parsed as a whole before further action is taken. The issue here is that &lt;code&gt;;&lt;/code&gt; and a line feed can't be used interchangeably. This work is an attempt to implement this interchangeability.&lt;/p&gt;
&lt;p&gt;At first, I thought on implementing this in &lt;code&gt;sh.func.c&lt;/code&gt;, the source relating to built-in commands. The work was supposed to create and run temporary functions. However, I realized this would be dangerous and ineffective. Therefore, the implementation is now residing in &lt;code&gt;sh.sem.c&lt;/code&gt;, the source relating to post-parsing execution. I parse semicolons into a new node mode (&lt;code&gt;NODE_LINE&lt;/code&gt;). I implemented a new mode (&lt;code&gt;F_LINE&lt;/code&gt;). I &quot;reparse&quot; the entire tree, setting the &lt;code&gt;F_LINE&lt;/code&gt; bit to every command node (&lt;code&gt;NODE_COMMAND&lt;/code&gt;). Then, in &lt;code&gt;execute()&lt;/code&gt;, I walk through the entire tree, analyzing which of the &lt;code&gt;NODE_COMMAND&lt;/code&gt; entries contain the &lt;code&gt;F_LINE&lt;/code&gt; mode. The node shall be saved in a circular linked list for each containing the &lt;code&gt;F_LINE&lt;/code&gt; mode. Next, the circular linked list is analyzed from &lt;code&gt;search()&lt;/code&gt;. &lt;code&gt;search()&lt;/code&gt; shall perform a recursive operation, and analyze the opening and enclosing block for each keyword.&lt;/p&gt;
&lt;p&gt;Because I first tried to implement this on top of a temporary function, I altered how semicolons are parsed. Notably, the command-line must end with a semicolon. This is only true when a semicolon is inserted. E.g.: &lt;code&gt;echo hello ; echo world ;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Moreover, I guess this implementation shall make a good companion to aliases. Even though I've worked on the &lt;code&gt;function&lt;/code&gt; built-in, I realized aliases may make a great fit for functions. With this ongoing implementation on one-line control structure parsing, aliases may come handy for functions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;alias myfunc 'if \!^ then ; echo hello ; else ; echo bye ; endif ;'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This might be ugly, but is one way to implement functions.&lt;/p&gt;
&lt;p&gt;I'm seeking for testers. The implementation is done.&lt;/p&gt;
&lt;p&gt;I'd appreciate for contributions and feedback.&lt;/p&gt;
&lt;p&gt;Thank you.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26223/one-line-control-structure-parsing</guid>
<pubDate>Fri, 04 Sep 2026 20:27:33 +0000</pubDate>
</item>
<item>
<title>Git Will Humble You (And That's Okay) </title>
<link>https://coderlegion.com/22711/git-will-humble-you-and-thats-okay</link>
<description>&lt;p&gt;Hey fam &lt;/p&gt;
&lt;p&gt;&lt;em&gt;This is my first ever dev article, and yes - I'm nervous.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;So real talk — nobody warned me that learning to code would sometimes feel like arguing with a robot that's always right and never explains why you're wrong.&lt;/p&gt;
&lt;p&gt;That robot? Git.&lt;/p&gt;
&lt;p&gt;If you're a first-time developer, you've probably already had that moment. You type some command with full confidence, hit enter, and then... chaos. Your files are gone. Or duplicated. Or you're somehow on a branch that doesn't exist. Or the classic — you pushed to main when you absolutely should not have.&lt;/p&gt;
&lt;p&gt;We've all been there. This post is for you. &lt;/p&gt;
&lt;p&gt; What Even IS Git?&lt;/p&gt;
&lt;p&gt;Git is a version control system — basically a time machine for your code. It tracks every change you make, lets you collaborate with other developers, and (in theory) saves you from destroying your own project.&lt;/p&gt;
&lt;p&gt;The key word there is in theory.&lt;/p&gt;
&lt;p&gt; The Commands That Will Ruin Your Day (If You're Not Careful)&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;git push --force&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Oh, this one. This is the &quot;delete everything and ask questions never&quot; command. If you force push to a shared branch without knowing what you're doing, your teammates will find you. Don't do it until you really understand what it does.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;git reset --hard&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Hard reset sounds powerful because it is. It throws away all your uncommitted changes — no warning, no undo. Ran it once thinking it would fix my mess. It made a bigger mess. &lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;git merge vs git rebase&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These two will have you in a full philosophy debate at 2am. Both combine branches. Both will cause merge conflicts that look like ancient hieroglyphics. Learn the difference early — your future self will thank you.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Committing with no message (or a terrible one)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;git commit -m &quot;fix&quot; — said every developer who regretted it 3 weeks later. Be descriptive. Always.&lt;/p&gt;
&lt;p&gt; Why Mastering Git Early Actually Matters&lt;/p&gt;
&lt;p&gt;When you're just starting out, it's easy to think Git is just a way to upload your code to GitHub or Gitea and look productive. But here's the thing — Git is your safety net. The better you understand it:&lt;/p&gt;
&lt;p&gt;The less likely you are to lose work&lt;br&gt;
The easier it is to collaborate without chaos&lt;br&gt;
The more confident you look in team settings (yes, employers notice)&lt;br&gt;
The faster you can recover when things break — and they will break&lt;/p&gt;
&lt;p&gt;Git isn't just a tool. It's a habit. Build it early.&lt;/p&gt;
&lt;p&gt; Resources to Actually Get Good&lt;/p&gt;
&lt;p&gt;Here are some solid places to level up your Git, Go, Shell, and Rust game:&lt;/p&gt;
&lt;p&gt;Git:&lt;/p&gt;
&lt;p&gt;Pro Git Book — free, thorough, the bible&lt;br&gt;
Oh My Git! — learn Git through a game (yes, really)&lt;br&gt;
Learn Git Branching — visual and interactive&lt;/p&gt;
&lt;p&gt;Go (Golang):&lt;/p&gt;
&lt;p&gt;Go by Example — clean, no fluff&lt;/p&gt;
&lt;p&gt;Shell / Bash:&lt;/p&gt;
&lt;p&gt;The Linux Command Line by William Shotts — free online&lt;/p&gt;
&lt;p&gt;Rust:&lt;/p&gt;
&lt;p&gt;The Rust Book — the official one and it's actually great&lt;/p&gt;
&lt;p&gt; Now I Wanna Hear From YOU&lt;/p&gt;
&lt;p&gt;Drop it in the comments:&lt;/p&gt;
&lt;p&gt; What was your first Git disaster?&lt;br&gt;
 What's the command that made you question your life choices?&lt;br&gt;
 Are you learning Go, Shell, or Rust right now? How's it going?&lt;/p&gt;
&lt;p&gt;Let's laugh about it together — because if you're not breaking things, you're not learning.&lt;/p&gt;
&lt;p&gt;If this helped you even a little, hit that ❤️, drop a comment, and let's connect!&lt;/p&gt;
&lt;p&gt; Find me on LinkedIn → Mayan Okul — @mayan Okul — come say hi, seriously.&lt;/p&gt;
&lt;p&gt;Tags: #git #golang #shell #rust #Zone01 #codenewbie #devjourney #programming #firstpost&lt;/p&gt;
&lt;p&gt;&quot;Push code, not limits — unless it's a feature branch. Then push everything.&quot; &lt;br&gt;
— stay debugging, stay dangerous ⚡&lt;/p&gt;
&lt;p&gt;— Mayan, your fav/baddest developer__&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22711/git-will-humble-you-and-thats-okay</guid>
<pubDate>Wed, 15 Jul 2026 05:28:26 +0000</pubDate>
</item>
<item>
<title>The Bash $PATH Debugger I Run Whenever I Get &quot;Command Not Found&quot;</title>
<link>https://coderlegion.com/17087/the-bash-path-debugger-i-run-whenever-i-get-command-not-found</link>
<description>&lt;p&gt;Every experienced Linux user has been there: you install something, or move directories around, and suddenly &lt;code&gt;command not found&lt;/code&gt; appears for tools you know are installed.&lt;/p&gt;
&lt;p&gt;You check &lt;code&gt;which&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, echo &lt;code&gt;$PATH&lt;/code&gt;, and still waste 10-20 minutes hunting down the issue. I've done it more times than I care to admit — especially after fresh server setups or when cleaning up dotfiles.&lt;/p&gt;
&lt;p&gt;That's why I built (and now constantly use) a &lt;strong&gt;Bash $PATH Debugger&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;paste-your-path-and-get-instant-diagnosis&quot;&gt;Paste your $PATH and get instant diagnosis&lt;/h4&gt;
&lt;p&gt;Here's how it works in practice:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Copy your current PATH: &lt;code&gt;echo $PATH&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Paste it into the tool&lt;/li&gt;
&lt;li&gt;Get a full report + fixed version in seconds&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Try it here:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/path-debugger.html&quot;&gt;Bash $PATH Debugger&lt;/a&gt;&lt;/p&gt;
&lt;h4 id=&quot;what-the-tool-does&quot;&gt;What the tool does&lt;/h4&gt;
&lt;p&gt;The debugger takes your raw &lt;code&gt;$PATH&lt;/code&gt; string and analyzes every directory with these checks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Existence&lt;/strong&gt;: Does the directory actually exist on the filesystem?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Duplicates&lt;/strong&gt;: Are you wasting lookup time with repeated entries?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Permissions&lt;/strong&gt;: Are any directories missing execute permissions for your user?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Order&lt;/strong&gt;: Shows the exact search priority (first match wins)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clean output&lt;/strong&gt;: Generates a cleaned, deduplicated &lt;code&gt;export PATH=...&lt;/code&gt; line ready for &lt;code&gt;.bashrc&lt;/code&gt; or &lt;code&gt;.profile&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It even highlights common problems like missing &lt;code&gt;~/.local/bin&lt;/code&gt;, collapsed entries after moves, or directories that exist but aren't in your current session.&lt;/p&gt;
&lt;h4 id=&quot;why-this-tool-saves-so-much-time&quot;&gt;Why this tool saves so much time&lt;/h4&gt;
&lt;p&gt;&quot;command not found&quot; (exit code 127) is one of the most searched Bash errors online. Yet most resources just tell you to &lt;code&gt;export PATH=...&lt;/code&gt; manually without diagnosing &lt;em&gt;why&lt;/em&gt; it broke.&lt;/p&gt;
&lt;p&gt;This tool does the diagnosis for you. It's especially powerful on fresh VPS setups, after migrating user accounts, or when working with Docker/container environments where PATH behaves differently.&lt;/p&gt;
&lt;h4 id=&quot;real-world-use-cases&quot;&gt;Real-world use cases&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;After a &lt;code&gt;apt install&lt;/code&gt; or &lt;code&gt;brew install&lt;/code&gt; that doesn't add itself to PATH properly&lt;/li&gt;
&lt;li&gt;Debugging why a script works in one terminal but not another&lt;/li&gt;
&lt;li&gt;Cleaning up an overloaded PATH that accumulated junk over years&lt;/li&gt;
&lt;li&gt;Preparing a clean minimal PATH for production servers&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;pro-tip-test-on-a-clean-vps&quot;&gt;Pro tip: Test on a clean VPS&lt;/h4&gt;
&lt;p&gt;One of my favorite workflows is spinning up a fresh $4-5/mo VPS, installing my usual tools, running them through the debugger, and copying the optimized PATH into my base server image.&lt;/p&gt;
&lt;h4 id=&quot;common-variations-amp-advanced-usage&quot;&gt;Common variations &amp;amp; advanced usage&lt;/h4&gt;
&lt;p&gt;The tool also supports:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Relative path expansion&lt;/li&gt;
&lt;li&gt;Handling of &lt;code&gt;~&lt;/code&gt; and environment variables in PATH entries&lt;/li&gt;
&lt;li&gt;Export formats for different shells (bash, zsh, fish compatibility notes)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Full interactive tool with live diagnosis, cleaned PATH generator, and detailed explanations:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/path-debugger.html&quot;&gt;https://bashsnippets.xyz/tools/path-debugger.html&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Paste your PATH. Fix the problem. Move on.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/17087/the-bash-path-debugger-i-run-whenever-i-get-command-not-found</guid>
<pubDate>Fri, 08 May 2026 18:09:24 +0000</pubDate>
</item>
<item>
<title>A Soft Introduction to EasyShell</title>
<link>https://coderlegion.com/16702/a-soft-introduction-to-easyshell</link>
<description>&lt;blockquote&gt;&lt;p&gt;&quot;Can it be simpler?&quot; - A recurring thought I've had while working on build automation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MethodoxTech/EasyShell&quot;&gt;EasyShell&lt;/a&gt; (command: &lt;code&gt;easy&lt;/code&gt;) is a minimal shell scripting language designed from the ground up for &lt;strong&gt;simplicity and cross-platform build/process automation&lt;/strong&gt;. If you’ve ever felt that PowerShell’s syntax is powerful but easy to forget, or that traditional shell scripts get messy across operating systems, EasyShell aims to strike a cleaner balance.&lt;/p&gt;
&lt;p&gt;It's implemented in C# and runs on .NET, giving you natural access to the entire .NET ecosystem while keeping the scripting experience lightweight and approachable.&lt;/p&gt;
&lt;h3 id=&quot;design-principles&quot;&gt;Design Principles&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;One command per line (shell-like flow)&lt;/li&gt;
&lt;li&gt;Very small set of core concepts&lt;/li&gt;
&lt;li&gt;Strongly-typed global variables&lt;/li&gt;
&lt;li&gt;Seamless .NET interop via reflection&lt;/li&gt;
&lt;li&gt;Expressions in parentheses for inline evaluation&lt;/li&gt;
&lt;li&gt;Easy to read, write, and remember&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MethodoxTech/EasyShell/releases/tag/v0.1.0&quot;&gt;Version &lt;strong&gt;0.1.0&lt;/strong&gt;&lt;/a&gt; is now available — functional for everyday automation tasks.&lt;/p&gt;
&lt;h3 id=&quot;core-language-features&quot;&gt;Core Language Features&lt;/h3&gt;
&lt;h4 id=&quot;variables&quot;&gt;Variables&lt;/h4&gt;
&lt;p&gt;All variables are declared with an explicit type and live in global scope:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;INTVAR Count 42
BOOLVAR Debug TRUE
DOUBLEVAR Ratio 3.14
STRINGVAR Message &quot;Hello from EasyShell&quot;
HANDLEVAR Now System.DateTime.Now   # stores object instances
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Variable names are &lt;strong&gt;case-insensitive&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Reference with &lt;code&gt;$VarName&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Re-assign with &lt;code&gt;$VarName = value&lt;/code&gt; (or expressions)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;expressions-the-power-feature-&quot;&gt;Expressions (The Power Feature)&lt;/h4&gt;
&lt;p&gt;Wrap sub-commands in parentheses to evaluate them inline:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;STRINGVAR Formatted (System.String.Format &quot;Count = {0}&quot; $Count)
BOOLVAR IsBig (&amp;gt; $Count 100)
STRINGVAR Today (format &quot;{0:yyyy-MM-dd}&quot; (GetDate))
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;calling-net&quot;&gt;Calling .NET&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Static members&lt;/strong&gt; (fully qualified):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;System.Console.WriteLine &quot;Hello World!&quot;
System.IO.File.WriteAllText &quot;output.txt&quot; $Message
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instance methods&lt;/strong&gt; via HANDLE:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;HANDLEVAR Now System.DateTime.Now
STRINGVAR Stamp (CALL $Now ToString)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;control-flow&quot;&gt;Control Flow&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;IF (== $Count 42)
    print &quot;The answer!&quot;
ELSEIF (&amp;gt; $Count 50)
    print &quot;Too high&quot;
ELSE
    print &quot;Something else&quot;
END

INTVAR I 0
WHILE (&amp;lt; $I 5)
    System.Console.WriteLine $I
    $I = (+ $I 1)   # arithmetic helpers coming soon
END
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;functions&quot;&gt;Functions&lt;/h4&gt;
&lt;p&gt;Simple named blocks (no parameters — use globals):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;FUNC BuildProject
    print &quot;Building...&quot;
    dotnet build
END

CALL BuildProject
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;external-commands&quot;&gt;External Commands&lt;/h4&gt;
&lt;p&gt;Any command that isn’t a keyword or .NET call falls back to executing an external process (with stdout capture):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;git status
dotnet publish ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;real-world-example&quot;&gt;Real-World Example&lt;/h3&gt;
&lt;p&gt;Check out the full build script used by the project itself:&lt;br&gt;
&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MethodoxTech/EasyShell/blob/main/BuildScripts/BuildEasyShell.easy&quot;&gt;BuildEasyShell.easy&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It demonstrates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Path handling and folder creation&lt;/li&gt;
&lt;li&gt;Running &lt;code&gt;dotnet publish&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Conditional logic for OS/architecture&lt;/li&gt;
&lt;li&gt;Creating a dated zip package&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This script is self-contained and shows how EasyShell can manage non-trivial automation tasks.&lt;/p&gt;
&lt;h3 id=&quot;quick-start&quot;&gt;Quick Start&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# REPL mode
easy

# Run a script
easy path/to/script.easy

# Help
easy --help
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A dedicated &lt;strong&gt;VS Code syntax highlighting extension&lt;/strong&gt; is available: &lt;a rel=&quot;nofollow&quot; href=&quot;https://marketplace.visualstudio.com/items?itemName=Methodox.easyshell&quot;&gt;Methodox.easyshell&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;who-is-it-for-&quot;&gt;Who Is It For?&lt;/h3&gt;
&lt;p&gt;EasyShell is ideal for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Build automation scripts&lt;/li&gt;
&lt;li&gt;Simple CI/CD glue&lt;/li&gt;
&lt;li&gt;Daily developer tooling&lt;/li&gt;
&lt;li&gt;Anyone who wants .NET power without heavy dependency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's intentionally minimal — no advanced Bash-style features, just the essentials done in a way that’s hard to forget.&lt;/p&gt;
&lt;p&gt;Give it a try and let me know what you think!&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/16702/a-soft-introduction-to-easyshell</guid>
<pubDate>Tue, 05 May 2026 02:31:45 +0000</pubDate>
</item>
<item>
<title>DevLog 20251214: Yet Another Shell Script – Keeping Build Automation Simple with EasyShell</title>
<link>https://coderlegion.com/16701/devlog-20251214-yet-another-shell-script-keeping-build-automation-simple-with-easyshell</link>
<description>&lt;p&gt;As we roll out more tools, both internal and public, there has been a growing need for easy-to-maintain shell scripting and build automation. Build automation is complex enough without all the syntax burden - my favorite is to use PowerShell, but often it feels like wrestling with the tool itself. PowerShell is powerful, sure, but its depth often means hunting down syntax I &lt;em&gt;know&lt;/em&gt; I've used a dozen times before. Bash has its quirks across platforms, and honestly I don't know good ways of using it outside MinGW, Git Bash, or WSL - none of which felt quite &quot;right&quot;.&lt;/p&gt;
&lt;p&gt;I wanted something lighter: a shell scripting tool that's &lt;strong&gt;easy to pick up, cross-platform, and hard to forget&lt;/strong&gt; once you start using it.&lt;/p&gt;
&lt;p&gt;So I made &lt;strong&gt;EasyShell&lt;/strong&gt; (or just &lt;code&gt;easy&lt;/code&gt;): a minimal shell scripting language implemented in C# that focuses on process automation with a very small set of concepts.&lt;/p&gt;
&lt;h3 id=&quot;motivation-and-design&quot;&gt;Motivation and Design&lt;/h3&gt;
&lt;p&gt;EasyShell is for &quot;lazy&quot; developers (like me) who just want to automate common tasks - calling .NET APIs, running external tools, handling simple logic - without a steep syntax curve or platform-specific headaches. It combines a shell-like one-command-per-line flow with strong typing and easy .NET interop. No advanced Bash-style wizardry; just straightforward scripting.&lt;/p&gt;
&lt;p&gt;It could also serve as a nice companion alongside other tools in the long-term Divooka ecosystem.&lt;/p&gt;
&lt;h3 id=&quot;quick-taste-of-the-syntax&quot;&gt;Quick Taste of the Syntax&lt;/h3&gt;
&lt;p&gt;Files typically use the &lt;code&gt;.easy&lt;/code&gt; extension, with syntax highlighting available via the &lt;a rel=&quot;nofollow&quot; href=&quot;https://marketplace.visualstudio.com/items?itemName=Methodox.easyshell&quot;&gt;VS Code extension&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here's a small sample from the repo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-easy&quot;&gt;# Comments start with #
HANDLEVAR NOW System.DateTime.Now

# Instance method via CALL
STRINGVAR DATE (CALL $NOW ToString)

# Static .NET call with expression
STRINGVAR VALUE (System.String.Format &quot;Current time: {0}&quot; $DATE)

STRINGVAR PATH &quot;C:/temp/greeting.txt&quot;
System.IO.File.WriteAllText $PATH $VALUE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Variables are declared with their type (&lt;code&gt;INTVAR&lt;/code&gt;, &lt;code&gt;STRINGVAR&lt;/code&gt;, &lt;code&gt;BOOLVAR&lt;/code&gt;, &lt;code&gt;DOUBLEVAR&lt;/code&gt;, &lt;code&gt;HANDLEVAR&lt;/code&gt;), names are case-insensitive, and everything lives in global scope for simplicity. Expressions are wrapped in parentheses for inline evaluation, like &lt;code&gt;(== $X 10)&lt;/code&gt; or sub-commands.&lt;/p&gt;
&lt;p&gt;Control flow sticks to the basics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;IF ... ELSEIF ... ELSE ... END&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WHILE ... END&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Simple &lt;code&gt;FUNC ... END&lt;/code&gt; / &lt;code&gt;CALL&lt;/code&gt; (globals make &quot;returns&quot; easy via variables)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can call external executables naturally, and non-built-in commands fall through to process execution (stdout capture included).&lt;/p&gt;
&lt;h3 id=&quot;early-days&quot;&gt;Early Days&lt;/h3&gt;
&lt;p&gt;This is version 0.1.0 territory - functional for everyday build tasks, with room to grow (arithmetic helpers, more examples, etc.). You can run it in REPL mode (&lt;code&gt;easy&lt;/code&gt;) or point it at a script. Builds and packages are straightforward with the included scripts.&lt;/p&gt;
&lt;p&gt;If you've ever wished for something between a full PowerShell script and a quick batch file that &quot;just works&quot; across machines, maybe give EasyShell a try. The GitHub repo has the full language overview, examples, and build details: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MethodoxTech/EasyShell&quot;&gt;MethodoxTech/EasyShell&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16701/devlog-20251214-yet-another-shell-script-keeping-build-automation-simple-with-easyshell</guid>
<pubDate>Tue, 05 May 2026 02:23:48 +0000</pubDate>
</item>
<item>
<title>How to Detect and Monitor Multiple GPUs on Linux</title>
<link>https://coderlegion.com/12885/how-to-detect-and-monitor-multiple-gpus-on-linux</link>
<description>&lt;p&gt;If you run Linux on a system with both an &lt;strong&gt;integrated GPU&lt;/strong&gt; and a &lt;strong&gt;dedicated GPU&lt;/strong&gt;, you may have noticed something strange: some monitoring tools only show one GPU.&lt;/p&gt;
&lt;p&gt;This situation is common on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;laptops with hybrid graphics&lt;/li&gt;
&lt;li&gt;desktops with iGPU + dedicated GPU&lt;/li&gt;
&lt;li&gt;systems using different driver stacks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, a monitoring tool like &lt;strong&gt;btop&lt;/strong&gt; might show your AMD integrated GPU but completely ignore your NVIDIA card.&lt;/p&gt;
&lt;p&gt;In this article we will walk through how to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;detect all GPUs installed in your system&lt;/li&gt;
&lt;li&gt;verify that the correct drivers are loaded&lt;/li&gt;
&lt;li&gt;identify which GPU is rendering your desktop&lt;/li&gt;
&lt;li&gt;monitor usage for each GPU&lt;/li&gt;
&lt;li&gt;automate the entire diagnostic process with a small script&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-happens&quot;&gt;Why This Happens&lt;/h3&gt;
&lt;p&gt;Linux systems with multiple GPUs may behave differently depending on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;installed drivers (open source vs proprietary)&lt;/li&gt;
&lt;li&gt;kernel configuration&lt;/li&gt;
&lt;li&gt;which GPU is configured as the primary renderer&lt;/li&gt;
&lt;li&gt;monitoring tool capabilities&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example setup used in this article:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AMD Vega (integrated)&lt;/strong&gt; — Ryzen 5 5600G  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NVIDIA GTX 1050 Ti (dedicated)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this configuration, &lt;code&gt;btop&lt;/code&gt; showed only the AMD GPU while the NVIDIA card remained invisible in the default monitoring view.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-check-if-both-gpus-are-detected&quot;&gt;Step 1 — Check if Both GPUs Are Detected&lt;/h3&gt;
&lt;h4 id=&quot;1-1-list-gpus-via-pci&quot;&gt;1.1 List GPUs via PCI&lt;/h4&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lspci | grep -i vga
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;01:00.0 VGA compatible controller: NVIDIA Corporation GP107 [GeForce GTX 1050 Ti]
09:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Cezanne [Radeon Vega Series]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If two entries appear, your &lt;strong&gt;hardware is being detected correctly&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;1-2-check-loaded-drivers&quot;&gt;1.2 Check Loaded Drivers&lt;/h4&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lsmod | grep -E 'nvidia|amdgpu|radeon'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Typical modules:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;GPU &lt;/th&gt;&lt;th&gt;Kernel Module&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;NVIDIA &lt;/td&gt;&lt;td&gt;&lt;code&gt;nvidia&lt;/code&gt;, &lt;code&gt;nvidia_drm&lt;/code&gt;, &lt;code&gt;nvidia_modeset&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;AMD (modern) &lt;/td&gt;&lt;td&gt;&lt;code&gt;amdgpu&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;AMD (legacy) &lt;/td&gt;&lt;td&gt;&lt;code&gt;radeon&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If the modules appear, the &lt;strong&gt;drivers are loaded by the kernel&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;1-3-check-which-gpu-is-rendering-your-desktop&quot;&gt;1.3 Check Which GPU Is Rendering Your Desktop&lt;/h4&gt;
&lt;p&gt;Use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;glxinfo | grep &quot;OpenGL renderer&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OpenGL renderer string: NVIDIA GeForce GTX 1050 Ti
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;glxinfo&lt;/code&gt; is missing, install it.&lt;/p&gt;
&lt;p&gt;Ubuntu / Debian:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install mesa-utils
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Arch Linux:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo pacman -S mesa-utils
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This command reveals &lt;strong&gt;which GPU is rendering the graphical session&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In hybrid setups, the integrated GPU often renders the desktop while the dedicated GPU activates only for demanding workloads.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-monitoring-each-gpu&quot;&gt;Step 2 — Monitoring Each GPU&lt;/h3&gt;
&lt;h4 id=&quot;2-1-integrated-gpu-amd-intel-using-btop&quot;&gt;2.1 Integrated GPU (AMD / Intel) — Using btop&lt;/h4&gt;
&lt;p&gt;Launch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;btop
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside &lt;code&gt;btop&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;press &lt;strong&gt;g&lt;/strong&gt; to toggle GPU views&lt;/li&gt;
&lt;li&gt;press &lt;strong&gt;5 / 6 / 7&lt;/strong&gt; for specific GPU panels&lt;/li&gt;
&lt;li&gt;configure via &lt;strong&gt;F2 → Options → CPU box → Show GPU info&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-2-nvidia-dedicated-gpu-using-nvidia-smi&quot;&gt;2.2 NVIDIA Dedicated GPU — Using nvidia-smi&lt;/h3&gt;
&lt;p&gt;For NVIDIA GPUs the official tool is the most reliable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;watch -n 1 nvidia-smi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This updates every second and displays:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GPU utilization&lt;/li&gt;
&lt;li&gt;memory usage&lt;/li&gt;
&lt;li&gt;running processes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Structured output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu --format=csv -l 1
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-automating-gpu-diagnostics&quot;&gt;Step 3 — Automating GPU Diagnostics&lt;/h3&gt;
&lt;p&gt;Create a script called:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;check_gpus.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash

echo &quot;=== GPUs Detected ===&quot;
lspci | grep -E &quot;VGA|3D&quot;

echo -e &quot;\n=== Loaded Drivers ===&quot;
lsmod | grep -E &quot;nvidia|amdgpu|radeon&quot;

echo -e &quot;\n=== NVIDIA GPU ===&quot;
nvidia-smi --query-gpu=name,memory.total --format=csv 2&amp;gt;/dev/null || echo &quot;NVIDIA not detected&quot;

echo -e &quot;\n=== Rendering GPU ===&quot;
glxinfo | grep &quot;OpenGL renderer&quot; 2&amp;gt;/dev/null || echo &quot;glxinfo not available&quot;

echo -e &quot;\n=== DRM Devices ===&quot;
ls /sys/class/drm | grep card
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make executable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod +x check_gpus.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./check_gpus.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This quickly shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;detected GPUs&lt;/li&gt;
&lt;li&gt;active drivers&lt;/li&gt;
&lt;li&gt;rendering GPU&lt;/li&gt;
&lt;li&gt;NVIDIA status&lt;/li&gt;
&lt;li&gt;DRM device nodes&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;recommended-tools-summary&quot;&gt;Recommended Tools Summary&lt;/h3&gt;
&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;GPU Type&lt;/th&gt;
      &lt;th&gt;Recommended Tool&lt;/th&gt;
      &lt;th&gt;Command&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;AMD / Intel (integrated)&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;btop&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;btop&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;NVIDIA (dedicated)&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;nvidia-smi&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;watch -n 1 nvidia-smi&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Hardware detection&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;lspci&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;lspci | grep -i vga&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Rendering check&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;glxinfo&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code&gt;glxinfo | grep &quot;OpenGL renderer&quot;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Working with multiple GPUs on Linux doesn't have to be confusing.&lt;/p&gt;
&lt;p&gt;Using the commands shown above you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;verify hardware detection&lt;/li&gt;
&lt;li&gt;confirm driver loading&lt;/li&gt;
&lt;li&gt;identify which GPU renders your desktop&lt;/li&gt;
&lt;li&gt;monitor GPU usage in real time&lt;/li&gt;
&lt;li&gt;automate diagnostics with a small script&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Different GPU stacks require different tools, but combining a few commands provides &lt;strong&gt;full visibility into your graphics subsystem&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;additional-resources&quot;&gt;Additional Resources&lt;/h2&gt;
&lt;p&gt;btop documentation&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aristocratos/btop&quot;&gt;https://github.com/aristocratos/btop&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;NVIDIA System Management Interface&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.nvidia.com/nvidia-system-management-interface&quot;&gt;https://developer.nvidia.com/nvidia-system-management-interface&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Arch Linux Wiki — NVIDIA&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://wiki.archlinux.org/title/NVIDIA&quot;&gt;https://wiki.archlinux.org/title/NVIDIA&lt;/a&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/12885/how-to-detect-and-monitor-multiple-gpus-on-linux</guid>
<pubDate>Sun, 15 Mar 2026 00:22:28 +0000</pubDate>
</item>
<item>
<title>From 1.4s to 53ms: Optimizing zsh Startup on macOS</title>
<link>https://coderlegion.com/11428/from-1-4s-to-53ms-optimizing-zsh-startup-on-macos</link>
<description>&lt;p&gt;Every time I opened a terminal, I waited. Not long — maybe a second and a half — but long enough to notice. Long enough to be annoying. I finally decided to profile my zsh startup, and what I found took it from &lt;strong&gt;1.4 seconds down to 53 milliseconds&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here's what I learned.&lt;/p&gt;
&lt;h3 id=&quot;profiling-with-zprof&quot;&gt;Profiling with zprof&lt;/h3&gt;
&lt;p&gt;Zsh has a built-in profiler. Add &lt;code&gt;zmodload zsh/zprof&lt;/code&gt; at the top of your &lt;code&gt;.zshrc&lt;/code&gt; and &lt;code&gt;zprof&lt;/code&gt; at the bottom, then open a new shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# top of .zshrc
zmodload zsh/zprof

# ... your config ...

# bottom of .zshrc
zprof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My initial profile told a clear story:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Culprit &lt;/th&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;% of startup&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;NVM (&lt;code&gt;nvm.sh&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;~430ms &lt;/td&gt;&lt;td&gt;31%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Completion subprocesses (kubectl, helm, gh, ...) &lt;/td&gt;&lt;td&gt;~400ms &lt;/td&gt;&lt;td&gt;29%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;compinit&lt;/code&gt; (full rebuild every time) &lt;/td&gt;&lt;td&gt;~240ms &lt;/td&gt;&lt;td&gt;17%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;brew shellenv&lt;/code&gt; &lt;/td&gt;&lt;td&gt;~30ms &lt;/td&gt;&lt;td&gt;2%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;go env GOPATH&lt;/code&gt; &lt;/td&gt;&lt;td&gt;~20ms &lt;/td&gt;&lt;td&gt;1%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Everything else &lt;/td&gt;&lt;td&gt;~280ms &lt;/td&gt;&lt;td&gt;20%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Four of these five are subprocess calls — things like &lt;code&gt;eval &quot;$(brew shellenv)&quot;&lt;/code&gt; or &lt;code&gt;source &amp;lt;(kubectl completion zsh)&lt;/code&gt; that fork a process just to produce some static text. That's the low-hanging fruit.&lt;/p&gt;
&lt;h3 id=&quot;optimization-1-lazy-load-nvm&quot;&gt;Optimization 1: Lazy-load NVM&lt;/h3&gt;
&lt;p&gt;NVM was the single biggest offender. Sourcing &lt;code&gt;nvm.sh&lt;/code&gt; on every shell startup cost ~430ms, and I don't use &lt;code&gt;node&lt;/code&gt; in every terminal session. The fix: wrapper functions that defer loading until you actually call &lt;code&gt;nvm&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;npm&lt;/code&gt;, etc.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;export NVM_DIR=&quot;$HOME/.nvm&quot;
[ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
[ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;export NVM_DIR=&quot;$HOME/.nvm&quot;

_nvm_lazy_load() {
  unfunction nvm node npm npx corepack 2&amp;gt;/dev/null
  [ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
  [ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
}

nvm()      { _nvm_lazy_load; nvm &quot;$@&quot; }
node()     { _nvm_lazy_load; node &quot;$@&quot; }
npm()      { _nvm_lazy_load; npm &quot;$@&quot; }
npx()      { _nvm_lazy_load; npx &quot;$@&quot; }
corepack() { _nvm_lazy_load; corepack &quot;$@&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The wrapper functions replace themselves on first call via &lt;code&gt;unfunction&lt;/code&gt;, then delegate to the real command. Cost at startup: zero. Cost on first &lt;code&gt;node&lt;/code&gt; invocation: ~430ms (once).&lt;/p&gt;
&lt;h3 id=&quot;optimization-2-hardcode-static-values&quot;&gt;Optimization 2: Hardcode static values&lt;/h3&gt;
&lt;p&gt;Several lines in my config were spawning subprocesses to compute values that never change:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Before — subprocess every startup
eval &quot;$(/opt/homebrew/bin/brew shellenv)&quot;
export PATH=&quot;$PATH:$(go env GOPATH)/bin&quot;
. &quot;$HOME/.cargo/env&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These produce the same output every time. Just paste the result directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# After — zero subprocesses
export HOMEBREW_PREFIX=&quot;/opt/homebrew&quot;
export HOMEBREW_CELLAR=&quot;/opt/homebrew/Cellar&quot;
export HOMEBREW_REPOSITORY=&quot;/opt/homebrew&quot;
export PATH=&quot;/opt/homebrew/bin:/opt/homebrew/sbin:$PATH&quot;
[ -z &quot;${MANPATH-}&quot; ] || export MANPATH=&quot;:${MANPATH#:}&quot;
export INFOPATH=&quot;/opt/homebrew/share/info:${INFOPATH:-}&quot;

export GOPATH=&quot;$HOME/go&quot;
export PATH=&quot;$PATH:$GOPATH/bin&quot;

export PATH=&quot;$HOME/.cargo/bin:$PATH&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leave a comment like &lt;code&gt;# regenerate with: brew shellenv&lt;/code&gt; so future-you knows where the values came from.&lt;/p&gt;
&lt;h3 id=&quot;optimization-3-cache-completions-into-fpath&quot;&gt;Optimization 3: Cache completions into fpath&lt;/h3&gt;
&lt;p&gt;This was the big one. My original config eagerly sourced completions from 12 different tools on every shell startup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Before — 12 subprocesses, every startup
command -v kubectl  &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(kubectl completion zsh)
command -v helm     &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(helm completion zsh)
command -v minikube &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(minikube completion zsh)
command -v gh       &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(gh completion -s zsh)
# ... 8 more tools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;source &amp;lt;(tool completion zsh)&lt;/code&gt; forks a subprocess AND evaluates thousands of lines of shell code. Minikube's completion alone is 5,000 lines.&lt;/p&gt;
&lt;p&gt;The fix has two parts:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For completions:&lt;/strong&gt; write them to files in an fpath directory. Compinit loads these lazily — only when you actually press TAB on that command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;ZSH_COMP_CACHE=&quot;$HOME/.zsh-completion-cache&quot;
[[ -d &quot;$ZSH_COMP_CACHE&quot; ]] || mkdir -p &quot;$ZSH_COMP_CACHE&quot;

_cache_fpath() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/_$name&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
}

command -v kubectl &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; _cache_fpath kubectl kubectl completion zsh
command -v helm    &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; _cache_fpath helm    helm completion zsh
# ... etc

fpath=($ZSH_COMP_CACHE $fpath)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For plugins that must run at startup&lt;/strong&gt; (fzf keybindings, direnv hook, oh-my-posh prompt), cache their init output and &lt;code&gt;zcompile&lt;/code&gt; for faster sourcing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;_cache_source() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/$name.zsh&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
    zcompile &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
  source &quot;$cache_file&quot;
}

_cache_source fzf fzf --zsh
_cache_source direnv direnv hook zsh
_cache_source oh-my-posh oh-my-posh init zsh --config ~/.poshthemes/theme.omp.json --print
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both functions use a 24-hour cache expiry via zsh glob qualifiers. Delete &lt;code&gt;~/.zsh-completion-cache&lt;/code&gt; to force a refresh.&lt;/p&gt;
&lt;p&gt;I also cached &lt;code&gt;compinit&lt;/code&gt; itself — a full rebuild only runs once per day, and otherwise &lt;code&gt;compinit -C&lt;/code&gt; skips straight to the dump file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;autoload -Uz compinit
local -a zcompdump_stale=(~/.zcompdump(N.mh+24))
if (( $#zcompdump_stale )); then
  compinit
else
  compinit -C
fi
{ zcompile ~/.zcompdump } &amp;amp;!
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-bug-that-almost-ruined-everything&quot;&gt;The bug that almost ruined everything&lt;/h3&gt;
&lt;p&gt;After implementing all of this, I ran &lt;code&gt;time zsh -i -c exit&lt;/code&gt;. The result: &lt;strong&gt;1.59 seconds&lt;/strong&gt;. &lt;em&gt;Slower&lt;/em&gt; than before.&lt;/p&gt;
&lt;p&gt;I profiled again and saw this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;num  calls                time            self            name
-----------------------------------------------------------------
 1)   15   1180.06  97.34%  1169.02  96.43%  _cache_completion
 2)    1     26.83   2.21%     7.49   0.62%  compinit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The caching function was taking 97% of startup time across 15 calls. The caches existed on disk but were being &lt;strong&gt;regenerated every single time&lt;/strong&gt;. The staleness check was broken.&lt;/p&gt;
&lt;p&gt;I restructured the approach — separating completions (fpath-based, lazy) from plugins (source-based, eager) — and tried again. Same problem: &lt;code&gt;_cache_fpath&lt;/code&gt; at 72%, &lt;code&gt;compinit&lt;/code&gt; doing full rebuilds.&lt;/p&gt;
&lt;p&gt;The bug was in this line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;if [[ ! -f &quot;$cache_file&quot; || -n &quot;$cache_file&quot;(#qN.mh+24) ]]; then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks reasonable. The glob qualifier &lt;code&gt;(#qN.mh+24)&lt;/code&gt; means &quot;match if the file is older than 24 hours, with N (nullglob) to return empty string if no match.&quot; The &lt;code&gt;-n&lt;/code&gt; test checks if the result is non-empty.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The problem: glob qualifiers don't expand inside &lt;code&gt;[[ ]]&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Zsh's &lt;code&gt;[[ ]]&lt;/code&gt; conditional construct does not perform filename generation (globbing). The string &lt;code&gt;&quot;$cache_file&quot;(#qN.mh+24)&lt;/code&gt; is treated as the literal path with &lt;code&gt;(#qN.mh+24)&lt;/code&gt; appended as text. Since that string is always non-empty, the condition is &lt;strong&gt;always true&lt;/strong&gt;. Every cache was being regenerated on every startup. The caching was doing nothing.&lt;/p&gt;
&lt;p&gt;The same bug affected the &lt;code&gt;compinit&lt;/code&gt; staleness check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Also broken — compinit was doing a full rebuild every time
if [[ -n ~/.zcompdump(#qN.mh+24) ]]; then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; expand the glob into an array variable first, then check its length:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;local -a stale=($cache_file(N.mh+24))
if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Regular variable assignments DO perform globbing. The &lt;code&gt;(N.mh+24)&lt;/code&gt; qualifier (no &lt;code&gt;#q&lt;/code&gt; prefix needed outside &lt;code&gt;[[ ]]&lt;/code&gt;) expands the glob, and &lt;code&gt;$#stale&lt;/code&gt; gives us the match count. If the file is older than 24 hours, &lt;code&gt;stale&lt;/code&gt; contains one element; otherwise it's empty.&lt;/p&gt;
&lt;p&gt;This is a subtle footgun. The code looks correct, it doesn't produce errors, and the caches &lt;em&gt;are&lt;/em&gt; created — they're just never &lt;em&gt;reused&lt;/em&gt;. Without profiling, you'd never know.&lt;/p&gt;
&lt;h3 id=&quot;result&quot;&gt;Result&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ time zsh -i -c exit
zsh -i -c exit  0.03s user 0.02s system 93% cpu 0.053 total
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;53 milliseconds.&lt;/strong&gt; A 96% reduction from 1.4 seconds.&lt;/p&gt;
&lt;p&gt;Here's what each optimization contributed:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Optimization &lt;/th&gt;&lt;th&gt;Savings&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Lazy-load NVM &lt;/td&gt;&lt;td&gt;~430ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache completions into fpath (lazy compinit) &lt;/td&gt;&lt;td&gt;~500ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache plugin init scripts + zcompile &lt;/td&gt;&lt;td&gt;~200ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hardcode brew/go/cargo &lt;/td&gt;&lt;td&gt;~50ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;compinit -C (cached dump) &lt;/td&gt;&lt;td&gt;~170ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;~1,350ms&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The first shell open after 24 hours takes a couple of seconds to regenerate caches, but every subsequent shell is instant. You can force a full refresh anytime:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;rm -rf ~/.zsh-completion-cache ~/.zcompdump*
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Profile first.&lt;/strong&gt; &lt;code&gt;zprof&lt;/code&gt; told me exactly where the time was going. Don't guess.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subprocess calls add up.&lt;/strong&gt; Each &lt;code&gt;eval $(...)&lt;/code&gt; or &lt;code&gt;source &amp;lt;(...)&lt;/code&gt; forks a process. Twelve of them cost almost a full second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fpath &amp;gt; source for completions.&lt;/strong&gt; Compinit loads completion functions lazily from fpath. Don't eagerly source thousands of lines you might never use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test your caching actually works.&lt;/strong&gt; A cache that regenerates every time is worse than no cache — it has the overhead of both the generation AND the file I/O.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glob qualifiers don't work inside &lt;code&gt;[[ ]]&lt;/code&gt;.&lt;/strong&gt; This is the kind of bug that looks correct, produces no errors, and silently destroys your performance. Expand globs into variables first.&lt;/li&gt;
&lt;/ol&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11428/from-1-4s-to-53ms-optimizing-zsh-startup-on-macos</guid>
<pubDate>Wed, 11 Feb 2026 13:52:03 +0000</pubDate>
</item>
<item>
<title>From 1.4s to 53ms: Optimizing zsh Startup on macOS</title>
<link>https://coderlegion.com/11431/from-1-4s-to-53ms-optimizing-zsh-startup-on-macos</link>
<description>&lt;p&gt;Every time I opened a terminal, I waited. Not long — maybe a second and a half — but long enough to notice. Long enough to be annoying. I finally decided to profile my zsh startup, and what I found took it from &lt;strong&gt;1.4 seconds down to 53 milliseconds&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here's what I learned.&lt;/p&gt;
&lt;h3 id=&quot;profiling-with-zprof-1&quot;&gt;Profiling with zprof&lt;/h3&gt;
&lt;p&gt;Zsh has a built-in profiler. Add &lt;code&gt;zmodload zsh/zprof&lt;/code&gt; at the top of your &lt;code&gt;.zshrc&lt;/code&gt; and &lt;code&gt;zprof&lt;/code&gt; at the bottom, then open a new shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# top of .zshrc
zmodload zsh/zprof

# ... your config ...

# bottom of .zshrc
zprof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My initial profile told a clear story:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Culprit &lt;/th&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;% of startup&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;NVM (&lt;code&gt;nvm.sh&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;~430ms &lt;/td&gt;&lt;td&gt;31%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Completion subprocesses (kubectl, helm, gh, ...) &lt;/td&gt;&lt;td&gt;~400ms &lt;/td&gt;&lt;td&gt;29%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;compinit&lt;/code&gt; (full rebuild every time) &lt;/td&gt;&lt;td&gt;~240ms &lt;/td&gt;&lt;td&gt;17%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;brew shellenv&lt;/code&gt; &lt;/td&gt;&lt;td&gt;~30ms &lt;/td&gt;&lt;td&gt;2%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;go env GOPATH&lt;/code&gt; &lt;/td&gt;&lt;td&gt;~20ms &lt;/td&gt;&lt;td&gt;1%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Everything else &lt;/td&gt;&lt;td&gt;~280ms &lt;/td&gt;&lt;td&gt;20%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Four of these five are subprocess calls — things like &lt;code&gt;eval &quot;$(brew shellenv)&quot;&lt;/code&gt; or &lt;code&gt;source &amp;lt;(kubectl completion zsh)&lt;/code&gt; that fork a process just to produce some static text. That's the low-hanging fruit.&lt;/p&gt;
&lt;h3 id=&quot;optimization-1-lazy-load-nvm-1&quot;&gt;Optimization 1: Lazy-load NVM&lt;/h3&gt;
&lt;p&gt;NVM was the single biggest offender. Sourcing &lt;code&gt;nvm.sh&lt;/code&gt; on every shell startup cost ~430ms, and I don't use &lt;code&gt;node&lt;/code&gt; in every terminal session. The fix: wrapper functions that defer loading until you actually call &lt;code&gt;nvm&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;npm&lt;/code&gt;, etc.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;export NVM_DIR=&quot;$HOME/.nvm&quot;
[ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
[ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;export NVM_DIR=&quot;$HOME/.nvm&quot;

_nvm_lazy_load() {
  unfunction nvm node npm npx corepack 2&amp;gt;/dev/null
  [ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
  [ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
}

nvm()      { _nvm_lazy_load; nvm &quot;$@&quot; }
node()     { _nvm_lazy_load; node &quot;$@&quot; }
npm()      { _nvm_lazy_load; npm &quot;$@&quot; }
npx()      { _nvm_lazy_load; npx &quot;$@&quot; }
corepack() { _nvm_lazy_load; corepack &quot;$@&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The wrapper functions replace themselves on first call via &lt;code&gt;unfunction&lt;/code&gt;, then delegate to the real command. Cost at startup: zero. Cost on first &lt;code&gt;node&lt;/code&gt; invocation: ~430ms (once).&lt;/p&gt;
&lt;h3 id=&quot;optimization-2-hardcode-static-values-1&quot;&gt;Optimization 2: Hardcode static values&lt;/h3&gt;
&lt;p&gt;Several lines in my config were spawning subprocesses to compute values that never change:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Before — subprocess every startup
eval &quot;$(/opt/homebrew/bin/brew shellenv)&quot;
export PATH=&quot;$PATH:$(go env GOPATH)/bin&quot;
. &quot;$HOME/.cargo/env&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These produce the same output every time. Just paste the result directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# After — zero subprocesses
export HOMEBREW_PREFIX=&quot;/opt/homebrew&quot;
export HOMEBREW_CELLAR=&quot;/opt/homebrew/Cellar&quot;
export HOMEBREW_REPOSITORY=&quot;/opt/homebrew&quot;
export PATH=&quot;/opt/homebrew/bin:/opt/homebrew/sbin:$PATH&quot;
[ -z &quot;${MANPATH-}&quot; ] || export MANPATH=&quot;:${MANPATH#:}&quot;
export INFOPATH=&quot;/opt/homebrew/share/info:${INFOPATH:-}&quot;

export GOPATH=&quot;$HOME/go&quot;
export PATH=&quot;$PATH:$GOPATH/bin&quot;

export PATH=&quot;$HOME/.cargo/bin:$PATH&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leave a comment like &lt;code&gt;# regenerate with: brew shellenv&lt;/code&gt; so future-you knows where the values came from.&lt;/p&gt;
&lt;h3 id=&quot;optimization-3-cache-completions-into-fpath-1&quot;&gt;Optimization 3: Cache completions into fpath&lt;/h3&gt;
&lt;p&gt;This was the big one. My original config eagerly sourced completions from 12 different tools on every shell startup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Before — 12 subprocesses, every startup
command -v kubectl  &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(kubectl completion zsh)
command -v helm     &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(helm completion zsh)
command -v minikube &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(minikube completion zsh)
command -v gh       &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(gh completion -s zsh)
# ... 8 more tools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;source &amp;lt;(tool completion zsh)&lt;/code&gt; forks a subprocess AND evaluates thousands of lines of shell code. Minikube's completion alone is 5,000 lines.&lt;/p&gt;
&lt;p&gt;The fix has two parts:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For completions:&lt;/strong&gt; write them to files in an fpath directory. Compinit loads these lazily — only when you actually press TAB on that command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;ZSH_COMP_CACHE=&quot;$HOME/.zsh-completion-cache&quot;
[[ -d &quot;$ZSH_COMP_CACHE&quot; ]] || mkdir -p &quot;$ZSH_COMP_CACHE&quot;

_cache_fpath() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/_$name&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
}

command -v kubectl &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; _cache_fpath kubectl kubectl completion zsh
command -v helm    &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; _cache_fpath helm    helm completion zsh
# ... etc

fpath=($ZSH_COMP_CACHE $fpath)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For plugins that must run at startup&lt;/strong&gt; (fzf keybindings, direnv hook, oh-my-posh prompt), cache their init output and &lt;code&gt;zcompile&lt;/code&gt; for faster sourcing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;_cache_source() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/$name.zsh&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
    zcompile &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
  source &quot;$cache_file&quot;
}

_cache_source fzf fzf --zsh
_cache_source direnv direnv hook zsh
_cache_source oh-my-posh oh-my-posh init zsh --config ~/.poshthemes/theme.omp.json --print
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both functions use a 24-hour cache expiry via zsh glob qualifiers. Delete &lt;code&gt;~/.zsh-completion-cache&lt;/code&gt; to force a refresh.&lt;/p&gt;
&lt;p&gt;I also cached &lt;code&gt;compinit&lt;/code&gt; itself — a full rebuild only runs once per day, and otherwise &lt;code&gt;compinit -C&lt;/code&gt; skips straight to the dump file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;autoload -Uz compinit
local -a zcompdump_stale=(~/.zcompdump(N.mh+24))
if (( $#zcompdump_stale )); then
  compinit
else
  compinit -C
fi
{ zcompile ~/.zcompdump } &amp;amp;!
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-bug-that-almost-ruined-everything-1&quot;&gt;The bug that almost ruined everything&lt;/h3&gt;
&lt;p&gt;After implementing all of this, I ran &lt;code&gt;time zsh -i -c exit&lt;/code&gt;. The result: &lt;strong&gt;1.59 seconds&lt;/strong&gt;. &lt;em&gt;Slower&lt;/em&gt; than before.&lt;/p&gt;
&lt;p&gt;I profiled again and saw this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;num  calls                time            self            name
-----------------------------------------------------------------
 1)   15   1180.06  97.34%  1169.02  96.43%  _cache_completion
 2)    1     26.83   2.21%     7.49   0.62%  compinit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The caching function was taking 97% of startup time across 15 calls. The caches existed on disk but were being &lt;strong&gt;regenerated every single time&lt;/strong&gt;. The staleness check was broken.&lt;/p&gt;
&lt;p&gt;I restructured the approach — separating completions (fpath-based, lazy) from plugins (source-based, eager) — and tried again. Same problem: &lt;code&gt;_cache_fpath&lt;/code&gt; at 72%, &lt;code&gt;compinit&lt;/code&gt; doing full rebuilds.&lt;/p&gt;
&lt;p&gt;The bug was in this line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;if [[ ! -f &quot;$cache_file&quot; || -n &quot;$cache_file&quot;(#qN.mh+24) ]]; then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks reasonable. The glob qualifier &lt;code&gt;(#qN.mh+24)&lt;/code&gt; means &quot;match if the file is older than 24 hours, with N (nullglob) to return empty string if no match.&quot; The &lt;code&gt;-n&lt;/code&gt; test checks if the result is non-empty.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The problem: glob qualifiers don't expand inside &lt;code&gt;[[ ]]&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Zsh's &lt;code&gt;[[ ]]&lt;/code&gt; conditional construct does not perform filename generation (globbing). The string &lt;code&gt;&quot;$cache_file&quot;(#qN.mh+24)&lt;/code&gt; is treated as the literal path with &lt;code&gt;(#qN.mh+24)&lt;/code&gt; appended as text. Since that string is always non-empty, the condition is &lt;strong&gt;always true&lt;/strong&gt;. Every cache was being regenerated on every startup. The caching was doing nothing.&lt;/p&gt;
&lt;p&gt;The same bug affected the &lt;code&gt;compinit&lt;/code&gt; staleness check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Also broken — compinit was doing a full rebuild every time
if [[ -n ~/.zcompdump(#qN.mh+24) ]]; then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; expand the glob into an array variable first, then check its length:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;local -a stale=($cache_file(N.mh+24))
if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Regular variable assignments DO perform globbing. The &lt;code&gt;(N.mh+24)&lt;/code&gt; qualifier (no &lt;code&gt;#q&lt;/code&gt; prefix needed outside &lt;code&gt;[[ ]]&lt;/code&gt;) expands the glob, and &lt;code&gt;$#stale&lt;/code&gt; gives us the match count. If the file is older than 24 hours, &lt;code&gt;stale&lt;/code&gt; contains one element; otherwise it's empty.&lt;/p&gt;
&lt;p&gt;This is a subtle footgun. The code looks correct, it doesn't produce errors, and the caches &lt;em&gt;are&lt;/em&gt; created — they're just never &lt;em&gt;reused&lt;/em&gt;. Without profiling, you'd never know.&lt;/p&gt;
&lt;h3 id=&quot;result-1&quot;&gt;Result&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ time zsh -i -c exit
zsh -i -c exit  0.03s user 0.02s system 93% cpu 0.053 total
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;53 milliseconds.&lt;/strong&gt; A 96% reduction from 1.4 seconds.&lt;/p&gt;
&lt;p&gt;Here's what each optimization contributed:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Optimization &lt;/th&gt;&lt;th&gt;Savings&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Lazy-load NVM &lt;/td&gt;&lt;td&gt;~430ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache completions into fpath (lazy compinit) &lt;/td&gt;&lt;td&gt;~500ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache plugin init scripts + zcompile &lt;/td&gt;&lt;td&gt;~200ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hardcode brew/go/cargo &lt;/td&gt;&lt;td&gt;~50ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;compinit -C (cached dump) &lt;/td&gt;&lt;td&gt;~170ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;~1,350ms&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The first shell open after 24 hours takes a couple of seconds to regenerate caches, but every subsequent shell is instant. You can force a full refresh anytime:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;rm -rf ~/.zsh-completion-cache ~/.zcompdump*
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;takeaways-1&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Profile first.&lt;/strong&gt; &lt;code&gt;zprof&lt;/code&gt; told me exactly where the time was going. Don't guess.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subprocess calls add up.&lt;/strong&gt; Each &lt;code&gt;eval $(...)&lt;/code&gt; or &lt;code&gt;source &amp;lt;(...)&lt;/code&gt; forks a process. Twelve of them cost almost a full second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fpath &amp;gt; source for completions.&lt;/strong&gt; Compinit loads completion functions lazily from fpath. Don't eagerly source thousands of lines you might never use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test your caching actually works.&lt;/strong&gt; A cache that regenerates every time is worse than no cache — it has the overhead of both the generation AND the file I/O.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glob qualifiers don't work inside &lt;code&gt;[[ ]]&lt;/code&gt;.&lt;/strong&gt; This is the kind of bug that looks correct, produces no errors, and silently destroys your performance. Expand globs into variables first.&lt;/li&gt;
&lt;/ol&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11431/from-1-4s-to-53ms-optimizing-zsh-startup-on-macos</guid>
<pubDate>Wed, 11 Feb 2026 09:35:03 +0000</pubDate>
</item>
<item>
<title>I Replaced nvm, Homebrew Go, and Homebrew Python with a Single Tool</title>
<link>https://coderlegion.com/11429/i-replaced-nvm-homebrew-go-and-homebrew-python-with-a-single-tool</link>
<description>&lt;p&gt;I maintain a symlink-based dotfiles repo for macOS/zsh. Over time, I'd accumulated three different approaches to managing runtime versions: nvm for Node.js, &lt;code&gt;brew install go&lt;/code&gt; for Go, and &lt;code&gt;brew install python@3.13&lt;/code&gt; for Python. Each had its own quirks, its own update ritual, and its own startup cost. &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/&quot;&gt;mise&lt;/a&gt; consolidates all of them into one tool with a single config file.&lt;/p&gt;
&lt;p&gt;This article walks through the actual migration: what changed, what broke, and the non-obvious tricks that made it worth doing.&lt;/p&gt;
&lt;h3 id=&quot;the-starting-point-an-nvm-lazy-load-hack&quot;&gt;The starting point: an nvm lazy-load hack&lt;/h3&gt;
&lt;p&gt;nvm is slow. Loading it adds 300-500ms to every new shell. My workaround was a lazy-load wrapper in &lt;code&gt;zprofile&lt;/code&gt; that deferred the cost until you actually called &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;npm&lt;/code&gt;, or friends:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# NVM (lazy-loaded for fast startup)
export NVM_DIR=&quot;$HOME/.nvm&quot;

_nvm_lazy_load() {
  unfunction nvm node npm npx corepack 2&amp;gt;/dev/null
  [ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
  [ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
}

nvm()      { _nvm_lazy_load; nvm &quot;$@&quot; }
node()     { _nvm_lazy_load; node &quot;$@&quot; }
npm()      { _nvm_lazy_load; npm &quot;$@&quot; }
npx()      { _nvm_lazy_load; npx &quot;$@&quot; }
corepack() { _nvm_lazy_load; corepack &quot;$@&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This worked, but it was 15 lines of shell plumbing to avoid a startup penalty from a tool that only manages Node. Go and Python had no version management at all — I just installed whatever Homebrew gave me.&lt;/p&gt;
&lt;h3 id=&quot;the-mise-config&quot;&gt;The mise config&lt;/h3&gt;
&lt;p&gt;mise replaces all of that with a TOML file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tools]
node = &quot;22&quot;
go = &quot;1.24&quot;
python = &quot;3.13&quot;

[settings]
idiomatic_version_file = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. &lt;code&gt;mise install&lt;/code&gt; downloads the right versions. The &lt;code&gt;idiomatic_version_file&lt;/code&gt; setting means mise reads &lt;code&gt;.nvmrc&lt;/code&gt;, &lt;code&gt;.node-version&lt;/code&gt;, &lt;code&gt;.python-version&lt;/code&gt;, and &lt;code&gt;.go-version&lt;/code&gt; files from existing projects — so teams using nvm or pyenv don't need to change anything.&lt;/p&gt;
&lt;h3 id=&quot;shell-integration-the-dual-activation-trick&quot;&gt;Shell integration: the dual activation trick&lt;/h3&gt;
&lt;p&gt;This is the part that took some thought. mise offers two modes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shims&lt;/strong&gt; (&lt;code&gt;mise activate zsh --shims&lt;/code&gt;): Lightweight stubs in &lt;code&gt;~/.local/share/mise/shims/&lt;/code&gt; that resolve to the right binary. Fast, works everywhere, but they don't auto-switch versions when you &lt;code&gt;cd&lt;/code&gt; into a project with an &lt;code&gt;.nvmrc&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Activate&lt;/strong&gt; (&lt;code&gt;mise activate zsh&lt;/code&gt;): Hooks into your shell's &lt;code&gt;precmd&lt;/code&gt;/&lt;code&gt;chpwd&lt;/code&gt; to dynamically update &lt;code&gt;PATH&lt;/code&gt;. Supports auto-switching, but doesn't run in non-interactive contexts (scripts, IDE terminals running commands, cron jobs).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You need both.&lt;/p&gt;
&lt;p&gt;In &lt;code&gt;zprofile&lt;/code&gt; (runs for all sessions, including non-interactive):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Mise — shims for non-interactive sessions (scripts, IDEs, cron)
eval &quot;$(mise activate zsh --shims)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In &lt;code&gt;plugins.zsh&lt;/code&gt; (sourced only in interactive shells via &lt;code&gt;zshrc&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;_cache_source mise mise activate zsh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The activate call in interactive shells overrides the shims with real &lt;code&gt;PATH&lt;/code&gt; entries, enabling directory-aware version switching. Non-interactive sessions fall back to shims, which resolve to whatever version the config specifies.&lt;/p&gt;
&lt;h3 id=&quot;the-cache-source-zcompile-trick&quot;&gt;The &lt;code&gt;_cache_source&lt;/code&gt; + zcompile trick&lt;/h3&gt;
&lt;p&gt;That &lt;code&gt;_cache_source&lt;/code&gt; call above is doing more than it looks. Running &lt;code&gt;mise activate zsh&lt;/code&gt; generates a shell script on every new shell — and that generation adds 25-300ms depending on the machine. For a shell that opens in 50ms total, that's unacceptable.&lt;/p&gt;
&lt;p&gt;The solution: cache the output and compile it to zsh bytecode.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;_cache_source() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/$name.zsh&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
    zcompile &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
  source &quot;$cache_file&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's what it does:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Runs &lt;code&gt;mise activate zsh&lt;/code&gt; and captures the output to a cache file&lt;/li&gt;
&lt;li&gt;Compiles it to zsh bytecode with &lt;code&gt;zcompile&lt;/code&gt; (creates a &lt;code&gt;.zwc&lt;/code&gt; file)&lt;/li&gt;
&lt;li&gt;Sources the cached file on every subsequent shell open&lt;/li&gt;
&lt;li&gt;Regenerates after 24 hours (the &lt;code&gt;(N.mh+24)&lt;/code&gt; glob qualifier checks mtime)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Why does the compiled version load faster? When you &lt;code&gt;source&lt;/code&gt; a plain &lt;code&gt;.zsh&lt;/code&gt; file, zsh reads the text, tokenizes the shell syntax, and parses it into an internal representation — every single time. &lt;code&gt;zcompile&lt;/code&gt; does that work once and writes the result as a &lt;code&gt;.zwc&lt;/code&gt; file — a pre-parsed &quot;wordcode&quot; format that zsh can memory-map and load directly, skipping the tokenize-and-parse steps entirely. For a large generated script like &lt;code&gt;mise activate&lt;/code&gt; output, this eliminates the most expensive part of &lt;code&gt;source&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The clever bit is that zsh handles &lt;code&gt;.zwc&lt;/code&gt; lookup transparently. When you run &lt;code&gt;source foo.zsh&lt;/code&gt;, zsh first checks for &lt;code&gt;foo.zsh.zwc&lt;/code&gt;. If the &lt;code&gt;.zwc&lt;/code&gt; exists and is newer than the source file, zsh loads the compiled version without being told to. This means &lt;code&gt;_cache_source&lt;/code&gt; doesn't need any special logic to prefer the compiled file — &lt;code&gt;source &quot;$cache_file&quot;&lt;/code&gt; does the right thing on its own.&lt;/p&gt;
&lt;p&gt;And if the &lt;code&gt;.zwc&lt;/code&gt; is stale or missing? Zsh silently falls back to parsing the source file the normal way. Nothing breaks — you just lose the speed benefit until the cache regenerates. A stale &lt;code&gt;.zwc&lt;/code&gt; can never serve incorrect code because zsh won't use it if the source has changed. This makes the whole approach safe to leave running unattended.&lt;/p&gt;
&lt;p&gt;The result: mise activation costs &amp;lt;5ms instead of 25-300ms. This same function works for any &quot;run a command, source the output&quot; pattern — I use it for fzf, direnv, and mise.&lt;/p&gt;
&lt;p&gt;This is probably the most reusable thing in the whole migration. If you maintain a zsh config and source any tool's &lt;code&gt;init&lt;/code&gt; or &lt;code&gt;activate&lt;/code&gt; output, wrap it in something like this.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;For the full story on this caching approach — including a subtle glob qualifier bug that silently breaks it — see &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/martin_oehlert/from-14s-to-53ms-optimizing-zsh-startup-on-macos-5f09&quot;&gt;From 1.4s to 53ms: Optimizing zsh Startup on macOS&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;brew-changes&quot;&gt;Brew changes&lt;/h3&gt;
&lt;p&gt;The cleanup was straightforward:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Remove:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nvm&lt;/code&gt; — replaced by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;node&lt;/code&gt; — installed by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;go&lt;/code&gt; — installed by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python@3.13&lt;/code&gt; — installed by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yarn&lt;/code&gt; — moved to corepack (see below)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Added:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mise&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One gotcha: some Homebrew formulae declare &lt;code&gt;node&lt;/code&gt; as a dependency. In my case, &lt;code&gt;markdownlint-cli2&lt;/code&gt; depends on the Homebrew &lt;code&gt;node&lt;/code&gt; formula. Removing &lt;code&gt;node&lt;/code&gt; from the Brewfile triggers warnings on &lt;code&gt;brew bundle&lt;/code&gt;, but it's cosmetic — mise's shims satisfy the actual runtime need. The formula's dependency declaration is about the build, not about your shell having &lt;code&gt;node&lt;/code&gt; on &lt;code&gt;PATH&lt;/code&gt;. I left it as-is and the warnings are harmless.&lt;/p&gt;
&lt;h3 id=&quot;corepack-for-yarn&quot;&gt;Corepack for yarn&lt;/h3&gt;
&lt;p&gt;With nvm gone, yarn no longer comes from Homebrew either. Node ships with corepack, which manages yarn (and pnpm) natively:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;corepack enable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates symlinks inside the Node install directory. The catch: when mise installs a new Node version, those symlinks live in the old version's directory. You need to re-run &lt;code&gt;corepack enable&lt;/code&gt; after &lt;code&gt;mise install node@&amp;lt;new-version&amp;gt;&lt;/code&gt;. It's a one-liner, but easy to forget when you bump Node versions.&lt;/p&gt;
&lt;h3 id=&quot;the-migration-procedure&quot;&gt;The migration procedure&lt;/h3&gt;
&lt;p&gt;The key insight is that nvm and mise can coexist. This makes the migration safe to do in phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 — Install mise alongside nvm:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;brew install mise&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;mise/config.toml&lt;/code&gt; with your current versions&lt;/li&gt;
&lt;li&gt;Add the dual activation (shims in &lt;code&gt;zprofile&lt;/code&gt;, activate in &lt;code&gt;plugins.zsh&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Open a new shell, verify &lt;code&gt;node --version&lt;/code&gt;, &lt;code&gt;go version&lt;/code&gt;, &lt;code&gt;python3 --version&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 — Verify everything works:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Run your builds, tests, dev servers&lt;/li&gt;
&lt;li&gt;Check IDE integration (VS Code, JetBrains)&lt;/li&gt;
&lt;li&gt;Verify non-interactive contexts: &lt;code&gt;zsh -c 'node --version'&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;corepack enable&lt;/code&gt; and confirm &lt;code&gt;yarn --version&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 3 — Remove nvm:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Delete the lazy-load block from &lt;code&gt;zprofile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Remove &lt;code&gt;nvm&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;go&lt;/code&gt;, &lt;code&gt;python@3.13&lt;/code&gt;, &lt;code&gt;yarn&lt;/code&gt; from &lt;code&gt;Brewfile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;brew bundle cleanup --force&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Commit&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Safety net:&lt;/strong&gt; nvm's data (&lt;code&gt;~/.nvm/&lt;/code&gt;) stays untouched through all of this. If something breaks, you can restore the old &lt;code&gt;zprofile&lt;/code&gt; block and you're back to where you started. The whole thing is one &lt;code&gt;git checkout -- shell/zprofile&lt;/code&gt; away from reverting.&lt;/p&gt;
&lt;h3 id=&quot;-nvmrc-compatibility&quot;&gt;&lt;code&gt;.nvmrc&lt;/code&gt; compatibility&lt;/h3&gt;
&lt;p&gt;If your team uses &lt;code&gt;.nvmrc&lt;/code&gt; files (and most Node teams do), the &lt;code&gt;idiomatic_version_file = true&lt;/code&gt; setting in mise's config handles it. When you &lt;code&gt;cd&lt;/code&gt; into a directory with an &lt;code&gt;.nvmrc&lt;/code&gt;, mise reads it and switches to the specified Node version — same as nvm would.&lt;/p&gt;
&lt;p&gt;This also works for &lt;code&gt;.node-version&lt;/code&gt;, &lt;code&gt;.python-version&lt;/code&gt;, and &lt;code&gt;.go-version&lt;/code&gt;. So mise supports the conventions from nvm, nodenv, pyenv, and goenv without any extra configuration.&lt;/p&gt;
&lt;h3 id=&quot;final-result&quot;&gt;Final result&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;zprofile&lt;/code&gt; went from 30 lines to 18. The nvm lazy-load hack — 15 lines of shell functions — became a single &lt;code&gt;eval&lt;/code&gt; line. Three separate version management approaches (nvm, Homebrew Go, Homebrew Python) became one config file.&lt;/p&gt;
&lt;p&gt;Shell startup stayed at ~0.05s thanks to the caching strategy. The dual activation means shims handle non-interactive contexts while the full activate gives you directory-aware switching in your terminal.&lt;/p&gt;
&lt;p&gt;The commit tells the story:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replace nvm with mise for Node.js, Go, and Python version management

8 files changed, 17 insertions(+), 19 deletions(-)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;More deletions than insertions. That's usually a good sign.&lt;/p&gt;
&lt;h3 id=&quot;what-else-mise-can-do&quot;&gt;What else mise can do&lt;/h3&gt;
&lt;p&gt;This article focused on version management, but mise is a broader dev environment tool. Here's what else it offers once you have it installed.&lt;/p&gt;
&lt;h4 id=&quot;task-runner&quot;&gt;Task runner&lt;/h4&gt;
&lt;p&gt;mise has a &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/tasks/&quot;&gt;built-in task runner&lt;/a&gt; that replaces Makefiles and npm scripts. Tasks defined in &lt;code&gt;mise.toml&lt;/code&gt; (or as standalone scripts in a &lt;code&gt;mise-tasks/&lt;/code&gt; directory) run with the full mise environment — correct tool versions and env vars already set. Dependencies between tasks execute in parallel by default, and &lt;code&gt;mise watch&lt;/code&gt; re-runs tasks on file changes. For projects that already have a &lt;code&gt;mise.toml&lt;/code&gt; for version management, adding tasks means one fewer tool in the chain.&lt;/p&gt;
&lt;h4 id=&quot;environment-variables&quot;&gt;Environment variables&lt;/h4&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/environments/&quot;&gt;&lt;code&gt;[env]&lt;/code&gt; section&lt;/a&gt; in &lt;code&gt;mise.toml&lt;/code&gt; sets project-level environment variables that activate when you &lt;code&gt;cd&lt;/code&gt; into the directory — the same behavior as direnv, without a separate tool. It supports dotenv files, required variables with validation, and redactions for secrets that shouldn't appear in logs. Earlier in this article, &lt;code&gt;_cache_source&lt;/code&gt; caches direnv's activation output alongside mise's — mise's native env management could replace direnv entirely, removing one more tool from the shell startup chain.&lt;/p&gt;
&lt;h4 id=&quot;hooks&quot;&gt;Hooks&lt;/h4&gt;
&lt;p&gt;mise can run &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/hooks.html&quot;&gt;shell commands on directory enter/leave events&lt;/a&gt; and after tool installations. The postinstall hook is particularly relevant to this migration — it could automate the &lt;code&gt;corepack enable&lt;/code&gt; step that's currently manual after Node upgrades.&lt;/p&gt;
&lt;h4 id=&quot;lockfile-for-reproducible-environments&quot;&gt;Lockfile for reproducible environments&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/dev-tools/mise-lock.html&quot;&gt;&lt;code&gt;mise.lock&lt;/code&gt;&lt;/a&gt; pins exact tool versions and checksums per platform, similar to &lt;code&gt;package-lock.json&lt;/code&gt;. For teams sharing a &lt;code&gt;mise.toml&lt;/code&gt;, the lockfile ensures everyone gets the same binary — not just the same version range. Enable it with &lt;code&gt;mise settings lockfile=true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This article covered the version management migration, but mise's scope goes well beyond that — it's closer to a unified dev environment than a version manager.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The full &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MO2k4/config&quot;&gt;dotfiles repo&lt;/a&gt; and migration commit are available on GitHub. The &lt;code&gt;_cache_source&lt;/code&gt; function lives in &lt;code&gt;shell/zshrc.d/completions.zsh&lt;/code&gt; if you want to steal it.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11429/i-replaced-nvm-homebrew-go-and-homebrew-python-with-a-single-tool</guid>
<pubDate>Wed, 11 Feb 2026 08:00:21 +0000</pubDate>
</item>
</channel>
</rss>