Skip to main content

A package manager and registry for scripts — CLI client

Project description

Scripticus

The client for Scripticus, a package manager and registry for scripts. Publish, discover, version, and install the scripts your team shares — with proper namespacing, semver, dependency resolution, and a single bin directory on your PATH — instead of copying them around from wikis, chat, and assorted git repos.

Installing

The client requires Python 3.11+. Install it as an isolated CLI tool with pipx (recommended) or uv — either puts scripticus on your PATH without touching your project or system Python:

$ pipx install scripticus       # or: uv tool install scripticus

Then point the client at your organisation's registry and authenticate to it. login does both: given a URL it registers the remote as well as storing the token. Your org's onboarding docs will give you the exact line to run:

$ scripticus login tools https://scripticus.example.com

That's the whole setup. There is no separate initialisation step: the first install creates ~/.scripticus, puts its bin directory on your PATH, and tells you it did — you only need to restart your shell (or re-source your profile) once, afterwards, before the commands you installed will run.

To do that PATH change up front instead, run scripticus init; it is the same work, and safe to re-run. scripticus config remote add <name> <url> is the no-token way to add a remote if you would rather not log in yet.

Remotes are searched in the order you add them, which is also the search path for bare package names — so add them the way your organisation expects them to resolve. config remote list shows the current set.

There is nothing to configure for system tools. The first time a package needs one that your machine is missing, Scripticus looks for a package manager on your PATH, shows you the exact command it would run, and asks — once, then remembers your answer. See System tools.

Everyday usage

Finding packages

There are two discovery verbs, for two different questions.

search — "find me something that does X". It matches package content: name, description, and command names.

$ scripticus search backup --platform linux --lang bash
Package               Latest   Description
infra/backup-rotate   1.2.0    Rotate and prune backup sets
tools/db-backup       0.9.1    Dump and archive databases

search queries every configured remote in priority order (unlike install, which stops at the first remote that has the package) and merges the results, each shown at its latest non-yanked version. With more than one remote hit, a Remote column shows which one each result came from; --remote <name> restricts the search to a single remote. If a remote is unreachable it's reported as a warning and the rest of the results still show. The optional --platform and --language (or --lang) filters narrow results to packages that publish a matching artifact.

list — "show me what's there, by name". It enumerates package identity with a shell glob over namespace/name, dnf-style: an Installed section from your machine and an Available section from the remotes.

$ scripticus list 'infra/*'
Installed packages
Package               Version
infra/logrotate       0.4.1

Available packages
Package               Version
infra/backup-rotate   1.2.0

