Metadata-Version: 2.5
Name: salpa-cli
Version: 0.12.3
Summary: Salpa node authoring CLI — scaffold, validate, and push node packages into your running Salpa app.
Project-URL: Homepage, https://bocores.com/salpa-cli
Project-URL: Documentation, https://salpa.app/docs/custom-nodes
Author: Salpa
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: cli,computational-science,node-authoring,salpa,scaffold,workflow
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Code Generators
Requires-Python: >=3.9
Requires-Dist: rich>=13
Requires-Dist: tomli>=2.0; python_version < '3.11'
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# salpa-cli

The Salpa node authoring CLI. Scaffolds node packages that match the shipped
template contract — the deterministic path to a new node — checks them against
that contract, and puts them into the Salpa app on your machine.

```bash
python3 -m pip install salpa-cli
salpa new my-analyzer          # interactive
salpa new my-suite -t multi-node-package --yes
salpa dev                      # guided: what is left, and the command for each
salpa add gromacs mdanalysis   # find them, add them, narrow the platforms, solve
salpa validate my_suite        # will it install and register?
salpa smoke my_suite           # does it actually run?
salpa env status               # what is built, and which checks would run
salpa push my_suite            # into your running Salpa app
salpa unpush my_suite          # and back out again
```

Installing adds the `salpa` command. If your shell can't find `salpa` afterward,
your Python scripts directory isn't on your `PATH` —
installing into a virtual environment is the simplest fix (it puts `salpa` on `PATH`).

## Requirements

**Python 3.9 or newer.** `pip install salpa-cli` reports a clear error below that.

