diff --git a/.github/readme-source.json b/.github/readme-source.json new file mode 100644 index 0000000..e713697 --- /dev/null +++ b/.github/readme-source.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "sha256": "c492ea98e92ec200d47523e13a7cc8af93cd6f081c7352ff1959f202b8622c50", + "history": { + "forbidden_commits": [ + "3cbdabb71650fbca119b17e89ceed577f6bf81cd" + ] + } +} diff --git a/.github/runtime-sources.json b/.github/runtime-sources.json new file mode 100644 index 0000000..84722de --- /dev/null +++ b/.github/runtime-sources.json @@ -0,0 +1,6 @@ +[ + { + "source": "SKILL.prompt", + "target": "SKILL.md" + } +] diff --git a/.github/scripts/check-documentation.py b/.github/scripts/check-documentation.py new file mode 100644 index 0000000..ae3dbd9 --- /dev/null +++ b/.github/scripts/check-documentation.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Check tracked documentation layout and the reviewed usage README digest.""" +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import subprocess +import sys + +MARKDOWN = {'.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdwn', '.mdx', '.rmd', '.qmd'} +OTHER_DOCS = {'.rst', '.adoc', '.asciidoc'} + + +def check(root, include_history=True, history_required=False): + raw = subprocess.check_output(['git', 'ls-files', '--stage', '-z'], cwd=root) + entries = {} + errors = [] + for record in raw.split(b'\0'): + if not record: + continue + metadata, encoded = record.split(b'\t', 1) + mode, _oid, stage = metadata.decode().split() + name = encoded.decode('utf-8', 'surrogateescape') + if stage != '0': + errors.append('unmerged index entry: ' + name) + entries[name] = mode + path = PurePosixPath(name) + if name != 'README.md' and (path.suffix.lower() in MARKDOWN | OTHER_DOCS + or path.name.lower().startswith('readme.')): + errors.append('documentation must live in the private docs repository: ' + name) + if entries.get('README.md') != '100644' and entries.get('README.md') != '100755': + errors.append('a regular, tracked root README.md is required') + else: + readme = root / 'README.md' + if readme.is_symlink() or not readme.is_file(): + errors.append('root README.md must be a regular file') + else: + try: + body = readme.read_bytes() + if not any(line.startswith('# ') for line in body.decode('utf-8').splitlines()): + errors.append('root README.md needs a product heading') + policy_path = root / '.github/readme-source.json' + if policy_path.is_symlink(): + raise ValueError('README source policy cannot be a symlink') + policy = json.loads(policy_path.read_text()) + if policy.get('version') != 1: + raise ValueError('unsupported README source policy') + if hashlib.sha256(body).hexdigest() != policy.get('sha256'): + errors.append('README differs from its reviewed publication; update the private source first') + history = policy.get('history') + if history_required and history is None: + raise ValueError('public repositories require a history boundary') + if include_history and history is not None: + if set(history) != {'forbidden_commits'} or not isinstance(history['forbidden_commits'], list) or not history['forbidden_commits']: + raise ValueError('invalid public history boundary') + forbidden = set(history['forbidden_commits']) + if any(not isinstance(oid, str) or len(oid) != 40 or any(c not in '0123456789abcdef' for c in oid) for oid in forbidden): + raise ValueError('invalid original history identity') + shallow = subprocess.check_output(['git', 'rev-parse', '--is-shallow-repository'], cwd=root, text=True).strip() + if shallow != 'false': + raise ValueError('public history verification requires fetch-depth: 0') + ancestors = set(subprocess.check_output(['git', 'rev-list', 'HEAD'], cwd=root, text=True).splitlines()) + if ancestors & forbidden: + errors.append('original history was reintroduced; transplant changes onto the cleaned main history') + patterns = [':(icase)*'+suffix for suffix in sorted(MARKDOWN)] + past = subprocess.check_output(['git', 'log', '--format=', '--name-only', '-z', 'HEAD', '--', *patterns], cwd=root) + extra = {name.decode('utf-8', 'surrogateescape') for name in past.split(b'\0') if name and name != b'README.md'} + errors.extend('historical Markdown must remain in private docs: '+name for name in sorted(extra)) + + except (OSError, ValueError, UnicodeError) as error: + errors.append('invalid README source policy or README: ' + str(error)) + return errors + + +def main(): + root = Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()) + errors = check(root, history_required=os.environ.get('REQUIRE_CLEAN_HISTORY') == 'true') + for error in errors: + print(error, file=sys.stderr) + if errors: + return 1 + print('Documentation policy passed: one root README.md, reviewed source digest matches.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/workflows/documentation-policy.yml b/.github/workflows/documentation-policy.yml new file mode 100644 index 0000000..479ac0b --- /dev/null +++ b/.github/workflows/documentation-policy.yml @@ -0,0 +1,23 @@ +name: Documentation policy +on: + push: + pull_request: + merge_group: + workflow_dispatch: +permissions: + contents: read +jobs: + documentation-policy: + name: Documentation policy + runs-on: ubuntu-24.04 + timeout-minutes: 3 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + fetch-depth: ${{ github.event.repository.private && 1 || 0 }} + - name: Check documentation boundary + env: + REQUIRE_CLEAN_HISTORY: ${{ !github.event.repository.private }} + run: python3 .github/scripts/check-documentation.py diff --git a/.gitignore b/.gitignore index a8d5e41..1926130 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ tmp /bin /dist + +# Native runtime files generated from functional inputs. +/SKILL.md diff --git a/README.md b/README.md index a1c087f..62a6777 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,327 @@ -# pixellab-cli + -For current client installation and usage, see https://github.com/aviorstudio/pixellab-cli#readme. +# pxlb + +`pxlb` is a thin Go CLI for the [PixelLab v2 API](https://api.pixellab.ai/v2/docs). It exposes PixelLab routes directly, keeps request fields as simple flags, supports async job polling, and can save returned images or binary downloads. + +## Install + +### Option 1: mise + +Add `pxlb` to your `.mise.toml` as a GitHub release tool. Pick the asset for your OS and CPU architecture. + +Linux x86_64 example: + +```toml +[tools] +"github:aviorstudio/pixellab-cli" = { version = "latest", asset_pattern = "pxlb_Linux_x86_64.tar.gz", bin = "pxlb" } +``` + +Then install and verify: + +```bash +mise install +pxlb --version +``` + +Common asset patterns: + +```text +pxlb_Linux_x86_64.tar.gz +pxlb_Linux_arm64.tar.gz +pxlb_Darwin_x86_64.tar.gz +pxlb_Darwin_arm64.tar.gz +pxlb_Windows_x86_64.zip +pxlb_Windows_arm64.zip +``` + +### Option 2: install.sh + +macOS and Linux: + +```bash +curl -fsSL https://raw.githubusercontent.com/aviorstudio/pixellab-cli/main/install.sh | sh +``` + +Install a specific version: + +```bash +curl -fsSL https://raw.githubusercontent.com/aviorstudio/pixellab-cli/main/install.sh | VERSION=0.0.2 sh +``` + +Install to a specific directory: + +```bash +curl -fsSL https://raw.githubusercontent.com/aviorstudio/pixellab-cli/main/install.sh | INSTALL_DIR="$HOME/.local/bin" sh +``` + +Windows builds are available from [GitHub Releases](https://github.com/aviorstudio/pixellab-cli/releases). + +## Quick Start + +```bash +pxlb --help +pxlb --version +``` + +Set your API key: + +```bash +export PIXELLAB_API_KEY="..." +pxlb balance +``` + +You can also put `PIXELLAB_API_KEY=...` in a `.env` file in the directory where you run `pxlb`, or pass `--token ` for a single command. + +## Command Model + +Use the API route without the leading slash: + +```bash +pxlb [flags] +``` + +Examples: + +```bash +pxlb balance +pxlb create-image-pixen --description "cute dragon" --image-size 128x128 --out ./out +pxlb generate-image-v2 --description "crystal sword" --image-size 128x128 --wait --out ./out +pxlb objects//animations --animation-description "heavy sword slash" --frame-count 16 --wait +``` + +`pxlb` infers the HTTP method from the route: + +- Unique routes use their only supported method. +- Path parameters go directly in the route, such as `objects/abc123`; do not pass the same id again as `--object-id`. +- `GET`/`DELETE` collisions default to safe `GET`. +- Use `--http-method delete` for destructive delete routes. +- `/tilesets` has both `GET` and `POST`; body flags or `--body-json` select `POST`, otherwise it lists tilesets with `GET`. + +Collision examples: + +```bash +pxlb objects/ +pxlb objects/ --http-method delete +pxlb tilesets --limit 50 --offset 0 +pxlb tilesets --lower-description "ocean" --upper-description "beach" +``` + +## Built-In Help + +Show all routes with one-sentence descriptions: + +```bash +pxlb --help +``` + +Show build metadata: + +```bash +pxlb --version +``` + +## Global Flags + +| Flag | Purpose | +|---|---| +| `--token ` | PixelLab API key. Overrides `PIXELLAB_API_KEY` and `.env`. | +| `--base-url ` | API base URL. Defaults to `https://api.pixellab.ai/v2`. | +| `--http-method ` | Override inferred method for colliding routes. Usually only needed for delete. | +| `--json` | Print raw JSON response. | +| `--out ` | Save binary responses or returned base64 images. | +| `--wait` | Poll returned background jobs until completion. | +| `--poll-interval ` | Polling interval for `--wait`, for example `5s`. | +| `--timeout ` | Max wait duration, for example `10m`. | +| `--body-json ` | Send raw JSON from an inline JSON string or file. | +| `--quiet` | Suppress normal JSON output. | + +## Flag Conventions + +Request body fields become kebab-case flags: + +```bash +pxlb create-image-pixen \ + --description "crystal sword" \ + --image-size 128x128 \ + --outline "selective outline" \ + --no-background \ + --seed 123 +``` + +Common conversions: + +- API `snake_case` fields become CLI `--kebab-case` flags. +- Size values use `WIDTHxHEIGHT`, such as `128x128`. +- Image flags accept local file paths and are encoded as PixelLab `Base64Image` objects. +- Repeated flags create arrays, such as `--tag enemy --tag boss`. +- Complex request bodies can always use `--body-json`. + +Raw JSON escape hatch: + +```bash +pxlb objects//animations \ + --body-json ./animation-request.json \ + --wait +``` + +## Async Jobs And Output + +Many generation routes return a background job. Add `--wait` to poll until completion: + +```bash +pxlb generate-image-v2 \ + --description "crystal sword" \ + --image-size 128x128 \ + --wait \ + --out ./out +``` + +Check a job manually: + +```bash +pxlb background-jobs/ +``` + +Save binary downloads: + +```bash +pxlb characters//zip --out character.zip +``` + +## Common Workflows + +### Account + +```bash +pxlb balance +``` + +### Image Generation + +```bash +pxlb create-image-pixen --description "cute dragon" --image-size 128x128 --out ./out +pxlb create-image-bitforge --description "ancient shield" --image-size 128x128 +pxlb create-image-pixflux --description "fire spell icon" --image-size 128x128 +pxlb generate-image-v2 --description "crystal sword" --image-size 128x128 --wait --out ./out +pxlb generate-with-style-v2 --style-image style.png --description "wooden chest" --image-size 128x128 --wait +``` + +### Image Editing + +```bash +pxlb remove-background --image input.png --image-size 128x128 --out out.png +pxlb image-to-pixelart --image input.png --image-size 512x512 --output-size 128x128 +pxlb resize --description "resize cleanly" --reference-image input.png --reference-image-size 64x64 --target-size 128x128 +pxlb inpaint-v3 --description "golden crown" --inpainting-image image.png --mask-image mask.png +pxlb edit-image --image input.png --image-size 128x128 --description "make it golden" --width 128 --height 128 +``` + +### Objects + +```bash +pxlb create-1-direction-object --description "wooden barrel" --size 128 --view sidescroller +pxlb create-8-direction-object --description "stone fountain" --size 128 --view "low top-down" +pxlb objects --limit 50 --offset 0 +pxlb objects/ +pxlb objects/ --http-method delete +pxlb objects//tags --tag barrel --tag prop +``` + +Review-pack object workflow: + +```bash +pxlb create-1-direction-object --description "forest mushroom" --size 128 --wait +pxlb objects//select-frames --index 0 --index 3 --common-tag props +pxlb objects//dismiss-review +``` + +Object states: + +```bash +pxlb objects//states --edit-description "make it golden" +``` + +### Object Animation + +```bash +pxlb objects//animations \ + --animation-description "heavy sword slash" \ + --display-name attack \ + --frame-count 16 \ + --wait +``` + + +### Characters + +```bash +pxlb create-character-v3 --description "blue wizard" --image-size 64x64 +pxlb create-character-pro --description "armored knight" --image-size 96x96 +pxlb create-character-with-4-directions --description "goblin" --image-size 64x64 +pxlb create-character-with-8-directions --description "goblin" --image-size 64x64 +pxlb characters --limit 50 --offset 0 +pxlb characters/ +pxlb characters//zip --out character.zip +pxlb characters/ --http-method delete +``` + +Character states and animations: + +```bash +pxlb create-character-state --character-id --edit-description "wearing red armor" +pxlb animate-character --character-id --mode v3 --action-description "walking" --frame-count 8 +pxlb characters/animations --character-id --action-description "walking" +``` + +### Animation API Routes + +```bash +pxlb animate-with-text-v3 --first-frame idle.png --action "heavy sword slash" --frame-count 16 +pxlb animate-with-text-v2 --reference-image sprite.png --reference-image-size 128x128 --action "attack" --image-size 128x128 +pxlb animate-with-skeleton --reference-image sprite.png --image-size 64x64 --skeleton-keypoints-json poses.json +pxlb interpolation-v2 --start-image start.png --end-image end.png --action "slash" --image-size 128x128 +pxlb edit-animation-v2 --description "add glow" --frames-json frames.json --image-size 128x128 +``` + +### Tiles And Tilesets + +```bash +pxlb create-isometric-tile --description "grass on dirt" --image-size 32x32 +pxlb isometric-tiles --limit 50 --offset 0 +pxlb isometric-tiles/ + +pxlb create-tiles-pro --description "1). grass 2). dirt 3). stone" --tile-type isometric --tile-size 32 +pxlb tiles-pro/ + +pxlb tilesets --lower-description "ocean" --upper-description "beach" +pxlb tilesets --limit 50 --offset 0 +pxlb tilesets/ + +pxlb tilesets-sidescroller --lower-description "stone bricks" --transition-description "moss" +pxlb create-tileset-sidescroller --lower-description "stone bricks" --transition-description "moss" +``` + +### Prompt Enhancement + +```bash +pxlb enhance-pixen-prompt --description "dragon" --image-size 128x128 +pxlb enhance-character-v3-prompt --description "blue wizard" --image-size 64x64 +pxlb enhance-animation-v3-prompt --first-frame idle.png --action "walking" +``` + +## Documentation + +- [PixelLab API docs](https://api.pixellab.ai/v2/docs): upstream API reference. +- [PixelLab OpenAPI spec](https://api.pixellab.ai/v2/openapi.json): upstream machine-readable schema. +- [PixelLab LLM docs](https://api.pixellab.ai/v2/llms.txt): condensed upstream docs for agents and LLMs. + +## Prepare runtime files + +After cloning this repository, run the following with Python 3 before loading its skills, agents, or plugins: + +```sh +python3 scripts/prepare-runtime.py +``` + +This creates the native discovery files from the tracked functional inputs. Run it again after pulling an update; if a generated file has local edits, preserve those edits before replacing it. diff --git a/SKILL.prompt b/SKILL.prompt new file mode 100644 index 0000000..22c9cf3 --- /dev/null +++ b/SKILL.prompt @@ -0,0 +1,80 @@ +--- +name: pixellab-cli +description: Use pxlb, the PixelLab v2 API CLI, for generating PixelLab images, objects, characters, animations, tilesets, checking account balance, downloading outputs, or automating PixelLab asset generation. +--- + +# PixelLab CLI + +`pxlb` is a thin Go CLI for the PixelLab v2 API. It exposes API routes directly, maps request fields to flags, supports async job polling, and can save returned images or binary downloads. + +Use this skill when the user wants to run PixelLab API operations from the command line or automate PixelLab asset generation. + +## Command Model + +Use built-in help to inspect available routes and CLI behavior before guessing flags: + +```bash +pxlb --help +``` + +Use API routes without the leading slash: + +```bash +pxlb [flags] +``` + +Examples: + +```bash +pxlb balance +pxlb generate-image-v2 --description "crystal sword" --image-size 128x128 --wait --out ./out +pxlb create-1-direction-object --description "wooden barrel" --size 128 --view sidescroller +pxlb objects//animations --animation-description "heavy sword slash" --frame-count 16 --wait +``` + +Route and method rules: + +- Path parameters go directly in the route, such as `objects/abc123`; do not pass duplicate ID flags for path params. +- The HTTP method is inferred for unique routes. +- `GET`/`DELETE` route collisions default to safe `GET`. +- Use `--http-method delete` for destructive deletes. +- Use `--body-json` as an escape hatch for complex request bodies. +- Use `--wait` to poll async background jobs. +- Use `--out ` to write binary or image outputs. + +## Common Workflows + +Check account balance: + +```bash +pxlb balance +``` + +Generate and download an image: + +```bash +pxlb generate-image-v2 --description "pixel art crystal sword" --image-size 128x128 --wait --out ./out +``` + +Create and inspect an object: + +```bash +pxlb create-1-direction-object --description "wooden barrel" --size 128 +pxlb objects/ +``` + +Animate an object: + +```bash +pxlb objects//animations --animation-description "heavy sword slash" --frame-count 16 --wait +``` + +Delete an object explicitly: + +```bash +pxlb objects/ --http-method delete +``` + +## References + +- PixelLab API docs: `https://api.pixellab.ai/v2/docs`. diff --git a/scripts/prepare-runtime.py b/scripts/prepare-runtime.py new file mode 100644 index 0000000..2d6a957 --- /dev/null +++ b/scripts/prepare-runtime.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Materialize native runtime filenames from tracked functional inputs.""" +import json +from pathlib import Path +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] + +def prepare(): + records = json.loads((ROOT / '.github/runtime-sources.json').read_text()) + for row in records: + source, target = ROOT / row['source'], ROOT / row['target'] + if not source.resolve().is_relative_to(ROOT.resolve()) or not target.parent.resolve().is_relative_to(ROOT.resolve()): + raise ValueError('runtime path escapes checkout') + content = source.read_bytes() + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink() or target.exists(): + expected = row.get('symlink') + if expected is not None and target.is_symlink() and os.readlink(target) == expected: + continue + if expected is None and not target.is_symlink() and target.read_bytes() == content: + continue + raise ValueError('refusing to replace modified runtime input: ' + row['target']) + if 'symlink' in row: + target.symlink_to(row['symlink']) + else: + target.write_bytes(content) + return len(records) + +if __name__ == '__main__': + print('Prepared', prepare(), 'runtime inputs.')