A glob containing / scopes by namespace (infra/*); a bare glob matches the name in any namespace (*-backup). --installed restricts to what you have installed and needs no network; --available restricts to the remotes' catalog (excluding what's already installed). --remote <name> picks which remote supplies the available list.

Installing

$ scripticus install infra/backup-rotate

With a version or semver range:

$ scripticus install infra/backup-rotate@1.2.0
$ scripticus install "infra/backup-rotate@^1.2"

If your namespace search path is configured, bare names work too:

$ scripticus install backup-rotate

Bare names are resolved against your configured namespace list in priority order — they are a client-side convenience; the installed package is always recorded under its full namespaced identity.

Before anything is written, Scripticus resolves the full dependency set and shows you a transaction summary:

Installing infra/backup-rotate 1.2.0

New packages:
  infra/backup-rotate   1.2.0   (commands: backup-rotate)
  infra/log-common      2.0.3   (dependency)

Required system tools: jq, curl        [found]
Optional system tools: fzf             [not found — some features degraded]

Shim conflicts:
  backup-rotate  currently owned by tools/old-backup 0.4.0 — will be overwritten

Proceed? [y/N]

Non-interactive use:

  • -y / --yes (equivalent to --force=no-conflicts): accept the transaction, but abort entirely (nothing installed, non-zero exit) if it would overwrite an existing command shim.
  • --force=all: accept everything, including shim overwrites. Every overwritten shim is reported in the output.
  • --skip-tools: skip the system-tool check and installation entirely.

Required system tools missing from your PATH are installed before any package file or shim is written, by running the command your machine's [tools] configuration provides — see System tools for how that gets set, which is a question rather than a setup step. Optional tools are only reported, never installed.

Install from a local archive (no registry involved):

$ scripticus install -f ./some-local-pkg-0.0.1.tar.gz

Locally-installed packages are tracked with local provenance; update will skip them with a warning rather than trying to resolve them against a remote.

System tools

Some packages need a system tool (jq, curl, …) that isn't a script and can't be shipped as a package. Scripticus encodes no package-manager logic — it runs a command you provide. But it doesn't make you write that command from scratch, and it doesn't ask at setup time: most packages need no system tools at all, so the question only arrives if and when it matters.

The first time an install needs a tool your machine is missing, Scripticus looks for a package manager on your PATH and offers its command:

Missing required system tools: jq
No tool installer is configured, but APT is on your PATH:

  sudo apt-get install -y jq

Save this as your tool installer and use it? [y/N]:

Answer yes and it's written to config.toml, so you're never asked again. Answer no and nothing is installed — the message tells you the config tools line that would have been set, so you can adjust it (a proxy, a mirror, an internal repo) and set it yourself.

The managers Scripticus knows about, in the order it prefers them:

Platform Looked for, in order
Linux apt-get, dnf, yum, zypper, pacman, apk, xbps-install
macOS brew, port
Windows winget, choco, scoop
FreeBSD pkg

Elevation is worked out for your machine rather than assumed: nothing when you're already root, otherwise sudo, falling back to doas. Homebrew is never elevated — it refuses to run under sudo.

Two things it deliberately won't do. It never prompts non-interactively (-y/--force): a CI run fails with the suggested command named, rather than stopping on a question or quietly acquiring a sudo command. And a suggestion is only ever text until you accept it — nothing from this table runs unless it's in your config.toml.

scripticus config tools shows what's configured, and the suggestion for this machine if nothing is:

$ scripticus config tools --install="apt-get install -y {packages}" --escalate=sudo

Updating and uninstalling

$ scripticus update                 # everything
$ scripticus update backup-rotate   # one package
$ scripticus uninstall backup-rotate

uninstall shows what will be removed and asks for confirmation (-y skips the prompt). If a removed command is also provided by another installed package — for example the uninstalled package had taken the shim over — you are offered a numbered list of replacements to re-point the command at, with "No replacement" as the default:

$ scripticus uninstall new-backup

Uninstalling tools/new-backup 2.0.0

Command shims to remove: backup-rotate

Proceed? [y/N]: y

Uninstalled tools/new-backup 2.0.0

'backup-rotate' is also provided by other installed packages:
  0) No replacement
  1) tools/old-backup  1.4.2
Select a replacement for 'backup-rotate' [0]: 1
'backup-rotate' now points at tools/old-backup 1.4.2

With -y no replacement is ever selected automatically; the alternatives are listed with the scripticus use command that would restore each one.

Command conflicts

Every command is installed under three names: the bare command, a namespace-qualified form, and a fully-qualified form —

$ backup-verify --help                        # bare (convenient, can collide)
$ infra.backup-verify --help                  # namespace-qualified
$ infra.backup-rotate.backup-verify --help    # <namespace>.<package>.<command>

The fully-qualified form is guaranteed unique, so every installed command is always runnable no matter what else you install. The two shorter forms are conveniences: if another package provides the same command name, the most recently installed package takes the contested name (you are warned at install time, as above). To re-point a contested name at a specific package, name the shim you want changed:

$ scripticus use tools/old-backup backup-rotate        # the bare shim
$ scripticus use infra/other-tool infra.backup-rotate  # a namespaced shim

Snippets

Some code is not worth running — it is worth pasting. Argument parsing and signal traps are reusable in shape but different in every script, so a library cannot serve them: you want the text, to read and edit. That is a snippet, and snip prints one:

$ snip args.sh              # the snippet, on stdout
$ snip trap.sh >> deploy.sh # composition is the shell's job
$ snip args -c              # print it and put it on the clipboard

snip is installed alongside scripticus (and is also available as scripticus snip). The everyday form is <name>.<ext> — the extension picks the language, because one snippet often exists in several:

$ snip args.py
$ snip args.cpp

A bare snip args works when there is only one variant. When there is more than one — several languages, or two installed packages providing the same one — snip lists the candidates rather than guessing, and you pick with the fully namespaced form:

$ snip args
error: 'args' is ambiguous; it could be:
  infra/boilerplate:args.py
  infra/boilerplate:args.sh

$ snip infra/boilerplate:args.sh

Everything except the snippet itself goes to stderr, so snip x > file either gets the snippet or gets nothing. -c/--copy still prints the snippet, and where there is no clipboard (over SSH, say) it warns rather than failing.

Snippets are found with search like anything else — the search matches the snippet's name and description, and --language matches its file extension:

$ scripticus search trap
$ scripticus search --language python args

Libraries

Shell is the one common scripting language with no package manager for reusable code — every project reinvents a lib/ directory and a pile of source lines. A library package fills that: shell code you source rather than run.

Nothing needs installing by hand. A command that depends on a library gets it as an ordinary dependency, and can source it by name:

#!/usr/bin/env bash
scr_load infra/strings      # namespace/name — never a version

scr_strings_upper "shout"

scr_load is already in scope: Scripticus puts it there when it launches your command. In your own ad-hoc scripts — anything not installed as a package — source it first:

. "$SCRIPTICUS_LIB/scr_load.sh"
scr_load infra/strings

The PATH bootstrap exports SCRIPTICUS_LIB alongside the PATH entry, so this works in any shell once your first install (or scripticus init) has run and you have restarted it.

Loading is transitive (a library may load other libraries), and repeat loads are free — loading the same library twice, or via two different paths, does nothing the second time. A missing library returns non-zero rather than killing your script, so you can decide what to do:

if ! scr_load infra/optional-helpers; then
    echo "running without the helpers" >&2
fi

References never carry a version. The resolved closure already pins exactly one version of each package, so there is nothing to choose at load time; upgrading the library with scripticus update is enough, and every consumer picks up the new code without being rebuilt.

Libraries are sh or bash only. Python, Ruby, PowerShell and the rest already have pip, gem and PSGallery — Scripticus does not duplicate them. A sh library can be sourced by both sh and bash consumers; a bash library only by bash ones, and asking for anything else is refused when the install is resolved, not at 3am in production.

Authoring packages

Scaffolding

$ scripticus new bash my-cool-script -n infra

The namespace (-n/--namespace) is required: it is the namespace the package will be published under (a Gitea user or organisation), and it goes straight into the generated manifest. Namespaces are lower-case letters, digits, and dashes, and must begin with a letter.

This creates:

my-cool-script/
├── meta.toml
├── LICENSE
├── README.md
├── src/
│   └── main.sh
└── test/

Package names are lower-case with dashes (my-cool-script). Script files inside the package follow the conventions of their own language — a PowerShell package's named command scripts will be PascalCase.ps1, for example.

Because packages are plain scripts, the development loop is direct: cd into the directory and run them. To exercise the installed experience (shims, PATH) while developing:

$ scripticus install --editable .

which points the shim at your working directory.

The manifest

[package]
namespace = "infra"
name = "backup-rotate"
version = "1.2.0"
language = "bash"                 # sh, bash, python, powershell
description = "Rotate and prune backup sets"

[platforms]
os = ["linux", "macos"]
distros = ["debian", "arch"]      # optional, narrows os

[dependencies.tools]
requires = ["jq", "curl"]
optional = ["fzf"]

[dependencies.packages]
"infra/log-common" = "^2.0"

# Optional. If omitted, src/main.<ext> is the single entrypoint and the
# command name is the package name.
[commands]
backup-rotate = "src/main.sh"
backup-verify = "src/BackupVerify.sh"

Entrypoint rules:

  • No [commands] table: src/ must contain main.<ext> (extension per the package language). Typing the package name runs it.

  • [commands] table present: each entry maps a command name to a script path. Every listed command gets a shim on install.

    One name is unavailable: a command cannot be called app. Command names become the last segment of the installed shims (infra.my-tool.app), which macOS reads as an application-bundle extension and refuses to run. Package names are unaffected — infra/app providing backup is fine.

  • [library] or [snippet.<name>] instead: the package provides no commands at all, and gets no shims. See the sections below.

Versions must be strict semver; publishes with non-conforming versions are rejected.

Authoring snippets

A snippet package declares [snippet.<name>] sections instead of [commands] — a package is one kind or the other. It has no language and no platforms: a snippet is never run, so its language is just the extension of the file it lives in, and one snippet can exist in as many languages as you write it in.

$ scripticus new --snippet argparse -n infra          # src/argparse.sh
$ scripticus new --snippet argparse -n infra --ext py # src/argparse.py
[package]
namespace = "infra"
name = "boilerplate"
version = "1.0.0"
description = "The bits nobody remembers exactly"

[snippet.args]
description = "Argument parsing"

[snippet.trap]
description = "Cleanup on exit"
boilerplate/
├── meta.toml
└── src/
    ├── args.sh      # snip args.sh
    ├── args.py      # snip args.py
    └── trap.sh      # snip trap.sh

The code lives in flat src/<name>.<ext> files, so shellcheck, syntax highlighting, and bash -n all work on real source rather than strings in TOML. You never list the languages: they are read off the filenames, at publish for the index and at install for snip. Add src/args.rb and snip args.rb works — including for languages Scripticus cannot run as commands (C++, Rust, Go). The two must agree, though: a declared snippet with no file, or a file with no section, is a packing error.

Authoring libraries

A library package declares a fieldless [library] table instead of [commands], and its entry point is src/load.<ext> — the sourced counterpart of src/main.<ext>:

$ scripticus new bash strings -n infra --lib
[package]
namespace = "infra"
name = "strings"
version = "1.0.0"
language = "bash"          # sh or bash — libraries are shell only
description = "String helpers"

[platforms]
os = ["linux", "macos"]

[library]
strings/
├── meta.toml
└── src/
    ├── load.sh      # sourced by scr_load infra/strings
    └── helpers.sh   # sourced by load.sh, if you want the split

load.sh is sourced into the caller's shell, so it should define functions and little else — anything at top level runs in someone else's script. Shell has one flat function namespace, so prefix your functions with something identifying (scr_strings_upper, not upper).

To source a sibling file, use $SCR_LIB_DIR, which is set to your package's directory before load.sh runs:

. "$SCR_LIB_DIR/src/helpers.sh"

A library may depend on other libraries and load them the same way any consumer does — scr_load other/library inside load.sh. The manifest lists nothing about what you source; only package dependencies, as usual.

Choose sh unless you need bash. An sh library can be sourced by everything in the family; a bash one only by bash consumers, and Scripticus refuses the mismatch when the install is resolved.

Manifest accuracy is your responsibility. Scripticus performs no correctness checks on the declared platforms or tool dependencies — neither at publish nor install. Nothing checks that a load.sh is actually sourceable, either. If the manifest is wrong, the package will be wrong, exactly as with a broken pyproject.toml or package.json. Test your packages.

Multi-file packages

A package is a directory, so an entrypoint can call sibling helper scripts or read sibling data files. The whole tree is packed and installed together, and a command's shim runs its entrypoint by absolute path from wherever you happen to be standing — it does not cd into the package, and the package directory is not added to PATH. So there is one rule for reaching a sibling:

Resolve siblings relative to your own script file, never relative to the current directory. A command runs with the user's working directory, not the package's, so ./helper.sh or a bare open("data.txt") looks in the wrong place. Anchor on the script's own location instead:

Language Do this Not this
bash dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; "$dir/helper.sh" ./helper.sh
python Path(__file__).resolve().parent / "helper.py" open("helper.py")
powershell Join-Path $PSScriptRoot 'helper.ps1' .\helper.ps1

This is ordinary script hygiene — the same code would break under any bin-dir installer — and it is the reason a plain ./helper.sh cannot be made to work no matter how the shim is written. Only helper scripts you want to expose as their own commands need a [commands] entry; internal helpers and data files just ride along in the tree.

Packing

To archive a package directory into a distributable artifact:

$ scripticus pack path/to/my-cool-script-proj
$ scripticus pack path/to/my-cool-script-proj -o builds   # write into builds/

The manifest is validated first; the archives land in the current directory unless -o/--output names another one (created if needed). One archive is produced per format the declared target platforms call for — .tar.gz covering the POSIX/macOS targets, .zip covering Windows — so a package targeting both produces two archives with identical content. Filenames carry wheel-style tags (name, version, platforms, language, with dashes in name/version normalised to underscores):

my_tool-1.2.0-linux.macos-python.tar.gz
my_tool-1.2.0-windows-python.zip

The filename is human-legible redundancy only; the manifest inside the archive is the source of truth.

Publishing

Publishing authenticates with a Gitea personal access token: create one in your Gitea user settings (it needs package-write scope), then log in to a remote by name:

$ scripticus login origin
Token: ********
Logged in to origin (https://scripts.example.com) as kevin-c

The first time you log in to a remote that isn't already in config.toml, give its URL too — this registers the remote as well as authenticating:

$ scripticus login origin https://scripts.example.com
Token: ********
Logged in to origin (https://scripts.example.com) as kevin-c

login verifies the token against the remote before storing it and prints the Gitea account it authenticated as, so a mistyped token fails right away rather than at your first publish. A rejected token, or a remote that can't be reached, is reported as such and nothing is written.

The token is stored in ~/.scripticus/credentials.toml, readable only by you, and sent with each publish — the registry itself holds no credentials. In CI, set the SCRIPTICUS_TOKEN environment variable instead; it takes precedence over the stored token.

publish doesn't pack for you — build the archive(s) first, then point publish at them by name-version, the same identifier pack just used for the filenames:

$ scripticus pack my-cool-script
$ scripticus publish my-cool-script-0.1.2

The argument is a path whose last segment is <name>-<version>; everything in that directory whose filename matches those fields (D26's tags — dashes in the name are matched against the filename's underscore form automatically) gets published. That means a package targeting both format groups publishes both archives from one command:

$ scripticus pack my-cool-script -o builds
$ scripticus publish builds/my-cool-script-0.1.2
Published my-cool-script 0.1.2:
  my_cool_script-0.1.2-linux.macos-bash.tar.gz
  my_cool_script-0.1.2-windows-powershell.zip

Every matched archive goes up in a single request, and the whole batch is published together or rejected together — the index service validates all of them before writing any blob to Gitea or committing anything, so there is no state where one variant is live and another silently isn't. If publish fails, nothing in that batch was published; fix the problem and re-run.

With more than one remote configured, publish targets the first one listed in config.toml unless you say otherwise:

$ scripticus publish builds/my-cool-script-0.1.2 --remote public

A published version is immutable. If you publish something broken:

$ scripticus yank infra/backup-rotate@1.2.0

Yanked versions disappear from search and latest resolution, but remain fetchable by anything that pins them directly (including lockfiles), so existing consumers do not break. yank takes an exact version (it is whole-version — a range is rejected), and needs a token for the namespace, exactly like publish.

Changed your mind? --undo reverses a yank — the same version becomes visible again, with no time limit on when you can do it:

$ scripticus yank --undo infra/backup-rotate@1.2.0

Platform variants

The same package version may be published as multiple platform/language variants (for example a linux/bash artifact and a windows/powershell artifact). The client automatically selects the variant matching the installing machine. POSIX/macOS artifacts are .tar.gz; Windows artifacts are .zip.

Configuration

Client configuration lives in ~/.scripticus/:

  • config.toml — remotes as a [[remotes]] array of { name, url } entries; list order is priority (this list doubles as the bare-name namespace search path, and publish defaults to the first entry) — and other defaults. For example:

    [[remotes]]
    name = "origin"
    url = "https://scripts.example.com"
    
    [[remotes]]
    name = "public"
    url = "https://scripticus.example.org"
    

    An optional [tools] table tells Scripticus how to install missing required system tools. Scripticus encodes no package-manager logic — you provide the command, and the missing tool names are substituted into a {packages} placeholder (shell-quoted; appended if the placeholder is absent). It runs once through your shell, inheriting the environment, so proxies/mirrors/credentials come from the machine environment rather than this (org-distributable) file:

    [tools]
    install = "apt-get install -y {packages}"   # your machine's package manager
    escalate = "sudo"                            # optional; elevates only this command
    

    escalate is prepended to the tool command alone — Scripticus itself never needs privilege (its state is entirely under ~/.scripticus). Leave it out when already root or on Windows-as-admin. You rarely write this table by hand: see System tools for the prompt that offers to fill it in. With no [tools] install configured and no offer accepted, Scripticus never invokes a package manager: missing required tools abort the install (with the --skip-tools escape). Tool satisfiability in v1 is PATH presence only.

  • credentials.toml — one Gitea access token per remote, keyed by URL and registered with scripticus login. Kept separate from config.toml so org-distributed configuration never carries a token.

  • installed.lock — install state: every installed package, its exact resolved version and content hash, the full resolved dependency closure (with direct vs transitive marking), and provenance (remote or local file).

  • bin/ — the shim directory on your PATH.

Licence

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

scripticus-0.8.0.tar.gz (61.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

scripticus-0.8.0-py3-none-any.whl (73.8 kB view details)

Uploaded Python 3

File details

Details for the file scripticus-0.8.0.tar.gz.

File metadata

  • Download URL: scripticus-0.8.0.tar.gz
  • Upload date:
  • Size: 61.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for scripticus-0.8.0.tar.gz
Algorithm Hash digest
SHA256 22b563ec38d9f73e99e2ed1931560ad68a5ace8ed0b225957074edadbed1ae9d
MD5 395031b727ac3390d5551ad5057a586c
BLAKE2b-256 2c56190e1936a6a49e34e6b6f048e776f97451edcb3b804ab177df94bab1545a

See more details on using hashes here.

Provenance

The following attestation bundles were made for scripticus-0.8.0.tar.gz:

Publisher: release.yml on kevinchannon/scripticus

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file scripticus-0.8.0-py3-none-any.whl.

File metadata

  • Download URL: scripticus-0.8.0-py3-none-any.whl
  • Upload date:
  • Size: 73.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for scripticus-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ecf4d94caed00c90f9d24fbaf3ecee821bbc8add5041cbc5d2b9425d2811f97d
MD5 56d8187d3fe14b49f63220b72aa1b113
BLAKE2b-256 c9a34ac674e9f90a57750c11e28c10eec796e58a2bb870a8e828c41e8951d6d3

See more details on using hashes here.

Provenance

The following attestation bundles were made for scripticus-0.8.0-py3-none-any.whl:

Publisher: release.yml on kevinchannon/scripticus

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page