<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ByteLogger]]></title><description><![CDATA[Building dev tools & AI projects]]></description><link>https://bytelogger.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69f5baa1ec32cba9e5ce4379/e73efed5-2847-4449-b6de-021b36d2630d.png</url><title>ByteLogger</title><link>https://bytelogger.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 17 Sep 2026 02:42:22 GMT</lastBuildDate><atom:link href="https://bytelogger.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Running Git Commands from Python ]]></title><description><![CDATA[Once scaffold writes all the project files, it needs to turn the directory into an actual git repository - run git init, stage everything, and make the first commit. I went with running the git binary]]></description><link>https://bytelogger.hashnode.dev/running-git-commands-from-python</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/running-git-commands-from-python</guid><category><![CDATA[Python]]></category><category><![CDATA[cli]]></category><category><![CDATA[Git]]></category><category><![CDATA[subprocess]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Sun, 13 Sep 2026 11:55:55 GMT</pubDate><content:encoded><![CDATA[<p>Once <code>scaffold</code> writes all the project files, it needs to turn the directory into an actual git repository - run <code>git init</code>, stage everything, and make the first commit. I went with running the <code>git</code> binary as a subprocess rather than using a library like <code>GitPython</code>. It meant only less dependency, and I already knew the exact git command I needed. No reason to learn a new library's API for something the command line already handles well.</p>
<h2>The subprocess call</h2>
<p>Here's the helper that wraps every git command <code>scaffoldr</code> runs:</p>
<pre><code class="language-python">def _git(args: list[str], cwd: Path) -&gt; None:
    result = subprocess.run(
        ["git", *args],
        cwd=cwd,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        typer.echo(f"git error: {result.stderr.strip()}", err=True)
        raise typer.Exit(code=1)
</code></pre>
<p><code>subprocess.run</code> executes a command as if you'd typed it in a terminal. The first argument is a list - <code>["git", *args]</code> - where <code>*args</code> unpacks whatever command-specific arguments get passed in. <code>cwd</code> tells it which directory to run the command in.</p>
<h2>Why <code>cwd</code> matters</h2>
<p><code>subprocess.run</code> runs the command starting from Python's own working directory by default - wherever you launched <code>scaffoldr</code> from, not the directory you're turning into a repo. Passing <code>cwd</code> tells it to run the command as if you'd <code>cd</code>'d into that directory first. Without this, <code>git init</code> would initialize a repo in the wrong place.</p>
<h2>Capturing output intead of letting it print</h2>
<p><code>capture_output=True</code> redirects the subprocess's stdout and stderr into the <code>result</code> object instead of letting them print directly to the terminal. <code>text=True</code> decodes the output as a string instead of raw bytes, so you can call <code>.strip()</code> directly on <code>result.stderr</code>.</p>
<p>This matters because raw <code>subprocess</code> output would be messy - git's own stdout/stderr mixed in with whatever else <code>scaffoldr</code> is printing at the same time. Capturing it means the error message stays clean and readable: we can print just the relevant part.</p>
<h2>Checking for failure</h2>
<p><code>result.returncode</code> is the exit code the <code>git</code> process finished with. <code>0</code> means success - this is a convention nearly every command-line program follows, not something specific to git. Any non-zero value means something went wrong.</p>
<p>If <code>returncode != 0</code>, <code>scaffoldr</code> prints the captured stderr and exits with <code>typer.Exit(code=1)</code>. This propagates the failure up - if <code>git init</code> fails, <code>scaffold</code> doesn't continue trying to <code>git add</code> and <code>git commit</code> on a repo that was never created.</p>
<h2>Running the sequence</h2>
<p>Back in <code>scaffold</code>, the actual git setup is three calls:</p>
<pre><code class="language-python">_git(["init"], cwd=root)
_git(["add", "."], cwd=root)
_git(["commit", "-m", "chore: initial scaffold"], cwd=root)
</code></pre>
<p>Initialize the repo, stage every file, commit. Each calls reuses the same <code>_git</code> helper, so error handling for all three is identical - no repeated <code>subprocess.run</code> boilerplate for each command.</p>
<h2>What's next</h2>
<p>With local scaffolding done - files written, git repo initialized - the next step was making <code>scaffoldr new</code> actually talk to GitHub. That means authenticating, creating a repo through the API, and connecting the local repo to it. That's the next post.</p>
<p>The code is on <a href="https://github.com/mg4603/scaffoldr">GitHub</a></p>
]]></content:encoded></item><item><title><![CDATA[Rendering and Writing Files in Python]]></title><description><![CDATA[With the CLI shell in place, the next problem was what scaffoldr init actually creates. In v0.1.0, every project got the same directory structure and files - README, CONTRIBUTING guide, pyproject.toml]]></description><link>https://bytelogger.hashnode.dev/rendering-and-writing-files</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/rendering-and-writing-files</guid><category><![CDATA[Python]]></category><category><![CDATA[cli]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Tue, 01 Sep 2026 01:06:22 GMT</pubDate><content:encoded><![CDATA[<p>With the CLI shell in place, the next problem was what <code>scaffoldr init</code> actually creates. In v0.1.0, every project got the same directory structure and files - README, CONTRIBUTING guide, <code>pyproject.toml</code>, an ADR template, a .gitignore, and a CI workflow. All hardcoded.</p>
<h2>String templates as functions</h2>
<p>Each file's content lives in its own function, returning a formatted string. Here's the README:</p>
<pre><code class="language-python">def readme(project_name: str, author: str) -&gt; str:
    return f"""\
# {project_name}
&gt; Short description of what this project does.
## Installation
```bash
pip install {project_name}
```
## Usage
```bash
{project_name} --help
```
## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and
contribution guidelines.
## License
{author} - MIT
"""
</code></pre>
<p>The <code>\</code> right after the opening <code>"""</code> strips the leading newline that would otherwise appear before <code># {project_name}</code>. Without it, the string starts with a blank line.</p>
<p>Every file in the scaffold follows this pattern - a function that takes whatever variables it needs (<code>project_name</code>, <code>author</code>, <code>python_version</code>) and returns the finished file content as a f-string.</p>
<h2>Why functions instead of one big template</h2>
<p>I could have written this as one function - something that takes every possible variable (<code>project_name</code>, <code>author</code>, <code>python_version</code>, <code>license_</code>) plus a parameter saying which file's content to return - then branching internally to build the right string.</p>
<p>Splitting it into six separate functions instead means each one's signature only lists what it actually uses. <code>gitignore()</code> takes nothing. <code>pyproject()</code> takes <code>project_name</code>, <code>author</code>, <code>license_</code>, <code>python_version</code>. A single dispatcher function would need every one of those parameters available at once, even though most individual files only use a subset.</p>
<p>It also means adding a new content-generating function later doesn't touch the existing ones - no shared dispatcher to extend, no risk of breaking <code>readme()</code> while adding <code>license_file()</code>.</p>
<h2>Creating the folder structure</h2>
<p>The actual scaffolding happens in <code>scaffold</code>:</p>
<pre><code class="language-python">def scaffold(project_name: str, path: Path) -&gt; None:
    cfg = Config.load()
    root = path / project_name
    if root.exists():
        typer.echo(f"Error: {root} already exists.", err=True)
        raise typer.Exit(code=1)
    typer.echo(f"Creating project at {root} ...")
    (root / project_name).mkdir(parents=True)
    (root / "tests").mkdir()
    (root / "docs" / "adr").mkdir(parents=True)
    (root / ".github" / "workflows").mkdir(parents=True)
</code></pre>
<p>Before creating anything, <code>scaffold</code> checks if root already exists and exits with an error if it does. This avoids silently overwriting someone's files.</p>
<p><code>mkdir(parents=True)</code> creates every missing directory in the path, not just the final one. <code>docs / adr</code> is two levels deep - <code>docs</code> and <code>docs/adr</code>. Without <code>parents=True</code>, you'd need two separate <code>mkdir()</code> calls, one for each level.</p>
<h2>Writing the files</h2>
<p>Once the directories exist, writing files is straightforward:</p>
<pre><code class="language-python">(root / "README.md").write_text(readme(project_name, cfg.author))
(root / "CONTRIBUTING.md").write_text(contributing(project_name))
(root / "pyproject.toml").write_text(
    pyproject(
        project_name, cfg.author, cfg.python_version, cfg.license
    )
)
</code></pre>
<p>Each file gets written the same way - call the content-generating function, pass the result to <code>Path.write_text()</code>.</p>
<p><code>__init__.py</code> files follow the same pattern, just with empty strings:</p>
<pre><code class="language-python">(root / "tests" / "__init__.py").write_text("")
(root / project_name / "__init__.py").write_text("")
</code></pre>
<p>This makes both the project's own package folder and its test folder proper Python packages.</p>
<h2>What's next</h2>
<p>At this point, scaffold finishes by initializing git and making the first commit. That's the next post.</p>
<p>The code is on <a href="https://github.com/mg4603/scaffoldr">GitHub</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Setting up a CLI with Typer]]></title><description><![CDATA[I'd used argparse before. It's clunky - lots of boilerplate for even simple commands. Typer works differently. You write a normal function with type annotated parameters. Typer builds the CLI from the]]></description><link>https://bytelogger.hashnode.dev/setting-up-a-cli-with-typer</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/setting-up-a-cli-with-typer</guid><category><![CDATA[Python]]></category><category><![CDATA[cli]]></category><category><![CDATA[Typer]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Sat, 29 Aug 2026 01:16:50 GMT</pubDate><content:encoded><![CDATA[<p>I'd used <code>argparse</code> before. It's clunky - lots of boilerplate for even simple commands. Typer works differently. You write a normal function with type annotated parameters. Typer builds the CLI from the signature.</p>
<h2>A basic command</h2>
<p>Here's the <code>init</code> command as it shipped with v0.1.0:</p>
<pre><code class="language-python">from pathlib import Path
from typer import Typer
from typer import Argument as typer_argument
from typer import Option as typer_option

app = Typer(help="Scaffold a new project locally")

@app.command("init")
def init(
    project_name: str = typer_argument(
        ..., help="Name of the new project"
    ),
    path: Path = typer_option(
        Path("."), help="Where to create the project"
    ),
) -&gt; None:
    """Scaffold a new project locally."""
    ...
</code></pre>
<p>No parser setup. No <code>add_argument</code> calls. The function signature is the whole CLI definition.</p>
<h2>Arguments vs options</h2>
<p>Typer splits inputs into two kinds: arguments and options.</p>
<p><code>project_name</code> uses <code>typer_argument</code>. Arguments are positional and required by default. Run <code>scaffoldr init myproject</code>, and <code>myproject</code> fills that slot. The <code>...</code> as the first value tells Typer that this argument has no default - it must be provided.</p>
<p><code>path</code> uses <code>typer_option</code>. Options use <code>--flag value</code> syntax, like <code>scaffoldr init myproject --path ~/projects</code>. Options always have a default, set by the first value passed to <code>typer_option</code>. Skip the flag, and it falls back to <code>Path(".")</code>.</p>
<h2>Type hints drive validation</h2>
<p>Look at <code>path: Path</code>. Typer reads that type hint. It converts whatever the user types into a <code>pathlib.Path</code> object. No conversion needed. It arrives as the right type inside the function.</p>
<p>Booleans work the same way. For example:</p>
<pre><code class="language-python">protect: bool = typer_option(
    True, help="Enable branch protection on main."
)
</code></pre>
<p>Typer creates <code>--protect</code> and <code>--no-protect</code> flags automatically. No extra code for that either.</p>
<h2>Docstrings become help text</h2>
<p>The docstring under the function - <code>"""Scaffold a new project locally."""</code> - isn't just for developers reading the code. Typer shows it when someone runs <code>scaffoldr init --help</code>. The <code>help=</code> argument on <code>typer_argument</code> and <code>typer_option</code> works the same way. It shows per-parameter help text.</p>
<p>The function signature and docstring are the only source of truth for the CLI's documentation.</p>
<h2>Registering the entry point</h2>
<p>Once the function is decorated with <code>@app.command("init")</code>, it needs to be wired into the installable CLI. In <code>pyproject.toml</code>:</p>
<pre><code class="language-toml">[project.scripts]
scaffoldr = "scaffoldr.main:app"
</code></pre>
<p>This tells Python's packaging tools to create a <code>scaffoldr</code> executable. It runs the app object from <code>scaffoldr/main.py</code>. <code>app</code> is the Typer instance every command gets registered to.</p>
<h2>What's next</h2>
<p>With the CLI shell in place, the next problem was defining what gets created when someone runs <code>scaffoldr init</code> - the folder structure and files that make up a scaffolded project. In v0.1.0, this was hardcoded. That's covered in the next post.</p>
]]></content:encoded></item><item><title><![CDATA[watchr v0.1.0: A Retrospective - Lessons and What's Next]]></title><description><![CDATA[This series has covered why watchr looks the way it does: TOML for config, debouncing for file events, graceful shutdown, and readable output.
v0.1.0 is shipped. This post is about what I'd change, an]]></description><link>https://bytelogger.hashnode.dev/watchr-v0-1-0-retrospective</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/watchr-v0-1-0-retrospective</guid><category><![CDATA[Rust]]></category><category><![CDATA[cli]]></category><category><![CDATA[devtools]]></category><category><![CDATA[Retrospective]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Sat, 22 Aug 2026 01:40:36 GMT</pubDate><content:encoded><![CDATA[<p>This series has covered why <code>watchr</code> looks the way it does: TOML for config, debouncing for file events, graceful shutdown, and readable output.</p>
<p>v0.1.0 is shipped. This post is about what I'd change, and what's already queued up for v0.2.0.</p>
<h2>The duplicate-run bug</h2>
<p>While testing the output formatting, I hit something odd: a single file save sometimes triggered multiple runs of the configured command. I don't have the root cause yet, but I have suspects.</p>
<h3>Most likely</h3>
<ul>
<li><p><code>notify-debouncer-full</code> itself might be batching events in a way that lets duplicates slip through before my filtering logic catches them</p>
</li>
<li><p>If the channel sender gets cloned more than once for the same entry, that could cause a duplicate send</p>
</li>
</ul>
<h3>Less likely</h3>
<ul>
<li><p>The debounce window may not be catching both events if they land far enough apart</p>
</li>
<li><p>Multiple debouncers could be watching overlapping directories and each reporting the same change</p>
</li>
</ul>
<p>The first two feel more likely given how I hit this: a single entry in Neovim, whose write pattern should fit comfortably inside the default 500ms window. That points more toward the debouncer's internal batching or a channel-cloning issue.</p>
<p>This is a real bug, not a nice-to-have. It goes against the exact thing debouncing is supposed to guarantee: one save, one configured command run.</p>
<h2>Better logging</h2>
<p>Better logging gives visibility into what <code>watchr</code> is doing internally - which events fired, how debouncing grouped them, what decisions got made along the way. This matters for diagnosing bugs like the one above, where the actual cause is still unclear without seeing what happened internally at the moment it occurred.</p>
<p>The plan: add <code>tracing</code>, a structured logging crate for Rust. <code>watchr</code> can then report what it's doing internally at different verbosity levels.</p>
<p>This is separate from command output (that's the result of <em>your</em> command). Tracing logs are about what <code>watchr</code> itself is doing, and only show up if you ask for them.</p>
<h2>Knowing which watcher fired</h2>
<p>If you configure more than one <code>[[watcher]]</code> entry, <code>watchr</code> can't tell you which one triggered a given command.</p>
<p>The event only holds the command string, not the entry name:</p>
<pre><code class="language-rust">pub enum WatchEvent {
    Command(String),
    Shutdown,
}
</code></pre>
<p>Fixing this means adding the entry name to <code>Command</code>, so output can say which watcher ran, not just what it ran. Small change, but it matters once you have more than one entry watching different things.</p>
<h2>Naming cleanup</h2>
<p>Some types are named <code>Watcher-something</code> (<code>WatcherError</code>), others are named <code>Watchr-something</code> (<code>WatchrConfig</code>). There's no rule - it just happened inconsistently.</p>
<p>That's not a bug, but it's friction. Every time I add a new type, or use an existing type, I have to stop and think about which spelling to use.</p>
<p>v0.2.0 standardizes on <code>Watcher</code> everywhere internally, keeping <code>watchr</code> only as the name of the CLI tool.</p>
<h2>What I'd tell myself starting over</h2>
<p>Decide naming conventions before writing the first struct. Fixing inconsistent names later means touching files that already work - tedious, and slows down your flow.</p>
<p>Test the actual user-facing output earlier. The raw debug output from weeks ago looked bad, but I didn't notice until I actually used it. I was testing that the <em>logic</em> worked, not that the <em>output</em> was usable. Those are different things.</p>
<h2>Where this leaves things</h2>
<p>v0.1.0 works. The remaining gaps are known, filed, and scoped - which is where I want to be before v0.2.0. If you want to follow the actual work, the issues are public on the <a href="https://github.com/mg4603/watchr">watchr</a> repo.</p>
]]></content:encoded></item><item><title><![CDATA[From println! to Structured Output: A UX Lesson]]></title><description><![CDATA[In the last post, I covered graceful shutdown. This post is about a bug that wasn't really a bug - just bad output that made watchr hard to actually use.
What it looked like
Initially, watchr ran your]]></description><link>https://bytelogger.hashnode.dev/println-to-structured-output</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/println-to-structured-output</guid><category><![CDATA[Rust]]></category><category><![CDATA[cli]]></category><category><![CDATA[devtools]]></category><category><![CDATA[UX]]></category><category><![CDATA[error handling]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Sun, 16 Aug 2026 00:02:16 GMT</pubDate><content:encoded><![CDATA[<p>In the last post, I covered graceful shutdown. This post is about a bug that wasn't really a bug - just bad output that made <code>watchr</code> hard to actually use.</p>
<h2>What it looked like</h2>
<p>Initially, <code>watchr</code> ran your command and printed whatever it got back - straight from Rust's debug formatting:</p>
<pre><code class="language-rust">match output {
    Ok(out) =&gt; println!("{:?}", out),
    Err(e) =&gt; println!("{:?}", e),
}
</code></pre>
<p>Here's the output looked like after running a test suite:</p>
<pre><code class="language-text">Output { status: ExitStatus(unix_wait_status(0)), stdout: "test\n", stderr: "" }
</code></pre>
<p>Technically correct. Yet, completely unreadable. You're staring at Rust's internal struct representation instead of your test results.</p>
<h2>Why this mattered</h2>
<p><code>watchr</code>'s whole job is running your command and showing you what happened. If the output is unreadable, the tool fails its purpose.</p>
<p>This wasn't a "nice to have" fix. It was a release blocker. Shipping v0.1.0 with debug structs as the primary output would've made the tool unusable.</p>
<h2>Designing the fix</h2>
<p>Before writing the code, I finalized what the user actually needs to see:</p>
<ul>
<li><p>The command that was run.</p>
</li>
<li><p>Did the command succeed or fail?</p>
</li>
<li><p>If it failed, why - an exit code, or did it not even run?</p>
</li>
<li><p>What did the command actually print?</p>
</li>
</ul>
<p>That's it. No Rust internals, no exposed structs.</p>
<h2>The fix</h2>
<pre><code class="language-rust">fn print_output(
    cmd: &amp;str,
    output: Result&lt;process::Output, std::io::Error&gt;,
) {
    println!("$ {}", cmd);

    match output {
        Ok(out) if out.status.success() =&gt; {
            println!("✓ success");
            match String::from_utf8_lossy(&amp;out.stdout).trim() {
                "" =&gt; println!("(no output)"),
                out =&gt; println!("{}", out),
            }
        }
        Ok(out) =&gt; {
            match out.status.code() {
                Some(code) =&gt; {
                    println!("✗ failed (exit code {})", code)
                }
                None =&gt; println!("✗ failed (terminated)"),
            }

            match String::from_utf8_lossy(&amp;out.stderr).trim() {
                "" =&gt; eprintln!("(no output)"),
                err =&gt; eprintln!("{}", err),
            }
        }
        Err(e) =&gt; {
            println!("✗ failed to spawn: {}", e);
        }
    }
}
</code></pre>
<p>Walking through the three cases:</p>
<ul>
<li><p><code>Ok(out) if out.status.success()</code> - the command ran and succeeded. Print a checkmark, then the command's actual output.</p>
</li>
<li><p><code>Ok(out)</code> (the fallthrough) - the command ran but failed. Show the exit code if there is one, or <code>"terminated"</code> if the process was killed by a signal instead of exiting normally. Then print stderr.</p>
</li>
<li><p><code>Err(e)</code> - the command never ran at all. This happens if, say, the shell itself couldn't be found.</p>
</li>
</ul>
<p>It is worth noting that <code>Command::output()</code> returns a <code>Result&lt;Output, io::Error&gt;</code>, not just <code>Output</code>. <code> Ok</code>means the process spawned successfully (whether it succeeded or failed is a separate question, answered by status).<code>Err</code> means it couldn't even start.</p>
<p><code>String::from_utf8_lossy</code> turns the raw bytes from stdout and stderr into a displayable string. <code>.trim()</code> matters because command output often ends in a trailing newline, or is nothing but whitespace. Without trimming, that would fail to match the <code>""</code> case and print a blank line instead of <code>"(no output)"</code>. A blank line looks like the tool might be stuck. A clear label prevents this confusion.</p>
<h2>What you get now</h2>
<pre><code class="language-text">$ cargo test
✓ success
running 20 tests
test cli::tests::test_is_init_true ... ok
...
test result: ok. 20 passed; 0 failed
</code></pre>
<p>No debug structs. Success or failure is the first thing you see, not something you have to dig for.</p>
<h2>Next</h2>
<p>A retrospective on what's changing in v0.2.0</p>
]]></content:encoded></item><item><title><![CDATA[Graceful Shutdown in Rust CLI Tools]]></title><description><![CDATA[In the last post, I covered debouncing - how watchr groups rapid file events into a single one. This post covers something smaller but just as important: what happens when you hit Ctrl+C
The problem
w]]></description><link>https://bytelogger.hashnode.dev/graceful-shutdown-rust-cli</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/graceful-shutdown-rust-cli</guid><category><![CDATA[Rust]]></category><category><![CDATA[cli]]></category><category><![CDATA[devtools]]></category><category><![CDATA[signals]]></category><category><![CDATA[concurrency]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Sat, 08 Aug 2026 01:31:12 GMT</pubDate><content:encoded><![CDATA[<p>In the last post, I covered debouncing - how <code>watchr</code> groups rapid file events into a single one. This post covers something smaller but just as important: what happens when you hit Ctrl+C</p>
<h2>The problem</h2>
<p><code>watchr</code> runs in a loop, watching files and running commands forever, until you stop it. The obvious way is to hit Ctrl+C.</p>
<p>But Ctrl+C sends a signal that kills your process immediately. No cleanup, no "wrapping up now" message. If <code>watchr</code> were mid-command when you hit Ctrl+C, the process would just stop - no final message, no confirmation it actually shut down cleanly.</p>
<p>This is bad for UX. I wanted <code>watchr</code> to catch Ctrl+C, print something reassuring, and exit cleanly.</p>
<h2>The approach: a channel and an extra event</h2>
<p><code>watchr</code> already uses a channel to pass events from its file watchers to the main loop. Channels in Rust are a way for one part of your code to send messages to another, even across threads. One side holds a <code>Sender</code>, the other holds a <code>Receiver</code>.</p>
<p>Here's the event type:</p>
<pre><code class="language-rust">#[derive(Debug)]
pub enum WatchEvent {
    Command(String),
    Shutdown,
}
</code></pre>
<p>The #[derive(Debug)] attribute is a Rust feature that automatically generates code to print these values for debugging. The enum itself defines two possible types of events the system can handle: Command (when a file changes) and Shutdown (when the user exits).</p>
<p>The <code>Shutdown</code> variant was added to support graceful shutdown. The channel still carries the same <code>WatchEvent</code> type - it just now has two cases.</p>
<h2>Why not just use a bool flag?</h2>
<p>You might wonder: why not just use a shared AtomicBool flag that the Ctrl+C handler sets to true? That works too, but it means adding a check on every loop iteration, and reasoning about when exactly that flag gets read.</p>
<p>Routing shutdown through the existing channel is cleaner: the event loop already blocks on <code>rx.recv()</code> waiting for the next thing to do. Shutdown is just another message that the loop has to handle. No new synchronization mechanism to reason about.</p>
<h2>Installing the Ctrl+C handler</h2>
<pre><code class="language-rust">fn create_shutdown_handler(
    tx: Sender&lt;WatchEvent&gt;,
) -&gt; Result&lt;(), WatcherError&gt; {
    ctrlc::try_set_handler(move || {
        let _ = tx.send(WatchEvent::Shutdown);
    })?;
    Ok(())
}
</code></pre>
<p><code>ctrlc::try_set_handler</code> takes a closure and runs it when Ctrl+C is pressed. The closure here does one thing: send <code>WatchEvent::Shutdown</code> down the channel.</p>
<p>That's the whole trick. Ctrl+C doesn't kill anything directly. It just drops a message in the same queue the file watchers already use.</p>
<h2>Handling it in the event loop</h2>
<pre><code class="language-rust">fn run_event_loop(rx: Receiver&lt;WatchEvent&gt;) {
    loop {
        match rx.recv() {
            Ok(WatchEvent::Command(cmd)) =&gt; {
                let output = process::Command::new("sh")
                    .arg("-c")
                    .arg(&amp;cmd)
                    .output();

                print_output(&amp;cmd, output)
            }
            Ok(WatchEvent::Shutdown) =&gt; {
                println!("Shutting down gracefully...");
                break;
            }
            Err(_) =&gt; break,
        }
    }
}
</code></pre>
<p>This loop calls <code>rx.recv()</code> and blocks until a message shows up. The match statement checks what type of event we received.</p>
<p>If it's a <code>Command</code>, run it. If it's <code>Shutdown</code>, print a message and break out of the loop - which ends the function and terminates the program.</p>
<p>No special-casing, no separate shutdown path. Shutdown is just another message the loop has to handle.</p>
<h2>What you get</h2>
<p>Hit Ctrl+C, and <code>watchr</code> prints "Shutting down gracefully..." and exits. No abrupt kills, no wondering if all command runs were actually terminated.</p>
<h2>Next</h2>
<p>The story behind issue <a href="https://github.com/mg4603/watchr/issues/23">#23</a> - how <code>watchr</code>'s command output went from unreadable debug structs to something that is actually useful.</p>
]]></content:encoded></item><item><title><![CDATA[Building a File Watcher in Rust: Debouncing Done Right]]></title><description><![CDATA[In the last post, I talked about why watchr uses TOML for its config file. This one covers how watchr actually watches files - and why debouncing is the piece that makes it usable.
The problem
You hit]]></description><link>https://bytelogger.hashnode.dev/debouncing-file-watcher-rust</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/debouncing-file-watcher-rust</guid><category><![CDATA[Rust]]></category><category><![CDATA[cli]]></category><category><![CDATA[devtools]]></category><category><![CDATA[file system]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Sat, 01 Aug 2026 03:27:57 GMT</pubDate><content:encoded><![CDATA[<p>In the last post, I talked about why <code>watchr</code> uses TOML for its config file. This one covers how <code>watchr</code> actually watches files - and why debouncing is the piece that makes it usable.</p>
<h2>The problem</h2>
<p>You hit save in your editor. Feels like one event, right? It often isn't. A single save can fire off several filesystem events within milliseconds — a write, a metadata update, sometimes a temp-file-then-rename depending on your editor.</p>
<p>If <code>watchr</code> reacted to every event, one save would trigger <code>cargo test</code> two or three times in a row. That's wasteful, and confusing if you don't know what is happening.</p>
<p><strong>Debouncing</strong> fixes this. Instead of reacting to every event immediately, you wait a short window to see if more events are coming. If several arrive close together, you treat them as one and react just once.</p>
<h2>Why not raw <code>notify</code>?</h2>
<p><code>notify</code> is Rust's standard crate for watching files. It's solid but low-level - it hands you raw events as they happen, with no grouping or debouncing built in.</p>
<p>Using it directly would mean building my own debounce logic: a timer, a buffer for pending events, and logic to determine if the debounce window has closed. Doable, but not the problem I set out to solve.</p>
<p>So I used <code>notify-debouncer-full</code> instead. It wraps <code>notify</code> and handles debouncing for you. The tradeoff is less low-level control and one more dependency - a fair trade for not increasing the likelihood of introducing bugs.</p>
<h2>The code</h2>
<p>Here's the function that sets up a watcher for each entry in your config:</p>
<pre><code class="language-rust">fn create_debouncers(
    debounce_ms: u64,
    entries: Vec&lt;WatchrEntry&gt;,
    tx: Sender&lt;WatchEvent&gt;,
) -&gt; Result&lt;
    Vec&lt;Debouncer&lt;RecommendedWatcher, NoCache&gt;&gt;,
    WatcherError,
&gt; {
    let mut debouncers = Vec::new();
    for entry in entries {
        let tx = tx.clone();

        let mut debouncer = new_debouncer(
            Duration::from_millis(debounce_ms),
            None,
            move |result: DebounceEventResult| {
                handle_events(
                    result,
                    entry.ext.clone(),
                    entry.command.clone(),
                    tx.clone(),
                );
            },
        )?;

        for dir in &amp;entry.dirs {
            debouncer.watch(dir, RecursiveMode::Recursive)?;
        }
        debouncers.push(debouncer);
    }
    Ok(debouncers)
}
</code></pre>
<p>Each <code>[[watcher]]</code> entry in your <code>.watchr.toml</code> gets its own debouncer, watching its own directories with its own command.</p>
<p><code>Duration::from_millis(debounce_ms)</code> sets the wait window - your <code>debounce_ms</code> value from the config. This is how long <code>watchr</code> waits for things to go quiet before treating a burst of events as one.</p>
<p>The closure passed into <code>new_debouncer</code> is the callback that runs once the window closes. It receives a batch of grouped events and hands them off to <code>handle_events</code>, which checks if they match your configured file extensions before running anything.</p>
<p><code>debouncer.watch(dir, RecursiveMode::Recursive)</code> registers each directory to be watched, including subdirectories.</p>
<h2>What this buys you</h2>
<p>Your editor fires three events for one save. <code>watchr</code> waits, groups them, and treats them as one change. You get exactly what you expect: one save, one command run.</p>
<p>One known gap: certain saves still trigger a command multiple times. This is a real bug, and I'll cover the fix in a later post on v0.2.0 changes.</p>
<h2>Next</h2>
<p>How <code>watchr</code> shuts down cleanly when you hit Ctrl+C, instead of getting killed mid command.</p>
]]></content:encoded></item><item><title><![CDATA[Why I Chose TOML for watchr's Config File]]></title><description><![CDATA[When I started building watchr - a CLI tool that watches directories and runs a command whenever a file changes - I hit a decision early on that felt small but wasn't: what format should the config fi]]></description><link>https://bytelogger.hashnode.dev/why-toml-for-watchr-config</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/why-toml-for-watchr-config</guid><category><![CDATA[Rust]]></category><category><![CDATA[TOML]]></category><category><![CDATA[YAML]]></category><category><![CDATA[cli]]></category><category><![CDATA[devtools]]></category><category><![CDATA[config]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Sat, 25 Jul 2026 00:19:58 GMT</pubDate><content:encoded><![CDATA[<p>When I started building <code>watchr</code> - a CLI tool that watches directories and runs a command whenever a file changes - I hit a decision early on that felt small but wasn't: what format should the config file be in?</p>
<p><code>watchr</code> needs to know which directories to watch, which file extensions matter, and what command to run when something changes. That means a config file, and a config file means picking a format. Here's why I ended up picking TOML over YAML, even though it's the more familiar choice for a lot of people.</p>
<h2>What I actually needed</h2>
<p>Before picking a format, I wrote down what mattered:</p>
<ul>
<li><p><strong>Human readable:</strong> someone should be able to open the file and understand it without a manual</p>
</li>
<li><p><strong>Human editable:</strong> no weird escaping rules that make you afraid to touch it</p>
</li>
<li><p><strong>Good tooling support:</strong> I didn't want to hand-roll a parser</p>
</li>
</ul>
<p>Just "a normal person should be able to write this by hand."</p>
<h2>Why not YAML</h2>
<p>YAML is the obvious first candidate. It's used in Docker Compose, GitHub Actions, Kubernetes. If you've written a config file in the last few years, odds are it was YAML.</p>
<p>But YAML has some well-known pitfalls. One is <strong>implicit typing</strong>. YAML tries to guess what type a value is. Type <code>yes</code> and YAML might quietly turn it into the boolean <code>true</code> instead of the string <code>"yes"</code>. If you're not expecting that, it's a confusing bug to track down.</p>
<p>The <strong>Norway Problem</strong> is a famous example of this. Write the country code <code>NO</code> without quotes, and YAML parses it as <code>false</code>. Your country code silently becomes a boolean. That's the kind of surprise I didn't want baked into a file that users are hand-editing. </p>
<p>Another notable pitfall is the strict indentation rules that can lead to unexpected errors.</p>
<p>None of this makes YAML bad - it's used everywhere for good reasons. But for a small CLI tool's config file, I wanted fewer sharp edges.</p>
<h2>Why TOML</h2>
<p>TOML (Tom's Obvious, Minimal Language) is built to avoid this kind of ambiguity. Strings need quotes. Types are explicit. You always know exactly what you're getting.</p>
<p>Here's a real <code>watchr.toml</code> file:</p>
<pre><code class="language-toml">debounce_ms = 500

[[watcher]]
name = "tests"
dirs = ["src/"]
ext = ["rs", "toml"]
command = "cargo test"

[[watcher]]
name = "lint"
dirs = ["src/"]
command = "cargo clippy"
</code></pre>
<p>Walking through this:</p>
<ul>
<li><p><code>debounce_ms=500</code> is a plain integer.</p>
</li>
<li><p><code>[[watcher]]</code> defines an array of tables - TOML's way of saying "here's a list of entries, and each one has its own section below it." Every   <code>[[watcher]]</code> starts a new entry.</p>
</li>
<li><p><code>dirs = ["src/"]</code> and <code>ext = ["rs", "toml"]</code> are arrays of strings. Explicit, quoted, no surprises.</p>
</li>
<li><p><code>command = "cargo test"</code> is just a string. What you type is what you get.</p>
</li>
</ul>
<p>YAML can express all this too. The risk isn't that it can't - it's that small mistakes like a missing quote are easy to make and hard to spot.</p>
<p>TOML also has strong tooling support across languages, including Rust, where the <code>toml</code> crate makes parsing this into a config struct straightforward.</p>
<h2>The tradeoff</h2>
<p>If you're coming from a JavaScript or Python background, you're probably more used to JSON or YAML, so TOML might feel like one more syntax to learn.</p>
<p>But for a file that users hand-edit, hand-write, and copy-paste between projects, trading unfamiliarity for a format that doesn't quietly turn <code>NO</code> into <code>false</code> felt justified.</p>
<h2>What's next</h2>
<p>Next up: how <code>watchr</code> actually watches files and handles debouncing - the part where rapid file saves don't trigger a dozen redundant command runs.</p>
]]></content:encoded></item><item><title><![CDATA[I kept forgetting things at project setup - so I built scaffoldr]]></title><description><![CDATA[Everytime I created a new project, I'd go through the same ritual. Create the repo, clone it, set up the directory structure, write the initial pyproject.toml, configure CI, add branch protection, cre]]></description><link>https://bytelogger.hashnode.dev/i-kept-forgetting-things-at-project-setup-so-i-built-scaffoldr</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/i-kept-forgetting-things-at-project-setup-so-i-built-scaffoldr</guid><category><![CDATA[Python]]></category><category><![CDATA[cli]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Wed, 15 Jul 2026 11:24:23 GMT</pubDate><content:encoded><![CDATA[<p>Everytime I created a new project, I'd go through the same ritual. Create the repo, clone it, set up the directory structure, write the initial pyproject.toml, configure CI, add branch protection, create the initial generic issues. It took 20-30 minutes and I still managed to forget something almost every time. The problem wasn't getting blocked immediately, it was that things would silently not work as expected. CI wouldn't run on PRs because I hadn't configured it. Direct pushes to main would go through because branch protection wasn't set up. I'd only notice days later when something slipped through that shouldn't have.</p>
<h2>What I built</h2>
<p><code>scaffoldr</code> is a Python tool that handles all this in one command. Run <code>scaffoldr new myproject</code> and you get a local project with an opinionated directory structure, a GitHub repo created via the API, an initial commit pushed, default issues opened, and branch protection configured on main. What used to take 20-30 minutes and a checklist now takes seconds.</p>
<h2>Getting started</h2>
<p>Clone the repo and install locally:</p>
<pre><code class="language-bash">git clone https://github.com/mg4603/scaffoldr.git
cd scaffoldr
pipx install .
</code></pre>
<p>Set up your config once:</p>
<pre><code class="language-bash">scaffoldr config init
</code></pre>
<p>This creates <code>~/.config/scaffoldr/config.toml</code> with your GitHub username, token and preferences. Then scaffold a new project:</p>
<pre><code class="language-bash">scaffoldr new myproject --description "What it does"
</code></pre>
<p>If you just want the local repo without the GitHub setup:</p>
<pre><code class="language-bash">scaffoldr init myproject
</code></pre>
<p>Not sure what you'll get? Run with <code>--dry-run</code> first:</p>
<pre><code class="language-bash">scaffoldr new myproject --dry-run
</code></pre>
<h2>Templates</h2>
<p>By default <code>scaffoldr</code> uses a built-in Python project template. You can define your own in <code>~/.config/scaffoldr/templates/</code> and pass it with <code>--template</code>:</p>
<pre><code class="language-bash">scaffoldr new myproject --template mytemplate
</code></pre>
<p>Validate a template before using it:</p>
<pre><code class="language-bash">scaffoldr template validate ~/.config/scaffoldr/templates/mytemplate.toml
</code></pre>
<h2>How it works</h2>
<p><code>scaffoldr</code> talks to the GitHub API via <code>httpx</code>. Local scaffolding renders a TOML template using Python's <code>str.format_map</code>, writes the files, then runs <code>git init</code>, <code>git add .</code>, and an initial commit via a subprocess. <code>scaffoldr</code> then calls the GitHub API to create the repo, push the initial commit via the configured remote (SSH or HTTPS with token embedding), open the default issues, and set branch protection on main.</p>
<h2>What's next</h2>
<p>The code is on <a href="https://github.com/mg4603/scaffoldr">GitHub</a>. v0.2.0 just shipped with user defined templates, a <code>--dry-run</code> flag, and a template validate command. Feel free to open an issue if you have ideas or run into bugs.</p>
]]></content:encoded></item><item><title><![CDATA[watchr - Automate Your Dev Workflow When Files Change]]></title><description><![CDATA[The Problem
Every dev has been there - you save a file, switch to the terminal, run the tests, switch back. Save, switch, run, switch back. It's just constant friction that breaks your flow. watchr el]]></description><link>https://bytelogger.hashnode.dev/watchr-automate-your-dev-workflow-when-files-change</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/watchr-automate-your-dev-workflow-when-files-change</guid><category><![CDATA[Rust]]></category><category><![CDATA[cli]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[automation]]></category><category><![CDATA[workflow]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Mon, 01 Jun 2026 18:02:26 GMT</pubDate><content:encoded><![CDATA[<h3>The Problem</h3>
<p>Every dev has been there - you save a file, switch to the terminal, run the tests, switch back. Save, switch, run, switch back. It's just constant friction that breaks your flow. <code>watchr</code> eliminates that loop entirely by watching your directories and running commands automatically the monent something changes.</p>
<hr />
<h3>What watchr Does</h3>
<p><code>watchr</code> is a Rust CLI tool that watches one or more directories for file changes and runs a command in response. You configure it once, either via a <code>.watchr.toml</code> or directly on the CLI, and it handles the rest. Save a file, whatever you've configured for that directory runs automatically. No switching, no manual re-runs.</p>
<hr />
<h3>How It Works Under the Hood</h3>
<p><code>watchr</code> uses <code>notify-deboucer-full</code> for efficient filesystem monitoring. The debouce window prevents multiple runs from triggersing when you save repeatedly in a short period. Commands are executed via <code>sh -c</code> so you can chain them with &amp;&amp;, use pipes, or run anything you'd normally type in a shell.</p>
<hr />
<h3>Installation</h3>
<pre><code class="language-bash">git clone https://github.com/mg4603/watchr.git
cd watchr
cargo build --release
# Binary will be at target/release/watchr

cargo install --path . 
# To install to local Cargo bin dir
</code></pre>
<hr />
<h3>Quick Start</h3>
<pre><code class="language-bash">watchr watch src/ --cmd "cargo test"
</code></pre>
<p>Filter by extension:</p>
<pre><code class="language-bash">watchr watch src/ --ext rs,toml --cmd "cargo test"
</code></pre>
<hr />
<h3>Using a Config File</h3>
<p>For anything beyond a single watchr, a <code>.watchr.toml</code> is the right move. Generate a template:</p>
<pre><code class="language-bash">watchr init
</code></pre>
<p>Then edit it:</p>
<pre><code class="language-toml">debounce_ms = 500

[[watcher]]
name = "tests"
dirs = ["src/", "tests/"]
ext = ["rs"]
command = "cargo test"

[[watcher]]
name = "lint"
dirs = ["src/"]
command = "cargo clippy"
</code></pre>
<p>Start watching:</p>
<pre><code class="language-bash">watchr watch
</code></pre>
<p><code>watchr</code> walks up the directory tree to find <code>.watchr.toml</code> automatically. You can also point it at an explicit path:</p>
<pre><code class="language-bash">watchr watch --config ~/my-project/.watchr.toml
</code></pre>
<hr />
<h3>Get the Code</h3>
<p>Full source at <a href="https://github.com/mg4603/watchr">GitHub</a>. If it save you a few hundred manual test runs, give it a star.</p>
]]></content:encoded></item><item><title><![CDATA[denoiser - Clean audio for Dev Screencasts Without a Soundproof Room]]></title><description><![CDATA[The Problem
If you've ever recorded a screencast or a demo video, you've probably experienced this: you play it back and all you can hear is the hum of your pc, the fan in the background, or streetnoi]]></description><link>https://bytelogger.hashnode.dev/denoiser-clean-audio-for-dev-screencasts-without-a-soundproof-room</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/denoiser-clean-audio-for-dev-screencasts-without-a-soundproof-room</guid><category><![CDATA[Python]]></category><category><![CDATA[cli]]></category><category><![CDATA[audio]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[screencast]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Mon, 01 Jun 2026 14:41:00 GMT</pubDate><content:encoded><![CDATA[<h3>The Problem</h3>
<p>If you've ever recorded a screencast or a demo video, you've probably experienced this: you play it back and all you can hear is the hum of your pc, the fan in the background, or streetnoise bleeding through your window. Fixing it means either buying expensive equipment, building a makeshift soundproof setup, or just putting out bad audio and hoping nobody notices. None of these are good options. <code>denoiser</code> is a simple CLI tool that cleans your audio in one command.</p>
<hr />
<h3>What denoiser Does</h3>
<p><code>denoiser</code> takes your video file, builds a noise profile from a short sample of background noise at the beginning, and uses that profile to strip the noise from the entire recording. The result is cleaner audio without touching your recording setup.</p>
<hr />
<h3>How It Works Under the Hood</h3>
<p>Under the hood, denoiser uses the <code>noisereduce</code> library to do the heavy lifting. It samples a configuration duration of audio from the start of your file, defaulting to the first 2 seconds, treats that as the noise floor, and applies spectral gating across the full track to suppress it. The <code>--prop-decrease</code> flag controls how aggressively the noise is reduced.</p>
<hr />
<h3>Installation</h3>
<pre><code class="language-bash">git clone https://github.com/mg4603/denoiser.git
cd denoiser
pipx install .
</code></pre>
<hr />
<h3>Basic Usage</h3>
<pre><code class="language-bash">denoiser denoiser input.mp4 output.mp4
</code></pre>
<p>That's it. Default work well for most recordings.</p>
<hr />
<h3>The Flags Explained</h3>
<ul>
<li><code>--noise-reduce</code> (default: <code>2.0</code>): how many seconds from the start of the file are used to build the noise profile. </li>
<li><code>--prop-decrease (default: 1.0)</code>: how aggressively noise is reduced. <code>1.0</code> is full suppression. If output sounds hollow or over-processed, try <code>0.7</code> or <code>0.8</code>.</li>
</ul>
<pre><code class="language-bash">denoiser denoise --noise-duration 1.5 --prop-decrease input.mp4 output.mp4
</code></pre>
<hr />
<h3>Get the Code</h3>
<p>The full source is on <a href="https://github.com/mg4603/denoiser">GitHub</a>. If this saves you a reshoot, git it a star.</p>
]]></content:encoded></item><item><title><![CDATA[I got tired of switching terminals - so I built logsnap]]></title><description><![CDATA[A while back I was trying to install an audio driver on my laptop. Every time I tried a different configuration, I had to check three different log files to see what went wrong. Three terminals open, ]]></description><link>https://bytelogger.hashnode.dev/i-got-tired-of-switching-terminals-so-i-built-logsnap</link><guid isPermaLink="true">https://bytelogger.hashnode.dev/i-got-tired-of-switching-terminals-so-i-built-logsnap</guid><category><![CDATA[Python]]></category><category><![CDATA[cli]]></category><category><![CDATA[devtools]]></category><dc:creator><![CDATA[ByteLogger]]></dc:creator><pubDate>Tue, 12 May 2026 10:56:40 GMT</pubDate><content:encoded><![CDATA[<p>A while back I was trying to install an audio driver on my laptop. Every time I tried a different configuration, I had to check three different log files to see what went wrong. Three terminals open, constantly switching between them, grepping for errors, losing context. It was painful enough that I decided to build something to fix it.</p>
<h2>What I built</h2>
<p>logsnap is a Python CLI tools that lets you tail mutliple log files in one place, filter output by keyword, and snapshot the results for later analysis. Instead of juggling three terminals, you run one command and see everything in one unified view.</p>
<h2>Getting started</h2>
<p>Clone the repo and install locally:</p>
<pre><code class="language-bash">git clone https://github.com/mg4603/logsnap.git
cd logsnap
pipx install .
</code></pre>
<p>Initialize your config:</p>
<pre><code class="language-bash">logsnap config init
</code></pre>
<p>This creates a config file at <code>~/.config/logsnap/config.toml</code>. Edit it to point at your log files:</p>
<pre><code class="language-toml">[sources]
files = [
    "/var/log/syslog",
    "/var/log/app.log"
]
</code></pre>
<p>Then run:</p>
<pre><code class="language-bash">logsnap watch
</code></pre>
<p>Every line is timestamped and prefixed with its source file so you always know where output is coming from. When you see somthing worth saving:</p>
<pre><code class="language-bash">logsnap snap
</code></pre>
<p>Snapshots export in either plain text or JSONL format - useful if you want to query them later.</p>
<h2>How it works</h2>
<p>No external file-watching library. logsnap opens each log file, seeks to the end, then polls for new lines every 100ms. New lines get timestamped, tagged with their source path, written to your terminal and buffered to a session file simultaneously. Simple, dependency-light, and easy to extend.</p>
<h2>What's next</h2>
<p>The code is on <a href="https://github.com/mg4603/logsnap">GitHub</a>. Feel free to open an issue or submit a PR if you have ideas or run into bugs.</p>
]]></content:encoded></item></channel></rss>