CLI Contracts Specification

Technical specification for pair-cli command contracts, TypeScript types, validation rules, and error handling.

Technical specification for pair-cli command contracts including TypeScript types, validation rules, error handling, and exit codes.

CLI-Wide Rules

These apply to every command below, not to a single one.

  • Excess positionals are rejected. Commander runs with allowExcessArguments(false) for all registered commands, so a stray positional fails with error: too many arguments for '<command>' and exit code 1 instead of being silently dropped. This makes each command's positional-arity validation reachable, and catches the classic unquoted option value (--name Acme KB used to bind name to Acme and discard KB).

This is a behavior change, earlier releases ignored the extra argument, so a wrapper or CI invocation carrying one (e.g. pair-cli install ./a ./b) now fails; drop the extra argument or quote the value. GitHub Releases names the release that introduced it.

Command Option Types

Base Types

export interface CommandOptions {
  datasetRoot?: string
  customConfigPath?: string
  baseTarget?: string // Target resolution: INIT_CWD (pnpm) > [target] arg > CWD
  useDefaults?: boolean
  minLogLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error'
}
 
export interface CliOptions {
  url?: string // --url <url|path>
  kb: boolean // --no-kb flag (true=download, false=offline)
}

kb is read by the KB pre-flight (bootstrapEnvironment), which runs before install and update: false skips the pre-flight fetch, and false together with a url is rejected by validateCliOptions. Both were dead through 0.5.1: the pre-flight was unreachable, so pair-cli install --no-kb installed a KB anyway and the conflict never fired. See the --no-kb note in the CLI reference.

The conflict is checked in the CLI hook (runKbPreflight), above the pre-flight's own skip, and bootstrapEnvironment takes no url: a named source (--source, --git, --url, local or remote) skips the pre-flight entirely, because the command fetches that source itself into its own cache slot. A pre-flight that also warmed it downloaded the same archive twice.

Install Command

export interface InstallOptions extends CommandOptions, CliOptions {
  listTargets?: boolean
  config?: string
  source?: string
  offline?: boolean // derived from kb: false
}

Update Command

export interface UpdateOptions extends CommandOptions, CliOptions {
  config?: string
  source?: string
  offline?: boolean
}
export interface UpdateLinkOptions extends CommandOptions {
  url?: string
  dryRun?: boolean
  verbose?: boolean
}

Package Command

export interface PackageOptions {
  output?: string
  sourceDir?: string
  config?: string
  verbose?: boolean
  name?: string
  version?: string
  description?: string
  author?: string
  skipRegistries?: string[]
  root?: string // default: .pair/
  interactive: boolean // default: false
  tags: string[] // default: []
  license: string // default: MIT
  org?: boolean
  orgName?: string
  team?: string
  department?: string
  approver?: string
  compliance?: string[]
  distribution?: 'open' | 'private' | 'restricted'
}
 
export interface OrganizationMetadata {
  name: string
  team?: string
  department?: string
  approver?: string
  compliance: string[]
  distribution: 'open' | 'private' | 'restricted'
}
 
export interface ManifestMetadata {
  name: string
  version: string
  description: string
  author: string
  tags: string[]
  license: string
  created_at: string
  registries: string[]
  contentChecksum?: string
  organization?: OrganizationMetadata
}

Scaffold-KB Command

export interface ScaffoldKbCommandConfig {
  command: 'scaffold-kb'
  path: string // target directory, default: '.'
  host: 'github' | 'generic' // default: 'github'
  force: boolean // default: false
  name?: string // default: derived from the target directory basename
}
 
/** Ownership decides the re-scaffold policy */
export type ScaffoldFileKind = 'scaffold-owned' | 'seed'
 
export type FileAction = 'created' | 'overwritten' | 'unchanged' | 'skipped'

