<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Enrico Testori</title>
    <description>The latest articles on DEV Community by Enrico Testori (@hypertesto).</description>
    <link>https://dev.to/hypertesto</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4029274%2Fa35cedbe-4c9f-4c2c-a53f-04776bc77d12.png</url>
      <title>DEV Community: Enrico Testori</title>
      <link>https://dev.to/hypertesto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hypertesto"/>
    <language>en</language>
    <item>
      <title>One login to rule them all: centralized auth for internal tools with Caddy</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Mon, 20 Jul 2026 20:55:42 +0000</pubDate>
      <link>https://dev.to/hypertesto/one-login-to-rule-them-all-centralized-auth-for-internal-tools-with-caddy-3fof</link>
      <guid>https://dev.to/hypertesto/one-login-to-rule-them-all-centralized-auth-for-internal-tools-with-caddy-3fof</guid>
      <description>&lt;p&gt;I am lazy. Not in the &lt;em&gt;"I don't want to work"&lt;/em&gt; sense&lt;sup id="fnref1"&gt;1&lt;/sup&gt;, but in the &lt;em&gt;"I refuse to solve the same problem twice"&lt;/em&gt; sense. &lt;br&gt;
So when I found myself looking at a growing collection of internal tools (each one either completely unprotected or with its own half-baked auth bolted on) I decided it was time to stop and fix it properly.&lt;/p&gt;

&lt;p&gt;The dream: one login, backed by the identity provider the company already uses, and every new tool just gets two lines in a config file. No user databases to maintain, no password reset flows to implement, no &lt;em&gt;"hey can you add my colleague to the thing"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In our case the company uses Azure AD, so the plan was to put everything behind Caddy and add OAuth2 authentication against the existing Microsoft tenant. This post is about how that works and the two things that needed some extra attention to get right.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Caddy
&lt;/h2&gt;

&lt;p&gt;I've been through the nginx phase. The Apache phase. The &lt;em&gt;"let me just write a quick script to renew certificates"&lt;/em&gt; phase. &lt;br&gt;
At some point you just want something that works without making you feel like you're filing paperwork.&lt;/p&gt;

&lt;p&gt;Caddy hits a sweet spot I haven't found elsewhere: it's simple enough that you can read the whole config and understand it in one sitting, &lt;br&gt;
but complete enough that you rarely need to reach for anything else. The Caddyfile syntax is human-readable by design, and the defaults are sane, &lt;br&gt;
which sounds like a low bar until you've spent an afternoon debugging a cipher suite.&lt;/p&gt;

&lt;p&gt;The thing that genuinely removes the most friction though is automatic HTTPS. Point a domain at your server, add it to the Caddyfile, &lt;br&gt;
and Caddy handles the Let's Encrypt certificate negotiation, renewal, and everything in between. No certbot cron jobs, no &lt;code&gt;--nginx&lt;/code&gt; flags, &lt;br&gt;
no "oh the cert expired over the weekend" incidents. It just works, and it keeps working.&lt;sup id="fnref2"&gt;2&lt;/sup&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The pieces
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://caddyserver.com/" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; as reverse proxy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/greenpau/caddy-security" rel="noopener noreferrer"&gt;Caddy-security&lt;/a&gt; (also known as AuthCrunch) for authentication and authorization. It plugs directly into Caddy and adds an authentication portal with OAuth2/OIDC support.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Caddy supports plugins that get compiled directly into the binary. The canonical way to do this is &lt;code&gt;xcaddy&lt;/code&gt;, a build tool that fetches &lt;br&gt;
the plugins you want and compiles everything from source. It works, but it requires a Go toolchain on the server, the build takes a &lt;br&gt;
few minutes, and, going back to the laziness point, it's one more thing to maintain.&lt;/p&gt;

&lt;p&gt;The alternative is the official download page on the Caddy website. Select your platform, tick the plugins you want, and it hands you a &lt;br&gt;
pre-built binary. That's it. No Go installation, no waiting, no fuss.&lt;/p&gt;

&lt;p&gt;Head to the download page, select Linux amd64 (or your platform), add github.com/greenpau/caddy-security, and download. Then install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; /path/to/download /usr/bin/caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On SELinux-enabled systems (Fedora, RHEL and friends) you also need to label the binary correctly, otherwise systemd will refuse to run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;setcap &lt;span class="s1"&gt;'cap_net_bind_service=+ep'&lt;/span&gt; /usr/bin/caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setting up the service
&lt;/h2&gt;

&lt;p&gt;Caddy doesn't run as root. The recommended approach is a dedicated system user with no login shell and a home directory where Caddy &lt;br&gt;
can store certificates and other state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd &lt;span class="nt"&gt;--system&lt;/span&gt; caddy

&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gid&lt;/span&gt; caddy &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--home-dir&lt;/span&gt; /var/lib/caddy &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"Caddy web server"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the config directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/caddy
&lt;span class="nb"&gt;sudo touch&lt;/span&gt; /etc/caddy/Caddyfile
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; caddy:caddy /etc/caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official Caddy repository ships a ready-made systemd unit file, so grab that rather than writing one from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/systemd/system/caddy.service &lt;span class="se"&gt;\&lt;/span&gt;
    https://raw.githubusercontent.com/caddyserver/dist/master/init/caddy.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant bits of the unit file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;Service]
