Metadata-Version: 2.4
Name: scripticus
Version: 0.7.0
Summary: A package manager and registry for scripts — CLI client
License-Expression: MIT
Requires-Dist: typer>=0.16
Requires-Dist: rich>=13
Requires-Dist: httpx>=0.27
Requires-Dist: scripticus-schema>=0.5.0,<0.6.0
Requires-Dist: scripticus-common>=0.2.0,<0.3.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# Scripticus

The client for [Scripticus](https://github.com/kevinchannon/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](https://pipx.pypa.io) (recommended) or [uv](https://docs.astral.sh/uv/) —
either puts `scripticus` on your PATH without touching your project or system
Python:

```console
$ pipx install scripticus       # or: uv tool install scripticus
$ scripticus init               # creates ~/.scripticus, adds bin dir to PATH
```

Restart your shell (or re-source your profile) so `~/.scripticus/bin` is on
your PATH.

Point the client at your organisation's registries. Your org's onboarding
docs will give you the exact lines to run:

```console
$ scripticus config remote add tools https://scripticus.example.com
$ scripticus config tools --install="apt-get install -y {packages}" --escalate=sudo
```

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.

## 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.

```console
$ 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.

```console
$ 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

```console
$ scripticus install infra/backup-rotate
```

With a version or semver range:

```console
$ 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:

```console
$ 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:

```text
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 [Configuration](#configuration)). If a
required tool is missing and no installer is configured, the install aborts
listing the tools — install them yourself, configure `[tools] install`, or
re-run with `--skip-tools`. Optional tools are only reported, never
installed.

Install from a local archive (no registry involved):

```console
$ 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.

### Updating and uninstalling

```console
$ 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:

```console
$ 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 —

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```bash
#!/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:

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

`scripticus init` exports `SCRIPTICUS_LIB` alongside the PATH entry, so this
works in any shell once you have run 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:

```bash
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

```console
$ 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:

```text
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:

```console
$ scripticus install --editable .
```

which points the shim at your working directory.

### The manifest

```toml
[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](https://semver.org); 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.

```console
$ scripticus new --snippet argparse -n infra          # src/argparse.sh
$ scripticus new --snippet argparse -n infra --ext py # src/argparse.py
```

```toml
[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>`:

```console
$ scripticus new bash strings -n infra --lib
```

```toml
[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:

```bash
. "$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:

```console
$ 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):

```text
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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

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

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

```console
$ 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:

```console
$ 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:

  ```toml
  [[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:

  ```toml
  [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. With no `[tools] install`
  configured, 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