Validation: --host must be github or generic; --name must be non-empty, free of newlines/control characters and of ${{ (GitHub Actions would evaluate it as a live expression in the generated workflow), and at most 100 characters (it is embedded in generated YAML and bash); at most one positional argument (extra positionals are rejected, see CLI-Wide Rules, which applies to every command).

Refused targets: a path that exists and is not a directory, or a configured pair project (.pair/adoption/ present), not bypassable with --force. Re-scaffold policy: seed files (KB content) are never overwritten; scaffold-owned files are regenerated only when identical-check fails AND (--force OR interactive confirmation), otherwise reported as skipped.

The generated scripts/release.sh is written with mode 0755.

KB Verify Command

export interface KbVerifyCommandConfig {
  packagePath: string
  json?: boolean
  minLogLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error'
}
 
export interface VerificationReport {
  valid: boolean
  checks: {
    checksum: ChecksumCheck
    structure: StructureCheck
    manifest: ManifestCheck
  }
  size: number
  timestamp: string
}

Validation Error Catalog

Error Categories

CategoryExit CodeDescription
Option Validation1Invalid option combination or missing required option
File System1Path not found, permission denied, not writable
Network2Download failure, connection timeout, DNS error
Checksum2SHA256 validation failure
Configuration1Invalid config.json structure or values
KB Structure1Invalid KB directory structure

Option Validation Errors

CodeErrorTriggerResolution
V001Conflicting options--list-targets with target argsUse separately
V002Offline requires source--offline without --sourceProvide --source
V003Offline cannot use URL--offline with HTTP URLUse local path
V004Invalid config file--config points to missing fileFix path
V005Malformed JSONInvalid JSON in configFix syntax

File System Errors

CodeErrorTriggerResolution
FS001Path not foundTarget path missingCreate dir or fix path
FS002Permission deniedInsufficient permissionsFix permissions
FS003Not writableRead-only targetFix write permissions
FS004Disk fullInsufficient spaceFree space

Package Verification Errors

CodeErrorTriggerResolution
VER001Package not foundFile missingCheck path
VER002Invalid formatNot valid ZIPUse valid package
VER003Checksum mismatchContent tamperedRe-download
VER004Missing manifestNo manifest.jsonUse valid package
VER005Missing registryStructure mismatchRebuild package

Validation Logic

Install/Update Option Validation

function validateInstallUpdateOptions(options: InstallOptions | UpdateOptions): void {
  const offline = options.kb === false
 
  if (offline && !options.source) {
    throw new ValidationError('V002', '--offline requires --source with local filesystem path')
  }
 
  if (offline && options.source && isUrl(options.source)) {
    throw new ValidationError('V003', 'Cannot use --offline with remote URL')
  }
 
  if (options.config && !fileExists(options.config)) {
    throw new ValidationError('V004', `Config file not found: ${options.config}`)
  }
}

Exit Code Contract

CodeMeaningExamples
0SuccessCommand completed successfully
1Validation Error, or an install/update run that did not fully applyInvalid options, config errors, KB structure errors; a shipped registry that failed to copy; a run that installed/updated nothing
2Download/Network ErrorNetwork timeout, checksum mismatch, packaging error
3File System ErrorDisk full, permission denied during operation

install / update: the code follows the tally

Both commands classify every registry as ok, skipped or failed and derive the status code from that one tally: the same tally the printed summary is built from, so the text and the code cannot disagree:

TallySummaryCode
any failed… finished with errors (…)1
no failed, no ok (everything skipped)Nothing to install/update (…)1
at least one ok, no failedInstallation/Update complete (…)0

A registry the source does not ship is skipped, never failed, and any number of skips still exits 0 as long as something was installed. Two consequences for automation: 1 from these two commands is not necessarily a validation error (it may be a per-registry copy failure or a no-op), and update returns a non-zero code at all: it previously exited 0 unless it threw.

kb-verify / kb-info / kb-cache: their non-zero code now reaches the shell

Not new behaviour these commands report, new behaviour a caller observes. All three already returned 1 from their handlers (an unreadable or unextractable KB, a checksum mismatch, an unknown cache slot), but the entry point force-exited with an explicit process.exit(0), and in Node an explicit code OVERRIDES process.exitCode. Forwarding the reported code (the fix AC5 required for install) un-swallows theirs too:

CommandPrinted before and afterExit code beforeExit code now
pair-cli kb-verify <kb>the checksum/structure failure01
pair-cli kb-info <kb>the KB it could not read01
pair-cli kb-cache <op>the cache operation that failed01

A CI step that ran pair-cli kb-verify ./kb and passed while printing a checksum mismatch now fails that step. That is the contract these commands always described, but a pipeline relying on the old code turning green is the one place the change is felt.

Test Matrices

Install/Update Option Validation Matrix

--source--offlineMonorepoExpected ResultExit Code
--YesUse monorepo dataset0
--NoAuto-download0 or 2
https://...--Download from URL0 or 2
./local--Use local path0 or 1
./localYes-Use local (offline)0 or 1
-Yes-ERROR1
https://...Yes-ERROR1

The codes above are the OPTION-validation outcome only. A run that gets past validation still exits per the tally above, 1 when a shipped registry fails or when nothing was installed, whatever the source.