Writing a Nix helper script

On a random Thursday evening, I had the idea to run a bash command that’d write directly into my packages.nix file. That single command eventually became what I now call syd.

There wasn’t a plan or a roadmap or literally anything that's linked with productivity, just that quiet moment where you fix a small annoyance, and it slowly becomes something that feels right. That’s what syd is. Something that took form over time.

What is syd?

syd is a lightweight command-line tool written in Python for managing your NixOS packages declaratively. It lets you install, remove, list, and search for packages by directly editing your Nix configuration files, so you don’t have to open them manually every time you want to make a change.

It was originally written in Bash, then rewritten in Python a day later - mostly because it felt cleaner, and gave me more room to build on top of it.

Why the name

syd is named after Syd Barrett, Pink Floyd’s original frontman who left the band in 1968 due to mental illness. There’s no deep reason behind the name - I just like rock. It used to be called nixstall, but that felt too generic. syd fit better.

How does it function?

Alright, so we’re gonna get a lil’ nerdy here and look at some code snippets.

Let’s open https://github.com/sidharthify/syd/blob/main/syd.py and look into it

INFO = f"{Fore.BLUE}syd:{Style.RESET_ALL}"
SUCCESS = f"{Fore.GREEN}SUCCESS:{Style.RESET_ALL}"
ERROR = f"{Fore.RED}ERROR:{Style.RESET_ALL}"

sudo_user = os.environ.get("SUDO_USER")
if sudo_user and sudo_user != "root":
    real_home = Path(f"/home/{sudo_user}")
else:
    real_home = Path.home()

config_base = os.getenv("XDG_CONFIG_HOME", real_home / ".config")
CONFIG_DIR = Path(config_base) / "syd"
CONFIG_FILE = CONFIG_DIR / "config"
CONFIG_DIR.mkdir(parents=True, exist_ok=True)

This part defines the basic “personality” of syd - how it talks back to you in the terminal. INFO, SUCCESS, and ERROR use colorama to colorize text output, making logs easier to read and distinguish at a glance.

Then, it deals with configuration paths. if syd is run with sudo, it figures out who actually invoked it by checking the SUDO_USER environment variable. that’s important because running sudo changes your home directory to /root, and syd needs to know where your config really lives - not root’s.

Finally, it builds out the config directory structure using Python’s pathlib. if ~/.config/syd/ doesn't exist, it's created automatically.


help()

def help():
    print(f"{INFO} a lightweight declarative package manager for NixOS\\n")
    print(f"{Fore.GREEN}USAGE:{Style.RESET_ALL}")
    print("  sudo syd install <package> [more packages]")
    print("  sudo syd remove  <package> [more packages]")
    print("  syd search  <package> [more packages]")
    print("  syd isinstalled <package> [more packages]")
    print("  syd list")
    print("  syd --reset")
    print("  syd --help\\n")

    print(f"{Fore.GREEN}COMMANDS:{Style.RESET_ALL}")
    print("  install       Add one or more packages to your nix packages file")
    print("  remove        Remove one or more packages from your nix packages file")
    print("  list          Show all packages currently listed in your nix file")
    print("  search        Search for packages in nixpkgs")
    print("  search        Check if package is listed in your nix file")
    print("  --reset       Reset stored packages file path and rebuild command")
    print("  --help        Show this help message and exit\\n")

    print(f"{Fore.GREEN}EXAMPLES:{Style.RESET_ALL}")
    print("  sudo syd install vim htop curl")
    print("  sudo syd remove neovim htop curl")
    print("  syd search discord")
    print("  syd search htop neovim curl\\n")

    print(f"{INFO} Current config file: {CONFIG_FILE}")

This function just prints out usage info when you run syd --help. unlike the rest of the program, there’s no logic or state manipulation here.

the use of colorama again makes it clean and readable: INFO shows context messages in blue, SUCCESS in green, and ERROR in red. the spacing and examples are designed to mirror what you’d see in standard nix or systemctl help pages, so even new users can get the gist quickly.

the last line helps confirm which configuration file syd is currently reading from. this is especially useful when testing with sudo - since you might accidentally be writing to root’s config otherwise.


now that we’ve seen the basics, let’s get into the actual logic that makes syd work.

check_pkg_exists()

