<?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: rbcn</title>
    <description>The latest articles on DEV Community by rbcn (@rbcn).</description>
    <link>https://dev.to/rbcn</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%2F3960223%2F49e53741-5124-4a60-abb8-bdfc16c8d509.png</url>
      <title>DEV Community: rbcn</title>
      <link>https://dev.to/rbcn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rbcn"/>
    <language>en</language>
    <item>
      <title>Constraints Are Interfaces: What the TrackPoint Teaches About Tool Design</title>
      <dc:creator>rbcn</dc:creator>
      <pubDate>Fri, 19 Jun 2026 01:09:49 +0000</pubDate>
      <link>https://dev.to/rbcn/constraints-are-interfaces-what-the-trackpoint-teaches-about-tool-design-f2d</link>
      <guid>https://dev.to/rbcn/constraints-are-interfaces-what-the-trackpoint-teaches-about-tool-design-f2d</guid>
      <description>&lt;h3&gt;
  
  
  The ThinkPad That Was Supposed to Be a Side Machine
&lt;/h3&gt;

&lt;p&gt;I bought a cheap used laptop as a casual work machine.&lt;/p&gt;

&lt;p&gt;It was a ThinkPad X1 Carbon Gen 7. I installed Linux Mint and meant to use it as a secondary machine: light research, writing, a bit of work on the go. Nothing more than that.&lt;/p&gt;

&lt;p&gt;Then it turned out to be better than expected.&lt;/p&gt;

&lt;p&gt;Thin. Light. Good keyboard. Linux Mint ran without much drama. For a corporate lease-return laptop, it was not living out its retirement. It was still a perfectly capable working machine. Before I knew it, the side machine had become my main one.&lt;/p&gt;

&lt;p&gt;But one thing kept bothering me.&lt;/p&gt;

&lt;p&gt;The touchpad.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sometimes Convenience Gets in the Way
&lt;/h3&gt;

&lt;p&gt;The touchpad itself was not bad.&lt;/p&gt;

&lt;p&gt;If anything, it was normally useful. It scrolls. It handles fine movements. On a modern laptop, it is the expected pointing device.&lt;/p&gt;

&lt;p&gt;But in my own use, misfires started to stand out.&lt;/p&gt;

&lt;p&gt;While writing, my palm or the base of my thumb would touch it without me noticing. The cursor would jump. I would type into the wrong place. A selection would change unexpectedly.&lt;/p&gt;

&lt;p&gt;A small accident.&lt;/p&gt;

&lt;p&gt;But for a machine used mainly for writing, it mattered.&lt;/p&gt;

&lt;p&gt;Then I remembered something.&lt;/p&gt;

&lt;p&gt;My first laptop had also been a ThinkPad: an IBM ThinkPad R31. Back then, there was no giant modern touchpad experience. There was a red dot in the middle of the keyboard, and you moved the pointer by pushing it.&lt;/p&gt;

&lt;p&gt;So maybe I should simply go back.&lt;/p&gt;

&lt;p&gt;Disable the touchpad. Return to the TrackPoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  At First, It Was Just touchpad-off
&lt;/h3&gt;

&lt;p&gt;The first step was simple.&lt;/p&gt;

&lt;p&gt;I checked the touchpad name with &lt;code&gt;xinput list&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SYNA8004:00 06CB:CD8B Touchpad
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I disabled it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xinput disable &lt;span class="s2"&gt;"SYNA8004:00 06CB:CD8B Touchpad"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone changed the feel of the machine.&lt;/p&gt;

&lt;p&gt;My hands stayed near the home position. Pointer movement converged on the red dot. As a writing machine, the whole posture became more consistent.&lt;/p&gt;

&lt;p&gt;At first, I treated this as a simple toggle.&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
xinput disable &lt;span class="s2"&gt;"SYNA8004:00 06CB:CD8B Touchpad"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if needed, turn it back on.&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
xinput &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="s2"&gt;"SYNA8004:00 06CB:CD8B Touchpad"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the world of &lt;code&gt;touchpad-off.sh&lt;/code&gt; and &lt;code&gt;touchpad-on.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At that point, it was still just a convenience setting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Colliding with an Old X11 UX
&lt;/h3&gt;

&lt;p&gt;But disabling the touchpad was not the end of it.&lt;/p&gt;

&lt;p&gt;To scroll with the TrackPoint, you hold the middle button and push the red dot. That gesture is very ThinkPad-like. But on Linux / X11, the middle button also has another meaning.&lt;/p&gt;

&lt;p&gt;Middle-click paste.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;select text
↓
it enters PRIMARY selection
↓
middle click
↓
paste
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an old X11 convenience. For people who know and use it, it is fast. But it was not part of the UX I had internalized when I first used a ThinkPad on Windows XP.&lt;/p&gt;

&lt;p&gt;For my body, the middle button was not a paste button.&lt;/p&gt;

&lt;p&gt;It was the button for TrackPoint scrolling.&lt;/p&gt;

&lt;p&gt;So accidents happened.&lt;/p&gt;

&lt;p&gt;I would be reading code or text. I would scroll. Some text I had selected somewhere would be pasted unexpectedly. Different from cursor jumps, but still another kind of input-path noise.&lt;/p&gt;

&lt;p&gt;I could not simply kill the middle button itself. I needed it for scrolling.&lt;/p&gt;

&lt;p&gt;The thing to kill was not the button, but its interpretation as paste.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xfconf-query &lt;span class="nt"&gt;-c&lt;/span&gt; xsettings &lt;span class="nt"&gt;-p&lt;/span&gt; /Gtk/EnablePrimaryPaste &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; bool &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;false &lt;/span&gt;2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; xfconf-query &lt;span class="nt"&gt;-c&lt;/span&gt; xsettings &lt;span class="nt"&gt;-p&lt;/span&gt; /Gtk/EnablePrimaryPaste &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, this only expressed a policy for GTK / Xfce applications. It did not remove middle-click paste from X11 as a whole. Electron apps, terminals, and some editors could still interpret Button2 on their own.&lt;/p&gt;

&lt;p&gt;So in the end, I stopped delivering Button2 from the TrackPoint device to applications.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xinput set-button-map &lt;span class="s2"&gt;"TPPS/2 Elan TrackPoint"&lt;/span&gt; 1 0 3 4 5 6 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;0&lt;/code&gt; means: do not send the middle button to applications. At the same time, libinput can still keep that physical button as the modifier for TrackPoint scrolling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xinput set-prop &lt;span class="s2"&gt;"TPPS/2 Elan TrackPoint"&lt;/span&gt; &lt;span class="s2"&gt;"libinput Scroll Method Enabled"&lt;/span&gt; 0 0 1
xinput set-prop &lt;span class="s2"&gt;"TPPS/2 Elan TrackPoint"&lt;/span&gt; &lt;span class="s2"&gt;"libinput Button Scrolling Button"&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the middle button remains for scrolling. Middle-click paste is sealed away. Paste is consolidated into Ctrl+V / Ctrl+Shift+V.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Became Mode-In
&lt;/h3&gt;

&lt;p&gt;At this point, what I was doing was no longer just turning the touchpad on and off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Disable the touchpad
Disable PrimaryPaste
Fix the TrackPoint as the primary input device
Keep the middle button for scrolling
Stop delivering Button2 to applications
Move paste into explicit keyboard shortcuts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a transition into an input mode.&lt;/p&gt;

&lt;p&gt;So I renamed the script.&lt;/p&gt;