&lt;span class="nv"&gt;User&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;caddy
&lt;span class="nv"&gt;Group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;caddy
&lt;span class="nv"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/caddy run &lt;span class="nt"&gt;--environ&lt;/span&gt; &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/caddy/Caddyfile
&lt;span class="nv"&gt;ExecReload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/caddy reload &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/caddy/Caddyfile &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ExecReload&lt;/code&gt; is worth noting: &lt;code&gt;systemctl reload caddy&lt;/code&gt; does a graceful config reload without dropping connections.&lt;br&gt;
You'll use this constantly while tweaking the Caddyfile.&lt;/p&gt;

&lt;p&gt;Enable and start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; caddy
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logs go through journald:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; caddy &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How caddy-security works
&lt;/h2&gt;

&lt;p&gt;The plugin introduces three concepts that work together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity provider&lt;/strong&gt;: where users actually authenticate. In our case Azure AD via OAuth2, but it works with any OIDC-compliant 
provider or even a local user database if you need a fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication portal&lt;/strong&gt;: the login page. It ties together one or more identity providers, manages session cookies, and handles 
the OAuth2 callback. It lives on a dedicated subdomain (e.g. &lt;code&gt;auth.yourdomain.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization policy&lt;/strong&gt;: the bouncer. Applied per-service, it checks whether the current user has the required roles and redirects to the portal if not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Azure AD setup
&lt;/h2&gt;

&lt;p&gt;Before touching the Caddyfile, register an application in the Azure Portal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Azure Active Directory → App registrations → New registration&lt;/li&gt;
&lt;li&gt;Name: anything sensible&lt;/li&gt;
&lt;li&gt;Supported account types: Accounts in this organizational directory only &lt;/li&gt;
&lt;li&gt;Redirect URI (Web): &lt;code&gt;https://auth.yourdomain.com/oauth2/azure/authorization-code-callback&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once created, note down the Application (client) ID and Directory (tenant) ID from the Overview page. Then go to Certificates &amp;amp; secrets → New client secret &lt;br&gt;
and copy the Value immediately; &lt;strong&gt;it's only shown once&lt;/strong&gt;.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Under API permissions add the Microsoft Graph delegated permissions: &lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;profile&lt;/code&gt;, &lt;code&gt;User.Read&lt;/code&gt;, and grant admin consent.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Caddyfile
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;{&lt;/span&gt;
    email your@email.com
    order authenticate before respond
    order authorize before reverse_proxy

    security &lt;span class="o"&gt;{&lt;/span&gt;
        oauth identity provider azure &lt;span class="o"&gt;{&lt;/span&gt;
            realm azure
            driver azure
            client_id YOUR_CLIENT_ID
            client_secret YOUR_CLIENT_SECRET
            tenant_id YOUR_TENANT_ID
            scopes openid email profile
            &lt;span class="nb"&gt;enable logout&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        authentication portal myportal &lt;span class="o"&gt;{&lt;/span&gt;
            crypto default token lifetime 28800
            crypto key sign-verify YOUR_JWT_SECRET
            &lt;span class="nb"&gt;enable &lt;/span&gt;identity provider azure
            cookie domain yourdomain.com
            cookie path /
            cookie lifetime 28800
            cookie samesite lax
            cookie guess domain
            trust login redirect uri domain suffix yourdomain.com path prefix /

            ui &lt;span class="o"&gt;{&lt;/span&gt;
                links &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="s2"&gt;"My App"&lt;/span&gt; https://myapp.yourdomain.com
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        authorization policy protected_services &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;set &lt;/span&gt;auth url https://auth.yourdomain.com/login
            &lt;span class="nb"&gt;enable &lt;/span&gt;login hint
            allow roles authp/guest user admin
            crypto key verify YOUR_JWT_SECRET
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

auth.yourdomain.com &lt;span class="o"&gt;{&lt;/span&gt;
    authenticate with myportal
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Protected&lt;/span&gt;
myapp.yourdomain.com &lt;span class="o"&gt;{&lt;/span&gt;
    authorize with protected_services
    reverse_proxy 10.0.0.2:8080
&lt;span class="o"&gt;}&lt;/span&gt;

anothertool.yourdomain.com &lt;span class="o"&gt;{&lt;/span&gt;
    authorize with protected_services
    reverse_proxy 10.0.0.3:3000
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Generate the JWT secret with &lt;code&gt;openssl rand -base64 32&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two things that needed extra attention
&lt;/h3&gt;
&lt;h4&gt;
  
  
  Role remapping is broken in recent builds
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;caddy-security&lt;/code&gt; has a transform user directive that lets you promote authenticated users to named roles based on their email domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;transform user &lt;span class="o"&gt;{&lt;/span&gt;
    suffix match email @yourdomain.com
    action add role user
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In theory this is the right tool: map everyone from your organization to the user role, restrict the authorization policy to allow roles &lt;br&gt;
user admin, and anyone outside your tenant gets nothing even if they somehow authenticated. Clean and explicit.&lt;/p&gt;

&lt;p&gt;In practice, as of the current build, the transform doesn't reliably fire after an OAuth2 login. Users end up with only the default &lt;br&gt;
authp/guest role regardless of what the transform says, which means a policy that only allows user will lock everyone out; including &lt;br&gt;
the people you just spent an afternoon trying to let in.&lt;/p&gt;

&lt;p&gt;The workaround is to allow authp/guest in the authorization policy. It's not as granular as role mapping, but given that the OAuth2 &lt;br&gt;
provider is already scoped to a single tenant, any user who successfully authenticates is implicitly trusted, the gatekeeping is happening &lt;br&gt;
at the Azure level anyway.&lt;/p&gt;

&lt;p&gt;This does mean that in practice everyone in the same Azure AD tenant gets access to all protected services. For us that's fine: we're a small &lt;br&gt;
team and internal tools are, well, internal. But the building blocks for something more structured are already there: once transform user is &lt;br&gt;
reliable again, you can map roles per email domain, per specific user, or combine multiple policies to restrict individual services to specific &lt;br&gt;
groups. The concept scales, even if the current implementation needs a nudge.&lt;/p&gt;

&lt;p&gt;Keep an eye on the &lt;a href="https://github.com/greenpau/caddy-security/issues" rel="noopener noreferrer"&gt;issue tracker&lt;/a&gt; if fine-grained role mapping matters for your use case.&lt;/p&gt;
&lt;h4&gt;
  
  
  Cookies and post-login redirects
&lt;/h4&gt;

&lt;p&gt;Two things need to be correct for the flow to work end to end.&lt;/p&gt;

&lt;p&gt;The first is cookie scoping. After login, the browser needs to send the access token cookie to all protected subdomains, not just &lt;code&gt;auth.yourdomain.com&lt;/code&gt;. &lt;br&gt;
&lt;code&gt;cookie guess domain&lt;/code&gt; handles this automatically. Without it, every request to a protected service finds no token and loops back to the login page indefinitely &lt;br&gt;
(which is as fun as it sounds).&lt;/p&gt;

&lt;p&gt;A counterintuitive note from the official docs: don't use a leading dot (&lt;code&gt;.yourdomain.com&lt;/code&gt;) for cookie domain. Despite being standard practice in most contexts, &lt;br&gt;
the plugin doesn't support it and things break silently.&lt;/p&gt;

&lt;p&gt;The second is the post-login redirect. After a successful login you'd expect to land on the page you were originally trying to reach. Without the right &lt;br&gt;
configuration you land on the portal homepage instead, which is &lt;em&gt;anti-climactic&lt;/em&gt;. Two things are needed in combination: &lt;code&gt;set auth url&lt;/code&gt; must point to &lt;code&gt;/login&lt;/code&gt; &lt;br&gt;
explicitly (not just the root domain), and the portal needs to know which redirect destinations are trusted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trust login redirect uri domain suffix yourdomain.com path prefix /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this the portal ignores the &lt;code&gt;redirect_url&lt;/code&gt; query parameter entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final result
&lt;/h2&gt;

&lt;p&gt;The next time someone asks for a new internal tool, the answer is: spin it up, add two lines to the Caddyfile, reload. &lt;br&gt;
No user database, no password reset flow; &lt;em&gt;nothing&lt;/em&gt;.&lt;br&gt;&lt;br&gt;
Everyone with a company account can log in, everyone else can't.  &lt;/p&gt;

&lt;p&gt;I also added &lt;code&gt;enable login hint&lt;/code&gt; so in case anyone needs to login after the previous session expired, it will be presented with the Azure AD login page already filled&lt;br&gt;
with the account of the expired session: easy and effecient.&lt;/p&gt;

&lt;p&gt;My laziness form is now complete. Larry Wall would be proud.&lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Well, not &lt;em&gt;only&lt;/em&gt; in that sense.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Caddy also gets an A+ on SSL Labs out of the box, in case you needed another reason.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;There are two columns: Secret ID (a GUID) and Value (a long random string). You want the Value. Using the ID will give you a cryptic &lt;code&gt;AADSTS7000215&lt;/code&gt; error and a solid fifteen minutes of confusion.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;Larry Wall, creator of Perl, listed the three virtues of a programmer as: Laziness, Impatience, and Hubris. Laziness is first. We are vindicated&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>authentication</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Adding a custom DeepSeek model in Junie CLI (No YAML demons required)</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:11:48 +0000</pubDate>
      <link>https://dev.to/hypertesto/adding-a-custom-deepseek-model-in-junie-cli-no-yaml-demons-required-3em2</link>
      <guid>https://dev.to/hypertesto/adding-a-custom-deepseek-model-in-junie-cli-no-yaml-demons-required-3em2</guid>
      <description>&lt;p&gt;I switched my Junie CLI default model to DeepSeek last month. The reason wasn't philosophical: Claude and GPT are great, but they cost real money when you're hammering a CLI for hours. DeepSeek is cheaper. Sometimes the best technical decision is the one your wallet makes for you.&lt;/p&gt;

&lt;p&gt;The switch itself was anticlimactic. There's no &lt;code&gt;junie model add&lt;/code&gt; wizard, no guided setup, and the &lt;code&gt;--help&lt;/code&gt; output mentions custom models only in passing with a &lt;code&gt;--model&lt;/code&gt; flag that reads like an afterthought. What you actually do is drop a JSON file into &lt;code&gt;~/.junie/models/&lt;/code&gt; and… that's it. Junie picks it up.&lt;/p&gt;

&lt;p&gt;This post documents that process: adding &lt;code&gt;deepseek-v4-pro&lt;/code&gt; to Junie CLI, using the actual config format running on my machine right now.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother?
&lt;/h2&gt;

&lt;p&gt;Before we touch any config, let's answer the obvious: &lt;em&gt;why not just use the built-in providers?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Because in real life we optimize for different things at different times:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want…&lt;/th&gt;
&lt;th&gt;So you reach for…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Latency minimization&lt;/td&gt;
&lt;td&gt;a flash/fast variant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep refactors with context&lt;/td&gt;
&lt;td&gt;a large-context model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost control on long sessions&lt;/td&gt;
&lt;td&gt;a cheaper provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider redundancy&lt;/td&gt;
&lt;td&gt;a backup endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Experimentation&lt;/td&gt;
&lt;td&gt;whatever just dropped on HuggingFace&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your tooling lets you swap models cleanly, you gain &lt;strong&gt;operational sanity&lt;/strong&gt;. And operational sanity is underrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Junie CLI installed.&lt;/strong&gt; You should already have &lt;code&gt;junie&lt;/code&gt; on your &lt;code&gt;PATH&lt;/code&gt;. Verify:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   junie &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A DeepSeek API key.&lt;/strong&gt; Get one from &lt;a href="https://platform.deepseek.com/" rel="noopener noreferrer"&gt;platform.deepseek.com&lt;/a&gt;. You'll need to top up credits.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Basic JSON literacy.&lt;/strong&gt; No YAML, no TOML, no arcane DSL. Just one JSON file. You've got this.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How Junie CLI discovers custom models
&lt;/h2&gt;

&lt;p&gt;Junie looks for model definitions in &lt;strong&gt;two default locations&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.junie/models/&lt;/code&gt;: global, available to all projects&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;project&amp;gt;/.junie/models/&lt;/code&gt;: per-project overrides&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each model is a single &lt;code&gt;.json&lt;/code&gt; file. The filename becomes the model's identifier within Junie. So &lt;code&gt;~/.junie/models/deepseek.json&lt;/code&gt; registers a model called… &lt;code&gt;deepseek&lt;/code&gt;. Creative, I know.&lt;/p&gt;

&lt;p&gt;You can also point Junie at additional directories with &lt;code&gt;--model-location &amp;lt;path&amp;gt;&lt;/code&gt; if you prefer to keep your model configs in a dotfiles repo or shared volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Create the model JSON
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;~/.junie/models/deepseek.json&lt;/code&gt; (or &lt;code&gt;&amp;lt;project&amp;gt;/.junie/models/deepseek.json&lt;/code&gt; if you want it scoped to a single codebase):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek-v4-pro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.deepseek.com/chat/completions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apiType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OpenAICompletion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-your-deepseek-api-key-here"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"primaryModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek-v4-pro"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fasterModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek-v4-flash"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's unpack what each field does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The primary model identifier. Junie displays this in the model picker and passes it in API requests.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The endpoint Junie POSTs to. DeepSeek's API is OpenAI-compatible, so we point it at their chat/completions endpoint.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apiType&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tells Junie which request/response format to use. &lt;code&gt;"OpenAICompletion"&lt;/code&gt; covers OpenAI, DeepSeek, and most compatible providers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apiKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your API key. &lt;strong&gt;Yes, it's plaintext in a JSON file.&lt;/strong&gt; We'll talk about this.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;temperature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls response randomness. &lt;code&gt;0&lt;/code&gt; means deterministic; bump it to &lt;code&gt;0.3&lt;/code&gt;–&lt;code&gt;0.7&lt;/code&gt; for more creative output.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;primaryModel.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The model ID sent in the API request body. This must match the DeepSeek model name exactly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fasterModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;(Optional)&lt;/em&gt; A lighter/faster model for quick tasks like inline autocomplete or summarization.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A stern word about &lt;code&gt;apiKey&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Yeah, it's sitting there in plaintext. I know. No, Junie doesn't currently read it from an environment variable for custom models&lt;sup id="fnref2"&gt;2&lt;/sup&gt;. Your options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set restrictive file permissions:&lt;/strong&gt; &lt;code&gt;chmod 600 ~/.junie/models/deepseek.json&lt;/code&gt;. At least other users on the system can't read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a LiteLLM proxy&lt;/strong&gt; as middleware (see the LiteLLM section below): this keeps the key out of Junie's config entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept it and don't commit the file&lt;/strong&gt; to a public dotfiles repo. You're an adult.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For what it's worth, Junie stores the key in your home directory, which on a single-user development machine is about as secure as any other dotfile with secrets in it. Not ideal, but not the end of the world either&lt;sup id="fnref3"&gt;3&lt;/sup&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Use the model
&lt;/h2&gt;

&lt;p&gt;Once the JSON file is in place, you have three ways to invoke it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CLI flag (one-off)&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  junie &lt;span class="nt"&gt;--model&lt;/span&gt; custom:deepseek &lt;span class="s2"&gt;"Explain this function."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will start junie in interactive mode using the custom model defined previously in json configuration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Interactive mode&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Launch Junie interactively, then pick the model from the selector (usually &lt;code&gt;/model&lt;/code&gt; or the equivalent model-switching shortcut):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  junie
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your custom &lt;code&gt;deepseek&lt;/code&gt; model should appear in the list alongside the built-in providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Settings override (persistent default)
&lt;/h3&gt;

&lt;p&gt;Edit &lt;code&gt;~/.junie/settings.json&lt;/code&gt; and add or edit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modelForLaunch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"custom:deepseek"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every session starts with DeepSeek unless you override with &lt;code&gt;--model&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Verify it works
&lt;/h2&gt;

&lt;p&gt;Before trusting it with a 400-line refactor, run a quick sanity check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;junie &lt;span class="nt"&gt;--model&lt;/span&gt; custom:deepseek &lt;span class="s2"&gt;"Say hello and confirm which model you're running on."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the response comes back coherent and the logs show &lt;code&gt;deepseek-v4-pro&lt;/code&gt;, you're in business. If you get an auth error, double-check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API key is correct and has credits&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;baseUrl&lt;/code&gt; ends with &lt;code&gt;/chat/completions&lt;/code&gt; (no trailing slash weirdness)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;primaryModel.id&lt;/code&gt; matches exactly what DeepSeek expects&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Junie CLI's custom model system is refreshingly straightforward: drop a JSON file in a directory, and you're done. No YAML anchors, no multi-file inheritance chains, no environment-variable indirection puzzles. Just a flat config that tells the CLI where to send requests and which model ID to use.&lt;/p&gt;

&lt;p&gt;Is the plaintext API key ideal? No. But with file permissions and/or a LiteLLM proxy, you can mitigate the concern without adding much friction. For single user dev-machines it's a worth tradeoff.&lt;/p&gt;

&lt;p&gt;Now go wire up your DeepSeek model and tell it to write you some code. Or a haiku about YAML. Your call.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;If you're looking for the accompanying Italian version of this post… there isn't one. Some topics just work better in English, and CLI config files are &lt;em&gt;definitely&lt;/em&gt; in that camp. &lt;em&gt;"Drop un JSON nella cartella models"&lt;/em&gt; doesn't quite hit the same way.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;If you're reading this from the future and Junie &lt;em&gt;does&lt;/em&gt; support &lt;code&gt;apiKeyEnv&lt;/code&gt; or similar: congratulations, the timeline is better than mine. Please update this post.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;&lt;em&gt;"Not the end of the world"&lt;/em&gt; is the security posture equivalent of &lt;em&gt;"it compiles, ship it."&lt;/em&gt; Take appropriate precautions.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>llm</category>
      <category>cli</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Balancing flexibility and strictness with lenient Duration deserialization in Kotlin</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Thu, 16 Jul 2026 10:41:53 +0000</pubDate>
      <link>https://dev.to/hypertesto/balancing-flexibility-and-strictness-with-lenient-duration-deserialization-in-kotlin-385p</link>
      <guid>https://dev.to/hypertesto/balancing-flexibility-and-strictness-with-lenient-duration-deserialization-in-kotlin-385p</guid>
      <description>&lt;p&gt;I have written before about Kotlin's &lt;code&gt;Duration&lt;/code&gt; class, and its blend of technical sophistication and developer friendliness continues to impress. Recently, a unit test highlighted an important distinction in how Kotlin handles duration parsing. The duration string &lt;code&gt;"1s"&lt;/code&gt; works perfectly with &lt;code&gt;Duration.parse()&lt;/code&gt;, but fails during JSON deserialization when dealing with real-world data. &lt;/p&gt;

&lt;p&gt;This apparent inconsistency is actually a deliberate design choice. The &lt;code&gt;Duration.parse()&lt;/code&gt; method is flexible by design, while &lt;code&gt;kotlinx.serialization&lt;/code&gt; is intentionally strict to enforce standardization.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens under the hood?
&lt;/h2&gt;

&lt;p&gt;Examining Kotlin's source code clarifies this behavior. First, let's look at the &lt;code&gt;Duration&lt;/code&gt; class itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In Duration.kt&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;inline&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Comparable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;parseDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strictIso&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"Invalid duration string format: '$value'."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few key points stand out in this implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Duration&lt;/code&gt; is an inline class with an internal constructor.&lt;/li&gt;
&lt;li&gt;This constructor cannot be used for deserialization due to its internal visibility.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;parse()&lt;/code&gt; method intentionally accepts non-ISO formats by explicitly setting &lt;code&gt;strictIso = false&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, let's look at how &lt;code&gt;kotlinx.serialization&lt;/code&gt; handles the &lt;code&gt;Duration&lt;/code&gt; class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;DurationSerializer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KSerializer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SerialDescriptor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;PrimitiveSerialDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kotlin.time.Duration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PrimitiveKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toIsoString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;deserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseIsoString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decodeString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The serializer makes a deliberate choice to use &lt;code&gt;parseIsoString()&lt;/code&gt; for deserialization. Instead of relying on the more permissive &lt;code&gt;parse()&lt;/code&gt; method, it enforces the ISO-8601 standard for data integrity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing a lenient custom serializer
&lt;/h2&gt;

&lt;p&gt;Since we cannot change the built-in behavior of &lt;code&gt;kotlinx.serialization&lt;/code&gt;—and generally shouldn't, given the importance of enforcing standards in data exchange—we can create our own custom lenient serializer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;LenientDurationSerializer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KSerializer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SerialDescriptor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;PrimitiveSerialDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kotlin.time.Duration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PrimitiveKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;deserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decodeString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;// Using the lenient parser&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toIsoString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;// Still formal in output using ISO&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This serializer accepts casual input formats by using &lt;code&gt;parse()&lt;/code&gt;, but ensures formal ISO-8601 formatting on output by using &lt;code&gt;toIsoString()&lt;/code&gt;. You can apply it to your data classes using the &lt;code&gt;@Serializable&lt;/code&gt; annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;ProcessDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@Serializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LenientDurationSerializer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Now both these work:&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;formal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""{"duration": "PT1S"}"""&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;casual&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""{"duration": "1s"}"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach adheres to a fundamental principle of data exchange: be flexible in what you accept, but strict in what you produce. Watch how our duration gets standardized during serialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;What&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;goes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1s"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;What&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;comes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;out&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT1S"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both strings represent the exact same duration, but the output is standardized. When writing data that might be consumed by other systems, enforcing an ISO format is a necessary practice for maintaining a reliable ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standards vs. reality
&lt;/h2&gt;

&lt;p&gt;While enforcing ISO-8601 in serialization is technically correct, real-world data is often much less organized. Anyone who has dealt with dates, times, and durations in production environments knows the variety of formats that can appear. &lt;/p&gt;

&lt;p&gt;In a typical production environment, you might encounter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Natural language timestamps&lt;/strong&gt; generated by NLP parsing services (e.g., "Last Monday").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent timezones&lt;/strong&gt; represented in various ways such as "GMT+1", "UTC+1", "+0100", or "Europe/Paris".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Varying date formats&lt;/strong&gt; originating from legacy systems (e.g., "23-11-2023", "11-23-2023", or "2023/11/23").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw millisecond values&lt;/strong&gt; typically found in Java integrations ("5400000ms").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excessively verbose XML-style durations&lt;/strong&gt; from older architectures ("P0Y0M0DT1H30M0S").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The variety of time formats is a persistent challenge in software development. While standards like ISO-8601 are crucial for reliable data exchange, being overly rigid can cause integrations to fail. A pragmatic approach requires us to handle the data as it arrives, gracefully converting it into a standardized format for the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Dealing with rigid data formats can sometimes feel like &lt;em&gt;P100Y of ISO-litude&lt;/em&gt;&lt;sup id="fnref1"&gt;1&lt;/sup&gt;: an endless century of strict parsing errors and isolated systems. However, examining Kotlin's source code reveals the thoughtful design behind its time packages. &lt;/p&gt;

&lt;p&gt;By allowing &lt;code&gt;Duration.parse()&lt;/code&gt; to welcome various formats for developer convenience, while keeping &lt;code&gt;kotlinx.serialization&lt;/code&gt; anchored to ISO standards, the ecosystem strikes a perfect balance. You do not have to be isolated by strict formats; you simply need a custom serializer to bridge the gap between human-friendly inputs and machine-standardized outputs.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;For the uninitiated: P100Y is the valid ISO-8601 duration string for exactly 100 years, with apologies to Gabriel García Márquez ;-)&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>programming</category>
      <category>kotlin</category>
      <category>serialization</category>
    </item>
    <item>
      <title>Fish, Foot and beyond: a terminal setup that finally smells right 🐟👣</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:59:22 +0000</pubDate>
      <link>https://dev.to/hypertesto/fish-foot-and-beyond-a-terminal-setup-that-finally-smells-right-48k3</link>
      <guid>https://dev.to/hypertesto/fish-foot-and-beyond-a-terminal-setup-that-finally-smells-right-48k3</guid>
      <description>&lt;p&gt;Picture this: there I was, happily using Tilix for my terminal needs on Fedora.&lt;br&gt;
Split panes, dropdown terminal, the whole shebang. Life was good... until it wasn't.&lt;br&gt;
Recent Fedora updates started making Tilix throw tantrums, and suddenly my trusty terminal setup felt like that friend who keeps flaking on plans.&lt;/p&gt;

&lt;p&gt;After some soul-searching (and a lot of Reddit threads), I realized I needed to break down my terminal needs: a rock-solid Wayland-native terminal emulator, a shell that wouldn't make me hate scripting, and a multiplexer that could keep up with modern workflows.&lt;/p&gt;

&lt;p&gt;Enter the new trio: &lt;a href="https://codeberg.org/dnkl/foot" rel="noopener noreferrer"&gt;Foot&lt;/a&gt;, &lt;a href="https://fishshell.com/" rel="noopener noreferrer"&gt;Fish&lt;/a&gt;, and &lt;a href="https://zellij.dev/" rel="noopener noreferrer"&gt;Zellij&lt;/a&gt;. Yes, I know - between Fish and Foot, this setup sounds like it came from a very weird pet shop. But stick with me here!&lt;/p&gt;
&lt;h2&gt;
  
  
  Sometimes less is more
&lt;/h2&gt;

&lt;p&gt;For terminal emulation I wanted something simple, stable, and Wayland-native. Foot checked all these boxes and then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;is blazing fast thanks to being written in C&lt;/li&gt;
&lt;li&gt;is hardware accelerated rendering (hello, smooth scrolling!)&lt;/li&gt;
&lt;li&gt;has minimal dependencies (goodbye, random breakages!)&lt;/li&gt;
&lt;li&gt;has simple configuration in a single file&lt;/li&gt;
&lt;li&gt;has &lt;a href="https://en.wikipedia.org/wiki/Sixel" rel="noopener noreferrer"&gt;Sixel&lt;/a&gt; support for those fancy terminal graphics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, I'll be honest - when people talked about "fast terminals," I used to roll my eyes. How fast does a terminal need to be to show some text? Well, turns out I was wonderfully wrong - you don't realize how much terminal lag affects your workflow until you try a faster one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Life's too short for boring shells!
&lt;/h2&gt;

&lt;p&gt;Remember the first time you saw someone's terminal autocomplete something magical? That's Fish, all day, every day; no hacky setups required.&lt;br&gt;
Here's what made me fall hook, line, and sinker for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fish remembers your commands and suggests them as you type. It's like having a very eager assistant who actually knows what they're doing. Autosuggestions feel like mind-reading!&lt;/li&gt;
&lt;li&gt;Colors! So many colors! And they actually mean something (unlike my old prompt that I made pretty but couldn't remember what anything meant)&lt;/li&gt;
&lt;li&gt;No more &lt;code&gt;if [ -f something ]&lt;/code&gt; syntax that looks like it came from the 80s. Fish makes scripting feel like actual programming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sure, there's the occasional &lt;em&gt;"this shell script isn't compatible with Fish"&lt;/em&gt; moment, but let's be real - when was the last time you looked at a bash script and thought "wow, this is so intuitive"? Fish makes the trade-off simple: give up some backwards compatibility, get a shell that feels like it was actually designed in this century. And for those rare occasions when you really need to run a bash just prefix it with &lt;code&gt;bash&lt;/code&gt; and move on with your life.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting on the right Foot
&lt;/h2&gt;

&lt;p&gt;First things first - let's get our Foot in the door (sorry, not sorry).&lt;br&gt;
On Fedora, it's as simple as this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;foot fish zellij
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Easy, isn't it?&lt;/p&gt;

&lt;p&gt;And of course, &lt;code&gt;CTRL-ALT-T&lt;/code&gt; still spawns my terminal - some habits are worth keeping.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkocu5oohh8b99bytuhx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkocu5oohh8b99bytuhx.png" title="Shortcut configured on Gnome" alt="Gnome Shortcut" width="722" height="662"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Foot configuration
&lt;/h3&gt;

&lt;p&gt;My Foot configuration is straightforward but purposeful. Let's break it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[main]&lt;/span&gt;
&lt;span class="py"&gt;font&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;JetBrains Mono:size=16&lt;/span&gt;
&lt;span class="py"&gt;initial-window-mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;fullscreen&lt;/span&gt;
&lt;span class="py"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/fish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main section is minimal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JetBrains Mono because it's a fantastic monospace font with great readability&lt;/li&gt;
&lt;li&gt;terminals starts fullscreen because that's what I ended up doing as first action every time I opened a Tilix in my old setup&lt;/li&gt;
&lt;li&gt;last but not least, of course, Fish is set as the default shell because that's the whole point of this setup.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[colors]&lt;/span&gt;
&lt;span class="c"&gt;# A wild variety of Catppuccin Mocha colors...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While I'm unsure about the theme interaction with zellij, it's working fine, and so I'll briefly give an overview of it. I'm using Catppuccin Mocha - a soothing dark theme that's easy on the eyes. It provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dark background (&lt;code&gt;1e1e2e&lt;/code&gt;) with light text (&lt;code&gt;cdd6f4&lt;/code&gt;) for good contrast&lt;/li&gt;
&lt;li&gt;Carefully chosen colors for different terminal elements&lt;/li&gt;
&lt;li&gt;Nice selection colors that don't make you squint (I am colorblind myself)&lt;/li&gt;
&lt;li&gt;Special colors for search and jump labels that actually stand out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The URLs are highlighted in a gentle blue (&lt;code&gt;89b4fa&lt;/code&gt;) - because clickable links should be obvious but not screaming for attention.&lt;br&gt;
Nothing fancy, no transparency effects or padding tweaks - just a clean, functional setup that gets out of your way while being pleasant to look at.&lt;/p&gt;
&lt;h3&gt;
  
  
  Fish configuration
&lt;/h3&gt;

&lt;p&gt;My Fish config is minimalist but gets the job done. Let's see what's happening here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set -x ZELLIJ_AUTO_EXIT true
if status is-interactive
    # Commands to run in interactive sessions can go here
    eval (zellij setup --generate-auto-start fish | string collect)
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The first line sets up Zellij to automatically exit when the last pane closes (as easy as pressing &lt;code&gt;CTRL-D&lt;/code&gt;). It's a quality-of-life setting - when you're done with your last terminal window, Zellij closes cleanly instead of hanging around with an empty session.&lt;/li&gt;
&lt;li&gt;Then, we check if we're in an interactive shell (as opposed to running a script)&lt;/li&gt;
&lt;li&gt;Lastly, we auto-start Zellij using its built-in Fish integration. The string collect bit ensures all the output from the setup command is handled properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result? Every time I open Foot, it automatically launches Fish, which automatically starts Zellij. No manual steps, no &lt;em&gt;"oops I forgot to start my multiplexer"&lt;/em&gt; moments - everything just flows.&lt;br&gt;
You know those tiny tasks that seem insignificant? &lt;em&gt;"Oh, it's just two keypresses to start Zellij..."&lt;/em&gt; But when you do them 20 times a day, those micro-interruptions add up and break your flow. Now my terminal setup just works, and I can focus on what I actually opened the terminal for in the first place.&lt;/p&gt;
&lt;h3&gt;
  
  
  Zellij configuration
&lt;/h3&gt;

&lt;p&gt;Nothing, it works out of the box! No question asked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31o3bpigi8hfj8o9ue4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31o3bpigi8hfj8o9ue4u.png" title="Zellij in action with 1 tab and 2 panels" alt="Zellij in action" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Out of the box, Zellij shows you what shortcuts you can use right in the status bar.&lt;br&gt;
It's a nice way to learn while you work, until your fingers start doing their thing automatically.&lt;/p&gt;
&lt;h3&gt;
  
  
  A small note about SSH
&lt;/h3&gt;

&lt;p&gt;Here's a little gotcha I discovered when working with remote servers. By default, Foot uses its own terminal type (&lt;code&gt;foot&lt;/code&gt; or &lt;code&gt;foot-direct&lt;/code&gt;), which is great for local use but can confuse some remote hosts. Adding this tiny bit to my &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Host&lt;/span&gt; *
&lt;span class="n"&gt;SetEnv&lt;/span&gt; &lt;span class="n"&gt;TERM&lt;/span&gt;=&lt;span class="n"&gt;xterm&lt;/span&gt;-&lt;span class="m"&gt;256&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells SSH to identify as the widely-supported &lt;code&gt;xterm-256color&lt;/code&gt; when connecting to remote machines.&lt;br&gt;
It's a simple fix that prevents those annoying "unknown terminal type" errors and weird formatting issues when SSHing into servers.&lt;br&gt;
Sure, you lose some of Foot's fancy features on remote connections, but it's a fair trade for having everything just work™ without&lt;br&gt;
having to install terminal definitions on every server you access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future experiments: down the Zellij rabbit hole
&lt;/h2&gt;

&lt;p&gt;While this setup is already solid, I've got my eyes on some interesting Zellij possibilities. First up is the SSH client multiplexing - being able to manage multiple SSH connections in a single Zellij session, with each remote server neatly organized in its own space. Gone would be the days of juggling terminal windows trying to find that one production session.&lt;/p&gt;

&lt;p&gt;Zellij's plugin system caught my attention too. Since plugins can be written in any language that compiles to WebAssembly, I might try my hand at building something that scratches my own itches - maybe something to streamline my SSH session management or Git workflow. The project is still quite active, so there's room to play around.&lt;/p&gt;

&lt;p&gt;I also want to dig into Foot's session management. The idea is to have a single main Foot window that all new terminal instances connect to when I hit my terminal shortcut. Should be doable with Foot's &lt;code&gt;--server&lt;/code&gt; mode and some window manager tweaks. That way I could keep my workflow exactly the same but avoid the window clutter.&lt;/p&gt;

&lt;p&gt;These might be solutions looking for problems, but hey - isn't that half the fun of terminal customization? Plus, unlike my Tilix tweaking days, if something breaks, my base setup remains rock-solid. Stay tuned for potential updates if any of these experiments turn out particularly useful!&lt;/p&gt;

&lt;h2&gt;
  
  
  Final considerations
&lt;/h2&gt;

&lt;p&gt;After a few months with this setup, I can't imagine going back. The combination of Foot's speed, Fish's smarts, and Zellij's flexibility has genuinely improved my daily workflow. Sure, it took a bit of tweaking to get everything just right, but that's the beauty of it - each piece is simple enough that customizing doesn't feel like solving a puzzle.&lt;/p&gt;

&lt;p&gt;Looking back at my Tilix days, I realize sometimes "just works" beats "works with all the features." This setup is faster, more stable, and honestly, just more enjoyable to use. Plus, there's something satisfying about having a terminal setup that's both modern and respectful of system defaults. If you're on the fence about modernizing your terminal setup, consider this your sign to take the plunge - your future self will thank you.&lt;/p&gt;

&lt;p&gt;Oh, and here's a neat thing about this setup: it doesn't touch your system's default shell at all. Bash remains as the system shell, handling all its traditional duties, while Fish only kicks in when you're actually using the terminal interactively. It's like having your cake and eating it too: the system keeps humming along with bash while you get all the nice Fish goodies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;P.S. No Fish were harmed in the making of this terminal setup, though some bad shell scripts were rightfully abandoned.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/em&gt;&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;I realized I haven't left any &lt;em&gt;foot&lt;/em&gt; notes in a post about Foot. Had to fix that!&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>linux</category>
      <category>productivity</category>
      <category>tooling</category>
      <category>bash</category>
    </item>
  </channel>
</rss>
