Skip to main content

px_cli/
lib.rs

1use clap::{Parser, Subcommand};
2use std::path::PathBuf;
3
4#[derive(Parser, Debug)]
5#[command(name = "px", version, about, long_about = None)]
6pub struct Cli {
7    /// Base directory for repository repositories.
8    /// Defaults to $PX_DIR, or ~/.px if unset.
9    #[arg(long, short = 'd', global = true, env = "PX_DIR")]
10    pub base_dir: Option<PathBuf>,
11
12    /// Enable verbose debug logging.
13    #[arg(long, short = 'v', global = true)]
14    pub verbose: bool,
15
16    /// Resolve repository reads through the configured Lore server (the default).
17    // Keep a stable, explicit Clap id.  `push --remote-name <name>` names a
18    // Git/Lore destination, while this flag selects server-backed reads.
19    #[arg(
20        id = "read_remote",
21        long = "remote",
22        global = true,
23        conflicts_with = "local"
24    )]
25    pub remote: bool,
26
27    /// Resolve repository reads from an explicitly checked-out local working tree.
28    #[arg(long, global = true, conflicts_with = "read_remote")]
29    pub local: bool,
30
31    #[command(subcommand)]
32    pub command: Commands,
33}
34
35/// Subcommands for `px remote`.
36#[derive(Subcommand, Debug)]
37pub enum RemoteCmd {
38    /// Add a remote to a repository repository.
39    Add {
40        /// Repository name.
41        repository: String,
42        /// Remote name (e.g., "origin").
43        name: String,
44        /// Remote URL.
45        url: String,
46    },
47    /// List remotes on a repository repository.
48    Ls {
49        /// Repository name.
50        repository: String,
51    },
52    /// Remove a remote from a repository repository.
53    Rm {
54        /// Repository name.
55        repository: String,
56        /// Remote name to remove.
57        name: String,
58    },
59}
60
61/// Subcommands for `px choose`.
62#[derive(Subcommand, Debug)]
63pub enum ChooseCmd {
64    /// Choose backend provider.
65    Backend {
66        /// Provider type: local, portals-cloud, or remote.
67        provider: String,
68
69        /// Remote URL (required for remote provider).
70        #[arg(long)]
71        remote_url: Option<String>,
72
73        /// Workspace ID (for remote provider).
74        #[arg(long)]
75        workspace_id: Option<String>,
76
77        /// Reset the provider configuration file.
78        #[arg(long)]
79        reset: bool,
80    },
81}
82
83/// Subcommands for `px backend` (deprecated: use `px configure`).
84#[derive(Subcommand, Debug)]
85pub enum BackendCmd {
86    /// Configure the version-control backend.
87    ///
88    /// After configuration, existing unversioned repositories in this PX home
89    /// are offered an initial commit so their current filesystem state becomes
90    /// the repository baseline (unless --no-initial-commit is given).
91    #[command(alias = "set")]
92    Configure {
93        /// Backend type: local or remote.
94        backend: String,
95
96        /// Remote endpoint URL (required for remote backend).
97        #[arg(long, alias = "remote-url", alias = "remote_url")]
98        endpoint: Option<String>,
99
100        /// Workspace ID (for remote backend).
101        #[arg(long)]
102        workspace_id: Option<String>,
103
104        /// Bootstrap existing repositories with an initial commit without prompting.
105        #[arg(long)]
106        initial_commit: bool,
107
108        /// Skip bootstrapping existing repositories with an initial commit.
109        #[arg(long)]
110        no_initial_commit: bool,
111    },
112
113    /// Show the current version-control backend configuration.
114    Status,
115}
116
117/// Unified backend configuration — merges `px choose` + `px backend`.
118///
119/// `px configure` is the single entry point for selecting and inspecting the
120/// version-control backend. It supports all provider types (`local`,
121/// `portals-cloud`, `remote`), `remote` URL aliases (`--remote-url` /
122/// `--endpoint`), workspace scoping, reset, and the bootstrap flags from
123/// `px backend`. Bare `px configure` or `px configure status` shows the
124/// current config (like `px status`); `px configure <provider>` sets it.
125#[derive(Subcommand, Debug)]
126pub enum ConfigureCmd {
127    /// Show current backend configuration and connectivity (default when no provider is given).
128    Status,
129}
130
131#[derive(Debug, Parser)]
132pub struct ConfigureArgs {
133    /// Provider type: local, portals-cloud, or remote. Positional for ergonomics;
134    /// omit to show current config (or use `px configure status`).
135    #[arg(value_name = "PROVIDER", conflicts_with = "provider_flag")]
136    pub provider: Option<String>,
137
138    /// Provider type flag (alternative to positional `PROVIDER`).
139    #[arg(
140        long = "provider",
141        value_name = "PROVIDER",
142        hide = true,
143        conflicts_with = "provider"
144    )]
145    pub provider_flag: Option<String>,
146
147    /// Remote URL (required for `remote`). Aliases: --endpoint, --remote_url.
148    #[arg(long, alias = "endpoint", alias = "remote_url", value_name = "URL")]
149    pub remote_url: Option<String>,
150
151    /// Workspace ID (for `remote` and `portals-cloud`).
152    #[arg(long, alias = "workspace", value_name = "ID")]
153    pub workspace_id: Option<String>,
154
155    /// Reset provider configuration before (re)configuring.
156    #[arg(long)]
157    pub reset: bool,
158
159    /// Bootstrap existing unversioned repositories with an initial commit without prompting.
160    #[arg(long)]
161    pub initial_commit: bool,
162
163    /// Skip bootstrapping existing repositories.
164    #[arg(long)]
165    pub no_initial_commit: bool,
166
167    #[command(subcommand)]
168    pub cmd: Option<ConfigureCmd>,
169}
170
171/// Interactive authentication commands for the configured Lore provider.
172#[derive(Subcommand, Debug)]
173pub enum AuthCmd {
174    /// Sign in through the configured Lore authentication service.
175    Login {
176        /// Exchange a service-account API key instead of opening a browser.
177        #[arg(long)]
178        api_key: bool,
179
180        /// Environment variable containing the API key.
181        #[arg(long, default_value = "PORTALS_CLOUD_API_KEY", requires = "api_key")]
182        api_key_env: String,
183
184        /// Print the login URL without opening a browser.
185        #[arg(long, conflicts_with = "api_key")]
186        no_browser: bool,
187    },
188    /// Show the currently cached Lore identity without printing tokens.
189    Status,
190    /// Remove locally cached Lore credentials.
191    Logout,
192}
193
194#[derive(Subcommand, Debug)]
195pub enum Commands {
196    /// Manage secure authentication for the configured Lore provider.
197    Auth {
198        /// Authentication operation.
199        #[command(subcommand)]
200        cmd: AuthCmd,
201    },
202
203    /// Install required dependencies.
204    Install {
205        /// Target to install (e.g., "lore" or "mcp").
206        target: String,
207    },
208
209    /// Initialize a repository repository and/or configure the backend provider.
210    ///
211    /// When a repository name is provided, creates the repository structure
212    /// (directories, config, repository manifest, initial commit).
213    /// When --provider is given (or no provider is configured), sets up the
214    /// backend provider. Both can be combined:
215    ///
216    ///   px init toystory                     # create repository
217    ///   px init toystory --provider local    # create repository + configure provider
218    ///   px init --provider local             # configure provider only
219    Init {
220        /// Repository name. If provided, initializes a new repository repository.
221        repository: Option<String>,
222
223        /// Provider type: local, portals-cloud, or remote.
224        #[arg(long)]
225        provider: Option<String>,
226
227        /// Remote URL (required for remote provider).
228        #[arg(long)]
229        remote_url: Option<String>,
230
231        /// Workspace ID (for remote provider).
232        #[arg(long)]
233        workspace_id: Option<String>,
234
235        /// Remote URL to add as origin after init.
236        ///
237        /// This is deliberately `--origin`: `--remote` selects server-backed
238        /// reads globally and must remain unambiguous on every command.
239        #[arg(long = "origin")]
240        remote: Option<String>,
241
242        /// Reset the provider configuration file.
243        #[arg(long)]
244        reset: bool,
245    },
246
247    /// Configure version-control backend (unified: replaces `px choose` + `px backend`).
248    ///
249    /// Examples:
250    ///   px configure                          # show current config
251    ///   px configure status                   # show current config
252    ///   px configure local                    # switch to local daemon
253    ///   px configure remote --remote-url lore://192.168.0.27:41337
254    ///   px configure portals-cloud --workspace-id my-ws
255    ///   px configure --provider remote --remote-url lore://host:41337 --reset
256    #[command(alias = "config")]
257    Configure {
258        #[command(flatten)]
259        args: ConfigureArgs,
260    },
261
262    /// Choose backend provider (deprecated: use `px configure`).
263    #[command(hide = true)]
264    Choose {
265        /// Subcommand for choose.
266        #[command(subcommand)]
267        cmd: ChooseCmd,
268    },
269
270    /// Configure or inspect the version-control backend (deprecated: use `px configure`).
271    #[command(hide = true)]
272    Backend {
273        /// Subcommand for backend.
274        #[command(subcommand)]
275        cmd: BackendCmd,
276    },
277
278    /// Generate shell completions for `px`.
279    ///
280    /// Usage:
281    ///   px completions bash > ~/.local/share/bash-completion/completions/px
282    ///   px completions zsh > ~/.zfunc/_px
283    ///   px completions fish > ~/.config/fish/completions/px.fish
284    ///   source <(px completions bash)   # ephemeral
285    Completions {
286        /// Shell to generate completions for.
287        shell: clap_complete::Shell,
288    },
289
290    /// Run diagnostics and repair.
291    Doctor {
292        /// Auto-repair detected issues.
293        #[arg(long)]
294        repair: bool,
295    },
296
297    /// Show system status.
298    Status,
299
300    /// Sync with remote.
301    Sync {
302        /// Repository name.
303        repository: String,
304    },
305
306    /// Create a new entity manifest.
307    Create {
308        /// Entity type (any non-empty string, e.g. character, location, custom-type).
309        entity_type: String,
310
311        /// Entity ID (slug). e.g., "woody".
312        entity_id: String,
313
314        /// Repository name.
315        #[arg(long, short = 'u')]
316        repository: String,
317
318        /// Human-readable name.
319        #[arg(long, short = 'n')]
320        name: String,
321
322        /// Author identifier.
323        #[arg(long, short = 'a', default_value = "px")]
324        author: String,
325    },
326
327    /// Resolve a PX URI to its manifest or a subtree.
328    ///
329    /// Fragment queries are supported via the URI:
330    ///   px resolve px://toystory/character/woody#references.appears_in
331    Resolve {
332        /// PX URI. e.g., "px://toystory/character/woody"
333        uri: String,
334
335        /// Resolve at a specific branch.
336        #[arg(long)]
337        branch: Option<String>,
338
339        /// Resolve at a specific commit hash.
340        #[arg(long)]
341        commit: Option<String>,
342
343        /// Output format: yaml, json.
344        #[arg(long, short = 'f', default_value = "yaml", env = "PX_OUTPUT")]
345        format: String,
346
347        /// Include condensed per-file provenance for the manifest and direct representations.
348        #[arg(long)]
349        provenance: bool,
350
351        /// Hydrate known readable provenance artifacts such as prompts and run records.
352        #[arg(long)]
353        include_blobs: bool,
354    },
355
356    /// Create a time-limited public URL for a committed representation.
357    #[command(
358        long_about = r#"Create a time-limited public URL for a committed representation.
359
360Pass the entity ID first and the representation name second:
361
362```bash
363px presign 25th-chapter/character/nathan-gunn item
364```
365
366- `25th-chapter/character/nathan-gunn` identifies the repository, entity type,
367  and entity ID. The `px://` prefix is optional.
368- `item` is the exact key under the entity manifest's `representations` map.
369  It is not a file path or the entity's display name.
370
371The equivalent fully qualified command is:
372
373```bash
374px presign px://25th-chapter/character/nathan-gunn item
375```
376
377### How the representation is located
378
379PX reads the entity manifest at the selected revision and looks up
380`representations.item`. For example:
381
382```yaml
383representations:
384  item:
385    hash: blake3:<content hash>
386    format: jpg
387    uri: item.jpg
388```
389
390Representation URIs are relative to the entity's asset directory, matching
391`px add`. For this entity, `uri: item.jpg` resolves to
392`character/nathan-gunn/item.jpg` within the repository. Keep `uri: item.jpg`;
393there is no need to put the entity ID into the representation URI.
394
395### Revision and lifetime
396
397```bash
398px presign 25th-chapter/character/nathan-gunn item \
399  --branch main \
400  --ttl-seconds 900
401```
402
403Use either `--branch` or `--commit`, never both. When neither is supplied, PX
404uses the repository's configured default branch, falling back to the global
405default branch. Branches are pinned to a commit before PX reads the manifest
406and content address. Lore applies its configured lifetime bounds and defaults
407when `--ttl-seconds` is omitted.
408
409The manifest and representation file must be committed at the selected
410revision, and the content must have been pushed to the Lore server.
411External URLs, linked repositories, absolute paths, path traversal, URI
412fragments, and unversioned working-tree files are not supported.
413
414### Output
415
416In a terminal, the command prints the URL, expiration, and pinned revision.
417When piped or redirected, it emits JSON with `url`, `expires_at`, `revision`,
418`repository_id`, `address`, `representation`, and `format`.
419
420The returned URL is a bearer capability: anyone who has it can download the
421immutable bytes until it expires. Do not place it in logs, analytics, exception
422messages, source control, or long-lived storage.
423
424### Automatic configuration
425
426PX records the Lore HTTP origin in `provider.toml` during backend setup and
427backfills older provider configurations automatically. Local Lore uses
428`http://127.0.0.1:41339`; standard remote Lore uses the same host on port 41339;
429TLS deployments behind port 443 use the same HTTPS origin. Portals Cloud uses
430`https://lore.portals.works`. The normal command needs no additional flags:
431
432```bash
433px presign 25th-chapter/character/nathan-gunn item
434```
435
436Authenticated requests reuse the active `px auth login` / Lore identity.
437Only unexpired repository-scoped tokens authorized for the HTTP recipient are
438used. Automatic credential reuse requires HTTPS for remote servers; loopback
439HTTP is supported for development. No separate HTTP token setup is needed.
440
441Operators with custom proxy layouts can set `http_url` in `provider.toml`.
442Explicit `--http-url` or `PX_LORE_HTTP_URL` overrides take precedence.
443Bearer-token environment overrides remain available for automation.
444
445### Server setup and signing-key security
446
447New PX-managed local installations create a unique 32-byte signing key in
448owner-only server configuration and bind to loopback. Existing managed configs
449receive a missing key without replacing existing keys or other settings.
450Restart an already running server after its configuration changes.
451
452Standalone development Lore provisions a persistent owner-only `presign.key`
453in its configuration directory when no signing key is supplied. Persist this
454directory across restarts. Never copy that key into client configuration.
455
456Only Lore uses the key, to sign and validate download capabilities. It is
457independent of login tokens, JWT signing keys, and API-key peppers; PX clients
458never need it. Keep the key stable across restarts and private to the server.
459Server logs omit signing keys and signed query tokens. Signed responses prevent
460caching and referrer leakage. Development URLs require network access to the host.
461
462Production / Portals Cloud presign is WIP. PX derives the Cloud HTTP origin,
463but deployment still needs a dedicated shared signing key, scoped HTTPS routes,
464and query-token-safe logging. Without a supplied production key, presign stays
465disabled. These are operator concerns, not end-user flags or secrets.
466
467### SDK methods
468
469All three methods take the entity ID and representation name as their first
470two arguments, using the same lookup as the CLI:
471
472- Rust: `Resolver::presign_representation(entity_id, representation, &options)`
473  is asynchronous.
474- Python: `presign_representation(entity_id, representation, **options)` is
475  synchronous.
476- TypeScript: `presignRepresentation(entityId, representation, options)` is
477  asynchronous.
478
479Python:
480
481```python
482from px_sdk import presign_representation
483
484result = presign_representation(
485    "25th-chapter/character/nathan-gunn", "item", branch="main", ttl_seconds=900
486)
487```
488
489TypeScript:
490
491```typescript
492import { presignRepresentation } from "@portalshq/px";
493
494const result = await presignRepresentation(
495  "25th-chapter/character/nathan-gunn",
496  "item",
497  { branch: "main", ttlSeconds: 900 },
498);
499```
500
501The SDKs return the same fields as the CLI JSON output.
502"#
503    )]
504    Presign {
505        /// Entity ID, e.g. 25th-chapter/character/nathan-gunn. The px:// prefix is optional; fragments are not supported.
506        #[arg(value_name = "ENTITY_ID")]
507        uri: String,
508
509        /// Representation name (manifest key), e.g. item. Its URI is relative to the entity's asset directory.
510        representation: String,
511
512        /// Resolve at a specific branch.
513        #[arg(long, conflicts_with = "commit")]
514        branch: Option<String>,
515
516        /// Resolve at a specific commit hash.
517        #[arg(long, conflicts_with = "branch")]
518        commit: Option<String>,
519
520        /// Requested lifetime in seconds; Lore enforces its configured bounds.
521        #[arg(long)]
522        ttl_seconds: Option<u64>,
523
524        /// Explicit Lore HTTP origin, such as http://127.0.0.1:41339.
525        #[arg(long)]
526        http_url: Option<String>,
527
528        /// Environment variable containing a repository-scoped bearer token.
529        #[arg(long)]
530        token_env: Option<String>,
531    },
532
533    /// Query a subtree from a manifest.
534    Query {
535        /// PX URI.
536        uri: String,
537
538        /// Dot-notation path. e.g., "appearances.audienceVotes".
539        path: String,
540
541        /// Output format: yaml, json.
542        #[arg(long, short = 'f', default_value = "json", env = "PX_OUTPUT")]
543        format: String,
544    },
545
546    /// Commit changes to a repository repository.
547    Commit {
548        /// Repository name.
549        repository: String,
550
551        /// Commit message.
552        #[arg(long, short = 'm')]
553        message: String,
554
555        /// Author identifier.
556        #[arg(long, short = 'a', default_value = "px")]
557        author: String,
558    },
559
560    /// View commit history for an entity.
561    History {
562        /// PX URI.
563        uri: String,
564
565        /// Maximum number of commits to show.
566        #[arg(long, short = 'n', default_value = "20")]
567        limit: usize,
568    },
569
570    /// List repositories or entities within a repository.
571    List {
572        /// Repository name. Omit to list all repositories.
573        repository: Option<String>,
574
575        /// Entity type to list (if repository is specified).
576        #[arg(long, short = 't')]
577        entity_type: Option<String>,
578    },
579
580    /// Create or list branches.
581    Branch {
582        /// Repository name.
583        repository: String,
584
585        /// Branch name to create. Omit to list all branches.
586        name: Option<String>,
587    },
588
589    /// Set a property on an entity manifest.
590    Set {
591        /// PX URI.
592        uri: String,
593
594        /// Property key (dot-notation).
595        key: String,
596
597        /// Property value.
598        value: String,
599
600        /// Commit message.
601        #[arg(long, short = 'm', default_value = "set property")]
602        message: String,
603
604        /// Author identifier.
605        #[arg(long, short = 'a', default_value = "px")]
606        author: String,
607    },
608
609    /// Add a file representation to an entity manifest.
610    #[command(alias = "add-repr")]
611    Add {
612        /// PX URI.
613        uri: String,
614
615        /// Representation key. e.g., "reference_image".
616        key: String,
617
618        /// File path to the asset.
619        file: PathBuf,
620
621        /// Asset format. e.g., "png", "glb".
622        #[arg(long)]
623        format: String,
624
625        /// Commit message.
626        #[arg(long, short = 'm', default_value = "add representation")]
627        message: String,
628
629        /// Author identifier.
630        #[arg(long, short = 'a', default_value = "px")]
631        author: String,
632    },
633
634    /// Revert a commit by hash (undoes all changes in that commit).
635    Revert {
636        /// Repository name.
637        repository: String,
638
639        /// Commit hash to revert.
640        #[arg(long, short = 'c')]
641        commit: String,
642
643        /// Author identifier.
644        #[arg(long, short = 'a', default_value = "px")]
645        author: String,
646    },
647
648    /// Clone or pull a repository from a remote.
649    ///
650    /// If the argument is a URL, the repo is cloned (name is read from the
651    /// repo's own config).  If it's a repository name, the repo must already
652    /// exist locally and will be updated via pull.
653    Pull {
654        /// URL (clone) or repository name (pull existing).
655        url_or_name: String,
656    },
657
658    /// Push the current branch to its configured upstream remote.
659    #[command(alias = "publish")]
660    Push {
661        /// Repository name.
662        repository: String,
663
664        /// Remote name (default: tracking branch's remote, or "origin").
665        ///
666        /// `--remote` selects server-backed reads globally; use this distinct
667        /// spelling to name the push destination.
668        #[arg(long = "remote-name", default_value = "origin")]
669        remote: String,
670
671        /// Branch to push (default: current branch).
672        #[arg(long)]
673        branch: Option<String>,
674    },
675
676    /// Manage remotes on a repository.
677    #[command(subcommand)]
678    Remote(RemoteCmd),
679
680    /// Sign a manifest (stub for v0).
681    #[command(hide = true)]
682    Sign {
683        /// PX URI.
684        uri: String,
685    },
686
687    /// Verify a manifest signature (stub for v0).
688    #[command(hide = true)]
689    Verify {
690        /// PX URI.
691        uri: String,
692    },
693
694    /// Switch to a branch.
695    Switch {
696        /// Repository name.
697        repository: String,
698        /// Branch name to switch to.
699        name: String,
700    },
701
702    /// Show the current HEAD commit hash.
703    #[command(alias = "head-hash", alias = "head_hash")]
704    Head {
705        /// Repository name.
706        repository: String,
707    },
708
709    /// Validate a manifest against the PX schema.
710    Validate {
711        /// PX URI of the entity to validate.
712        uri: Option<String>,
713        /// Path to a manifest YAML file to validate.
714        #[arg(long)]
715        file: Option<PathBuf>,
716    },
717
718    /// Print a JSON Schema for manifest or commit types.
719    Schema {
720        /// Schema name: 'manifest' or 'commit'.
721        name: String,
722        /// Output format: json, yaml.
723        #[arg(long, short = 'f', default_value = "json")]
724        format: String,
725    },
726
727    /// Show diff between two manifest files or versions.
728    Diff {
729        /// Base (left) manifest file.
730        base_file: PathBuf,
731        /// Candidate (right) manifest file.
732        candidate_file: PathBuf,
733        /// Output format: json, yaml.
734        #[arg(long, short = 'f', default_value = "yaml")]
735        format: String,
736    },
737
738    /// Three-way merge of JSON/YAML values.
739    Merge {
740        /// Base (common ancestor) file.
741        base: PathBuf,
742        /// Current (ours) file.
743        current: PathBuf,
744        /// Proposed (theirs) file.
745        proposed: PathBuf,
746        /// Output format: json, yaml.
747        #[arg(long, short = 'f', default_value = "yaml")]
748        format: String,
749    },
750
751    /// Compute the BLAKE3 content hash of a file.
752    ContentHash {
753        /// Path to the file to hash.
754        file: PathBuf,
755    },
756}