def check_pkg_exists(pkg: str) -> bool:
    result = subprocess.run(
        [
            "/run/current-system/sw/bin/nix",
            "--extra-experimental-features", "nix-command",
            "--extra-experimental-features", "flakes",
            "eval",
            f"github:NixOS/nixpkgs/nixos-unstable#{pkg}.meta.name"
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )
    return result.returncode == 0

This one’s simple enough. it uses nix itself to check if a package exists inside nixpkgs. it runs a command through subprocess and just checks the exit code (0 means success). if nix says it found the package, syd takes that as a yes.


reset_config()

def reset_config():
    if CONFIG_FILE.exists():
        CONFIG_FILE.unlink()
        print(f"{INFO} Config reset. Next run will ask for path and rebuild command again.")
    else:
        print(f"{ERROR} No config file found at {CONFIG_FILE}")

This deletes the current config file if it exists, basically a reset button. it’s how you tell syd to start from zero. if there’s nothing to delete, it politely complains.


setup_config()

def setup_config():
    PACKAGES = None
    REBUILD = None

    if CONFIG_FILE.exists():
        with open(CONFIG_FILE, "r") as f:
            for line in f:
                if line.startswith("packages_file="):
                    PACKAGES = Path(line.split("=", 1)[1].strip())
                elif line.startswith("rebuild_cmd="):
                    REBUILD = line.split("=", 1)[1].strip()
    else:
        PACKAGES = Path(input(f"{INFO} Enter path to your nix packages file: ").strip())
        if not PACKAGES.exists():
            print(f"{ERROR} File not found: {PACKAGES}")
            sys.exit(1)

        REBUILD = input(f"{INFO} Enter your rebuild command (e.g. sudo nixos-rebuild switch): ").strip()
        if not REBUILD:
            print(f"{ERROR} No rebuild command entered.")
            sys.exit(1)

        with open(CONFIG_FILE, "w") as f:
            f.write(f"packages_file={PACKAGES}\\n")
            f.write(f"rebuild_cmd={REBUILD}\\n")

        print(f"{SUCCESS} Saved config to {CONFIG_FILE}")

    return PACKAGES, REBUILD

This is syd’s “setup wizard.” it checks whether a config file exists and reads paths from it if it does. if not, it asks the user for two things:

once entered, syd saves that info in ~/.config/syd/config so you never have to enter it again.


rebuild_prompt()

def rebuild_prompt():
    read = input(f"{INFO} Rebuild NixOS? [y/N]: ").strip().lower()
    if read != "y":
        print(f"{INFO} Skipping rebuild.")
        return

    cmd = REBUILD.strip()
    if os.geteuid() == 0 and cmd.startswith("sudo "):
        cmd = cmd.replace("sudo ", "", 1)
        print(f"{INFO} Running (sudo stripped): {cmd}")

    env = os.environ.copy()
    env["PATH"] = "/run/wrappers/bin:/usr/local/bin:/usr/bin:/bin:/run/current-system/sw/bin"

    print(f"{INFO} Running: {cmd}")
    result = subprocess.run(cmd, shell=True, env=env)

    if result.returncode != 0:
        print(f"{ERROR} Rebuild command failed.")
        sys.exit(1)

This one asks if you want to rebuild nixos after making changes. it also handles edge cases - like when syd is run as root, it strips out any sudo from the rebuild command to avoid nesting. it fixes PATH issues too, since python sometimes forgets where binaries live inside nix shells.


install_pkgs()

def install_pkgs(*pkgs):
    for pkg in pkgs:
        with open(PACKAGES, "r") as f:
            lines = f.readlines()

        if any(pkg in line for line in lines):
            print(f"{INFO} {ERROR} {pkg} already listed.")
            continue

        if check_pkg_exists(pkg):
            insert_index = len(lines)
            for i, line in enumerate(lines):
                if "]" in line:
                    insert_index = i
                    break

            lines.insert(insert_index, f"  {pkg}\\n")

            with open(PACKAGES, "w") as f:
                f.writelines(lines)

            print(f"{SUCCESS} Added '{pkg}' to {PACKAGES}")
        else:
            print(f"{ERROR} Package '{pkg}' not found in nixpkgs.")

    rebuild_prompt()

This is syd’s main feature - adding packages declaratively. it checks whether a package already exists, verifies it actually exists in nixpkgs, and then inserts it into the list just before the closing ] in packages.nix. finally, it calls rebuild_prompt() to offer a system rebuild.


remove_pkgs()

def remove_pkgs(*pkgs):
    for pkg in pkgs:
        with open(PACKAGES, "r") as f:
            lines = f.readlines()

        new_lines = [line for line in lines if pkg not in line.strip()]

        if len(new_lines) == len(lines):
            print(f"{ERROR} Package '{pkg}' not found in config.")
            continue

        with open(PACKAGES, "w") as f:
            f.writelines(new_lines)

        print(f"{SUCCESS} Removed {pkg}")

    rebuild_prompt()

this one’s just the inverse of install_pkgs(). it filters out any line that matches the package name, rewrites the file, and calls rebuild again.


list_pkgs()

def list_pkgs():
    print(f"{INFO} Packages listed in {PACKAGES}:")

    with open(PACKAGES, "r") as f:
        lines = f.readlines()

    pkgs = [
        line.strip()
        for line in lines
        if line.strip()
        and not line.strip().startswith("#")
        and "[" not in line and "]" not in line
    ]

    if not pkgs:
        print(f"{INFO} No packages found.")
    else:
        for pkg in pkgs:
            print(f"  {pkg}")

    print(f"\\n{INFO} Total packages: {len(pkgs)}")