&lt;p&gt;Not &lt;code&gt;touchpad-off.sh&lt;/code&gt;, but &lt;code&gt;thinkpadder-mode-in.sh&lt;/code&gt;.&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;TOUCHPAD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"SYNA8004:00 06CB:CD8B Touchpad"&lt;/span&gt;
&lt;span class="nv"&gt;TRACKPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"TPPS/2 Elan TrackPoint"&lt;/span&gt;
&lt;span class="nv"&gt;PRIMARY_PASTE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Gtk/EnablePrimaryPaste"&lt;/span&gt;

xinput disable &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOUCHPAD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;xfconf-query &lt;span class="nt"&gt;-c&lt;/span&gt; xsettings &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PRIMARY_PASTE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; bool &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;false &lt;/span&gt;2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; xfconf-query &lt;span class="nt"&gt;-c&lt;/span&gt; xsettings &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PRIMARY_PASTE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nb"&gt;false

&lt;/span&gt;&lt;span class="k"&gt;if &lt;/span&gt;xinput list &lt;span class="nt"&gt;--name-only&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Fxq&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRACKPOINT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;xinput set-prop &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRACKPOINT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"libinput Scroll Method Enabled"&lt;/span&gt; 0 0 1 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  &lt;/span&gt;xinput set-prop &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRACKPOINT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"libinput Button Scrolling Button"&lt;/span&gt; 2 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  &lt;/span&gt;xinput set-button-map &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRACKPOINT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 1 0 3 4 5 6 7 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I registered it with Xfce autostart.&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;[Desktop Entry]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ThinkPadder Mode In&lt;/span&gt;
&lt;span class="py"&gt;Comment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Enter TrackPoint-first ThinkPad input mode&lt;/span&gt;
&lt;span class="py"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/rbcn2000/.local/bin/thinkpadder-mode-in.sh&lt;/span&gt;
&lt;span class="py"&gt;OnlyShowIn&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;XFCE;&lt;/span&gt;
&lt;span class="py"&gt;X-GNOME-Autostart-enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;Terminal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the machine starts, it enters ThinkPadder Mode automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Constraints Are Interfaces
&lt;/h3&gt;

&lt;p&gt;This is not for everyone.&lt;/p&gt;

&lt;p&gt;Many people are better off with the touchpad. Some people love X11 middle-click paste. Some people want the escape route visible at all times.&lt;/p&gt;

&lt;p&gt;But for me, cutting off the retreat and fixing the operation path was rational.&lt;/p&gt;

&lt;p&gt;I wanted fewer misfires. I wanted a stable input posture. I wanted to return to a TrackPoint-centered bodily feel. For that goal, reducing options was not merely making the machine less convenient.&lt;/p&gt;

&lt;p&gt;The constraint was the interface.&lt;/p&gt;

&lt;p&gt;UX is not only about adding what users can do. Sometimes the body stops hesitating because something becomes impossible. Disable the touchpad, and the hand returns to the red dot. Stop delivering Button2 to applications, and the middle button becomes trustworthy as a scrolling modifier.&lt;/p&gt;

&lt;p&gt;Customization is not only about making the tool adapt to you.&lt;/p&gt;

&lt;p&gt;There is also a kind of customization where you make your body adapt to the tool's philosophy.&lt;/p&gt;

&lt;p&gt;The TrackPoint is not a substitute for a mouse.&lt;/p&gt;

&lt;p&gt;It is an extension of the keyboard.&lt;/p&gt;

&lt;p&gt;If you see it that way, disabling the touchpad is not just nostalgia. It is a way to govern input paths and support embodied use.&lt;/p&gt;




&lt;p&gt;This essay looked at TrackPoint UX through the lens of constraints and embodied tool design.&lt;/p&gt;

&lt;p&gt;The more personal memory behind the same experience is here.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://fragmentofview.rbcn.cc/posts/en/red-dot-in-the-middle-touchpad/" rel="noopener noreferrer"&gt;https://fragmentofview.rbcn.cc/posts/en/red-dot-in-the-middle-touchpad/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Japanese version:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://emptytheory.rbcn.cc/ja/constraints-are-interfaces-trackpoint-ux/" rel="noopener noreferrer"&gt;https://emptytheory.rbcn.cc/ja/constraints-are-interfaces-trackpoint-ux/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>emptytheory</category>
      <category>interfacedesign</category>
      <category>ux</category>
      <category>embodiedcognition</category>
    </item>
    <item>
      <title>Running Obsidian Community Plugins in Flatpak — Battle Notes and a Move to AppImage</title>
      <dc:creator>rbcn</dc:creator>
      <pubDate>Sun, 14 Jun 2026 15:33:28 +0000</pubDate>
      <link>https://dev.to/rbcn/running-obsidian-community-plugins-in-flatpak-battle-notes-and-a-move-to-appimage-4c1f</link>
      <guid>https://dev.to/rbcn/running-obsidian-community-plugins-in-flatpak-battle-notes-and-a-move-to-appimage-4c1f</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;For a while now, I've been running Obsidian in Flatpak on Linux.&lt;/p&gt;

&lt;p&gt;The reason was simple: easier package management, a clean system thanks to sandboxing, and better update discipline than dropping a random binary into &lt;code&gt;/usr/local/bin&lt;/code&gt;. For a proprietary app like Obsidian that ships its own distribution, Flatpak just makes sense. That was all there was to it.&lt;/p&gt;

&lt;p&gt;Then I started using community plugins seriously — and everything changed.&lt;/p&gt;

&lt;p&gt;Git sync, AI coding assistance, local CLI integration: almost every plugin that does something interesting assumes it can spawn subprocesses in the same environment as your shell. Flatpak's sandbox quietly breaks that assumption.&lt;/p&gt;

&lt;p&gt;This is the battle log.&lt;/p&gt;




&lt;h3&gt;
  
  
  Flatpak's Sandbox Looks Transparent. It Isn't.
&lt;/h3&gt;

&lt;p&gt;Let me set the stage.&lt;/p&gt;

&lt;p&gt;Flatpak runs applications in an isolated sandbox. Your home directory is mounted. The network works by default. In day-to-day use you barely notice the sandbox at all.&lt;/p&gt;