- **macOS** — the system `python3` is 3.9, which is enough. For a newer one:
  `brew install python`, or [python.org](https://www.python.org/downloads/).
- **Linux** — most distributions ship 3.9+. Otherwise use your package manager
  (`apt install python3`, `dnf install python3`, …) or [pyenv](https://github.com/pyenv/pyenv).
- **Windows** — often has no `python3` at all; install from
  [python.org](https://www.python.org/downloads/) or the Microsoft Store, and tick
  "Add python.exe to PATH".

**pixi** — needed only for `pixi run test` (building a node's
environment and running its tests). You do **not** have to install it separately if
you run Salpa: the app keeps a pixi under `~/.bocoflow`, and `salpa new` finds it
and prints its path. To have one on your own `PATH`, install it from
[pixi.sh](https://pixi.sh). Stdlib-only nodes need no pixi at all.

`salpa new` creates `./<under_scored_name>/` from a bundled template, with every
placeholder substituted and the directory underscored (the dir name becomes the
Python package name, so a hyphen would break `from .core import ...`; meta.toml's
`name` stays kebab-case).

## Templates

Templates ship inside the wheel and are the single source of truth for the node
contract, so there is no second copy to drift.

| Template | For |
|----------|-----|
| `individual-node` | one node with its own pixi env (default) |
| `multi-node-package` | several related nodes sharing one pixi env, each runnable on its own |

## Options

```
salpa new [NAME]
  -t, --template     individual-node | multi-node-package
  -d, --description  one-line description
      --author       author name
      --category     UI category (e.g. "Cheminformatics")
  -o, --output       directory to create the package in (default: .)
      --hashtags     comma-separated discovery hashtags, 5-10
  -y, --yes          non-interactive: accept defaults, no prompts (and no guided setup)
```

## The guided loop

```
salpa dev [PATH]
      --status       print the checklist and exit — no prompts, no actions
      --port         port the Salpa app is listening on
```

`salpa dev` reads your package, shows where it stands, and lets you press a number
instead of remembering a verb. `salpa new` offers to drop you straight into it.

```
╭─ solvate-box single ───────────────────────────────────────────────────╮
│  DESCRIBE IT                                                       0/2 │
│ ▸   1 ○ Metadata      description, author, category, hashtags          │
│     2 ○ The science   core.py is still the placeholder                 │
│  BUILD IT                                                          0/2 │
│     3 ○ Dependencies  only python + redis-py                           │
│     4 ○ Environment   not built                                        │
│  PROVE IT                                                          1/2 │
│     5 ✓ Checks        no findings (structure only)                     │
│     6 · Does it run?  needs the environment first                      │
│  SHIP IT                                                           0/1 │
│     7 ? Push to Salpa not checked — pushing finds out                  │
╰────────────────────────────────────────────────────────── 1 of 7 done ─╯
  Metadata — walks the fields; every comment in the file survives.
  ↑↓ step · ←→ phase · enter run · 1-7 jump · r refresh · q quit
```

The checklist **is** the menu. **↑ ↓** move between steps, **← →** between phases,
**Enter** runs the highlighted one, **1-7** runs one directly. The cursor starts on the
first outstanding step and moves on after each one lands, so most of a session is Enter.
The line under the box says what the highlighted step *will do*, which is why the rows
can stay short. Grouped by phase because the steps are not equal: describing is one-time,
**prove it** is an inner loop you run many times while editing `core.py`, shipping is the
end. Framed because it sits directly under the last step's output, and without a border
the two are the same material.

Without a terminal it reads a line instead and the numbers do everything — it never
offers keys that will not work. `SALPA_NO_KEYS=1` forces that mode if your terminal or
multiplexer misbehaves with the arrow keys. Every step is a command
you could type yourself; this is a shorter path through them, not a different one.

The panel is erased before each step and drawn again after, so it appears once, at the
bottom, always current — while the output you need to read is printed once and kept.

**`?` is not a pass.** Whether the node runs, and whether the app has it, cannot be read
off the disk — running them is what finds out. They stay `?` until you do, and they go
back to `?` when you edit your code, because "it passed" is a statement about a package
that no longer exists.

Step 1 walks the metadata fields (Enter keeps the current value) and writes `meta.toml`
back **one line per field, with every comment intact** — the comments in these files are
the documentation. The whole result is re-parsed and checked before anything is written,
so a value that would break the TOML is refused rather than written on a best effort.
That step also catches what `salpa validate` structurally cannot: a fresh scaffold with
`author = "TODO"` passes validation with zero errors, because its placeholder check only
matches literal `{{BRACES}}` and `salpa new` fails outright if any survive.

`--status` (or piped input) prints the checklist once and exits 0. It is a report, not a
gate — `salpa validate --strict` is the gate, and two gates drift.

## Dependencies and platforms

```
salpa add PACKAGE... [--path PATH]
      --spec         version for a SINGLE package; with several, put it on the name
      --keep-platforms   report what cannot run there, but leave `platforms` alone
      --dry-run      say what would change; write nothing
      --json         machine-readable report
```

You name the packages — nothing can infer `biopython` from `import Bio`. Salpa finds each
one in the channels your manifest already declares (so the conda-forge-only trap cannot
bite), works out which of your declared platforms can run **all** of them, writes them,
narrows `platforms` with a comment saying why, and solves.

```console
$ salpa add gromacs mdanalysis "biopython>=1.85"
  ok    gromacs 2026.3      conda-forge  ·  5 platform(s)
  ok    mdanalysis 2.10.0   conda-forge  ·  6 platform(s)
  ok    biopython 1.88      conda-forge  ·  7 platform(s)

  ok    linux-64
  ok    osx-64
  ok    osx-arm64
  no    win-64   no build for gromacs
  ...
Solved. gromacs, mdanalysis, biopython and everything already here resolve together
on all 3 platform(s).
```

Four rules worth knowing:

- **Several names, one solve.** The platform list becomes the *intersection* — what can
  run all of them — because that is what `pixi lock` insists on. A solve answers "do these
  resolve *together*", which is only meaningful once all of them are in the file.
- **Nothing is written unless every name resolves.** One typo out of four and you see the
  three that were found, a near miss for the fourth, and `Nothing was written`. The fix is
  to run the same command again, which is only safe because the failed run changed nothing.
- **One reason comment per dropped platform**, naming only the packages responsible for
  that one — a single "removed by salpa add gromacs mdanalysis" becomes a lie the moment
  two packages have different gaps. A later run replaces its own note rather than stacking
  a stale one.
- **It never widens.** When a package also builds somewhere you do not declare, it says so
  and stops. Adding a platform is a claim about *testing*, not only about solving.

Quote an inline version — `salpa add "numpy>=1.24"` — or your shell reads `>` as a
redirect and creates a file called `=1.24` before Salpa ever runs.

Full background, including the four places a dependency can be written and the one that
installs anything: `salpa docs dependencies-and-platforms`.

## Validating

`salpa validate [PATH]` checks a package against the contract the app enforces at
install and registration time. Every check maps to a real failure — a node missing
from `[package.nodes]`, a shared-environment name that drifted between three files,
a stray per-node `pixi.toml` that silently opts a node out of the shared env. It is
not a linter: style is not checked.

```
salpa validate [PATH]           # default: the current directory
      --strict                  # warnings become errors (CI; except H2 — see below)
      --ignore CODE             # suppress one check by code, repeatable
      --json                    # machine-readable report
      --no-import               # skip the import checks
      --python PATH             # override the auto-detected import-check interpreter
```

Exit codes: `0` clean · `1` at least one error · `2` the path is not a package.

Findings are `ERROR` (will not install or will not register) or `WARN` (installs,
but is probably not what you meant). Each carries a stable code (`E3`, `S2`, …) so
it can be quoted, grepped, and suppressed. `salpa docs codes` lists every one
of them with what it means.

**Import checks.** `I1`-`I3` import your `node.py` in a subprocess to confirm the
app can read its `OPTIONS`. They need the authoring SDK (`bocoflow_core`), and the
interpreter that has it is found for you — your package already declares the SDK in
its own `pixi.toml`, so its solved `test` environment is the right one to judge the
package against:

```bash
salpa env install    # solve the test env — the one your pixi.toml puts the SDK in
salpa validate       # the import checks now run, no flags
```

**They are skipped by default, and the summary says so.** Without the SDK there is
no way to import your `node.py`, so `salpa validate` reports the structural checks
only and marks the count:

```
0 errors, 3 warnings · import checks DID NOT RUN
```

Worth taking seriously rather than reading as a pass — `I1` is the check that catches
a `node.py` the app cannot load at all, which no amount of structural checking will
find.

The search order, first hit wins: `.pixi/envs/test` → `.pixi/envs/default` → the
interpreter running `salpa`. A candidate that cannot import `bocoflow_core` is
passed over, and the report names the one that answered. `pip install
bocoflow-core-sdk` beside `salpa` works too, and `--python PATH` overrides the
search entirely (a `--python` that lacks the SDK skips rather than falling back —
naming an interpreter is an instruction, not a hint).

With no interpreter at all, the checks are **skipped**, and both the status line and
the summary say so — `No findings (import checks skipped)` is a smaller claim than
`No findings. This package should install and register.`, and reads as one.

**Suppressing a check.** Some warnings describe a shape that is occasionally
correct — `E3` (a per-node `pixi.toml`) is a deliberate opt-out when a node's
dependencies conflict with its package's shared environment. `--ignore` lets such a
package pass `--strict` without silencing everything:

```bash
salpa validate --strict --ignore E3
```

Suppressed findings are still reported (and appear under `suppressed` in `--json`)
— they are hidden from the exit code, not from you.

**One code `--strict` does not promote.** `H2` reports build artifacts
(`__pycache__`, `.pytest_cache`), and the documented order is `pixi run test` then
`salpa validate` — so the test run creates exactly what the next command reports.
Promoting that would fail a package for having been tested, which it did: 37 of the
39 first-party packages were rejected, 36 of them on `H2` alone. It stays a `WARN`,
still reported and still counted. It is right about a *published* tree and wrong
about a working one, so it is enforced at publish time instead.

`salpa new` runs a validation pass over its own output and prints a one-line
summary. That pass covers structure, naming and environment only; the import
checks are left to `salpa validate`.

## Smoke-testing

`salpa validate` proves a package **installs and registers**, and every check it
makes is static — a node whose `execute()` raises on its own sample input passes it
cleanly. `salpa smoke [PATH]` closes that gap by running the thing:

```
salpa smoke [PATH]              # default: the current directory
      --python PATH             # interpreter to run the node with (same resolution
                                # as `validate`; name it explicitly in automation)
      --timeout SECONDS         # per node, default 300 — covers all of its runs
      --json                    # machine-readable report
```

Exit codes: `0` every node behaved · `1` one did not, **or the checks could not
run** · `2` the path is not a package.

Three checks:

- **smoke** — import `node.py`, call `execute()` on a file from `demo_data/`, and
  require `success` with a non-trivial payload.
- **negative** — run it again with its real input taken away: a path that does not
  exist for a node that **was given** a file, no upstream for a node that reads
  `predecessor_data`. *Given* is the operative word — a node that declares a file
  parameter but is driven by something else (an accession, a SMILES string) never had
  a file to lose, so the check reports **n/a** rather than accusing it. A node that still reports success is doing
  `except: success = True`, which is the most common way a broken node looks fine.
  Raising here is a **pass**; claiming success is a **failure**. If a node has
  neither kind of input, the report says the check did not apply — "not checked"
  must not look like "checked and fine".
- **determinism** — the same input twice gives the same payload.
- **idempotence** — running twice into the *same* output directory gives the same
  result. Catches the classic self-input leak: a node that scans its working
  directory for input and writes its output there picks up its own previous output
  on a re-run.
- **path-independence** — the same input read from a different directory still
  works, and still gives the same content. Catches a hardcoded path.

The last three are **advisory** — reported, never blocking. A node that samples may
legitimately be non-deterministic, and a node that appends may legitimately be
non-idempotent. They exist to tell you something a static check cannot.

Each node is executed **up to three times** — once for smoke, once for negative, and
once more for determinism (the smoke run is reused as the first sample rather than
running a fourth). All three share one `--timeout` budget, so raise it for an
expensive node.

**Multi-node packages are not chained.** Every node runs on its own `demo_data/` and
its own declared parameters, because a package manifest gives its *membership* and
never its *connections* — and guessing at connections produced both false failures
and undetectable false passes.

That is also the bar a node should meet: **every input is a parameter**, and
`predecessor_data` only fills one in when the canvas left it blank. A node that can
*only* be driven by its upstream cannot be tested on its own, which is a gap in the
node. Smoke reports such a node as **not checked** — never a failure, and never a
pass.

### Telling it how to run your node

`node.py` carries a `DEMO_CONFIG` dict — parameter name to value, with `demo_data/…` strings
resolved relative to the node. The scaffolded `node.py` test builds its `flow_vars` from the same
dict, so the declaration has exactly one source and the test proves it is right.

Without it, a checker can only infer from a parameter's *type* — which gives its shape and never its
value. It cannot know which of two files is the topology, and a node driven by an ID has no file to
infer from. Anything undeclared is still inferred, so it is worth adding incrementally.

### What it does not prove

That the node is **correct**. It proves the node runs, returns something, and
refuses bad input. A node whose science is wrong — or absent — passes all three as
long as it returns a plausible dict. No checker that reads a package in isolation
can do better: that needs an expected answer the package does not contain. Green
here means *"nothing obviously broken"*, which is a smaller claim than it reads as.

## The development environment

Your node's dependencies live in a pixi environment beside the package. `salpa env`
manages it — it never touches the Salpa app's environments, which are shared between
packages and removed by reference count.

```
salpa env status [PATH]      what is built, how big, and which checks would run now
salpa env install [PATH]     build the test environment (`pixi install -e test`)
salpa env clean [PATH]       remove the package's .pixi/ (keeps pixi.lock)
```

**`status` answers the question that otherwise has no home** — not what exists, but
what would happen:

```
$ salpa env status
my_analyzer (single)
  test     412.6 MB  python 3.12.13  SDK 0.1.1
  default  not built
  total 412.6 MB — `salpa env clean` reclaims it

Right now:
  salpa validate  import checks will RUN (.pixi/envs/test/bin/python)
  salpa smoke     will run with .pixi/envs/test/bin/python
  pixi run test   ready
```

**Why `test` and not the default environment.** The default one deliberately excludes
the authoring SDK — it would shadow the runtime `bocoflow_core` the app provides — so a
package built that way cannot run its tests, `validate`'s import checks, or
`salpa smoke`. `install` always builds `test`, which carries your dependencies as well.

`pixi run test` builds that environment on demand, so `install` is a convenience rather
than a requirement. `clean` is not: a trivial package's environment is ~100 MB and one
pulling AmberTools or GROMACS is gigabytes, and nothing else reclaims it.

## Pushing it into Salpa

`salpa push [PATH]` makes a package appear in your running Salpa app's
**Marketplace ▸ Browse**, ready to install.

**It is not publishing.** It targets the app on *your* machine; nothing is uploaded
anywhere, and there is no public hub involved.

```
salpa push [PATH]
      --copy           copy into the app instead of linking your directory
      --source-id      id for this source in the app
      --source-name    display name for this source
      --port           port the app is listening on (default 18000, or $SALPA_PORT)
      --force          push despite `salpa validate` errors; with --copy, also
                       replace a package copy that this push did not create
  -y, --yes            accept the layout change without asking
```

Exit codes: `0` ok · `1` refused or failed · `2` the path is not a package.

`push` validates, arranges your package into the layout a package source uses
(`registry.json` + `packages/<pkg>/`, asking before it moves anything), generates
the catalog file, and asks the app to sync it and put the package on the shelf.

**The install itself stays in the app.** `push` stops at the shelf — installing is
user-consented in the UI, and runs the same installer every other package goes
through. There is no second installer in this CLI, and no assumption anywhere about
where the app keeps its files: the app is asked, over the API its own UI uses.
Salpa must therefore be running; if it isn't, `push` says so and stops.

By default your directory *is* the source — nothing is copied, so re-running after
an edit refreshes what the app offers:

```bash
salpa push packages/my_suite      # edit, push, repeat
```

`--copy` copies the package into the app instead. Self-contained and survives
moving your working copy, at the cost of needing another `--copy` push per edit.
It lands in the directory the app keeps its own packages in, so it will replace an
earlier copy of yours but refuses to overwrite anything else — your `[package].name`
decides the directory name, and a collision there is someone else's package.

## Taking it back out

`salpa unpush [PATH]` is the reverse: it removes the package from the app's shelf
and deregisters the source, so the app stops offering it.

```
salpa unpush [PATH]
      --source-id      id of the source to remove (default: whichever the push created)
      --port           port the app is listening on (default 18000, or $SALPA_PORT)
      --force          remove it even though the package is installed
  -y, --yes            remove without asking
```

Exit codes: `0` ok (including nothing to remove) · `1` refused or failed · `2` the
path is not a package.

**Your own directory is never touched** — the `packages/` layout and `registry.json`
that `push` wrote stay put, and `salpa push` registers them again whenever you want
the package back. You do not have to remember whether you used `--copy`: `unpush`
reads which mode was used off what is actually in the app, and cleans up the copy
in the app's catalog when there is one.

Unpushing something that was never pushed reports `Nothing to remove` and exits `0`,
so it is safe to run twice.

`unpush` refuses while the package is **installed**, and points you at
Marketplace ▸ Installed to uninstall it first — removing the source out from under
an installed copy would leave it pointing at a source that no longer exists.
`--force` removes it anyway. As with `push` stopping at the shelf, `unpush` never
uninstalls anything itself; that stays in the app.

Full walkthrough: `salpa docs publishing-to-your-app`.

## Authoring guide

`salpa docs` lists the bundled guides; `salpa docs <name>` prints one. They ship inside
the wheel, so they match the version you installed:

| Guide | Covers |
|-------|--------|
| `the-authoring-loop` | `salpa dev` — the checklist, the metadata step, and why `?` is not a pass |
| `node-package-structure` | `meta.toml`, `core.py`, `node.py`, `pixi.toml`, `demo_data/` |
| `node-parameters` | every Parameter class and when values are checked |
| `dependencies-and-platforms` | `salpa add`, and the four tables only one of which installs anything |
| `multi-node-packages` | several nodes sharing one environment |
| `testing-and-loading-your-node` | `pixi run test`, `salpa smoke`, loading into Salpa |
| `publishing-to-your-app` | `salpa push` and `salpa unpush` |
| `machine-readable-output` | `--json` shapes, and where exit code 0 is not enough |
| `codes` | every `salpa validate` finding code |

Full documentation: https://salpa.app/docs/custom-nodes

## Reserved

`salpa publish` / `install` / `run` are reserved for later and not implemented
yet. The current surface is `salpa new`, `salpa dev`, `salpa add`,
`salpa validate`, `salpa smoke`, `salpa env`, `salpa push`, `salpa unpush`
and `salpa docs`.