lists everything currently in your nix config. it ignores comments, brackets, and blank lines - basically parsing only the package names. the total count at the end is just a nice little touch.


search_pkgs()

def search_pkgs(*pkgs):
    for pkg in pkgs:
        if check_pkg_exists(pkg):
            print(f"{INFO} '{pkg}' exists in nixpkgs.")
        else:
            print(f"{ERROR} '{pkg}' not found in nixpkgs.")

this uses check_pkg_exists() to quickly verify if a package exists upstream in nixpkgs. handy for double-checking before adding something.


is_installed()

def is_installed(*pkgs):
    with open(PACKAGES, "r") as f:
        lines = f.readlines()

    for pkg in pkgs:
        if any(re.search(rf"\\b{pkg}\\b", line) for line in lines):
            print(f"{SUCCESS} Yes. '{pkg}' exists in {PACKAGES}")
        else:
            print(f"{ERROR} Could not find '{pkg}' in {PACKAGES}")

The final utility function. checks if a given package is already listed in your local config file. unlike search_pkgs(), it doesn’t call nix - it just reads your own file. this helps you verify whether something is locally declared.


the main function

def main():
    if len(sys.argv) < 2:
        help()
        sys.exit(0)

    subcommand = sys.argv[1]
    args = sys.argv[2:]

    global PACKAGES, REBUILD
    PACKAGES, REBUILD = setup_config()

    # check for sudo in syd install and remove since they need elevated perms
    if subcommand in ["install", "remove",]:
        if os.geteuid() != 0:
            print(f"{ERROR} Root permissions required to modify {PACKAGES}")
            print(f"{INFO} Try: sudo syd {subcommand} <package>")
            sys.exit(1)

    # syd install
    if subcommand == "install":
        PACKAGES, REBUILD = setup_config()
        if len(args) == 0:
            print(f"{INFO} Usage: syd install <package>")
            sys.exit(1)
        install_pkgs(*args)

    # syd remove
    elif subcommand == "remove":
        PACKAGES, REBUILD = setup_config()
        if len(args) == 0:
            print(f"{INFO} Usage: syd remove <package>")
            sys.exit(1)
        remove_pkgs(*args)

    # syd search
    elif subcommand == "search":
        PACKAGES, REBUILD = setup_config()
        if len(args) == 0:
            print(f"{INFO} Usage: syd search <package>")
            sys.exit(1)
        search_pkgs(*args)

    # syd isinstalled
    elif subcommand == "isinstalled":
        if len(args) == 0:
            print(f"{ERROR} Usage: syd isinstalled <package>")
            sys.exit(1)
        is_installed(*args)

    # syd list
    elif subcommand == "list":
        if len(args) != 0:
            print(f"{INFO} Usage: syd list")
            sys.exit(1)
        PACKAGES, REBUILD = setup_config()
        list_pkgs()

    # syd --reset
    elif subcommand == "--reset":
        if len(args) != 0:
            print(f"{INFO} Usage: syd --reset")
            sys.exit(1)
        reset_config()

    # syd --help
    elif subcommand in ["--help", "-h", ""]:
        if len(args) != 0:
            print(f"{INFO} Usage: syd --help")
            sys.exit(1)
        help()

    else:
        print(f"{INFO} Unknown command: {subcommand}")
        print("Run 'syd --help' for usage.")
        sys.exit(1)

if __name__ == "__main__":
    main() # call the main function

This is the function that ties syd together. it’s the part that listens to your commands, figures out what you meant, and then calls the right function to do it.

Argument parsing

The first two lines check if you actually passed any arguments. if not, help() runs and the script exits.

Then it splits things up: subcommand holds whatever command you entered (like install or list), and args holds everything that comes after - the package names or flags.

Loading the config

It calls setup_config() early on to make sure syd knows where your packages.nix file and rebuild command live. this ensures that every command knows the right context to operate in.

Handling sudo

since install and remove modify system files (usually in /etc/nixos), syd checks if it’s running as root. if not, it prints an error and reminds you to re-run the command with sudo. this prevents permission errors before they happen.

Command routing

After that, it’s basically a command router - a series of if / elif blocks, each one handling a different syd command

Each section includes basic argument validation - if you forget to type the package name, syd doesn’t crash; it just prints the correct usage format and exits.

Error handling & exit behavior

For any unknown or invalid command, syd gives a clean error message and a pointer to syd --help.

The __main__ block

The final two lines are python’s way of saying “run this if it’s the main program.” this makes sure syd only executes when directly called (not when imported as a module). it’s the standard idiom:

if __name__ == "__main__":
    main()

End

And that's syd! Other documentation can be found on it's github repository: https://github.com/sidharthify/syd

I made it out of boredom and curiosity just randomly - and it turned out to be quite useful!

← back to all posts