&lt;p&gt;The problems start when an app tries to spawn a subprocess.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What Flatpak restricts:
  - Process namespace (isolated from host processes)
  - Filesystem (only explicitly granted paths are accessible)
  - Environment variables (host shell settings don't reach the sandbox)

What Flatpak passes through:
  - Network (allowed by default)
  - Home directory (mounted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"The file is right there but I can't execute it." "The command exists but it's not in PATH." These are classic Flatpak symptoms, born from this gap. Error messages often don't surface either, which makes debugging hard.&lt;/p&gt;




&lt;h3&gt;
  
  
  Battle One: Obsidian Git and the SSH Agent
&lt;/h3&gt;

&lt;p&gt;The first wall was SSH authentication in Obsidian Git.&lt;/p&gt;

&lt;p&gt;Connecting to GitHub from the terminal worked fine. But the Obsidian Git plugin kept asking for a passphrase, or failing authentication entirely.&lt;/p&gt;

&lt;p&gt;The cause was the reach of &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When you start &lt;code&gt;ssh-agent&lt;/code&gt; in a terminal, the &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; it exports only reaches that shell's child processes. Flatpak Obsidian, launched from the GNOME app launcher, sits outside that process tree entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terminal's ssh-agent
  └─ terminal process
      └─ zsh (SSH_AUTH_SOCK is set)
                              ← wall is here
Flatpak Obsidian (SSH_AUTH_SOCK never arrives)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried unifying everything through GNOME Keyring (gcr), but ran into a different problem: gcr-ssh-agent hung during the signing operation in a way I couldn't reliably fix.&lt;/p&gt;

&lt;p&gt;The solution that stuck was &lt;strong&gt;running OpenSSH ssh-agent as a systemd user service&lt;/strong&gt;, giving it a fixed socket path that exists before any shell or GUI app starts.&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="c"&gt;# ~/.config/systemd/user/ssh-agent.service&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Unit]
&lt;span class="nv"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;OpenSSH key agent

&lt;span class="o"&gt;[&lt;/span&gt;Service]
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;simple
&lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;%t/ssh-agent.socket
&lt;span class="nv"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/ssh-agent &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; %t/ssh-agent.socket

&lt;span class="o"&gt;[&lt;/span&gt;Install]
&lt;span class="nv"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a fixed socket at &lt;code&gt;/run/user/1000/ssh-agent.socket&lt;/code&gt;, I could expose it to Flatpak explicitly and reference it in a dedicated Obsidian Git wrapper script with &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; and &lt;code&gt;BatchMode=yes&lt;/code&gt; hardcoded.&lt;/p&gt;

&lt;p&gt;The full story is in the previous post:&lt;br&gt;
&lt;strong&gt;→ &lt;a href="https://emptytheory.rbcn.cc/en/migrating-to-zsh-broke-obsidian-git" rel="noopener noreferrer"&gt;Migrating to zsh Broke Obsidian Git — A Battle with SSH Agent and Flatpak&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;


&lt;h3&gt;
  
  
  Battle Two: The Codex CLI and stdio JSON-RPC
&lt;/h3&gt;

&lt;p&gt;With SSH sorted, things were stable for a while. Then AI plugin integration became my next problem.&lt;/p&gt;

&lt;p&gt;When you use AI coding assistance inside Obsidian, how it works depends entirely on the plugin — and the differences matter a lot in Flatpak.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin + Integration&lt;/th&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;CLI runtime&lt;/th&gt;
&lt;th&gt;Flatpak impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Smart Composer + Codex&lt;/td&gt;
&lt;td&gt;HTTP API (REST)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Network works → no problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claudian + Claude Code&lt;/td&gt;
&lt;td&gt;stdio JSON-RPC&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Bun standalone ELF&lt;/strong&gt; (self-contained)&lt;/td&gt;
&lt;td&gt;Worked from day one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claudian + Codex CLI&lt;/td&gt;
&lt;td&gt;stdio JSON-RPC&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Node.js script&lt;/strong&gt; (&lt;code&gt;#!/usr/bin/env node&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Broke&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Smart Composer with a Codex subscription worked out of the box because it &lt;strong&gt;calls the OpenAI REST API over the network&lt;/strong&gt;. Flatpak doesn't block network access.&lt;/p&gt;

&lt;p&gt;Claudian's Codex integration didn't, because it &lt;strong&gt;spawns the local &lt;code&gt;codex&lt;/code&gt; CLI as a subprocess and communicates over stdio using JSON-RPC&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claudian plugin
  → spawn: codex app-server --listen stdio://
  → JSON-RPC over stdin/stdout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  I Wrote a Wrapper Script
&lt;/h4&gt;

&lt;p&gt;To call &lt;code&gt;/home/rbcn2000/.npm-global/bin/codex&lt;/code&gt; from inside Flatpak, I needed &lt;code&gt;flatpak-spawn --host&lt;/code&gt; to launch it as a host-side process. My first attempt:&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="c"&gt;#!/usr/bin/env zsh&lt;/span&gt;

&lt;span class="nb"&gt;exec &lt;/span&gt;flatpak-spawn &lt;span class="nt"&gt;--host&lt;/span&gt; zsh &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'/home/rbcn2000/.npm-global/bin/codex "$@"'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-i&lt;/code&gt; (interactive) flag was there to load &lt;code&gt;.zshrc&lt;/code&gt; and get nvm's Node.js into PATH. That was the mistake.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;code&gt;zsh -i&lt;/code&gt; Poisons stdout
&lt;/h4&gt;

&lt;p&gt;I set this as the Codex CLI path in Claudian's settings. The integration still didn't work. No error message.&lt;/p&gt;

&lt;p&gt;Here's why.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;zsh -i&lt;/code&gt; starts as an interactive shell, which means it executes &lt;code&gt;.zshrc&lt;/code&gt; in full. My &lt;code&gt;.zshrc&lt;/code&gt; includes:&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;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;zoxide init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;     &lt;span class="c"&gt;# may write to stdout&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;starship init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# writes initialization output to stdout&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NVM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/nvm.sh"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NVM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/nvm.sh"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claudian's Codex integration &lt;strong&gt;starts reading stdout as a JSON-RPC stream the moment the process launches&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expected stdout:
  {"jsonrpc":"2.0","id":1,"result":{...}}

Actual stdout:
  (starship / zoxide initialization output)
  {"jsonrpc":"2.0","id":1,"result":{...}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON parser fails on the first non-JSON line. The connection drops. The error is reported as "could not connect" — with no hint about what was on stdout. The root cause is invisible.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Fix: Remove the Intermediate Shell
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;

&lt;span class="nv"&gt;HOST_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/usr/bin:/usr/local/bin:/home/rbcn2000/.npm-global/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;exec &lt;/span&gt;flatpak-spawn &lt;span class="nt"&gt;--host&lt;/span&gt; &lt;span class="nb"&gt;env &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  node /home/rbcn2000/.npm-global/lib/node_modules/@openai/codex/bin/codex.js &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No intermediate shell&lt;/strong&gt; — nothing can pollute stdout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;node&lt;/code&gt; called directly&lt;/strong&gt; — skips symlink resolution and shebang lookup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PATH built explicitly for the host&lt;/strong&gt; — Flatpak's sandbox PATH doesn't include &lt;code&gt;/usr/bin&lt;/code&gt; or npm-global, so I assemble the environment that &lt;code&gt;flatpak-spawn --host&lt;/code&gt; will pass to the host process myself&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That worked.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why Claude Code Worked From the Start
&lt;/h4&gt;

&lt;p&gt;It's worth asking why Claudian's Claude Code integration never needed any of this. The answer is a fundamental difference in how the two tools are built.&lt;/p&gt;

&lt;p&gt;Inspecting the Claude Code binary:&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="nv"&gt;$ &lt;/span&gt;file ~/.local/share/claude/versions/2.1.177
ELF 64-bit LSB executable, x86-64, dynamically linked

&lt;span class="nv"&gt;$ &lt;/span&gt;ldd ~/.local/share/claude/versions/2.1.177
  librt.so.1, libc.so.6, libpthread.so.0, libdl.so.2, libm.so.6

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; ~/.local/share/claude/versions/2.1.177
&lt;span class="nt"&gt;-rwxr-xr-x&lt;/span&gt;  239M  claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dependencies: only &lt;code&gt;libc&lt;/code&gt; family. No &lt;code&gt;libnode.so&lt;/code&gt;. No &lt;code&gt;libv8.so&lt;/code&gt;. Size: &lt;strong&gt;239 MB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the signature of a &lt;strong&gt;Bun &lt;code&gt;bun build --compile&lt;/code&gt; standalone ELF&lt;/strong&gt;. Bun statically bundles its JavaScript engine (JavaScriptCore) directly into the binary at compile time. The result needs no external Node.js runtime — it's fully self-contained.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code (Bun standalone):
  ELF binary
    └─ JavaScriptCore (statically bundled)
    └─ application code (statically bundled)
  → doesn't care what's in PATH
  → runs from anywhere, including Flatpak's sandbox

Codex CLI (Node.js script):
  codex.js  ← #!/usr/bin/env node
    → looks up `node` in PATH at runtime
    → nvm's node isn't in Flatpak's PATH
    → fails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If Codex moves to a native Rust binary&lt;/strong&gt;, the story changes again. A Rust binary is also self-contained, with no external runtime dependency. The wrapper problem would simply disappear.&lt;/p&gt;

&lt;p&gt;The discussion of "Claude Code is Bun, Codex is moving to Rust" has been floating around lately — but for Flatpak users, it maps directly onto a concrete lived experience: &lt;em&gt;why did one work and the other didn't?&lt;/em&gt; An abstract build-system choice showed up as the difference between "works on first try" and "two hours of silent debugging."&lt;/p&gt;




&lt;h3&gt;
  
  
  Why Community Plugins Don't Handle Flatpak
&lt;/h3&gt;

&lt;p&gt;After these two battles, the structural reasons became clear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Flatpak Obsidian users are a thin slice of a thin slice&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Linux desktop users are a minority among Obsidian's userbase. Flatpak Obsidian users are a minority among Linux desktop users. If you're a plugin developer on macOS, Windows, or even a &lt;code&gt;.deb&lt;/code&gt;/AppImage Linux setup, you'll never encounter Flatpak's behavior. There's no incentive to test for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② Flatpak failures are invisible&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;command not found&lt;/code&gt; or &lt;code&gt;permission denied&lt;/code&gt; are easy to debug. Flatpak problems tend to appear as &lt;strong&gt;timeouts&lt;/strong&gt; and &lt;strong&gt;silent connection failures&lt;/strong&gt; — exactly the symptoms both of my issues produced. It's hard to file a useful bug report for "it just doesn't connect," and hard for a developer to reproduce without a Flatpak environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Plugins assume "same environment as the shell"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any plugin that spawns a subprocess implicitly assumes that &lt;code&gt;PATH&lt;/code&gt;, environment variables, and sockets like &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; have the same values as in a terminal session. Flatpak silently breaks that assumption.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Decision: Move to AppImage
&lt;/h3&gt;

&lt;p&gt;Every workaround I added made the overall setup more fragile.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For SSH agent: a systemd service, a wrapper script, a &lt;code&gt;.desktop&lt;/code&gt; file override&lt;/li&gt;
&lt;li&gt;For Codex CLI: a &lt;code&gt;flatpak-spawn&lt;/code&gt; wrapper, manually assembled PATH&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each fix is correct in isolation. Together, they form a web of dependencies where changing one thing can break another. Upgrade nvm → PATH shifts. Adjust systemd → socket path changes.&lt;/p&gt;

&lt;p&gt;AppImage is a different approach. It doesn't sandbox anything. It runs as a regular process in the host environment, inheriting your PATH, your &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt;, your nvm — everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AppImage Obsidian:
  PATH              → inherited from host
  SSH_AUTH_SOCK     → inherited from host
  subprocess spawn  → runs in host environment
  flatpak-spawn     → not needed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The maintenance calculus has flipped. AppImage is now the simpler choice.&lt;/p&gt;




&lt;h3&gt;
  
  
  Takeaways
&lt;/h3&gt;

&lt;p&gt;Pushing Flatpak Obsidian to its limits produced two real lessons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH agent&lt;/strong&gt;: Environment variables don't teleport — they propagate through process inheritance. A socket that works in your terminal doesn't exist in a GUI app spawned from a launcher. Anchoring the agent to a fixed path via systemd removes the dependency on how the app was launched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codex CLI / stdio JSON-RPC&lt;/strong&gt;: When a plugin communicates over stdio as a protocol, the spawned process must produce nothing but protocol output. An interactive shell between the plugin and the binary is a silent killer — init scripts write to stdout, the JSON parser fails, the connection drops with no error trace.&lt;/p&gt;

&lt;p&gt;Neither failure was Flatpak's fault. Flatpak sandboxed exactly what it was supposed to sandbox. The failure was in not accounting for those boundaries earlier.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never put an interactive shell between a caller and a subprocess that uses stdio as a protocol.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the single most concrete rule I can extract from this.&lt;/p&gt;

&lt;p&gt;And the broader one: &lt;strong&gt;workarounds compound. At some point, the maintenance cost of staying exceeds the switching cost of leaving.&lt;/strong&gt; Moving to AppImage is an acknowledgment of reaching that point.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>flatpak</category>
      <category>obsidian</category>
      <category>node</category>
    </item>
    <item>
      <title>I Built a Pipeline to Publish This Post.</title>
      <dc:creator>rbcn</dc:creator>
      <pubDate>Sat, 30 May 2026 18:25:54 +0000</pubDate>
      <link>https://dev.to/rbcn/i-built-a-pipeline-to-publish-this-post-5fij</link>
      <guid>https://dev.to/rbcn/i-built-a-pipeline-to-publish-this-post-5fij</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;I write in Obsidian. I publish on Hashnode. Until recently, moving between the two looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the vault note&lt;/li&gt;
&lt;li&gt;Copy the content&lt;/li&gt;
&lt;li&gt;Open Hashnode editor&lt;/li&gt;
&lt;li&gt;Paste and reformat the frontmatter manually&lt;/li&gt;
&lt;li&gt;Hunt for a cover image&lt;/li&gt;
&lt;li&gt;Upload it&lt;/li&gt;
&lt;li&gt;Hit publish&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Somewhere between ten and fifteen minutes of friction between "I'm done writing" and "the post is live."&lt;/p&gt;

&lt;p&gt;I got tired of it. So I built a pipeline.&lt;/p&gt;

&lt;p&gt;This post documents that pipeline — and it was published through it.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Problem with Manual Publishing
&lt;/h3&gt;

&lt;p&gt;My Obsidian notes have their own frontmatter schema. Hashnode has its own. They're similar enough to feel like they should be the same thing, and different enough that copy-pasting always broke something.&lt;/p&gt;

&lt;p&gt;The tags format was different. The title needed quoting sometimes and not others. The cover image had to be uploaded separately. The &lt;code&gt;## 📝 Draft&lt;/code&gt; section header had to be stripped out.&lt;/p&gt;

&lt;p&gt;None of these are hard problems. But they were the kind of small, repetitive friction that makes you skip publishing altogether.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;

&lt;p&gt;The pipeline has three components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;04_Publish note  (Obsidian vault)
  ↓
/hashnode-publish  (Claude Code skill)
  converts frontmatter, generates slug, writes file
  ↓
post/&amp;lt;timestamp&amp;gt;/slug.md  (GitHub relay repo)
  ↓  git push → GitHub Actions
  ├─ Resolve Unsplash cover image
  └─ Hashnode API  →  live post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each component has a single responsibility. The vault stays clean. The publishing logic lives outside it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Component 1: The GitHub Relay Repo
&lt;/h3&gt;

&lt;p&gt;The first piece is a GitHub repository whose only job is to receive markdown files and forward them to Hashnode.&lt;/p&gt;

&lt;p&gt;The GitHub Actions workflow triggers on any push that touches &lt;code&gt;post/**/*.md&lt;/code&gt;. The nested path pattern matters — &lt;code&gt;post/*.md&lt;/code&gt; wouldn't match, so every article goes into a timestamp-named subfolder: &lt;code&gt;post/20260517143000/slug.md&lt;/code&gt;. That folder name also serves as a natural ordering key.&lt;/p&gt;

&lt;p&gt;When the workflow fires, it runs two steps per changed file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Resolve Unsplash cover image&lt;/strong&gt; — more on this below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashnode publish&lt;/strong&gt; — calls the Hashnode API via &lt;code&gt;raunakgurud09/hashnode-publish@v1.1&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Hashnode API key lives in GitHub Secrets. It never touches the local machine.&lt;/p&gt;




&lt;h3&gt;
  
  
  Component 2: The Claude Code Skill
&lt;/h3&gt;

&lt;p&gt;The second piece is a Claude Code slash command: &lt;code&gt;/hashnode-publish&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When invoked, it reads the current vault note, parses its frontmatter, and converts it to Hashnode's format. Then it generates a slug from the filename, creates the timestamp folder, writes the converted file, and pushes to the relay repo.&lt;/p&gt;

&lt;p&gt;The frontmatter conversion handles the differences between the vault schema and Hashnode's schema: tag format, field ordering, stripping vault-specific fields, dropping the &lt;code&gt;## 📝 Draft&lt;/code&gt; header from the body. If &lt;code&gt;description&lt;/code&gt; is empty, it stops and asks to fill it in first. If &lt;code&gt;publish&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, it warns before proceeding.&lt;/p&gt;

&lt;p&gt;From the writer's perspective, the workflow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Write the note in Obsidian
2. Fill in the frontmatter (title, description, tags, etc.)
3. Type /hashnode-publish
4. Done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entire publishing process — conversion, file creation, commit, push — runs in under ten seconds.&lt;/p&gt;




&lt;h3&gt;
  
  
  Component 3: Unsplash Cover Images
&lt;/h3&gt;

&lt;p&gt;The third piece solves the cover image problem.&lt;/p&gt;

&lt;p&gt;The vault frontmatter accepts a &lt;code&gt;splash_keyword&lt;/code&gt; field. When it's set and &lt;code&gt;cover_image&lt;/code&gt; is empty, the skill passes the keyword through to the relay repo file. GitHub Actions picks it up, calls the Unsplash API, and injects the result as &lt;code&gt;cover_image&lt;/code&gt; before passing the file to Hashnode.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In the vault note frontmatter&lt;/span&gt;
&lt;span class="na"&gt;splash_keyword&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;developer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;workspace"&lt;/span&gt;
&lt;span class="na"&gt;cover_image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions:
  splash_keyword found → GET /photos/random?query=developer+workspace
  → cover_image: https://images.unsplash.com/photo-...
  → splash_keyword removed from frontmatter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Unsplash API key lives in GitHub Secrets alongside the Hashnode key. Nothing sensitive is stored locally.&lt;/p&gt;

&lt;p&gt;One more thing: the Unsplash API terms encourage attribution. So whenever an image is fetched, the workflow appends a line to the article body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="ge"&gt;*cover image: [unsplash](https://unsplash.com/)*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cover image for this post was chosen by that step. I wrote &lt;code&gt;splash_keyword: "developer workspace"&lt;/code&gt; and the algorithm did the rest.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Decision to Use a Relay Repo
&lt;/h3&gt;

&lt;p&gt;A simpler design would call the Hashnode API directly from the Claude Code skill. No relay repo, no GitHub Actions — just a local script with the API key.&lt;/p&gt;

&lt;p&gt;I chose the relay repo approach for one reason: &lt;strong&gt;the API keys live in GitHub Secrets, not on the local machine&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This matters more than it might seem. A Claude Code skill runs with access to the local environment. Keeping secrets out of that environment, and inside a CI/CD system with audit logs and rotation tooling, is a better default. The relay repo adds one step but removes a category of risk.&lt;/p&gt;

&lt;p&gt;There's a secondary benefit: the relay repo is a record. Every published post has a corresponding commit with a timestamp and a slug. That's useful.&lt;/p&gt;




&lt;h3&gt;
  
  
  What the Frontmatter Looks Like
&lt;/h3&gt;

&lt;p&gt;A complete vault note ready for publishing looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Post&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Title."&lt;/span&gt;
&lt;span class="na"&gt;subtitle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Subtitle&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;here."&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag1"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag2"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;two&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sentences&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;describing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;post."&lt;/span&gt;
&lt;span class="na"&gt;cover_image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;span class="na"&gt;splash_keyword&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relevant&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;term"&lt;/span&gt;
&lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;enableTableOfContent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;isNewsletterActivated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fields drive the cover image behavior:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;cover_image&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;splash_keyword&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Use the URL directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;td&gt;Fetch from Unsplash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;td&gt;No cover image&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  Lessons
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;The friction between "done writing" and "published" is a publishing problem, not a writing problem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Optimizing the writing environment — better tools, better structure — doesn't help if the last mile is still manual. The pipeline had to be built separately.&lt;/p&gt;

&lt;p&gt;And a pattern that recurred across all three components:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Put secrets where they belong. API keys in GitHub Secrets, not environment variables, not shell configs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's the same principle that came up in the SSH agent post: scope matters. A credential should only be reachable from the system that actually needs to use it.&lt;/p&gt;




&lt;h3&gt;
  
  
  What's Next
&lt;/h3&gt;

&lt;p&gt;The pipeline is functional. A few things I'd add eventually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Update existing posts&lt;/strong&gt;: the current workflow only handles new files; updating a published post requires a separate flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview step&lt;/strong&gt;: see the formatted output before pushing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Japanese articles&lt;/strong&gt;: the same pipeline works, but slug generation from Japanese filenames needs a romanization step&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For now, typing &lt;code&gt;/hashnode-publish&lt;/code&gt; and watching the commit appear in the relay repo is satisfying enough.&lt;/p&gt;

</description>
      <category>obsidian</category>
      <category>hashnode</category>
      <category>githubactions</category>
      <category>claude</category>
    </item>
    <item>
      <title>Migrating to zsh Broke Obsidian Git.</title>
      <dc:creator>rbcn</dc:creator>
      <pubDate>Sat, 30 May 2026 17:53:20 +0000</pubDate>
      <link>https://dev.to/rbcn/migrating-to-zsh-broke-obsidian-git-2blj</link>
      <guid>https://dev.to/rbcn/migrating-to-zsh-broke-obsidian-git-2blj</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;In the previous post, I quit &lt;code&gt;.bashrc&lt;/code&gt; and gave bash and zsh separate responsibilities. The terminal environment was clean.&lt;/p&gt;

&lt;p&gt;The next day, I opened Obsidian and Git sync had stopped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looked like "switching to zsh broke Obsidian." But the shell wasn't the real culprit.&lt;br&gt;
&lt;strong&gt;The ssh-agent's scope simply wasn't reaching GUI applications.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the record of how I tracked down that cause and fought my way through the Flatpak sandbox to fix it.&lt;/p&gt;


&lt;h3&gt;
  
  
  The Structure of the Problem
&lt;/h3&gt;

&lt;p&gt;Checking from the terminal, the SSH keys were visible.&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# /run/user/1000/gcr/ssh&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt;              &lt;span class="c"&gt;# work-github / personal-github&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yet Obsidian Git plugin kept asking for a passphrase every single time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terminal (zsh) → OK
Obsidian (GUI) → NG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem broke down like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh-agent launched from a shell
  → only valid within that shell's process tree

Obsidian launched by the GUI
  → never reads .zshrc
  → never reaches the ssh-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In other words, &lt;strong&gt;this wasn't a shell configuration problem — it was a login session design problem&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Why Did It Work Before?
&lt;/h3&gt;

&lt;p&gt;A question immediately surfaced: when everything was crammed into &lt;code&gt;.bashrc&lt;/code&gt;, Obsidian Git worked fine. Why?&lt;/p&gt;

&lt;p&gt;The answer turned out to involve GNOME Keyring (gcr).&lt;/p&gt;

&lt;p&gt;gcr-ssh-agent integrates with the GNOME login session and automatically exposes SSH keys when the login keyring is unlocked. That meant Obsidian had always been talking to gcr directly, without going through the shell at all.&lt;/p&gt;

&lt;p&gt;It wasn't that passphrases were &lt;em&gt;unnecessary&lt;/em&gt; — it was that &lt;strong&gt;GNOME Keyring had been silently providing the previously-saved passphrase all along&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Attempting gcr-ssh-agent Unification (and Failing)
&lt;/h3&gt;

&lt;p&gt;My first plan was to leverage this mechanism and route everything through gcr.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gcr-ssh-agent
  → /run/user/1000/gcr/ssh
  → shared by Terminal / Editor / Obsidian / Flatpak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I exposed the gcr socket to the Flatpak Obsidian sandbox.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak override &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filesystem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xdg-run/gcr &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/run/user/1000/gcr/ssh &lt;span class="se"&gt;\&lt;/span&gt;
  md.obsidian.Obsidian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked — temporarily. GitHub authentication went through.&lt;/p&gt;

&lt;p&gt;Then I rebooted. &lt;strong&gt;The problem came back.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Signing hangs
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt;              &lt;span class="c"&gt;# personal-github / work-github → visible&lt;/span&gt;
ssh &lt;span class="nt"&gt;-T&lt;/span&gt; github-personal  &lt;span class="c"&gt;# → hangs (no response)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verbose log showed GitHub &lt;em&gt;was&lt;/em&gt; accepting the key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Offering public key: id_ed25519_personal
Server accepts key: id_ed25519_personal
signing using ssh-ed25519   ← hangs here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signing request sent to gcr-ssh-agent was never coming back.&lt;/p&gt;

&lt;h4&gt;
  
  
  Keys resurrect after &lt;code&gt;ssh-add -D&lt;/code&gt;
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;-D&lt;/span&gt;   &lt;span class="c"&gt;# All identities removed.&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt;   &lt;span class="c"&gt;# personal-github / work-github  ← instantly back&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gcr was auto-publishing keys from its keyring storage. And that auto-restored state was broken for signing.&lt;/p&gt;

&lt;h4&gt;
  
  
  Cleaning up via Seahorse was risky
&lt;/h4&gt;

&lt;p&gt;When I tried to delete SSH key entries in Seahorse (GNOME's keyring manager), I realized it looked like it would &lt;strong&gt;delete the actual private key files&lt;/strong&gt; (&lt;code&gt;~/.ssh/id_ed25519_personal&lt;/code&gt;) along with them.&lt;/p&gt;

&lt;p&gt;That was the end of the gcr unification attempt.&lt;/p&gt;




&lt;h3&gt;
  
  
  What Remained Unexplained
&lt;/h3&gt;

&lt;p&gt;I never found the root cause of gcr-ssh-agent's signing hang. Here's what I was able to rule out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The key files were not corrupted
The public keys on GitHub were registered correctly
Exposing the Flatpak socket was not the sole cause
~/.ssh/config Host aliases were not misconfigured

→ gcr-ssh-agent's auto-restored state was unstable at signing time
→ Root cause: unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a verdict that gcr-ssh-agent is broken. This problem happened in this environment. Given that, I chose to prioritize determinism and switch to OpenSSH ssh-agent.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pinning OpenSSH ssh-agent as a systemd User Service
&lt;/h3&gt;

&lt;p&gt;I decided to manage a plain OpenSSH ssh-agent with a fixed socket path under systemd user.&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.config/systemd/user

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.config/systemd/user/ssh-agent.service &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Unit]
Description=OpenSSH key agent

[Service]
Type=simple
Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket

[Install]
WantedBy=default.target
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; daemon-reload
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; ssh-agent.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The socket is now pinned at &lt;code&gt;/run/user/1000/ssh-agent.socket&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Updating &lt;code&gt;~/.ssh/config&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;I added explicit &lt;code&gt;IdentityAgent&lt;/code&gt; directives so SSH connections don't depend on the current value of &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host github-personal
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_personal
    IdentitiesOnly yes
    AddKeysToAgent yes
    IdentityAgent /run/user/1000/ssh-agent.socket

Host github-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_work
    IdentitiesOnly yes
    AddKeysToAgent yes
    IdentityAgent /run/user/1000/ssh-agent.socket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Normalizing the socket in bash and zsh
&lt;/h4&gt;

&lt;p&gt;Added to both &lt;code&gt;.bashrc&lt;/code&gt; and &lt;code&gt;.zshrc&lt;/code&gt;:&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="c"&gt;# OpenSSH ssh-agent (shared between .bashrc and .zshrc)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$XDG_RUNTIME_DIR&lt;/span&gt;&lt;span class="s2"&gt;/ssh-agent.socket"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$XDG_RUNTIME_DIR&lt;/span&gt;&lt;span class="s2"&gt;/ssh-agent.socket"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Dealing with Flatpak
&lt;/h3&gt;

&lt;p&gt;Flatpak's Obsidian runs inside its own sandbox. The host socket needs to be explicitly exposed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak override &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filesystem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xdg-run/ssh-agent.socket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/run/user/1000/ssh-agent.socket &lt;span class="se"&gt;\&lt;/span&gt;
  md.obsidian.Obsidian

&lt;span class="c"&gt;# Remove gcr access&lt;/span&gt;
flatpak override &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nofilesystem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xdg-run/gcr &lt;span class="se"&gt;\&lt;/span&gt;
  md.obsidian.Obsidian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  The Obsidian Git wrapper
&lt;/h4&gt;

&lt;p&gt;Since the Obsidian Git plugin uses &lt;code&gt;/app/bin/git&lt;/code&gt; inside the Flatpak, I wrapped 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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.local/bin/obsidian-git &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/usr/bin/env bash

SOCK="/run/user/1000/ssh-agent.socket"

export SSH_AUTH_SOCK="&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;"
export GIT_SSH_COMMAND="ssh &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -F /home/rbcn2000/.ssh/config &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -o IdentityAgent=&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -o BatchMode=yes &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -o ConnectTimeout=10 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -o ServerAliveInterval=5 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -o ServerAliveCountMax=1"

exec /app/bin/git "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/.local/bin/obsidian-git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BatchMode=yes&lt;/code&gt; is the critical part — it prevents the automated commit/sync process from silently hanging while waiting for a passphrase prompt that can never appear.&lt;/p&gt;

&lt;p&gt;In the Obsidian Git plugin settings, set &lt;code&gt;Custom Git binary path&lt;/code&gt; to &lt;code&gt;/home/rbcn2000/.local/bin/obsidian-git&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Automating Key Loading After Reboot
&lt;/h3&gt;

&lt;p&gt;Unlike gcr, OpenSSH ssh-agent does not restore keys automatically. After a reboot, the agent starts empty.&lt;/p&gt;

&lt;p&gt;I considered bringing keychain back, but decided against it — it risks creating duplicate agents. Instead, I wrote a minimal script whose only job is loading the keys.&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.local/bin/ssh-load-github &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/usr/bin/env bash

set -u

SOCK="/run/user/1000/ssh-agent.socket"
export SSH_AUTH_SOCK="&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;"

if [ ! -S "&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;" ]; then
  systemctl --user start ssh-agent.service 2&amp;gt;/dev/null || true
fi

if [ ! -S "&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;" ]; then
  echo "ssh-agent socket not found: &lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;" &amp;gt;&amp;amp;2
  exit 1
fi

# Don't wait for passphrase input in non-interactive environments
if [ ! -t 0 ]; then
  exit 0
fi

CURRENT_KEYS="&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"

if ! printf '%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;' "&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_KEYS&lt;/span&gt;&lt;span class="sh"&gt;" | grep -q 'personal-github'; then
  ssh-add "&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="sh"&gt;/.ssh/id_ed25519_personal" || exit 1
fi

CURRENT_KEYS="&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"

if ! printf '%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;' "&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_KEYS&lt;/span&gt;&lt;span class="sh"&gt;" | grep -q 'work-github'; then
  ssh-add "&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="sh"&gt;/.ssh/id_ed25519_work" || exit 1
fi
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/.local/bin/ssh-load-github
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it from &lt;code&gt;.zshrc&lt;/code&gt; and &lt;code&gt;.bashrc&lt;/code&gt; on interactive shell startup:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$-&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;i&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SSH_LOAD_GITHUB_ON_SHELL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.local/bin/ssh-load-github"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, the first time a terminal opens after reboot, you enter the passphrase once — and that's it.&lt;/p&gt;




&lt;h3&gt;
  
  
  The .desktop Launcher Trap
&lt;/h3&gt;

&lt;p&gt;I thought it was solved. There was one more problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After a reboot, launching Obsidian first meant Git didn't work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The culprit was the TTY check inside &lt;code&gt;ssh-load-github&lt;/code&gt;:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0   &lt;span class="c"&gt;# No TTY → do nothing&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Obsidian launches from a &lt;code&gt;.desktop&lt;/code&gt; file, there is no TTY. So &lt;code&gt;ssh-load-github&lt;/code&gt; exits immediately, keys never get loaded, and Obsidian starts up with an empty agent.&lt;/p&gt;

&lt;h4&gt;
  
  
  The fix: SSH_ASKPASS for a GUI dialog
&lt;/h4&gt;

&lt;p&gt;OpenSSH has a built-in mechanism for exactly this situation: &lt;code&gt;SSH_ASKPASS&lt;/code&gt;.&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;apt &lt;span class="nb"&gt;install &lt;/span&gt;ssh-askpass-gnome
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote a dedicated launcher that uses 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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.local/bin/open-obsidian &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/usr/bin/env bash

SOCK="/run/user/1000/ssh-agent.socket"
export SSH_AUTH_SOCK="&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;"

if [ ! -S "&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;" ]; then
  systemctl --user start ssh-agent.service 2&amp;gt;/dev/null || true
fi

if [ ! -S "&lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;" ]; then
  echo "ssh-agent socket not found: &lt;/span&gt;&lt;span class="nv"&gt;$SOCK&lt;/span&gt;&lt;span class="sh"&gt;" &amp;gt;&amp;amp;2
  exit 1
fi

# Accept passphrase input via GTK dialog even without a TTY
export SSH_ASKPASS="/usr/lib/openssh/gnome-ssh-askpass"
export SSH_ASKPASS_REQUIRE=force

CURRENT_KEYS="&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"

if ! printf '%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;' "&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_KEYS&lt;/span&gt;&lt;span class="sh"&gt;" | grep -q 'personal-github'; then
  ssh-add "&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="sh"&gt;/.ssh/id_ed25519_personal" || exit 1
fi

CURRENT_KEYS="&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-add &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"

if ! printf '%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;' "&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_KEYS&lt;/span&gt;&lt;span class="sh"&gt;" | grep -q 'work-github'; then
  ssh-add "&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="sh"&gt;/.ssh/id_ed25519_work" || exit 1
fi

exec flatpak run md.obsidian.Obsidian "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/.local/bin/open-obsidian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Overriding the .desktop file
&lt;/h4&gt;

&lt;p&gt;The system-side Flatpak &lt;code&gt;.desktop&lt;/code&gt; file can't be edited directly — Flatpak updates would overwrite it. Instead, copy it to the user applications directory and redirect the &lt;code&gt;Exec&lt;/code&gt; line.&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;cp&lt;/span&gt; /var/lib/flatpak/exports/share/applications/md.obsidian.Obsidian.desktop &lt;span class="se"&gt;\&lt;/span&gt;
   ~/.local/share/applications/md.obsidian.Obsidian.desktop

&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s|^Exec=.*|Exec=/home/rbcn2000/.local/bin/open-obsidian %U|'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   ~/.local/share/applications/md.obsidian.Obsidian.desktop

update-desktop-database ~/.local/share/applications/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, launching Obsidian from the app launcher shows a GTK passphrase dialog first, then starts Obsidian.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Final Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;systemd user
  ↓
OpenSSH ssh-agent.service
  ↓
/run/user/1000/ssh-agent.socket
  ├─ bash / zsh (ssh-load-github on shell startup)
  ├─ ~/.ssh/config (IdentityAgent explicit)
  └─ ~/.local/bin/open-obsidian  ← launched from .desktop
       └─ SSH_ASKPASS=gnome-ssh-askpass (GTK dialog)
            ↓
          Flatpak Obsidian
               ↓
             ~/.local/bin/obsidian-git (BatchMode=yes)
               ↓
             /app/bin/git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a reboot, behavior is now fully symmetric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Opening a terminal first:
  → ssh-load-github runs
  → passphrase entered once
  → shared across bash / zsh / Obsidian for the rest of the session

Opening Obsidian first:
  → open-obsidian runs
  → GTK dialog prompts for passphrase
  → shared across bash / zsh / Obsidian for the rest of the session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Separation of Concerns
&lt;/h3&gt;

&lt;p&gt;In the end, the responsibilities around ssh-agent sorted themselves into a clear table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Handled by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent startup&lt;/td&gt;
&lt;td&gt;systemd user &lt;code&gt;ssh-agent.service&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent selection&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; / &lt;code&gt;IdentityAgent&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key loading (terminal)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ssh-load-github&lt;/code&gt; (TTY present)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key loading (GUI launch)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;open-obsidian&lt;/code&gt; + &lt;code&gt;SSH_ASKPASS&lt;/code&gt; (no TTY)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unattended Git operations&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BatchMode=yes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tools like keychain that handle everything in one shot are convenient, but they make it easy to accidentally create duplicate agents or fail to reach GUI applications. Separating the responsibilities made it clear exactly what was happening at every step.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lessons Learned
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;An environment variable is not a "setting" — it is the result of process inheritance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Even if &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; is correctly set somewhere, it only reaches a child process if that child is spawned from a process that has it. Configuration written in &lt;code&gt;.zshrc&lt;/code&gt; simply does not reach GUI applications. That was the root of everything.&lt;/p&gt;

&lt;p&gt;And an additional lesson:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sometimes a fixed socket with a simple agent is more deterministic than a polished GUI integration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;gcr-ssh-agent's desktop integration is elegant. But in this environment, its auto-restore mechanism turned out to be unstable at signing time. A plain OpenSSH ssh-agent on a fixed socket was far more predictable.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ssh</category>
      <category>git</category>
      <category>flatpak</category>
    </item>
    <item>
      <title>I Quit .bashrc.</title>
      <dc:creator>rbcn</dc:creator>
      <pubDate>Sat, 30 May 2026 17:47:33 +0000</pubDate>
      <link>https://dev.to/rbcn/i-quit-bashrc-1d21</link>
      <guid>https://dev.to/rbcn/i-quit-bashrc-1d21</guid>
      <description>&lt;h3&gt;
  
  
  Introduction
&lt;/h3&gt;

&lt;p&gt;One morning, the &lt;code&gt;j&lt;/code&gt; command was gone.&lt;/p&gt;

&lt;p&gt;I had been using it as an alias for zoxide, but suddenly it returned &lt;code&gt;command not found&lt;/code&gt;. It worked fine in Ghostty, but not in VSCode's integrated terminal. Zed behaved differently yet again.&lt;/p&gt;

&lt;p&gt;I had built the perfect &lt;em&gt;"I can't explain why this works"&lt;/em&gt; environment.&lt;/p&gt;

&lt;p&gt;This is the story of what happens when you keep piling settings into &lt;code&gt;.bashrc&lt;/code&gt; — and what you gain when you stop.&lt;/p&gt;




&lt;h3&gt;
  
  
  Before: The Everything-in-.bashrc Era
&lt;/h3&gt;

&lt;p&gt;When I started Linux development, I added a new line to &lt;code&gt;.bashrc&lt;/code&gt; every time I introduced a useful tool.&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="c"&gt;# .bashrc (bloated state)&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;starship init bash&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;zoxide init bash&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;fzf &lt;span class="nt"&gt;--bash&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;keychain &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="nt"&gt;--eval&lt;/span&gt; &lt;span class="nt"&gt;--agents&lt;/span&gt; ssh id_ed25519_work id_ed25519_personal&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bash_aliases
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.local/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ... secrets, completions, etc.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aliases, completions, environment variables, and tool initializations were all jammed into a single file.&lt;/p&gt;

&lt;p&gt;This caused three main problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① PATH inconsistency&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some tools would have their paths registered and some wouldn't. Every time I opened a new terminal, things might behave differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② Behavioral differences across terminals&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Commands that worked in Ghostty didn't work in VSCode's integrated terminal. The disappearing &lt;code&gt;j&lt;/code&gt; command was the clearest symptom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Loss of reproducibility&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I could no longer explain &lt;em&gt;why&lt;/em&gt; things worked. Was it my config? Something I ran earlier? I had no idea.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Insight: Shell ≠ Environment
&lt;/h3&gt;

&lt;p&gt;As I worked through the problem, something clicked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Shell ≠ Environment&lt;br&gt;
Shell = UI (the interface)&lt;br&gt;
Environment = execution context&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;.bashrc&lt;/code&gt; had been doing two completely different jobs at once: configuring the OS-standard shell &lt;em&gt;and&lt;/em&gt; initializing the development environment. That was the root of all the confusion.&lt;/p&gt;

&lt;p&gt;The problem wasn't the tools themselves (zoxide, starship). It was the &lt;strong&gt;mixing of initialization paths&lt;/strong&gt;. Login shells and interactive shells read different files — that discrepancy was causing the behavioral differences across terminals.&lt;/p&gt;




&lt;h3&gt;
  
  
  After: Decomposing into Three Layers
&lt;/h3&gt;

&lt;p&gt;Based on this insight, I split the environment into three distinct layers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash     = OS-standard / preservation layer
zsh      = development environment layer
terminal = display layer (all unified to zsh -l)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Returning bash to a preservation layer
&lt;/h4&gt;

&lt;p&gt;I reset &lt;code&gt;.bashrc&lt;/code&gt; to the system default.&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;cp&lt;/span&gt; /etc/skel/.bashrc ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only thing I kept was an expanded &lt;code&gt;HISTSIZE&lt;/code&gt;. All development-specific configuration was removed.&lt;/p&gt;

&lt;h4&gt;
  
  
  Consolidating the development environment into zsh
&lt;/h4&gt;

&lt;p&gt;Development tool initialization moved to &lt;code&gt;.zshrc&lt;/code&gt;.&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="c"&gt;# ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;starship init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;zoxide init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zsh_aliases
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.local/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aliases were also separated into &lt;code&gt;.zsh_aliases&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Unifying all terminals to &lt;code&gt;zsh -l&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;I updated each terminal's configuration to launch zsh as a login shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ghostty&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;command = /usr/bin/zsh -l
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;VSCode&lt;/strong&gt;&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="nl"&gt;"terminal.integrated.profiles.linux"&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;"zsh"&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;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/usr/bin/zsh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-l"&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="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"terminal.integrated.defaultProfile.linux"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"zsh"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Zed (v1.0.0)&lt;/strong&gt;&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="nl"&gt;"terminal"&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;"shell"&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;"with_arguments"&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;"program"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/usr/bin/zsh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-l"&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="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;&lt;strong&gt;WezTerm (&lt;code&gt;~/.wezterm.lua&lt;/code&gt;)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;wezterm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s1"&gt;'wezterm'&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;default_prog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'/usr/bin/zsh'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-l'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="n"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wezterm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;font&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'JetBrainsMono Nerd Font'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;font_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;color_scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Dracula"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;enable_tab_bar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&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;return&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WezTerm takes the shell and its arguments as an array via &lt;code&gt;default_prog&lt;/code&gt;. Font settings can be unified here as well.&lt;/p&gt;




&lt;h3&gt;
  
  
  Gotchas Along the Way
&lt;/h3&gt;

&lt;p&gt;A few things tripped me up during the migration.&lt;/p&gt;

&lt;h4&gt;
  
  
  Icons turn into tofu squares in VSCode only
&lt;/h4&gt;

&lt;p&gt;Symptoms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ghostty → OK
Zed     → OK
VSCode  → icons render as □ (tofu)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause: VSCode has a separate font setting for its integrated terminal.&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="nl"&gt;"terminal.integrated.fontFamily"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JetBrainsMono Nerd Font Mono"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding this line fixed it.&lt;/p&gt;

&lt;h4&gt;
  
  
  The display name and actual name of the font don't match
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Display name: JetBrainsMono Nerd Font Mono
Actual name:  JetBrainsMono NFM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ghostty resolves this transparently via fontconfig. WezTerm and Zed may require specifying &lt;code&gt;JetBrainsMono NFM&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;h4&gt;
  
  
  The &lt;code&gt;j&lt;/code&gt; command disappeared
&lt;/h4&gt;

&lt;p&gt;I had &lt;code&gt;alias j='z'&lt;/code&gt; in &lt;code&gt;.bash_aliases&lt;/code&gt;, but after migrating to zsh it needed to move to &lt;code&gt;.zsh_aliases&lt;/code&gt;.&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="c"&gt;# ~/.zsh_aliases&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;j&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'z'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;ji&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'zi'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  &lt;code&gt;$0&lt;/code&gt; and &lt;code&gt;$SHELL&lt;/code&gt; show different values
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;      &lt;span class="c"&gt;# → zsh&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$SHELL&lt;/span&gt;  &lt;span class="c"&gt;# → /bin/bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$SHELL&lt;/code&gt; reflects the login shell, not necessarily the currently running shell. Adding this to &lt;code&gt;.zshrc&lt;/code&gt; aligns them:&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;export &lt;/span&gt;&lt;span class="nv"&gt;SHELL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/zsh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: everything in .bashrc → non-deterministic environment
After:  bash=preservation / zsh=development / terminal=display → determinism restored
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Same environment regardless of which terminal launches it&lt;/li&gt;
&lt;li&gt;PATH issues eliminated&lt;/li&gt;
&lt;li&gt;Debugging scope narrowed to the application itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest win: I can now explain &lt;em&gt;why&lt;/em&gt; things work.&lt;/p&gt;




&lt;h3&gt;
  
  
  Closing
&lt;/h3&gt;

&lt;p&gt;Quitting &lt;code&gt;.bashrc&lt;/code&gt; wasn't about abandoning tools.&lt;br&gt;
It was about &lt;strong&gt;making ownership of the environment explicit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Shell is UI. Environment is execution context. Cramming both into the same file was the source of all the confusion.&lt;/p&gt;

&lt;p&gt;In the next post, I'll cover what happened immediately after this migration: Obsidian Git's SSH authentication broke, and I found myself in a battle with ssh-agent and the Flatpak sandbox.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>shell</category>
      <category>bash</category>
      <category>zsh</category>
    </item>
  </channel>
</rss>
