diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4eff4cd..5790bd9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,6 +37,71 @@ jobs:
- name: Integration Tests (no auth)
run: go test ./tests/ -v -count=1 -timeout 120s
+ # The npm and PyPI packagers consume goreleaser's output. Nothing else in CI
+ # exercises them, so without this a packaging break is only discovered by a
+ # tag push — i.e. by a broken release.
+ packaging:
+ runs-on: ubuntu-latest
+ needs: test
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.26"
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - uses: goreleaser/goreleaser-action@v6
+ with:
+ distribution: goreleaser
+ version: "~> v2"
+ args: build --snapshot --clean
+
+ - name: Collect binaries
+ run: |
+ set -euo pipefail
+ jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \
+ dist/artifacts.json |
+ while IFS=$'\t' read -r target path; do
+ mkdir -p "artifacts/$target"
+ cp "$path" "artifacts/$target/"
+ done
+ test "$(find artifacts -type f | wc -l)" -eq 6
+
+ - name: Build npm packages
+ run: node packaging/npm/build.mjs v0.0.0 artifacts dist/npm
+
+ - name: Install and run the npm package
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp/npmcheck && cd /tmp/npmcheck && npm init -y >/dev/null
+ npm install --no-audit --no-fund \
+ "$GITHUB_WORKSPACE/dist/npm/modelslab-cli-linux-x64" \
+ "$GITHUB_WORKSPACE/dist/npm/modelslab-cli"
+ # The real check: the shim resolves the binary and the binary runs.
+ ./node_modules/.bin/modelslab --version
+
+ - name: Build PyPI wheels
+ run: python3 packaging/pypi/build.py v0.0.0 artifacts dist/pypi
+
+ - name: Install and run the wheel
+ run: |
+ set -euo pipefail
+ python3 -m pip install --quiet twine
+ python3 -m twine check dist/pypi/*.whl
+ python3 -m pip install --quiet \
+ dist/pypi/modelslab_cli-0.0.0-py3-none-manylinux2014_x86_64.whl
+ # Catches the executable-bit bug: twine check passes a wheel whose
+ # binary unpacks 0644, and only running it fails.
+ modelslab --version
+
build-matrix:
runs-on: ubuntu-latest
needs: test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 507bcd5..5e5e74c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -43,3 +43,65 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }}
+
+ # Both registries package the SAME binaries goreleaser just built, taken
+ # from dist/ rather than rebuilt, so npm, PyPI, Homebrew and Scoop can
+ # never ship different bytes for one tag.
+ - name: Collect release binaries
+ run: |
+ set -euo pipefail
+ # Read goreleaser's own manifest rather than parsing dist/ directory
+ # names: those carry microarchitecture suffixes (_v1, _v8.0) that move
+ # between goreleaser versions, and artifacts.json states goos/goarch
+ # outright.
+ jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \
+ dist/artifacts.json |
+ while IFS=$'\t' read -r target path; do
+ mkdir -p "artifacts/$target"
+ cp "$path" "artifacts/$target/"
+ done
+ find artifacts -type f | sort
+ test "$(find artifacts -type f | wc -l)" -eq 6
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ registry-url: "https://registry.npmjs.org"
+
+ - name: Build npm packages
+ run: node packaging/npm/build.mjs "${GITHUB_REF_NAME}" artifacts dist/npm
+
+ # `secrets` is not an available context in a step-level `if`, so the token
+ # is mapped to env and the guard reads that. Without the guard, a fork or a
+ # repo that has not configured the token fails the whole release.
+ - name: Publish to npm
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ if: ${{ env.NODE_AUTH_TOKEN != '' }}
+ run: |
+ set -euo pipefail
+ # Platform packages first: the entry package depends on them, and an
+ # entry published against versions that do not exist yet is an install
+ # that fails for everyone until the next step lands.
+ for pkg in dist/npm/modelslab-cli-*; do
+ npm publish "$pkg" --access public --provenance
+ done
+ npm publish dist/npm/modelslab-cli --access public --provenance
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Build PyPI wheels
+ run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi
+
+ - name: Publish to PyPI
+ env:
+ TWINE_USERNAME: __token__
+ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+ if: ${{ env.TWINE_PASSWORD != '' }}
+ run: |
+ set -euo pipefail
+ python3 -m pip install --quiet twine
+ python3 -m twine check dist/pypi/*.whl
+ python3 -m twine upload dist/pypi/*.whl
diff --git a/.gitignore b/.gitignore
index a6b98f4..d3a7739 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,9 @@ go.work.sum
# GoReleaser
dist/
+# Binaries collected from a release for the npm/PyPI packagers
+artifacts/
+
# IDE
.idea/
.vscode/
@@ -35,3 +38,4 @@ Thumbs.db
# Generated output
generated/
+__pycache__/
diff --git a/README.md b/README.md
index 8d1ab82..41edad5 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,26 @@ The official command-line interface for [ModelsLab](https://modelslab.com) — m
## Installation
+### npm
+
+```bash
+npm install -g modelslab-cli
+```
+
+Ships the prebuilt binary for your platform as an optional dependency — nothing
+is compiled and no install script runs.
+
+### PyPI
+
+```bash
+pip install modelslab-cli
+```
+
+Platform wheels; no build step and no Python dependencies.
+
+Both register the `modelslab` command. The `modelslab` packages on npm and
+PyPI are the **SDKs**, not this CLI — hence the `-cli` suffix.
+
### macOS (Homebrew)
```bash
diff --git a/packaging/README.md b/packaging/README.md
new file mode 100644
index 0000000..5112403
--- /dev/null
+++ b/packaging/README.md
@@ -0,0 +1,78 @@
+# Packaging
+
+The CLI is a single Go binary. Homebrew, Scoop, deb and rpm are produced by
+goreleaser directly; npm and PyPI are not registries goreleaser publishes to, so
+they are built here from the binaries goreleaser has already produced.
+
+Nothing in this directory compiles anything. Both builders take a directory of
+extracted release binaries and repackage them, so every channel ships the same
+bytes for a given tag.
+
+```
+artifacts/
+ darwin_amd64/modelslab
+ darwin_arm64/modelslab
+ linux_amd64/modelslab
+ linux_arm64/modelslab
+ windows_amd64/modelslab.exe
+ windows_arm64/modelslab.exe
+```
+
+## npm — `packaging/npm/build.mjs`
+
+```bash
+node packaging/npm/build.mjs v1.2.3 artifacts dist/npm
+```
+
+Produces seven packages: one entry package (`modelslab-cli`) whose only content
+is a launcher shim, plus one package per platform holding just the binary and
+the `os`/`cpu` fields npm filters on. The entry package declares the six as
+`optionalDependencies`, so npm installs exactly the one that matches.
+
+The alternative — one package with a postinstall that downloads a binary — was
+rejected deliberately. It needs network at install time and produces a silently
+broken install under `npm ci --ignore-scripts`, which many CI and agent sandboxes
+set. Six small packages buy an install that cannot half-work.
+
+**Publish platform packages before the entry package.** The entry package pins
+exact versions of all six; publishing it first leaves a window where every
+install fails.
+
+## PyPI — `packaging/pypi/build.py`
+
+```bash
+python3 packaging/pypi/build.py v1.2.3 artifacts dist/pypi
+```
+
+Produces one wheel per platform, each containing the binary and a console script
+that `execv`s it. Wheels are written directly rather than through a build
+backend: there is nothing to compile, so a backend would only add a dependency,
+and writing them here keeps the platform tag explicit instead of inferred from
+whatever host ran the build.
+
+Two things that are easy to get wrong and are covered by CI:
+
+- **Version normalisation.** Tags are semver, wheel filenames are PEP 440. A
+ `v1.2.3-rc1` tag naively becomes `modelslab_cli-1.2.3-rc1-py3-none-*.whl`,
+ which pip reads as version `1.2.3` with build tag `rc1` — and build tags must
+ start with a digit, so the file is invalid. `normalise_version()` converts it
+ to `1.2.3rc1`.
+- **The executable bit.** pip decides whether to mark an unpacked file executable
+ with `stat.S_ISREG(mode) and mode & 0o111`, so the zip entry's mode has to
+ carry the file-type bits, not just permissions. A bare `0o755` fails `S_ISREG`,
+ the binary lands `0o644`, and the first run dies with `EPERM`. `twine check`
+ passes either way; only installing and running catches it.
+
+## Releasing
+
+`.github/workflows/release.yml` runs both builders on a tag and publishes if the
+corresponding token is configured. Missing tokens skip that registry rather than
+failing the release.
+
+| Secret | Registry |
+| --- | --- |
+| `NPM_TOKEN` | npm (automation token with publish rights) |
+| `PYPI_TOKEN` | PyPI (project or account API token, used as `__token__`) |
+
+`.github/workflows/ci.yml` builds both on every PR and installs and *runs* the
+result, because both of the failure modes above pass every static check.
diff --git a/packaging/npm/README.md b/packaging/npm/README.md
new file mode 100644
index 0000000..a64d7c9
--- /dev/null
+++ b/packaging/npm/README.md
@@ -0,0 +1,20 @@
+# ModelsLab CLI
+
+AI generation and account management from the terminal. One command surface over
+the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication,
+billing, wallet, subscriptions and model discovery.
+
+```bash
+npm install -g modelslab-cli
+modelslab auth login
+modelslab generate image --prompt "a lighthouse at dusk" --model flux
+```
+
+The package installs a prebuilt binary for your platform as an optional
+dependency — nothing is compiled and no install script runs.
+
+Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64).
+
+- Docs: https://docs.modelslab.com
+- Source: https://github.com/ModelsLab/modelslab-cli
+- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh
diff --git a/packaging/npm/build.mjs b/packaging/npm/build.mjs
new file mode 100644
index 0000000..be50c1b
--- /dev/null
+++ b/packaging/npm/build.mjs
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+/**
+ * Builds the npm packages for a released version.
+ *
+ * Layout follows the esbuild/biome pattern: one entry package that declares an
+ * optionalDependency on a package per platform, each containing nothing but the
+ * binary and `os`/`cpu` fields. npm installs exactly the one that matches and
+ * skips the rest.
+ *
+ * The obvious alternative — one package with a postinstall script that downloads
+ * the right binary — was rejected on purpose. It needs network at install time
+ * and silently produces a broken install under `npm ci --ignore-scripts`, which
+ * plenty of CI and agent sandboxes set. Six small packages are the cost of an
+ * install that cannot half-work.
+ *
+ * Usage: node packaging/npm/build.mjs [out-dir]
+ * holds the goreleaser tarballs already extracted into
+ * /_/modelslab[.exe]
+ */
+import { mkdirSync, writeFileSync, copyFileSync, existsSync, chmodSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+
+const NAME = 'modelslab-cli';
+const BIN = 'modelslab';
+const REPO = 'https://github.com/ModelsLab/modelslab-cli';
+const DESCRIPTION =
+ 'ModelsLab CLI — AI generation and account management from the terminal';
+
+/** goreleaser target -> npm os/cpu. Windows is `win32` to npm, whatever Go calls it. */
+const TARGETS = [
+ { go: 'darwin_amd64', os: 'darwin', cpu: 'x64' },
+ { go: 'darwin_arm64', os: 'darwin', cpu: 'arm64' },
+ { go: 'linux_amd64', os: 'linux', cpu: 'x64' },
+ { go: 'linux_arm64', os: 'linux', cpu: 'arm64' },
+ { go: 'windows_amd64', os: 'win32', cpu: 'x64' },
+ { go: 'windows_arm64', os: 'win32', cpu: 'arm64' },
+];
+
+const [, , rawVersion, artifactsDir, outDirArg] = process.argv;
+
+if (!rawVersion || !artifactsDir) {
+ console.error('usage: build.mjs [out-dir]');
+ process.exit(1);
+}
+
+// Tags are v-prefixed; npm versions are not.
+const version = rawVersion.replace(/^v/, '');
+const outDir = resolve(outDirArg ?? 'dist/npm');
+
+if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
+ console.error(`refusing to build: "${version}" is not a semver version`);
+ process.exit(1);
+}
+
+const common = {
+ version,
+ description: DESCRIPTION,
+ homepage: 'https://modelslab.sh',
+ repository: { type: 'git', url: `git+${REPO}.git` },
+ bugs: { url: `${REPO}/issues` },
+ license: 'MIT',
+ author: 'ModelsLab ',
+};
+
+const platformPackages = [];
+
+for (const target of TARGETS) {
+ const exe = target.os === 'win32' ? `${BIN}.exe` : BIN;
+ const source = join(artifactsDir, target.go, exe);
+
+ if (!existsSync(source)) {
+ console.error(`missing binary for ${target.go}: ${source}`);
+ process.exit(1);
+ }
+
+ const pkgName = `${NAME}-${target.os}-${target.cpu}`;
+ const pkgDir = join(outDir, pkgName);
+ mkdirSync(join(pkgDir, 'bin'), { recursive: true });
+
+ const dest = join(pkgDir, 'bin', exe);
+ copyFileSync(source, dest);
+ // npm preserves the mode in the tarball; without this the shim cannot exec it.
+ chmodSync(dest, 0o755);
+
+ writeFileSync(
+ join(pkgDir, 'package.json'),
+ JSON.stringify(
+ {
+ name: pkgName,
+ ...common,
+ description: `${DESCRIPTION} (${target.os} ${target.cpu} binary)`,
+ os: [target.os],
+ cpu: [target.cpu],
+ // Only the binary. No lifecycle scripts, nothing to execute at install.
+ files: ['bin'],
+ preferUnplugged: true,
+ },
+ null,
+ 2
+ ) + '\n'
+ );
+
+ platformPackages.push(pkgName);
+ console.log(`built ${pkgName}`);
+}
+
+// --- entry package -----------------------------------------------------------
+const entryDir = join(outDir, NAME);
+mkdirSync(join(entryDir, 'bin'), { recursive: true });
+
+writeFileSync(
+ join(entryDir, 'package.json'),
+ JSON.stringify(
+ {
+ name: NAME,
+ ...common,
+ keywords: [
+ 'modelslab',
+ 'cli',
+ 'ai',
+ 'image-generation',
+ 'video-generation',
+ 'text-to-speech',
+ 'llm',
+ 'agent',
+ ],
+ bin: { [BIN]: 'bin/modelslab.cjs' },
+ files: ['bin', 'README.md'],
+ // Optional so an unsupported platform fails at run time with a
+ // readable message instead of failing the whole install.
+ optionalDependencies: Object.fromEntries(
+ platformPackages.map((name) => [name, version])
+ ),
+ engines: { node: '>=16' },
+ },
+ null,
+ 2
+ ) + '\n'
+);
+
+copyFileSync(
+ resolve(import.meta.dirname, 'shim.cjs'),
+ join(entryDir, 'bin', 'modelslab.cjs')
+);
+copyFileSync(
+ resolve(import.meta.dirname, 'README.md'),
+ join(entryDir, 'README.md')
+);
+
+console.log(`built ${NAME} (entry) with ${platformPackages.length} optional deps`);
+console.log(`output: ${outDir}`);
diff --git a/packaging/npm/shim.cjs b/packaging/npm/shim.cjs
new file mode 100644
index 0000000..8f4790c
--- /dev/null
+++ b/packaging/npm/shim.cjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+/**
+ * Locates the platform binary and hands the process over to it.
+ *
+ * `spawnSync` with `stdio: 'inherit'` rather than an exec: Node has no execv, and
+ * inheriting the streams keeps interactive prompts, pipes and TTY detection
+ * behaving as if the binary were invoked directly. The child's exit code and
+ * terminating signal are both propagated — a wrapper that swallowed a non-zero
+ * exit would break every script that checks it.
+ */
+const { spawnSync } = require('node:child_process');
+const { existsSync, realpathSync } = require('node:fs');
+const { join, dirname, sep } = require('node:path');
+
+const PLATFORM_PACKAGES = {
+ 'darwin x64': 'modelslab-cli-darwin-x64',
+ 'darwin arm64': 'modelslab-cli-darwin-arm64',
+ 'linux x64': 'modelslab-cli-linux-x64',
+ 'linux arm64': 'modelslab-cli-linux-arm64',
+ 'win32 x64': 'modelslab-cli-win32-x64',
+ 'win32 arm64': 'modelslab-cli-win32-arm64',
+};
+
+function resolveBinary() {
+ const key = `${process.platform} ${process.arch}`;
+ const pkg = PLATFORM_PACKAGES[key];
+
+ if (!pkg) {
+ throw new Error(
+ `ModelsLab CLI does not ship a binary for ${key}.\n` +
+ `Supported: ${Object.keys(PLATFORM_PACKAGES).join(', ')}.\n` +
+ `Build from source: https://github.com/ModelsLab/modelslab-cli`
+ );
+ }
+
+ const exe = process.platform === 'win32' ? 'modelslab.exe' : 'modelslab';
+ const subpath = `${pkg}/bin/${exe}`;
+
+ /*
+ * Three lookups, because one is not enough in practice.
+ *
+ * The plain resolve covers a normal `npm i -g`. It fails whenever this
+ * package is reached through a symlink — `npm install `, `npm link`,
+ * and every pnpm install — because module resolution then starts from the
+ * link target, which has no node_modules of its own. Anchoring the search at
+ * the real directory and at the caller's cwd covers those.
+ */
+ const anchors = [__dirname];
+ try {
+ anchors.push(realpathSync(__dirname));
+ } catch {
+ // realpath can fail on a broken link; the other anchors still apply.
+ }
+ anchors.push(process.cwd());
+
+ for (const resolver of [
+ () => require.resolve(subpath),
+ () => require.resolve(subpath, { paths: anchors }),
+ ]) {
+ try {
+ return resolver();
+ } catch {
+ // try the next strategy
+ }
+ }
+
+ /*
+ * Last resort: walk up from here looking for a sibling inside any
+ * node_modules directory. Catches layouts where the package is present but
+ * unreachable by Node's algorithm from where this file physically lives.
+ */
+ for (const anchor of anchors) {
+ let dir = anchor;
+ while (true) {
+ const candidate = join(dir, 'node_modules', pkg, 'bin', exe);
+ if (existsSync(candidate)) {
+ return candidate;
+ }
+ const parent = dirname(dir);
+ if (parent === dir || !parent.includes(sep)) {
+ break;
+ }
+ dir = parent;
+ }
+ }
+
+ {
+ /*
+ * The optional dependency is missing. Almost always one of:
+ * `--no-optional`, an npm version that skipped it, or an install that
+ * partially failed. Say which package so the fix is one command.
+ */
+ throw new Error(
+ `ModelsLab CLI is installed but the binary for ${key} is not.\n` +
+ `Expected the optional dependency "${pkg}".\n\n` +
+ `Fix it with: npm install ${pkg}\n` +
+ `Or reinstall: npm install -g modelslab-cli\n\n` +
+ `If you installed with --no-optional or --ignore-optional, that is why.`
+ );
+ }
+}
+
+let binary;
+try {
+ binary = resolveBinary();
+} catch (error) {
+ process.stderr.write(`${error.message}\n`);
+ process.exit(1);
+}
+
+const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
+
+if (result.error) {
+ process.stderr.write(`failed to run ModelsLab CLI: ${result.error.message}\n`);
+ process.exit(1);
+}
+
+// A signalled child has a null status; re-raise so the parent shell sees the
+// same thing it would have seen running the binary directly.
+if (result.signal) {
+ process.kill(process.pid, result.signal);
+}
+
+process.exit(result.status ?? 1);
diff --git a/packaging/pypi/README.md b/packaging/pypi/README.md
new file mode 100644
index 0000000..a64d7c9
--- /dev/null
+++ b/packaging/pypi/README.md
@@ -0,0 +1,20 @@
+# ModelsLab CLI
+
+AI generation and account management from the terminal. One command surface over
+the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication,
+billing, wallet, subscriptions and model discovery.
+
+```bash
+npm install -g modelslab-cli
+modelslab auth login
+modelslab generate image --prompt "a lighthouse at dusk" --model flux
+```
+
+The package installs a prebuilt binary for your platform as an optional
+dependency — nothing is compiled and no install script runs.
+
+Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64).
+
+- Docs: https://docs.modelslab.com
+- Source: https://github.com/ModelsLab/modelslab-cli
+- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh
diff --git a/packaging/pypi/build.py b/packaging/pypi/build.py
new file mode 100644
index 0000000..2d0e6ab
--- /dev/null
+++ b/packaging/pypi/build.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+"""Build platform wheels for the ModelsLab CLI.
+
+One wheel per platform, each containing the prebuilt Go binary and a console
+script that hands the process over to it.
+
+Wheels are written directly rather than through setuptools. The binary is not
+Python and there is nothing to compile, so a build backend would only add a
+dependency and a temp directory to the same zip file. The wheel format is
+specified well enough (PEP 427, PEP 425) to emit correctly, and doing it here
+keeps the platform tag explicit instead of inferred from the build host.
+
+Usage: python3 packaging/pypi/build.py [out-dir]
+ holds the goreleaser tarballs already extracted into
+ /_/modelslab[.exe]
+"""
+from __future__ import annotations
+
+import base64
+import csv
+import hashlib
+import io
+import os
+import re
+import stat
+import sys
+import zipfile
+from pathlib import Path
+
+NAME = "modelslab-cli"
+DIST = "modelslab_cli"
+SUMMARY = "ModelsLab CLI — AI generation and account management from the terminal"
+
+# goreleaser target -> PEP 425 platform tag.
+#
+# manylinux2014 (glibc 2.17, CentOS 7) rather than a newer baseline: the binary
+# is CGO_ENABLED=0, so it has no libc dependency at all and the tag only needs to
+# be old enough that pip on any live distro will accept it.
+TARGETS = [
+ ("darwin_amd64", "macosx_10_12_x86_64"),
+ ("darwin_arm64", "macosx_11_0_arm64"),
+ ("linux_amd64", "manylinux2014_x86_64"),
+ ("linux_arm64", "manylinux2014_aarch64"),
+ ("windows_amd64", "win_amd64"),
+ ("windows_arm64", "win_arm64"),
+]
+
+LAUNCHER = '''"""Console entry point for the ModelsLab CLI."""
+import os
+import sys
+from pathlib import Path
+
+
+def _binary() -> Path:
+ exe = "modelslab.exe" if os.name == "nt" else "modelslab"
+ return Path(__file__).resolve().parent / "bin" / exe
+
+
+def main() -> int:
+ binary = _binary()
+
+ if not binary.exists():
+ sys.stderr.write(
+ "ModelsLab CLI binary is missing from the installed package.\\n"
+ "Reinstall with: pip install --force-reinstall modelslab-cli\\n"
+ )
+ return 1
+
+ argv = [str(binary), *sys.argv[1:]]
+
+ if os.name == "nt":
+ # Windows has no exec that replaces the process in a way cmd respects;
+ # spawn and forward the exit code instead.
+ import subprocess
+
+ return subprocess.call(argv)
+
+ # Replace this process so signals, exit codes and TTY behaviour are the
+ # binary's own. A subprocess wrapper here would swallow SIGINT handling.
+ os.execv(str(binary), argv)
+ return 1 # unreachable; execv does not return
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+'''
+
+
+def _read_description() -> str:
+ readme = Path(__file__).resolve().parent / "README.md"
+ return readme.read_text(encoding="utf-8") if readme.is_file() else SUMMARY
+
+
+def _metadata(version: str) -> str:
+ return "\n".join(
+ [
+ "Metadata-Version: 2.1",
+ f"Name: {NAME}",
+ f"Version: {version}",
+ f"Summary: {SUMMARY}",
+ "Home-page: https://modelslab.sh",
+ "Author: ModelsLab",
+ "Author-email: support@modelslab.com",
+ "License: MIT",
+ "Project-URL: Source, https://github.com/ModelsLab/modelslab-cli",
+ "Project-URL: Documentation, https://docs.modelslab.com",
+ "Classifier: Development Status :: 4 - Beta",
+ "Classifier: Environment :: Console",
+ "Classifier: Intended Audience :: Developers",
+ "Classifier: License :: OSI Approved :: MIT License",
+ "Classifier: Programming Language :: Go",
+ "Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence",
+ "Requires-Python: >=3.8",
+ "Description-Content-Type: text/markdown",
+ "",
+ _read_description(),
+ ]
+ )
+
+
+def _wheel_tag(platform_tag: str) -> str:
+ # py3-none-: pure launcher, no Python ABI, platform-specific payload.
+ return f"py3-none-{platform_tag}"
+
+
+def _urlsafe_digest(payload: bytes) -> str:
+ digest = hashlib.sha256(payload).digest()
+ return "sha256=" + base64.urlsafe_b64encode(digest).decode().rstrip("=")
+
+
+def build_wheel(version: str, artifacts: Path, out_dir: Path, go_target: str, platform_tag: str) -> Path:
+ exe = "modelslab.exe" if go_target.startswith("windows") else "modelslab"
+ source = artifacts / go_target / exe
+
+ if not source.is_file():
+ raise SystemExit(f"missing binary for {go_target}: {source}")
+
+ tag = _wheel_tag(platform_tag)
+ dist_info = f"{DIST}-{version}.dist-info"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ wheel_path = out_dir / f"{DIST}-{version}-{tag}.whl"
+
+ records: list[tuple[str, str, int]] = []
+
+ def add(zf: zipfile.ZipFile, arcname: str, payload: bytes, *, executable: bool = False) -> None:
+ info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
+ # The mode must carry the file-TYPE bits, not just the permission bits.
+ # pip decides whether to make an unpacked file executable with
+ # `stat.S_ISREG(mode) and mode & 0o111`, so a bare 0o755 fails S_ISREG,
+ # the binary lands as 0o644, and execv dies with EPERM at first run.
+ # twine check does not catch this — only installing and running does.
+ mode = stat.S_IFREG | (0o755 if executable else 0o644)
+ info.external_attr = (mode << 16) | 0o600
+ info.compress_type = zipfile.ZIP_DEFLATED
+ zf.writestr(info, payload)
+ records.append((arcname, _urlsafe_digest(payload), len(payload)))
+
+ with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ add(zf, f"{DIST}/__init__.py", b'"""ModelsLab CLI."""\n')
+ add(zf, f"{DIST}/__main__.py", LAUNCHER.encode("utf-8"))
+ add(zf, f"{DIST}/bin/{exe}", source.read_bytes(), executable=True)
+
+ add(
+ zf,
+ f"{dist_info}/METADATA",
+ _metadata(version).encode("utf-8"),
+ )
+ add(
+ zf,
+ f"{dist_info}/WHEEL",
+ (
+ "Wheel-Version: 1.0\n"
+ "Generator: modelslab-cli packaging/pypi/build.py\n"
+ "Root-Is-Purelib: false\n"
+ f"Tag: {tag}\n"
+ ).encode("utf-8"),
+ )
+ add(
+ zf,
+ f"{dist_info}/entry_points.txt",
+ f"[console_scripts]\nmodelslab = {DIST}.__main__:main\n".encode("utf-8"),
+ )
+ add(zf, f"{dist_info}/top_level.txt", f"{DIST}\n".encode("utf-8"))
+
+ # RECORD lists every file including itself, with its own hash left blank.
+ buffer = io.StringIO()
+ writer = csv.writer(buffer, lineterminator="\n")
+ for arcname, digest, size in records:
+ writer.writerow([arcname, digest, size])
+ writer.writerow([f"{dist_info}/RECORD", "", ""])
+
+ record_info = zipfile.ZipInfo(f"{dist_info}/RECORD", date_time=(1980, 1, 1, 0, 0, 0))
+ record_info.external_attr = ((stat.S_IFREG | 0o644) << 16) | 0o600
+ record_info.compress_type = zipfile.ZIP_DEFLATED
+ zf.writestr(record_info, buffer.getvalue())
+
+ return wheel_path
+
+
+def normalise_version(raw: str) -> str:
+ """Turn a git tag into a PEP 440 version.
+
+ Tags are semver (`v1.2.3`, `v1.2.3-rc1`); PEP 440 is not. The hyphen matters
+ more than it looks: it is the field separator in a wheel filename, so
+ `1.2.3-rc1` produces `modelslab_cli-1.2.3-rc1-py3-none-*.whl`, which pip
+ parses as version `1.2.3` with a build tag of `rc1` — and build tags must
+ start with a digit, so the file is simply invalid.
+ """
+ version = raw.lstrip("v").strip()
+
+ match = re.fullmatch(
+ r"(\d+\.\d+\.\d+)"
+ r"(?:[-.]?(a|b|rc|alpha|beta)\.?(\d+))?",
+ version,
+ re.IGNORECASE,
+ )
+
+ if not match:
+ raise SystemExit(
+ f'refusing to build: "{raw}" is not a version this can express in PEP 440'
+ )
+
+ release, phase, number = match.groups()
+
+ if not phase:
+ return release
+
+ canonical = {"alpha": "a", "beta": "b", "a": "a", "b": "b", "rc": "rc"}[phase.lower()]
+
+ return f"{release}{canonical}{number}"
+
+
+def main() -> int:
+ if len(sys.argv) < 3:
+ sys.stderr.write("usage: build.py [out-dir]\n")
+ return 1
+
+ version = normalise_version(sys.argv[1])
+ artifacts = Path(sys.argv[2])
+ out_dir = Path(sys.argv[3]) if len(sys.argv) > 3 else Path("dist/pypi")
+
+ for go_target, platform_tag in TARGETS:
+ path = build_wheel(version, artifacts, out_dir, go_target, platform_tag)
+ print(f"built {path.name}")
+
+ print(f"output: {out_dir}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())