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