diff --git a/README.md b/README.md
index 0114e27..b4c308e 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
Slack
-A CLI for helping tech writers. Right now it only detects the rhetorical and structural tells of LLM-generated prose and surfaces diagnostics for people to fix.
+A CLI for technical writers: set up Promptless for your repositories, detect the rhetorical and structural tells of LLM-generated prose, and inspect documentation as an agent sees it.
## Install
@@ -41,6 +41,15 @@ npm install
npm run promptless -- slop-cop path/to/file.md
```
+Before the first npm release, run setup from this checkout:
+
+```sh
+npm ci
+npm run promptless -- setup --new-account
+```
+
+Omit `--new-account` to reuse an existing Promptless account. Use `--no-browser` to print the authorization link for another browser.
+
## Usage
```
@@ -51,6 +60,49 @@ Run `promptless --help` for the command list and `promptless --help` f
`slop-cop` exit codes: `0` clean, `1` violations found, `2` argument error.
+### `setup`
+
+`promptless setup` connects a GitHub organization to Promptless and starts keeping one documentation repository current from merged pull requests. Run it in an interactive terminal, ideally from a clone of a repository whose merges should update the docs:
+
+```sh
+npx -y @promptless/cli@latest setup
+```
+
+The command runs these steps in order:
+
+1. **Sign in.** Setup reuses the saved API key when it still works. Otherwise it opens the sign-in page in your browser and waits for the key, as `promptless login` does.
+2. **Connect GitHub.** When the organization has no GitHub App installation, setup opens the install page and checks every 3 seconds until the installation arrives, for up to 30 minutes. Choose "I can't connect right now" to stop; nothing is changed.
+3. **Find your documentation.** Setup reads the `origin` remote of the current directory, asks Promptless which installed repository holds the docs, and preselects the best match.
+4. **Choose source repositories.** Pick the repositories whose merged pull requests update the docs. The current directory's repository is preselected.
+5. **Review.** Setup shows the resulting `promptless.yaml` and applies it only after you choose Apply. Setup then completes onboarding, which starts the free trial.
+6. **First suggestion.** Setup offers to run Promptless on the last 7 days of merged pull requests in the source repositories. It starts the run and does not wait for it.
+
+| Flag | Effect |
+| --- | --- |
+| `--org ` | Run against this organization. When the saved key belongs to another organization, setup signs in again with this organization preselected on the sign-in page. |
+| `--docs-repo ` | Use this documentation repository instead of asking. |
+| `--source-repo ` | Watch this repository instead of asking. Repeat the flag for several repositories. |
+| `--trigger-on ` | The pull request events that update the docs: `opened`, `first_approval`, `merge`, or `updated`. Repeat the flag or separate events with commas. The default is `merge`. |
+| `--backfill` | Run on the last 7 days of merged pull requests without asking. |
+| `--new-account` | Open the sign-up page instead of the sign-in page. |
+| `--no-browser` | Print links instead of opening them. |
+
+**What setup writes.** Setup writes nothing to the current repository. Promptless adds one doc collection and one `github_pr` trigger with an explicit repository list to `promptless.yaml` in your organization's Promptless knowledge-base repository. The only local file setup writes is the API key in the `promptless login` config file.
+
+**Running setup again.** Setup skips every step that is already done: a working saved key, an existing GitHub installation, an existing doc collection and pull request trigger, and completed onboarding. A second run on a finished organization changes nothing and prints the dashboard link. To watch more repositories, run it again with `--source-repo`.
+
+**Errors.** Setup exits `0` when it finishes, `1` when it stops, and `2` for an argument error. Every stop also prints one line on stderr that a script can parse:
+
+```
+promptless-error: {"code":"github_not_connected","message":"..."}
+```
+
+The codes are listed in `src/lib/errors.ts`. Codes are only ever added. Without an interactive terminal, setup exits `1` with the code `interactive_terminal_required`.
+
+`PROMPTLESS_API_BASE_URL` and `PROMPTLESS_APP_BASE_URL` point setup at a local runtime and dashboard.
+
+**Server requirements.** The CLI requires the setup status, docs-candidate, and apply routes, CLI organization creation for new users, and onboarding completion that starts the trial. These changes are tracked in [Promptless/promptless#5372](https://github.com/Promptless/promptless/pull/5372), [#5358](https://github.com/Promptless/promptless/pull/5358), and [#5373](https://github.com/Promptless/promptless/pull/5373). Optional backfill requires [#5371](https://github.com/Promptless/promptless/pull/5371). Deploy the backend changes before releasing this command. When the setup status route is missing, setup exits with `setup_unavailable` and directs you to the dashboard.
+
## Releasing
Pushing a `v*` tag whose version matches `package.json` runs `.github/workflows/release.yml`, which publishes to npm with provenance and creates a GitHub release.
diff --git a/package.json b/package.json
index 0dbbae6..ba1f916 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "@promptless/cli",
"version": "0.1.0",
"license": "MPL-2.0",
- "description": "CLI for helping tech writers. Right now it only detects the rhetorical and structural tells of LLM-generated prose and surfaces diagnostics for people to fix.",
+ "description": "CLI for technical writers: set up Promptless, detect LLM prose tells, and inspect documentation.",
"type": "module",
"repository": {
"type": "git",
diff --git a/src/commands/completion.ts b/src/commands/completion.ts
index 6bef32e..8f1bb99 100644
--- a/src/commands/completion.ts
+++ b/src/commands/completion.ts
@@ -88,6 +88,15 @@ case $state in
'(-h --help)'{-h,--help}'[show help]'
;;
setup)
+ _arguments \\
+ '--org[organization id]:org:' \\
+ '--docs-repo[documentation repository]:owner/repo:' \\
+ '*--source-repo[source repository]:owner/repo:' \\
+ '*--trigger-on[pull request event]:event:(opened first_approval merge updated)' \\
+ '--backfill[run on the last 7 days of merged pull requests]' \\
+ '--new-account[open the sign-up page]' \\
+ '--no-browser[do not open a browser; print URLs only]' \\
+ '(-h --help)'{-h,--help}'[show help]'
;;
esac
;;
@@ -140,7 +149,15 @@ _promptless_complete() {
fi
;;
setup)
- COMPREPLY=()
+ case "\${COMP_WORDS[COMP_CWORD-1]}" in
+ --trigger-on)
+ COMPREPLY=($(compgen -W "opened first_approval merge updated" -- "$cur"))
+ return
+ ;;
+ esac
+ if [[ "$cur" == -* ]]; then
+ COMPREPLY=($(compgen -W "--org --docs-repo --source-repo --trigger-on --backfill --new-account --no-browser -h --help" -- "$cur"))
+ fi
;;
esac
}
@@ -176,6 +193,14 @@ function fishScript(): string {
"complete -c promptless -n '__fish_seen_subcommand_from logout' -s h -l help -d 'Show help'",
"complete -c promptless -n '__fish_seen_subcommand_from whoami' -l json -d 'Output JSON'",
"complete -c promptless -n '__fish_seen_subcommand_from whoami' -s h -l help -d 'Show help'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -l org -x -d 'Organization id'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -l docs-repo -x -d 'Documentation repository (owner/repo)'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -l source-repo -x -d 'Source repository (owner/repo)'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -l trigger-on -x -a 'opened first_approval merge updated' -d 'Pull request event'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -l backfill -d 'Run on the last 7 days of merged pull requests'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -l new-account -d 'Open the sign-up page'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -l no-browser -d 'Do not open a browser'",
+ "complete -c promptless -n '__fish_seen_subcommand_from setup' -s h -l help -d 'Show help'",
'',
)
return lines.join('\n')
diff --git a/src/commands/registry.ts b/src/commands/registry.ts
index 1543734..e0292ca 100644
--- a/src/commands/registry.ts
+++ b/src/commands/registry.ts
@@ -15,7 +15,7 @@ export interface CommandEntry {
export const COMMANDS: CommandEntry[] = [
{
name: 'setup',
- summary: 'Connect GitHub and start keeping your docs current (not available yet)',
+ summary: 'Connect GitHub and start keeping your docs current',
run: runSetup,
},
{
diff --git a/src/commands/setup/args.test.ts b/src/commands/setup/args.test.ts
new file mode 100644
index 0000000..9d03acc
--- /dev/null
+++ b/src/commands/setup/args.test.ts
@@ -0,0 +1,75 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { parseSetupArgs } from './args'
+import { SetupError } from './output'
+
+function flags(argv: string[]) {
+ const parsed = parseSetupArgs(argv)
+ assert.equal(parsed.kind, 'run')
+ return parsed.kind === 'run' ? parsed.flags : null
+}
+
+function rejects(argv: string[]): void {
+ assert.throws(
+ () => parseSetupArgs(argv),
+ (err) => err instanceof SetupError && err.code === 'bad_arguments',
+ )
+}
+
+describe('parseSetupArgs', () => {
+ it('defaults to merge triggers, an opened browser, and no overrides', () => {
+ assert.deepEqual(flags([]), {
+ org: null,
+ docsRepo: null,
+ sourceRepos: [],
+ triggerOn: ['merge'],
+ newAccount: false,
+ openBrowser: true,
+ backfill: false,
+ })
+ })
+
+ it('reads every flag in both value forms and repeats --source-repo', () => {
+ assert.deepEqual(
+ flags([
+ '--org=org_1',
+ '--docs-repo',
+ 'acme/docs',
+ '--source-repo',
+ 'acme/api',
+ '--source-repo=acme/web',
+ '--trigger-on',
+ 'opened,merge',
+ '--trigger-on=merge',
+ '--new-account',
+ '--no-browser',
+ '--backfill',
+ ]),
+ {
+ org: 'org_1',
+ docsRepo: 'acme/docs',
+ sourceRepos: ['acme/api', 'acme/web'],
+ triggerOn: ['opened', 'merge'],
+ newAccount: true,
+ openBrowser: false,
+ backfill: true,
+ },
+ )
+ })
+
+ it('returns help for -h and --help', () => {
+ assert.deepEqual(parseSetupArgs(['--org', 'x', '-h']), { kind: 'help' })
+ assert.deepEqual(parseSetupArgs(['--help']), { kind: 'help' })
+ })
+
+ it('rejects unknown flags, missing values, bad repositories, and bad events', () => {
+ rejects(['--ci'])
+ rejects(['--org'])
+ rejects(['--org', '--backfill'])
+ rejects(['--docs-repo='])
+ rejects(['--docs-repo', 'not-a-repo'])
+ rejects(['--source-repo', 'https://github.com/acme/api'])
+ rejects(['--trigger-on', 'closed'])
+ })
+})
diff --git a/src/commands/setup/args.ts b/src/commands/setup/args.ts
new file mode 100644
index 0000000..ea63014
--- /dev/null
+++ b/src/commands/setup/args.ts
@@ -0,0 +1,105 @@
+import { PR_LIFECYCLE_EVENTS } from '../../lib/setup-api'
+import type { PrLifecycleEvent } from '../../lib/setup-api'
+import { SetupError } from './output'
+
+export interface SetupFlags {
+ org: string | null
+ docsRepo: string | null
+ sourceRepos: string[]
+ triggerOn: PrLifecycleEvent[]
+ newAccount: boolean
+ openBrowser: boolean
+ backfill: boolean
+}
+
+export type ParsedArgs = { kind: 'help' } | { kind: 'run'; flags: SetupFlags }
+
+const REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/
+const VALUE_FLAGS = new Set(['--org', '--docs-repo', '--source-repo', '--trigger-on'])
+
+function badArguments(message: string): SetupError {
+ return new SetupError('bad_arguments', message)
+}
+
+function repoValue(flag: string, value: string): string {
+ if (!REPO_PATTERN.test(value)) throw badArguments(`${flag} expects owner/repo, got '${value}'`)
+ return value
+}
+
+function lifecycleEvents(value: string): PrLifecycleEvent[] {
+ return value.split(',').map((part) => {
+ const event = PR_LIFECYCLE_EVENTS.find((candidate) => candidate === part.trim())
+ if (event === undefined) {
+ throw badArguments(`--trigger-on expects ${PR_LIFECYCLE_EVENTS.join(', ')}; got '${part.trim()}'`)
+ }
+ return event
+ })
+}
+
+/**
+ * Parse `promptless setup` arguments. Value flags take `--flag value` or
+ * `--flag=value`; `--source-repo` and `--trigger-on` repeat, and
+ * `--trigger-on` also takes a comma-separated list.
+ *
+ * @throws SetupError with code `bad_arguments` for an unknown flag or a bad value.
+ */
+export function parseSetupArgs(argv: string[]): ParsedArgs {
+ const flags: SetupFlags = {
+ org: null,
+ docsRepo: null,
+ sourceRepos: [],
+ triggerOn: [],
+ newAccount: false,
+ openBrowser: true,
+ backfill: false,
+ }
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i]
+ if (arg === '-h' || arg === '--help') return { kind: 'help' }
+ if (arg === '--new-account') {
+ flags.newAccount = true
+ continue
+ }
+ if (arg === '--no-browser') {
+ flags.openBrowser = false
+ continue
+ }
+ if (arg === '--backfill') {
+ flags.backfill = true
+ continue
+ }
+
+ const eq = arg.indexOf('=')
+ const name = eq >= 0 ? arg.slice(0, eq) : arg
+ if (!VALUE_FLAGS.has(name)) throw badArguments(`unknown option ${arg}`)
+ let value: string
+ if (eq >= 0) {
+ value = arg.slice(eq + 1)
+ } else {
+ const next = argv[i + 1]
+ if (next === undefined || next.startsWith('--')) throw badArguments(`${name} needs a value`)
+ value = next
+ i++
+ }
+ if (value.length === 0) throw badArguments(`${name} needs a value`)
+
+ switch (name) {
+ case '--org':
+ flags.org = value
+ break
+ case '--docs-repo':
+ flags.docsRepo = repoValue(name, value)
+ break
+ case '--source-repo':
+ flags.sourceRepos.push(repoValue(name, value))
+ break
+ case '--trigger-on':
+ flags.triggerOn.push(...lifecycleEvents(value))
+ break
+ }
+ }
+
+ flags.triggerOn = flags.triggerOn.length === 0 ? ['merge'] : [...new Set(flags.triggerOn)]
+ return { kind: 'run', flags }
+}
diff --git a/src/commands/setup/backfill.ts b/src/commands/setup/backfill.ts
new file mode 100644
index 0000000..77fa18a
--- /dev/null
+++ b/src/commands/setup/backfill.ts
@@ -0,0 +1,90 @@
+import { ApiError } from '../../lib/api'
+import { getBackfillStatus, previewBackfill, startBackfill } from '../../lib/setup-api'
+import type { BackfillRequest } from '../../lib/setup-api'
+import { selectOne } from '../../lib/ui/prompt'
+import type { Session } from './login'
+import { done, note, step, warn } from './output'
+
+// Screen 7: offer a backfill over the last week of merged pull requests, so a
+// first suggestion arrives without waiting for the next merge. The CLI starts
+// the run and does not wait for it. This step is optional: a failure here
+// warns and setup still finishes.
+
+const LOOKBACK_DAYS = 7
+const BACKFILL_REQUEST: BackfillRequest = { lookback_days: LOOKBACK_DAYS, skip_processed: true }
+
+export interface BackfillOptions {
+ triggerKey: string
+ sourceRepos: string[] | 'all'
+ /** `--backfill`: start without asking. */
+ skipQuestion: boolean
+}
+
+function pullRequests(count: number, approximate: boolean): string {
+ return `${approximate ? 'about ' : ''}${count} pull ${count === 1 ? 'request' : 'requests'}`
+}
+
+function apiMessage(err: ApiError): string {
+ const body = typeof err.body === 'object' && err.body !== null ? (err.body as Record) : {}
+ return typeof body.message === 'string' ? body.message : err.message
+}
+
+async function describeActiveBackfill(session: Session, triggerKey: string): Promise {
+ try {
+ const job = (await getBackfillStatus(session.client, session.orgId, triggerKey)).latest_job
+ if (job !== null) {
+ return `A backfill is already running: ${job.dispatched_count} of ${job.matched_count ?? 'the matched'} pull requests dispatched.`
+ }
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err
+ warn(`Could not read the backfill progress: ${apiMessage(err)}`)
+ }
+ return 'A backfill is already running for this trigger.'
+}
+
+/**
+ * Offer and start a backfill. Returns true when a backfill is running for the trigger.
+ *
+ * @throws Error only for failures other than an API error response, such as a network failure.
+ */
+export async function offerBackfill(session: Session, opts: BackfillOptions): Promise {
+ step(6, 'First suggestion')
+ const { client, orgId } = session
+ const repos = opts.sourceRepos === 'all' ? 'your repositories' : opts.sourceRepos.join(', ')
+
+ try {
+ const preview = await previewBackfill(client, orgId, opts.triggerKey, BACKFILL_REQUEST)
+ if (preview.unsupported_reason !== null) {
+ note(`A backfill is not available for this trigger: ${preview.unsupported_reason}`)
+ return false
+ }
+ if (preview.count === 0) {
+ note(`No pull requests merged in ${repos} in the last ${LOOKBACK_DAYS} days.`)
+ return false
+ }
+
+ const count = pullRequests(preview.count, preview.approximate)
+ if (!opts.skipQuestion) {
+ const choice = await selectOne<'skip' | 'run'>({
+ message: `Run Promptless on the last ${LOOKBACK_DAYS} days of merged pull requests in ${repos}? (${count})`,
+ choices: [
+ { value: 'skip', label: 'Not now' },
+ { value: 'run', label: `Run on ${count}` },
+ ],
+ })
+ if (choice !== 'run') return false
+ }
+
+ const started = await startBackfill(client, orgId, opts.triggerKey, BACKFILL_REQUEST)
+ done(`Backfill accepted for ${count} (job ${started.job_id}). Suggestions arrive as pull requests.`)
+ return true
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err
+ if (err.code === 'backfill_already_active') {
+ note(await describeActiveBackfill(session, opts.triggerKey))
+ return true
+ }
+ warn(`Skipped the backfill: ${apiMessage(err)}`)
+ return false
+ }
+}
diff --git a/src/commands/setup/complete.ts b/src/commands/setup/complete.ts
new file mode 100644
index 0000000..29101bc
--- /dev/null
+++ b/src/commands/setup/complete.ts
@@ -0,0 +1,31 @@
+import { ApiError } from '../../lib/api'
+import { completeOnboarding, getSetupStatus } from '../../lib/setup-api'
+import type { SetupStatus } from '../../lib/setup-api'
+import type { Session } from './login'
+import { SetupError, done } from './output'
+
+// Screen 6: complete onboarding, which starts the free trial, unless the
+// organization already completed it. Then re-read status for the trial end.
+
+/**
+ * Complete onboarding when `status` shows it is not complete, and return a fresh status.
+ *
+ * @throws SetupError when the runtime has no doc collection for the organization yet.
+ */
+export async function completeSetup(session: Session, status: SetupStatus): Promise {
+ if (!status.onboarding_completed) {
+ try {
+ await completeOnboarding(session.client, session.orgId)
+ } catch (err) {
+ if (err instanceof ApiError && err.code === 'doc_collection_required') {
+ throw new SetupError(
+ 'doc_collection_required',
+ 'Promptless has not recorded the docs repository yet. Run `promptless setup` again in a minute.',
+ )
+ }
+ throw err
+ }
+ done('Setup complete')
+ }
+ return getSetupStatus(session.client, session.orgId)
+}
diff --git a/src/commands/setup/docs.ts b/src/commands/setup/docs.ts
new file mode 100644
index 0000000..988666c
--- /dev/null
+++ b/src/commands/setup/docs.ts
@@ -0,0 +1,168 @@
+import type { GitHubRepo } from '../../lib/git-remote'
+import { installedRepos, postDocsCandidates } from '../../lib/setup-api'
+import type { DocsCandidate, SetupDocCollection, SetupStatus } from '../../lib/setup-api'
+import { selectOne } from '../../lib/ui/prompt'
+import type { Choice } from '../../lib/ui/prompt'
+import { SetupError, answered, done, note, step, warn } from './output'
+import type { Session } from './login'
+
+// Screen 3: find the documentation repository. An existing doc collection is
+// kept; otherwise the runtime ranks the installed repositories and the top
+// candidate is preselected.
+
+export interface DocsChoice {
+ repo: string
+ framework: string | null
+ configPath: string | null
+ filter: string[]
+ /** True when the organization already has this doc collection. */
+ existing: boolean
+}
+
+const OTHER_REPO = Symbol('other repository')
+
+function sameRepo(a: string, b: string): boolean {
+ return a.toLowerCase() === b.toLowerCase()
+}
+
+/** A one-line summary of what the analysis found, for example `mintlify, docs/docs.json, 214 pages`. */
+export function describeCandidate(candidate: DocsCandidate): string {
+ const parts = [candidate.framework ?? 'no docs framework config']
+ if (candidate.config_path !== null) parts.push(candidate.config_path)
+ parts.push(`${candidate.doc_count} ${candidate.doc_count === 1 ? 'page' : 'pages'}`)
+ return parts.join(', ')
+}
+
+function fromCandidate(candidate: DocsCandidate): DocsChoice {
+ return {
+ repo: candidate.full_name,
+ framework: candidate.framework,
+ configPath: candidate.config_path,
+ filter: candidate.proposed_filter,
+ existing: false,
+ }
+}
+
+function fromCollection(collection: SetupDocCollection): DocsChoice {
+ return {
+ repo: collection.repo,
+ framework: collection.docs_framework,
+ configPath: collection.config_file_path,
+ filter: collection.filter,
+ existing: true,
+ }
+}
+
+function bareChoice(repo: string): DocsChoice {
+ return { repo, framework: null, configPath: null, filter: [], existing: false }
+}
+
+async function pickInstalledRepo(status: SetupStatus): Promise {
+ const repos = installedRepos(status)
+ if (repos.length === 0) {
+ throw new SetupError(
+ 'repos_not_installed',
+ 'The GitHub App installation lists no repositories. Grant it your docs repository on GitHub, ' +
+ 'then run `promptless setup` again.',
+ )
+ }
+ const repo = answered(
+ await selectOne({
+ message: 'Which repository holds your documentation?',
+ choices: repos.map((r) => ({ value: r.full_name, label: r.full_name })),
+ }),
+ )
+ return bareChoice(repo)
+}
+
+async function useExistingCollection(status: SetupStatus): Promise {
+ const collections = status.doc_collections
+ const repo =
+ collections.length === 1
+ ? collections[0].repo
+ : answered(
+ await selectOne({
+ message: 'Which documentation repository should setup use?',
+ choices: collections.map((c) => ({ value: c.repo, label: c.repo, hint: c.docs_framework ?? undefined })),
+ }),
+ )
+ return fromCollection(collections.find((c) => c.repo === repo) ?? collections[0])
+}
+
+function summarize(choice: DocsChoice): string {
+ const details = [choice.framework, choice.configPath].filter((part): part is string => part !== null)
+ if (choice.filter.length > 0) details.push(`filter: ${choice.filter.join(', ')}`)
+ const suffix = choice.existing ? ' (already set up)' : ''
+ return details.length > 0
+ ? `Documentation: ${choice.repo} (${details.join(', ')})${suffix}`
+ : `Documentation: ${choice.repo}${suffix}`
+}
+
+/**
+ * Screen 3: choose the documentation repository.
+ *
+ * @param docsRepoFlag `--docs-repo`, which skips the prompt.
+ * @param cwdRepo The github.com `origin` of the current directory, if any.
+ */
+export async function findDocsRepo(
+ session: Session,
+ status: SetupStatus,
+ docsRepoFlag: string | null,
+ cwdRepo: GitHubRepo | null,
+): Promise {
+ step(3, 'Find your documentation')
+
+ const flagged =
+ docsRepoFlag === null ? undefined : status.doc_collections.find((c) => sameRepo(c.repo, docsRepoFlag))
+ if (flagged !== undefined) {
+ const choice = fromCollection(flagged)
+ done(summarize(choice))
+ return choice
+ }
+ if (docsRepoFlag === null && status.doc_collections.length > 0) {
+ const choice = await useExistingCollection(status)
+ done(summarize(choice))
+ return choice
+ }
+
+ note(
+ cwdRepo === null
+ ? 'This directory has no github.com remote named origin.'
+ : `This directory is ${cwdRepo.fullName} (git remote origin).`,
+ )
+ const response = await postDocsCandidates(session.client, session.orgId, cwdRepo?.fullName ?? null)
+ if (cwdRepo !== null && !response.cwd_repo_installed) {
+ warn(`The GitHub App cannot see ${cwdRepo.fullName}. Grant it on GitHub if its merges should update the docs.`)
+ }
+ const candidates = response.candidates
+
+ let choice: DocsChoice
+ if (docsRepoFlag !== null) {
+ const candidate = candidates.find((c) => sameRepo(c.full_name, docsRepoFlag))
+ choice = candidate !== undefined ? fromCandidate(candidate) : bareChoice(docsRepoFlag)
+ } else if (candidates.length === 0) {
+ note('No installed repository looks like documentation.')
+ choice = await pickInstalledRepo(status)
+ } else {
+ const top = candidates[0]
+ note(`${top.full_name} looks like your documentation: ${describeCandidate(top)}.`)
+ const choices: Choice[] = candidates.map((c) => ({
+ value: c.full_name,
+ label: `Use ${c.full_name}`,
+ hint: describeCandidate(c),
+ }))
+ choices.push({ value: OTHER_REPO, label: 'Pick a different repository' })
+ const picked = answered(
+ await selectOne({
+ message: 'Which repository holds your documentation?',
+ choices,
+ initial: top.full_name,
+ }),
+ )
+ const candidate = candidates.find((c) => c.full_name === picked)
+ choice = candidate !== undefined ? fromCandidate(candidate) : await pickInstalledRepo(status)
+ }
+
+ done(summarize(choice))
+ return choice
+}
diff --git a/src/commands/setup/exit.test.ts b/src/commands/setup/exit.test.ts
new file mode 100644
index 0000000..52160ca
--- /dev/null
+++ b/src/commands/setup/exit.test.ts
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { buildExitBlock, formatTrialEnd, renderExitBlock } from './exit'
+import type { ExitBlockInput } from './exit'
+
+const APP = 'https://app.example.test'
+const API = 'https://api.example.test'
+
+const BASE: ExitBlockInput = {
+ appBaseUrl: APP,
+ apiBaseUrl: API,
+ docsRepo: 'acme/docs',
+ sourceRepos: ['acme/api', 'acme/web'],
+ billing: null,
+ backfillTriggerKey: null,
+}
+
+function kinds(input: ExitBlockInput): string[] {
+ return buildExitBlock(input).map((line) => line.kind)
+}
+
+function lineOf(input: ExitBlockInput, kind: string): string {
+ const line = buildExitBlock(input).find((l) => l.kind === kind)
+ assert.ok(line, `no ${kind} line`)
+ return line.text
+}
+
+describe('buildExitBlock', () => {
+ it('has a headline, the dashboard link, and next steps without a trial or backfill', () => {
+ assert.deepEqual(kinds(BASE), ['headline', 'dashboard', 'next_steps', 'next_step', 'next_step', 'next_step'])
+ assert.ok(lineOf(BASE, 'dashboard').endsWith(`${APP}/configuration`))
+ })
+
+ it('adds the trial line while trialing', () => {
+ const input: ExitBlockInput = { ...BASE, billing: { status: 'trialing', trial_end: '2026-10-15T00:00:00+00:00' } }
+ assert.deepEqual(kinds(input).slice(0, 3), ['headline', 'dashboard', 'trial'])
+ const trial = lineOf(input, 'trial')
+ assert.ok(trial.includes(formatTrialEnd('2026-10-15T00:00:00+00:00')))
+ assert.ok(trial.endsWith(`${APP}/settings/billing`))
+ })
+
+ it('omits the trial line for every other billing state', () => {
+ for (const status of ['pending', 'active', 'exempt'] as const) {
+ const input: ExitBlockInput = { ...BASE, billing: { status, trial_end: '2026-10-15T00:00:00+00:00' } }
+ assert.equal(kinds(input).includes('trial'), false, status)
+ }
+ assert.equal(kinds({ ...BASE, billing: { status: 'trialing', trial_end: null } }).includes('trial'), false)
+ })
+
+ it('links backfill progress and drops the merge-a-PR step once a backfill started', () => {
+ const input: ExitBlockInput = { ...BASE, backfillTriggerKey: 'source-merges' }
+ assert.deepEqual(kinds(input), ['headline', 'dashboard', 'backfill', 'next_steps', 'next_step', 'next_step'])
+ assert.ok(lineOf(input, 'backfill').includes(`${APP}/configuration`))
+ })
+
+ it('ends with the MCP handoff command on the API host', () => {
+ const lines = buildExitBlock(BASE)
+ assert.ok(lines[lines.length - 1].text.endsWith(`claude mcp add --transport http promptless ${API}/mcp`))
+ })
+
+ it('names the docs repository and each source repository in the headline', () => {
+ const headline = lineOf(BASE, 'headline')
+ for (const repo of ['acme/docs', 'acme/api', 'acme/web']) assert.ok(headline.includes(repo), repo)
+ })
+})
+
+describe('formatTrialEnd', () => {
+ it('formats the date in UTC', () => {
+ assert.equal(formatTrialEnd('2026-10-15T23:30:00-05:00'), '16 Oct 2026')
+ })
+})
+
+describe('renderExitBlock', () => {
+ it('writes one line per entry after a blank line', () => {
+ const lines = buildExitBlock(BASE)
+ const text = renderExitBlock(lines)
+ assert.equal(text.split('\n').length, lines.length + 2)
+ assert.ok(text.startsWith('\n'))
+ })
+})
diff --git a/src/commands/setup/exit.ts b/src/commands/setup/exit.ts
new file mode 100644
index 0000000..fe5126e
--- /dev/null
+++ b/src/commands/setup/exit.ts
@@ -0,0 +1,76 @@
+import type { BillingStatus } from '../../lib/setup-api'
+
+// The exit block: a short summary printed to stdout in the normal scrollback,
+// so it stays after the prompts are gone. Shape from PostHog's exit line (MIT,
+// github.com/PostHog/wizard src/ui/tui/exit-line.ts).
+
+export interface ExitBlockInput {
+ appBaseUrl: string
+ apiBaseUrl: string
+ docsRepo: string
+ sourceRepos: string[] | 'all'
+ billing: BillingStatus | null
+ /** The trigger a backfill was started for, or null when none was started. */
+ backfillTriggerKey: string | null
+}
+
+export type ExitLineKind = 'headline' | 'dashboard' | 'trial' | 'backfill' | 'next_steps' | 'next_step'
+
+export interface ExitLine {
+ kind: ExitLineKind
+ text: string
+}
+
+/** `15 Oct 2026`, in UTC so the date matches the stored trial end. */
+export function formatTrialEnd(iso: string): string {
+ return new Intl.DateTimeFormat('en-GB', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ timeZone: 'UTC',
+ }).format(new Date(iso))
+}
+
+/** Assemble the exit block. The trial line appears only while the organization is trialing. */
+export function buildExitBlock(input: ExitBlockInput): ExitLine[] {
+ const watched = input.sourceRepos === 'all' ? 'every repository the GitHub App can see' : input.sourceRepos.join(', ')
+ const lines: ExitLine[] = [
+ {
+ kind: 'headline',
+ text: `✔ Promptless is watching ${watched}. Documentation updates arrive as pull requests on ${input.docsRepo}.`,
+ },
+ { kind: 'dashboard', text: `Dashboard: ${input.appBaseUrl}/configuration` },
+ ]
+
+ if (input.billing?.status === 'trialing' && input.billing.trial_end !== null) {
+ lines.push({
+ kind: 'trial',
+ text: `Free trial ends ${formatTrialEnd(input.billing.trial_end)}. Add a card at ${input.appBaseUrl}/settings/billing`,
+ })
+ }
+ if (input.backfillTriggerKey !== null) {
+ lines.push({
+ kind: 'backfill',
+ text: `Backfill progress: ${input.appBaseUrl}/configuration (trigger ${input.backfillTriggerKey})`,
+ })
+ }
+
+ lines.push({ kind: 'next_steps', text: 'Next steps' })
+ if (input.backfillTriggerKey === null) {
+ const where = input.sourceRepos === 'all' ? 'a watched repository' : input.sourceRepos.join(' or ')
+ lines.push({ kind: 'next_step', text: ` • Merge a pull request in ${where} to see a first suggestion.` })
+ }
+ lines.push(
+ { kind: 'next_step', text: ` • Connect Slack, Jira, or Linear: ${input.appBaseUrl}/integrations` },
+ {
+ kind: 'next_step',
+ text: ` • Let your coding agent use Promptless: claude mcp add --transport http promptless ${input.apiBaseUrl}/mcp`,
+ },
+ )
+ return lines
+}
+
+/** Join the exit block into the text written to stdout, with a leading blank line. */
+export function renderExitBlock(lines: ExitLine[]): string {
+ return `\n${lines.map((line) => line.text).join('\n')}\n`
+}
diff --git a/src/commands/setup/github.test.ts b/src/commands/setup/github.test.ts
new file mode 100644
index 0000000..bdc335f
--- /dev/null
+++ b/src/commands/setup/github.test.ts
@@ -0,0 +1,208 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import type { SetupStatus } from '../../lib/setup-api'
+import { runGitHubGate } from './github'
+import type { GateChoice, GateDeps, GateEvent } from './github'
+
+function status(connected: boolean, pending: string[] = []): SetupStatus {
+ return {
+ onboarding_completed: false,
+ billing: null,
+ github: {
+ connected,
+ installations: connected
+ ? [{ owner: 'acme', repository_selection: 'selected', repos: [{ full_name: 'acme/docs', default_branch: 'main' }] }]
+ : [],
+ pending_approval: pending.map((login) => ({ github_org_login: login, requested_at: '2026-10-01T00:00:00Z' })),
+ },
+ doc_collections: [],
+ triggers: [],
+ }
+}
+
+/** A prompt that stays open until the gate aborts it, like a user who never answers. */
+function idleAsk(signal: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ if (signal.aborted) resolve(null)
+ signal.addEventListener('abort', () => resolve(null), { once: true })
+ })
+}
+
+interface Harness {
+ deps: GateDeps
+ events: GateEvent[]
+ opened: string[]
+ calls: { status: number; begin: number; pull: number; ask: number }
+}
+
+function harness(statuses: SetupStatus[], overrides: Partial = {}): Harness {
+ const events: GateEvent[] = []
+ const opened: string[] = []
+ const calls = { status: 0, begin: 0, pull: 0, ask: 0 }
+ const deps: GateDeps = {
+ fetchStatus: async () => statuses[Math.min(calls.status++, statuses.length - 1)],
+ begin: async () => {
+ calls.begin++
+ return 'https://github.com/apps/promptless/installations/new'
+ },
+ pull: async () => {
+ calls.pull++
+ },
+ openUrl: (url) => opened.push(url),
+ ask: (_opened, signal) => {
+ calls.ask++
+ return idleAsk(signal)
+ },
+ report: (event) => events.push(event),
+ openOnStart: true,
+ intervalMs: 2,
+ timeoutMs: 2_000,
+ pullEvery: 3,
+ ...overrides,
+ }
+ return { deps, events, opened, calls }
+}
+
+describe('runGitHubGate', () => {
+ it('passes straight through when GitHub is already connected', async () => {
+ const h = harness([status(true)])
+ const outcome = await runGitHubGate(h.deps)
+ assert.equal(outcome.kind, 'connected')
+ assert.equal(h.calls.begin, 0)
+ assert.equal(h.calls.ask, 0)
+ assert.deepEqual(
+ h.events.map((e) => e.kind),
+ ['already_connected'],
+ )
+ })
+
+ it('opens the install page and polls until an installation appears', async () => {
+ const statuses = [status(false), ...Array.from({ length: 7 }, () => status(false)), status(true)]
+ const h = harness(statuses)
+ const outcome = await runGitHubGate(h.deps)
+
+ assert.equal(outcome.kind, 'connected')
+ assert.deepEqual(h.opened, ['https://github.com/apps/promptless/installations/new'])
+ // One initial read, then poll attempts 1..8; attempts 3 and 6 also pull.
+ assert.equal(h.calls.status, 9)
+ assert.equal(h.calls.pull, 2)
+ assert.deepEqual(
+ h.events.map((e) => e.kind),
+ ['install_link', 'connected'],
+ )
+ })
+
+ it('only prints the link without opening a browser when openOnStart is off', async () => {
+ const h = harness([status(false), status(true)], { openOnStart: false })
+ await runGitHubGate(h.deps)
+ assert.deepEqual(h.opened, [])
+ assert.deepEqual(h.events[0], {
+ kind: 'install_link',
+ url: 'https://github.com/apps/promptless/installations/new',
+ opened: false,
+ })
+ })
+
+ it('stops polling when the user declines', async () => {
+ const h = harness([status(false)], { ask: async () => 'decline' })
+ const outcome = await runGitHubGate(h.deps)
+ assert.deepEqual(outcome, { kind: 'declined' })
+
+ const statusCallsAtDecline = h.calls.status
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ assert.equal(h.calls.status, statusCallsAtDecline)
+ })
+
+ it('reports a cancelled prompt as cancelled', async () => {
+ const h = harness([status(false)], { ask: async () => null })
+ assert.deepEqual(await runGitHubGate(h.deps), { kind: 'cancelled' })
+ })
+
+ it('re-opens the install page when asked, then keeps waiting', async () => {
+ const answers: Array = ['open']
+ const statuses = [status(false), ...Array.from({ length: 20 }, () => status(false)), status(true)]
+ const h = harness(statuses, {
+ ask: (opened, signal) => {
+ const next = answers.shift()
+ if (next !== undefined) {
+ assert.equal(opened, true)
+ return Promise.resolve(next)
+ }
+ return idleAsk(signal)
+ },
+ })
+ const outcome = await runGitHubGate(h.deps)
+ assert.equal(outcome.kind, 'connected')
+ assert.equal(h.opened.length, 2)
+ assert.deepEqual(
+ h.events.map((e) => (e.kind === 'install_link' ? `${e.kind}:${e.opened}` : e.kind)),
+ ['install_link:true', 'install_link:true', 'connected'],
+ )
+ })
+
+ it('reports a pending admin approval once per change, between prompts', async () => {
+ const statuses = [
+ status(false),
+ status(false, ['acme']),
+ status(false, ['acme']),
+ status(false, ['acme']),
+ status(false, ['acme', 'labs']),
+ status(true),
+ ]
+ let promptOpen = false
+ const h = harness(statuses, {
+ ask: (_opened, signal) => {
+ promptOpen = true
+ return idleAsk(signal).finally(() => {
+ promptOpen = false
+ })
+ },
+ })
+ const report = h.deps.report
+ h.deps.report = (event) => {
+ assert.equal(promptOpen, false, 'events print only while no prompt is open')
+ report(event)
+ }
+
+ const outcome = await runGitHubGate(h.deps)
+ assert.equal(outcome.kind, 'connected')
+ const pending = h.events.filter((e) => e.kind === 'pending_approval')
+ assert.deepEqual(
+ pending.map((e) => (e.kind === 'pending_approval' ? e.githubOrgs : [])),
+ [['acme'], ['acme', 'labs']],
+ )
+ })
+
+ it('reports a pending approval that exists before polling starts', async () => {
+ const h = harness([status(false, ['acme']), status(false, ['acme']), status(true)])
+ await runGitHubGate(h.deps)
+ assert.deepEqual(
+ h.events.map((e) => e.kind),
+ ['install_link', 'pending_approval', 'connected'],
+ )
+ })
+
+ it('reports the first failed check once and keeps polling', async () => {
+ let calls = 0
+ const h = harness([], {
+ fetchStatus: async () => {
+ calls++
+ if (calls === 1) return status(false)
+ if (calls <= 4) throw new Error('connection reset')
+ return status(true)
+ },
+ })
+ const outcome = await runGitHubGate(h.deps)
+ assert.equal(outcome.kind, 'connected')
+ assert.deepEqual(
+ h.events.map((e) => e.kind),
+ ['install_link', 'check_failed', 'connected'],
+ )
+ })
+
+ it('times out when no installation arrives', async () => {
+ const h = harness([status(false)], { timeoutMs: 20 })
+ assert.deepEqual(await runGitHubGate(h.deps), { kind: 'timed_out' })
+ })
+})
diff --git a/src/commands/setup/github.ts b/src/commands/setup/github.ts
new file mode 100644
index 0000000..19064c0
--- /dev/null
+++ b/src/commands/setup/github.ts
@@ -0,0 +1,252 @@
+import { openInBrowser } from '../../lib/browser'
+import { PollTimeoutError, pollUntil } from '../../lib/poll'
+import {
+ beginGitHubInstall,
+ getSetupStatus,
+ installedRepos,
+ pullGitHubInstallations,
+} from '../../lib/setup-api'
+import type { SetupStatus } from '../../lib/setup-api'
+import { link } from '../../lib/ui/link'
+import { selectOne } from '../../lib/ui/prompt'
+import { SetupError, done, note, step, warn } from './output'
+import type { Session } from './login'
+
+// Screen 2: the GitHub App gate. Pass straight through when an installation
+// exists; otherwise open the install page and poll `setup/status` until one
+// appears, while a prompt offers to decline or re-open the page. The loop
+// follows PostHog's GitHub gate (MIT, github.com/PostHog/wizard
+// src/ui/tui/screens/SelfDrivingGitHubScreen.tsx).
+
+const POLL_INTERVAL_MS = 3_000
+const POLL_TIMEOUT_MS = 30 * 60 * 1000
+/** Every this many polls, ask the runtime to re-read installations from GitHub (covers admin approval). */
+const PULL_EVERY = 10
+
+export type GateChoice = 'decline' | 'open'
+
+export type GateEvent =
+ | { kind: 'already_connected'; status: SetupStatus }
+ | { kind: 'install_link'; url: string; opened: boolean }
+ | { kind: 'pending_approval'; githubOrgs: string[] }
+ | { kind: 'check_failed'; error: unknown }
+ | { kind: 'connected'; status: SetupStatus }
+
+export type GateOutcome =
+ | { kind: 'connected'; status: SetupStatus }
+ | { kind: 'declined' }
+ | { kind: 'cancelled' }
+ | { kind: 'timed_out' }
+
+export interface GateDeps {
+ fetchStatus: () => Promise
+ /** Start an install and return the URL to open. */
+ begin: () => Promise
+ pull: () => Promise
+ openUrl: (url: string) => void
+ /**
+ * Ask whether to decline or open the install page. Resolve null when the
+ * user cancels or when `signal` aborts; the gate aborts the prompt to print
+ * an event or to finish, and asks again when it is still waiting.
+ */
+ ask: (installOpened: boolean, signal: AbortSignal) => Promise
+ /** Show an event. Called only while no prompt is open. */
+ report: (event: GateEvent) => void
+ /** Open the install page before the first prompt. */
+ openOnStart: boolean
+ intervalMs: number
+ timeoutMs: number
+ pullEvery: number
+}
+
+function pendingOrgs(status: SetupStatus): string[] {
+ return status.github.pending_approval.map((request) => request.github_org_login).sort()
+}
+
+/**
+ * Wait until the organization has a GitHub App installation, the user
+ * declines, the user cancels, or `timeoutMs` passes.
+ */
+export async function runGitHubGate(deps: GateDeps): Promise {
+ const initial = await deps.fetchStatus()
+ if (initial.github.connected) {
+ deps.report({ kind: 'already_connected', status: initial })
+ return { kind: 'connected', status: initial }
+ }
+
+ const url = await deps.begin()
+ let installOpened = false
+ if (deps.openOnStart) {
+ deps.openUrl(url)
+ installOpened = true
+ }
+ deps.report({ kind: 'install_link', url, opened: installOpened })
+
+ const queued: GateEvent[] = []
+ let promptAbort = new AbortController()
+ const notify = (event: GateEvent): void => {
+ queued.push(event)
+ promptAbort.abort()
+ }
+ const flush = (): void => {
+ for (const event of queued.splice(0)) deps.report(event)
+ }
+
+ let lastPending = pendingOrgs(initial).join(',')
+ if (lastPending.length > 0) queued.push({ kind: 'pending_approval', githubOrgs: pendingOrgs(initial) })
+
+ const pollAbort = new AbortController()
+ let pollFinished = false
+ const pollDone: Promise = pollUntil(
+ async (attempt) => {
+ if (attempt % deps.pullEvery === 0) await deps.pull()
+ const status = await deps.fetchStatus()
+ const pending = pendingOrgs(status)
+ if (pending.join(',') !== lastPending) {
+ lastPending = pending.join(',')
+ if (pending.length > 0 && !status.github.connected) notify({ kind: 'pending_approval', githubOrgs: pending })
+ }
+ return status.github.connected ? status : null
+ },
+ {
+ intervalMs: deps.intervalMs,
+ timeoutMs: deps.timeoutMs,
+ signal: pollAbort.signal,
+ onError: (error) => notify({ kind: 'check_failed', error }),
+ },
+ )
+ .then(
+ (status): GateOutcome => ({ kind: 'connected', status }),
+ (err: unknown): GateOutcome | null => {
+ if (err instanceof PollTimeoutError) return { kind: 'timed_out' }
+ if (pollAbort.signal.aborted) return null
+ throw err
+ },
+ )
+ .finally(() => {
+ pollFinished = true
+ promptAbort.abort()
+ })
+ // Awaited below; this branch only keeps a rejection from being reported as unhandled meanwhile.
+ pollDone.catch(() => {})
+
+ const askLoop = async (): Promise => {
+ for (;;) {
+ flush()
+ if (pollFinished) return null
+ promptAbort = new AbortController()
+ const choice = await deps.ask(installOpened, promptAbort.signal)
+ if (promptAbort.signal.aborted) continue
+ if (choice === null) return { kind: 'cancelled' }
+ if (choice === 'decline') return { kind: 'declined' }
+ deps.openUrl(url)
+ installOpened = true
+ deps.report({ kind: 'install_link', url, opened: true })
+ }
+ }
+
+ const userOutcome = await askLoop()
+ if (userOutcome !== null) {
+ pollAbort.abort()
+ await pollDone
+ return userOutcome
+ }
+
+ const outcome = (await pollDone) ?? { kind: 'timed_out' }
+ flush()
+ if (outcome.kind === 'connected') deps.report({ kind: 'connected', status: outcome.status })
+ return outcome
+}
+
+function describeConnection(status: SetupStatus): string {
+ const owners = status.github.installations
+ .map((installation) => installation.owner)
+ .filter((owner): owner is string => owner !== null)
+ const count = installedRepos(status).length
+ const repos = `${count} ${count === 1 ? 'repository' : 'repositories'}`
+ return owners.length > 0 ? `${owners.join(', ')}, ${repos}` : repos
+}
+
+function reportEvent(event: GateEvent): void {
+ switch (event.kind) {
+ case 'already_connected':
+ case 'connected':
+ done(`GitHub connected: ${describeConnection(event.status)}`)
+ return
+ case 'install_link':
+ note(
+ event.opened
+ ? `Opened ${link(event.url)} in your browser.`
+ : `Open ${link(event.url)} to install the GitHub App.`,
+ )
+ return
+ case 'pending_approval':
+ warn(
+ `The install is waiting for a GitHub admin of ${event.githubOrgs.join(', ')} to approve it. ` +
+ 'Setup continues once they approve.',
+ )
+ return
+ case 'check_failed': {
+ const message = event.error instanceof Error ? event.error.message : String(event.error)
+ warn(`Could not check the installation (${message}). Still checking.`)
+ return
+ }
+ }
+}
+
+async function askGateChoice(installOpened: boolean, signal: AbortSignal): Promise {
+ return selectOne({
+ message: 'Waiting for the installation...',
+ choices: [
+ { value: 'decline', label: "I can't connect right now" },
+ { value: 'open', label: installOpened ? 'Re-open GitHub App install' : 'Open GitHub App install' },
+ ],
+ signal,
+ })
+}
+
+/**
+ * Screen 2: make sure the GitHub App is installed, and return the status that shows it.
+ *
+ * @throws SetupError when the user declines or cancels, or the install does not arrive in 30 minutes.
+ */
+export async function connectGitHub(session: Session, openBrowser: boolean): Promise {
+ step(2, 'Connect GitHub')
+ const { client, orgId } = session
+
+ const outcome = await runGitHubGate({
+ fetchStatus: () => getSetupStatus(client, orgId),
+ begin: async () => {
+ note('Promptless needs the GitHub App on your docs repository and on the repositories whose merges')
+ note('should update it. Grant both when GitHub asks which repositories to include.')
+ return beginGitHubInstall(client, orgId)
+ },
+ pull: () => pullGitHubInstallations(client, orgId),
+ openUrl: openInBrowser,
+ ask: askGateChoice,
+ report: reportEvent,
+ openOnStart: openBrowser,
+ intervalMs: POLL_INTERVAL_MS,
+ timeoutMs: POLL_TIMEOUT_MS,
+ pullEvery: PULL_EVERY,
+ })
+
+ switch (outcome.kind) {
+ case 'connected':
+ return outcome.status
+ case 'declined':
+ throw new SetupError(
+ 'github_not_connected',
+ 'Promptless needs the GitHub App to read your documentation repository. ' +
+ 'Nothing was changed. Run `promptless setup` again when you are ready.',
+ )
+ case 'cancelled':
+ throw new SetupError('cancelled', 'Setup was cancelled. Nothing was changed.')
+ case 'timed_out':
+ throw new SetupError(
+ 'github_not_connected',
+ 'No GitHub App installation arrived within 30 minutes. ' +
+ 'Nothing was changed. Run `promptless setup` again when you are ready.',
+ )
+ }
+}
diff --git a/src/commands/setup/index.ts b/src/commands/setup/index.ts
index 65fa8c7..4ec6410 100644
--- a/src/commands/setup/index.ts
+++ b/src/commands/setup/index.ts
@@ -1,5 +1,159 @@
-// Placeholder so `setup` is wired into the command table and shell completion.
-export function runSetup(_argv: string[]): never {
- process.stderr.write('promptless setup: not available yet\n')
- process.exit(2)
+import { API_BASE_URL, APP_BASE_URL, ApiError, AuthError, NetworkError } from '../../lib/api'
+import { emitError } from '../../lib/errors'
+import type { ErrorLine } from '../../lib/errors'
+import { readOriginGitHubRepo } from '../../lib/git-remote'
+import { parseSetupArgs } from './args'
+import type { SetupFlags } from './args'
+import { offerBackfill } from './backfill'
+import { completeSetup } from './complete'
+import { findDocsRepo } from './docs'
+import { buildExitBlock, renderExitBlock } from './exit'
+import { connectGitHub } from './github'
+import { signIn } from './login'
+import { SetupError, done, step } from './output'
+import { reviewAndApply } from './review'
+import { chooseSourceRepos } from './sources'
+
+const HELP = `promptless setup — connect GitHub and keep your docs current from merged pull requests
+
+usage:
+ promptless setup [options]
+
+options:
+ --org Run against this organization. The sign-in page picks it
+ when the saved key belongs to another one.
+ --docs-repo Use this documentation repository instead of asking.
+ --source-repo
+ Update the docs when a pull request merges here. Repeat for
+ several repositories. Skips the repository prompt.
+ --trigger-on Pull request events that update the docs: opened,
+ first_approval, merge, updated. Repeat or comma-separate.
+ Default: merge.
+ --backfill Run Promptless on the last 7 days of merged pull requests
+ without asking.
+ --new-account Open the sign-up page instead of sign-in.
+ --no-browser Print links instead of opening them.
+ -h, --help Show this help
+
+description:
+ Signs in (reusing the saved API key when it still works), waits for the
+ Promptless GitHub App to be installed, finds your documentation repository,
+ and adds it to your organization's promptless.yaml together with a trigger
+ on the source repositories you choose. Completes onboarding, which starts the
+ free trial, and can run Promptless on recent merged pull requests.
+
+ promptless.yaml lives in your organization's Promptless knowledge-base
+ repository and is written by Promptless. Setup writes nothing to the current
+ repository; the only local file it writes is the API key in the config file
+ that \`promptless login\` uses.
+
+ Running setup again skips every step that is already done.
+
+ Setup needs an interactive terminal. Every fatal error also prints one line
+ on stderr: promptless-error: {"code": "...", "message": "..."}
+
+exit codes:
+ 0 setup finished
+ 1 setup stopped: declined, cancelled, or failed
+ 2 argument error
+`
+
+function errorLineFor(err: unknown): ErrorLine {
+ if (err instanceof SetupError) return { code: err.code, message: err.message }
+ if (err instanceof AuthError) return { code: 'auth_failed', message: err.message }
+ if (err instanceof ApiError && err.code === 'setup_unavailable') {
+ return { code: 'setup_unavailable', message: err.message }
+ }
+ if (err instanceof ApiError) return { code: 'api_error', message: err.message }
+ if (err instanceof NetworkError) return { code: 'network_error', message: err.message }
+ return { code: 'internal_error', message: err instanceof Error ? err.message : String(err) }
+}
+
+function fail(err: unknown): never {
+ const line = errorLineFor(err)
+ process.stderr.write(`\n${line.message}\n`)
+ emitError(line)
+ process.exit(line.code === 'bad_arguments' ? 2 : 1)
+}
+
+async function runScreens(flags: SetupFlags): Promise {
+ process.stderr.write(
+ 'Promptless setup\n' +
+ ' Keeps your documentation current from merged pull requests. About three minutes.\n' +
+ ' Nothing is written to this repository.\n',
+ )
+ const cwdRepo = readOriginGitHubRepo()
+
+ const session = await signIn({ org: flags.org, newAccount: flags.newAccount, openBrowser: flags.openBrowser })
+ const status = await connectGitHub(session, flags.openBrowser)
+ const docs = await findDocsRepo(session, status, flags.docsRepo, cwdRepo)
+ const sources = await chooseSourceRepos(status, docs.repo, docs.existing, flags.sourceRepos, cwdRepo)
+
+ let triggerKey: string
+ let triggerAdded: boolean
+ if (sources.kind === 'existing') {
+ step(5, 'Review')
+ done('promptless.yaml already has this collection and a pull request trigger. Nothing to change.')
+ triggerKey = sources.triggerKey
+ triggerAdded = false
+ } else {
+ const applied = await reviewAndApply(session, docs, sources.repos, flags.triggerOn)
+ triggerKey = applied.trigger.key
+ triggerAdded = applied.trigger.added
+ }
+
+ const finalStatus = await completeSetup(session, status)
+
+ let backfillTriggerKey: string | null = null
+ if (triggerAdded || flags.backfill) {
+ const started = await offerBackfill(session, {
+ triggerKey,
+ sourceRepos: sources.repos,
+ skipQuestion: flags.backfill,
+ })
+ if (started) backfillTriggerKey = triggerKey
+ }
+
+ process.stdout.write(
+ renderExitBlock(
+ buildExitBlock({
+ appBaseUrl: APP_BASE_URL,
+ apiBaseUrl: API_BASE_URL,
+ docsRepo: docs.repo,
+ sourceRepos: sources.repos,
+ billing: finalStatus.billing,
+ backfillTriggerKey,
+ }),
+ ),
+ )
+}
+
+export function runSetup(argv: string[]): never {
+ let flags: SetupFlags
+ try {
+ const parsed = parseSetupArgs(argv)
+ if (parsed.kind === 'help') {
+ process.stdout.write(HELP)
+ process.exit(0)
+ }
+ flags = parsed.flags
+ } catch (err) {
+ fail(err)
+ }
+
+ if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
+ fail(
+ new SetupError(
+ 'interactive_terminal_required',
+ 'promptless setup asks questions as it goes, so it needs an interactive terminal. ' +
+ 'Run it again in a terminal window.',
+ ),
+ )
+ }
+
+ void runScreens(flags).then(
+ () => process.exit(0),
+ (err: unknown) => fail(err),
+ )
+ return undefined as never
}
diff --git a/src/commands/setup/login.test.ts b/src/commands/setup/login.test.ts
new file mode 100644
index 0000000..6cefe77
--- /dev/null
+++ b/src/commands/setup/login.test.ts
@@ -0,0 +1,159 @@
+import assert from 'node:assert/strict'
+import { spawn } from 'node:child_process'
+import { constants, createPublicKey, publicEncrypt } from 'node:crypto'
+import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { createServer } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, it } from 'node:test'
+
+const ORIGINAL_CONFIG = 'PROMPTLESS_CLI_API_SECRET=sk-pl-previous\nOTHER_SETTING=keep\n'
+const NEW_KEY = 'sk-pl-new-test'
+const IDENTITY = {
+ user_id: 'user_test',
+ email: 'test@example.com',
+ organization_id: 'org_expected',
+ organization_name: 'Test organization',
+}
+
+interface LoginScenario {
+ responseStatus: number
+ responseBody: unknown
+ cancel?: boolean
+}
+
+// Run the real sign-in flow and encrypted loopback callback against a local
+// runtime. No production credentials, browser, or account is involved.
+async function runLogin(scenario: LoginScenario) {
+ const directory = mkdtempSync(join(tmpdir(), 'promptless-setup-login-'))
+ const configPath = join(directory, 'env')
+ writeFileSync(configPath, ORIGINAL_CONFIG, { mode: 0o600 })
+ const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []
+ const server = createServer((req, res) => {
+ requests.push({ url: req.url, authorization: req.headers.authorization })
+ res.writeHead(scenario.responseStatus, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(scenario.responseBody))
+ })
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
+ const child = spawn(process.execPath, [
+ '--import', 'tsx', '--input-type=module', '--eval',
+ `import { signIn } from ${JSON.stringify(new URL('./login.ts', import.meta.url).href)};
+ try {
+ await signIn({ org: 'org_expected', newAccount: true, openBrowser: false });
+ } catch (err) {
+ process.stderr.write(err.message + '\\n');
+ process.exitCode = 1;
+ }`,
+ ], {
+ env: {
+ ...process.env,
+ PROMPTLESS_API_BASE_URL: baseUrl,
+ PROMPTLESS_APP_BASE_URL: baseUrl,
+ PROMPTLESS_CLI_DEVELOPER_CONFIG_FILE: configPath,
+ PROMPTLESS_CLI_API_SECRET: '',
+ },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ })
+ let stderr = ''
+ let stdout = ''
+ child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
+ const finished = new Promise((resolve, reject) => {
+ child.once('close', resolve)
+ child.once('error', reject)
+ })
+ const deadline = setTimeout(() => child.kill('SIGKILL'), 10_000)
+
+ try {
+ const signUpUrl = await new Promise((resolve, reject) => {
+ const inspect = () => {
+ const match = /Open (http\S+) in your browser\./.exec(stderr)
+ if (match) resolve(new URL(match[1]))
+ }
+ child.stderr.on('data', inspect)
+ void finished.then(() => reject(new Error(`Sign-in ended before printing its URL: ${stderr}`)), reject)
+ inspect()
+ })
+ assert.equal(signUpUrl.pathname, '/sign-up')
+ const authUrl = new URL(signUpUrl.searchParams.get('redirect_url')!)
+ assert.equal(authUrl.pathname, '/cli/auth')
+ assert.equal(authUrl.searchParams.get('intent'), 'setup')
+ assert.equal(authUrl.searchParams.get('org_id'), 'org_expected')
+ const publicKey = createPublicKey({
+ key: Buffer.from(authUrl.searchParams.get('public_key')!, 'base64url'),
+ type: 'spki',
+ format: 'der',
+ })
+ const payload = scenario.cancel
+ ? { state: authUrl.searchParams.get('state'), error: 'user_cancelled', error_description: 'Cancelled' }
+ : {
+ state: authUrl.searchParams.get('state'),
+ key_type: 'v1',
+ encrypted_key: publicEncrypt({
+ key: publicKey,
+ padding: constants.RSA_PKCS1_OAEP_PADDING,
+ oaepHash: 'sha256',
+ }, Buffer.from(NEW_KEY)).toString('base64url'),
+ }
+ const callback = await fetch(authUrl.searchParams.get('redirect_uri')!, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Origin: baseUrl },
+ body: JSON.stringify(payload),
+ })
+ assert.equal(callback.status, 204)
+ const exitCode = await finished
+ assert.doesNotMatch(stdout + stderr, /sk-pl-(previous|new-test)/)
+ return {
+ exitCode,
+ stderr,
+ requests,
+ config: readFileSync(configPath, 'utf8'),
+ mode: statSync(configPath).mode & 0o777,
+ }
+ } finally {
+ clearTimeout(deadline)
+ child.kill('SIGKILL')
+ await finished
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ rmSync(directory, { recursive: true, force: true })
+ }
+}
+
+describe('setup sign-in', () => {
+ it('verifies a new key before saving it and preserves other settings', async () => {
+ const result = await runLogin({ responseStatus: 200, responseBody: IDENTITY })
+ assert.equal(result.exitCode, 0)
+ assert.equal(result.config, `PROMPTLESS_CLI_API_SECRET=${NEW_KEY}\nOTHER_SETTING=keep\n`)
+ assert.equal(result.mode, 0o600)
+ assert.deepEqual(result.requests, [{ url: '/v1/me', authorization: `Bearer ${NEW_KEY}` }])
+ })
+
+ it('keeps the previous key when the browser authorizes a different organization', async () => {
+ const result = await runLogin({
+ responseStatus: 200,
+ responseBody: { ...IDENTITY, organization_id: 'org_other' },
+ })
+ assert.equal(result.exitCode, 1)
+ assert.equal(result.config, ORIGINAL_CONFIG)
+ assert.match(result.stderr, /not org_expected/)
+ assert.doesNotMatch(result.stderr, /Saved the API key/)
+ })
+
+ it('keeps the previous key when identity verification rejects the new key', async () => {
+ const result = await runLogin({ responseStatus: 401, responseBody: { error: 'authentication_failed' } })
+ assert.equal(result.exitCode, 1)
+ assert.equal(result.config, ORIGINAL_CONFIG)
+ assert.match(result.stderr, /authentication failed/)
+ })
+
+ it('does not save a key or call the API when authorization is cancelled', async () => {
+ const result = await runLogin({ responseStatus: 200, responseBody: IDENTITY, cancel: true })
+ assert.equal(result.exitCode, 1)
+ assert.equal(result.config, ORIGINAL_CONFIG)
+ assert.deepEqual(result.requests, [])
+ assert.match(result.stderr, /Sign-in was cancelled/)
+ })
+})
diff --git a/src/commands/setup/login.ts b/src/commands/setup/login.ts
new file mode 100644
index 0000000..8c42865
--- /dev/null
+++ b/src/commands/setup/login.ts
@@ -0,0 +1,107 @@
+import { APP_BASE_URL, AuthError, whoami } from '../../lib/api'
+import type { WhoamiResponse } from '../../lib/api'
+import { buildSignUpUrl, startLoopbackLogin } from '../../lib/auth/loopback'
+import { openInBrowser } from '../../lib/browser'
+import { getApiSecret, loadConfigFile, resolveConfigFilePath, saveConfigFile } from '../../lib/config'
+import type { SetupClient } from '../../lib/setup-api'
+import { link } from '../../lib/ui/link'
+import { SetupError, done, note, step, warn } from './output'
+
+// Screen 1: sign in. Reuse the saved key when `/v1/me` accepts it, otherwise
+// run the loopback login, and resolve the organization the key is bound to.
+
+const LOGIN_TIMEOUT_MS = 15 * 60 * 1000
+
+export interface SignInOptions {
+ /** `--org`: the organization setup must run against. */
+ org: string | null
+ /** `--new-account`: open the sign-up page first and skip the saved key. */
+ newAccount: boolean
+ openBrowser: boolean
+}
+
+export interface Session {
+ client: SetupClient
+ email: string
+ orgId: string
+ orgName: string
+}
+
+async function whoamiOrNull(apiSecret: string): Promise {
+ try {
+ return await whoami(apiSecret)
+ } catch (err) {
+ if (err instanceof AuthError) return null
+ throw err
+ }
+}
+
+async function loopbackSignIn(opts: SignInOptions): Promise {
+ const login = await startLoopbackLogin({
+ timeoutMs: LOGIN_TIMEOUT_MS,
+ readPastedCode: true,
+ intent: 'setup',
+ orgId: opts.org ?? undefined,
+ onInvalidPaste: () => warn("That code didn't decrypt. Paste the full code, or wait for the browser."),
+ })
+ const url = opts.newAccount ? buildSignUpUrl(APP_BASE_URL, login.authUrl) : login.authUrl
+
+ if (opts.openBrowser) {
+ openInBrowser(url)
+ note(`Opened ${link(url)} in your browser.`)
+ } else {
+ note(`Open ${link(url)} in your browser.`)
+ }
+ if (!opts.newAccount) note('New to Promptless? Create an account on that page.')
+ note('If the page shows a code instead of returning here, paste the code and press Enter.')
+
+ const result = await login.result
+ if (!result.ok) {
+ if (result.code === 'user_cancelled') {
+ throw new SetupError('cancelled', 'Sign-in was cancelled in the browser. Nothing was changed.')
+ }
+ throw new SetupError('login_failed', `Sign-in failed: ${result.error}`)
+ }
+
+ return result.apiKey
+}
+
+/**
+ * Sign in and return the session every later screen uses.
+ *
+ * @throws SetupError when sign-in fails or is cancelled, or when the new key
+ * belongs to an organization other than `--org`.
+ */
+export async function signIn(opts: SignInOptions): Promise {
+ step(1, 'Sign in')
+
+ const saved = opts.newAccount ? undefined : getApiSecret()
+ if (saved) {
+ const me = await whoamiOrNull(saved)
+ if (me !== null && (opts.org === null || me.organization_id === opts.org)) return signedIn(saved, me)
+ if (me !== null) {
+ note(`The saved key belongs to ${me.organization_name ?? me.organization_id}. Sign in to choose ${opts.org}.`)
+ }
+ }
+
+ const apiSecret = await loopbackSignIn(opts)
+ const me = await whoami(apiSecret)
+ if (opts.org !== null && me.organization_id !== opts.org) {
+ throw new SetupError(
+ 'bad_arguments',
+ `You signed in to ${me.organization_name ?? me.organization_id} (${me.organization_id}), not ${opts.org}. ` +
+ 'Run `promptless setup` again and choose that organization on the sign-in page.',
+ )
+ }
+ const config = loadConfigFile()
+ config.PROMPTLESS_CLI_API_SECRET = apiSecret
+ saveConfigFile(config)
+ note(`Saved the API key to ${resolveConfigFilePath()}`)
+ return signedIn(apiSecret, me)
+}
+
+function signedIn(apiSecret: string, me: WhoamiResponse): Session {
+ const orgName = me.organization_name ?? me.organization_id
+ done(`Signed in as ${me.email}, organization ${orgName}`)
+ return { client: { apiSecret }, email: me.email, orgId: me.organization_id, orgName }
+}
diff --git a/src/commands/setup/output.ts b/src/commands/setup/output.ts
new file mode 100644
index 0000000..d6f5017
--- /dev/null
+++ b/src/commands/setup/output.ts
@@ -0,0 +1,41 @@
+import type { ErrorCode } from '../../lib/errors'
+
+// Progress output for `promptless setup`. Steps, notes, and prompts go to
+// stderr; only the exit block goes to stdout.
+
+/** A failure that ends setup. `index.ts` reports it as one `promptless-error:` line and exits 1. */
+export class SetupError extends Error {
+ readonly code: ErrorCode
+
+ constructor(code: ErrorCode, message: string) {
+ super(message)
+ this.name = 'SetupError'
+ this.code = code
+ }
+}
+
+/** Print a numbered step heading. */
+export function step(number: number, title: string): void {
+ process.stderr.write(`\n${number} ${title}\n`)
+}
+
+/** Print an indented note under the current step. */
+export function note(text: string): void {
+ process.stderr.write(` ${text}\n`)
+}
+
+/** Print a completed item under the current step. */
+export function done(text: string): void {
+ process.stderr.write(` ✓ ${text}\n`)
+}
+
+/** Print a warning under the current step. */
+export function warn(text: string): void {
+ process.stderr.write(` ! ${text}\n`)
+}
+
+/** Return a prompt answer, or end setup when the user cancelled the prompt (Ctrl-C or Escape). */
+export function answered(answer: T | null): T {
+ if (answer === null) throw new SetupError('cancelled', 'Setup was cancelled.')
+ return answer
+}
diff --git a/src/commands/setup/review.test.ts b/src/commands/setup/review.test.ts
new file mode 100644
index 0000000..b30a921
--- /dev/null
+++ b/src/commands/setup/review.test.ts
@@ -0,0 +1,56 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { ApiError } from '../../lib/api'
+import { buildApplyRequest, explainApplyError } from './review'
+
+describe('buildApplyRequest', () => {
+ it('maps the docs choice and sources to the apply body with no trigger directories', () => {
+ const body = buildApplyRequest(
+ { repo: 'acme/docs', framework: 'mintlify', configPath: 'docs/docs.json', filter: ['docs'], existing: false },
+ ['acme/api'],
+ ['merge'],
+ true,
+ )
+ assert.deepEqual(body, {
+ doc_collection: { repo: 'acme/docs', filter: ['docs'], docs_framework: 'mintlify', config_file_path: 'docs/docs.json' },
+ trigger: { repos: ['acme/api'], trigger_on: ['merge'], trigger_directories: [] },
+ dry_run: true,
+ })
+ })
+})
+
+describe('explainApplyError', () => {
+ it('names the missing repositories for repos_not_installed', () => {
+ const err = new ApiError(422, 'repos_not_installed', 'failed', {
+ error: 'repos_not_installed',
+ missing_repos: ['acme/api', 'acme/web'],
+ })
+ const explained = explainApplyError(err)
+ assert.equal(explained?.code, 'repos_not_installed')
+ assert.ok(explained?.message.includes('acme/api, acme/web'))
+ })
+
+ it('lists each validation path for a promptless.yaml validation failure', () => {
+ const err = new ApiError(422, null, 'failed', {
+ error: 'Validation failed',
+ validation_errors: [
+ { message: 'unknown framework', path: 'doc_collections.acme/docs.docs_framework', line: 3 },
+ { message: 'bad trigger', path: 'triggers.source-merges', line: null },
+ ],
+ })
+ const explained = explainApplyError(err)
+ assert.equal(explained?.code, 'config_invalid')
+ assert.ok(explained?.message.includes('doc_collections.acme/docs.docs_framework'))
+ assert.ok(explained?.message.includes('triggers.source-merges'))
+ })
+
+ it('asks for an admin on 403', () => {
+ assert.equal(explainApplyError(new ApiError(403, null, 'failed', { detail: 'Forbidden' }))?.code, 'admin_required')
+ })
+
+ it('leaves other errors to the caller', () => {
+ assert.equal(explainApplyError(new ApiError(502, 'kb_write_failed', 'failed', { error: 'kb_write_failed' })), null)
+ assert.equal(explainApplyError(new Error('network')), null)
+ })
+})
diff --git a/src/commands/setup/review.ts b/src/commands/setup/review.ts
new file mode 100644
index 0000000..387576a
--- /dev/null
+++ b/src/commands/setup/review.ts
@@ -0,0 +1,128 @@
+import { ApiError } from '../../lib/api'
+import { configValidationIssues, postApply, reposNotInstalled } from '../../lib/setup-api'
+import type { ApplyRequest, ApplyResponse, PrLifecycleEvent } from '../../lib/setup-api'
+import { selectOne } from '../../lib/ui/prompt'
+import type { DocsChoice } from './docs'
+import type { Session } from './login'
+import { SetupError, answered, done, note, step } from './output'
+
+// Screen 5: preview the promptless.yaml change with a dry run, confirm it
+// (Cancel listed first), and apply it. The runtime writes the file in the
+// organization's knowledge-base repository; nothing is written locally.
+
+export function buildApplyRequest(
+ docs: DocsChoice,
+ sourceRepos: string[],
+ triggerOn: PrLifecycleEvent[],
+ dryRun: boolean,
+): ApplyRequest {
+ return {
+ doc_collection: {
+ repo: docs.repo,
+ filter: docs.filter,
+ docs_framework: docs.framework,
+ config_file_path: docs.configPath,
+ },
+ trigger: { repos: sourceRepos, trigger_on: triggerOn, trigger_directories: [] },
+ dry_run: dryRun,
+ }
+}
+
+/**
+ * Translate an `apply` failure the user can act on into the error that ends
+ * setup, or return null for any other error.
+ */
+export function explainApplyError(err: unknown): SetupError | null {
+ const missing = reposNotInstalled(err)
+ if (missing !== null) {
+ const repos = missing.join(', ')
+ return new SetupError(
+ 'repos_not_installed',
+ `The Promptless GitHub App cannot see ${repos}. Grant it access on GitHub, ` +
+ 'then run `promptless setup` again.',
+ )
+ }
+
+ const issues = configValidationIssues(err)
+ if (issues !== null) {
+ const lines = issues.map((issue) => {
+ const where = issue.path === null ? '' : `${issue.path}: `
+ const line = issue.line === null ? '' : ` (line ${issue.line})`
+ return ` ${where}${issue.message}${line}`
+ })
+ return new SetupError('config_invalid', ['promptless.yaml would not be valid:', ...lines].join('\n'))
+ }
+
+ if (err instanceof ApiError && err.status === 403) {
+ return new SetupError(
+ 'admin_required',
+ 'Changing promptless.yaml needs an organization admin. Ask an admin to run `promptless setup`.',
+ )
+ }
+ if (err instanceof ApiError && err.code === 'write_in_progress') {
+ return new SetupError(
+ 'api_error',
+ 'Another change to promptless.yaml is in progress. Run `promptless setup` again in a minute.',
+ )
+ }
+ return null
+}
+
+async function apply(session: Session, body: ApplyRequest): Promise {
+ try {
+ return await postApply(session.client, session.orgId, body)
+ } catch (err) {
+ throw explainApplyError(err) ?? err
+ }
+}
+
+function indent(text: string): string {
+ return text
+ .trimEnd()
+ .split('\n')
+ .map((line) => ` ${line}`)
+ .join('\n')
+}
+
+/**
+ * Screen 5: review and apply the doc collection and trigger.
+ *
+ * @throws SetupError when the user cancels or the runtime rejects the change.
+ */
+export async function reviewAndApply(
+ session: Session,
+ docs: DocsChoice,
+ sourceRepos: string[],
+ triggerOn: PrLifecycleEvent[],
+): Promise {
+ step(5, 'Review')
+
+ const preview = await apply(session, buildApplyRequest(docs, sourceRepos, triggerOn, true))
+ if (!preview.changed) {
+ done('promptless.yaml already has this collection and trigger. Nothing to change.')
+ return preview
+ }
+
+ note('promptless.yaml after this change:')
+ process.stderr.write(`${indent(preview.yaml)}\n`)
+ const choice = answered(
+ await selectOne<'cancel' | 'apply'>({
+ message: 'Apply this change?',
+ choices: [
+ { value: 'cancel', label: 'Cancel' },
+ { value: 'apply', label: 'Apply' },
+ ],
+ }),
+ )
+ if (choice === 'cancel') {
+ throw new SetupError('cancelled', 'Nothing was changed. Run `promptless setup` again when you are ready.')
+ }
+
+ const result = await apply(session, buildApplyRequest(docs, sourceRepos, triggerOn, false))
+ done(
+ result.kb_commit_sha === null
+ ? 'Saved promptless.yaml'
+ : `Saved promptless.yaml (commit ${result.kb_commit_sha.slice(0, 7)})`,
+ )
+ return result
+}
diff --git a/src/commands/setup/sources.test.ts b/src/commands/setup/sources.test.ts
new file mode 100644
index 0000000..570f68e
--- /dev/null
+++ b/src/commands/setup/sources.test.ts
@@ -0,0 +1,24 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { defaultSourceRepos } from './sources'
+
+const INSTALLED = [
+ { full_name: 'Acme/API', default_branch: 'main' },
+ { full_name: 'acme/docs', default_branch: 'main' },
+]
+
+describe('defaultSourceRepos', () => {
+ it('preselects the installed cwd repository in inventory casing', () => {
+ assert.deepEqual(defaultSourceRepos(INSTALLED, 'acme/api', 'acme/docs'), ['Acme/API'])
+ })
+
+ it('preselects nothing when the cwd repository is the docs repository', () => {
+ assert.deepEqual(defaultSourceRepos(INSTALLED, 'acme/docs', 'Acme/Docs'), [])
+ })
+
+ it('preselects nothing without a cwd repository or when it is not installed', () => {
+ assert.deepEqual(defaultSourceRepos(INSTALLED, null, 'acme/docs'), [])
+ assert.deepEqual(defaultSourceRepos(INSTALLED, 'acme/web', 'acme/docs'), [])
+ })
+})
diff --git a/src/commands/setup/sources.ts b/src/commands/setup/sources.ts
new file mode 100644
index 0000000..68abf90
--- /dev/null
+++ b/src/commands/setup/sources.ts
@@ -0,0 +1,82 @@
+import type { GitHubRepo } from '../../lib/git-remote'
+import { installedRepos } from '../../lib/setup-api'
+import type { InstalledRepo, SetupStatus } from '../../lib/setup-api'
+import { selectMany } from '../../lib/ui/prompt'
+import { SetupError, answered, done, step } from './output'
+
+// Screen 4: choose the source repositories whose merged pull requests update
+// the docs. The list is always explicit, never `all`, so a later GitHub grant
+// does not widen the trigger unnoticed.
+
+/** Either the repositories for a trigger setup adds, or an existing trigger setup keeps. */
+export type SourceChoice =
+ | { kind: 'new'; repos: string[] }
+ | { kind: 'existing'; triggerKey: string; repos: string[] | 'all' }
+
+/**
+ * The repositories preselected in the prompt: the current directory's
+ * repository, in inventory casing, when it is installed and is not the docs repository.
+ */
+export function defaultSourceRepos(installed: InstalledRepo[], cwdRepo: string | null, docsRepo: string): string[] {
+ if (cwdRepo === null || cwdRepo.toLowerCase() === docsRepo.toLowerCase()) return []
+ const match = installed.find((repo) => repo.full_name.toLowerCase() === cwdRepo.toLowerCase())
+ return match === undefined ? [] : [match.full_name]
+}
+
+/**
+ * Screen 4: choose the source repositories.
+ *
+ * @param sourceRepoFlags `--source-repo` values, which skip the prompt.
+ * @param docsExisting True when the docs collection already exists; an existing
+ * `github_pr` trigger is then kept and the prompt is skipped.
+ */
+export async function chooseSourceRepos(
+ status: SetupStatus,
+ docsRepo: string,
+ docsExisting: boolean,
+ sourceRepoFlags: string[],
+ cwdRepo: GitHubRepo | null,
+): Promise {
+ step(4, 'Choose source repositories')
+
+ if (sourceRepoFlags.length > 0) {
+ done(`Update the docs when a pull request merges in: ${sourceRepoFlags.join(', ')}`)
+ return { kind: 'new', repos: sourceRepoFlags }
+ }
+
+ const existingTrigger = docsExisting
+ ? status.triggers.find((trigger) => trigger.trigger_type === 'github_pr' && trigger.repos !== null)
+ : undefined
+ if (existingTrigger !== undefined && existingTrigger.repos !== null) {
+ const repos = existingTrigger.repos
+ done(
+ `Already watching ${repos === 'all' ? 'every repository the GitHub App can see' : repos.join(', ')} ` +
+ `(trigger ${existingTrigger.key}). Pass --source-repo to add more.`,
+ )
+ return { kind: 'existing', triggerKey: existingTrigger.key, repos }
+ }
+
+ const installed = installedRepos(status)
+ if (installed.length === 0) {
+ throw new SetupError(
+ 'repos_not_installed',
+ 'The GitHub App installation lists no repositories. Grant it your source repositories on GitHub, ' +
+ 'then run `promptless setup` again.',
+ )
+ }
+ const cwd = cwdRepo?.fullName.toLowerCase() ?? null
+ const repos = answered(
+ await selectMany({
+ message: 'Update the docs when a pull request merges in:',
+ choices: installed.map((repo) => ({
+ value: repo.full_name,
+ label: repo.full_name,
+ hint: repo.full_name.toLowerCase() === cwd ? 'this directory' : undefined,
+ })),
+ initial: defaultSourceRepos(installed, cwdRepo?.fullName ?? null, docsRepo),
+ required: true,
+ }),
+ )
+ done(`Update the docs when a pull request merges in: ${repos.join(', ')}`)
+ return { kind: 'new', repos }
+}
diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts
index c6f526b..2fca7e7 100644
--- a/src/commands/whoami.ts
+++ b/src/commands/whoami.ts
@@ -37,12 +37,8 @@ async function _run(argv: string[]): Promise {
process.stdout.write(JSON.stringify(me, null, 2) + '\n')
} else {
process.stdout.write(`${me.email}${me.name ? ` (${me.name})` : ''}\n`)
- if (me.organizations && me.organizations.length > 0) {
- process.stdout.write('Organizations:\n')
- for (const org of me.organizations) {
- process.stdout.write(` - ${org.name} (${org.id})\n`)
- }
- }
+ const orgName = me.organization_name ?? me.organization_id
+ process.stdout.write(`Organization: ${orgName} (${me.organization_id})\n`)
}
process.exit(0)
} catch (err) {
diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts
index 75b3c8b..083ca16 100644
--- a/src/lib/api.test.ts
+++ b/src/lib/api.test.ts
@@ -4,7 +4,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import { after, before, describe, it } from 'node:test'
-import { ApiError, AuthError, parseErrorBody, request } from './api'
+import { ApiError, AuthError, NetworkError, parseErrorBody, request } from './api'
describe('parseErrorBody', () => {
it('reads a FastAPI string detail', () => {
@@ -194,7 +194,7 @@ describe('request', () => {
it('times out', async () => {
await assert.rejects(
request('GET', '/slow', { apiSecret: 'k', baseUrl, timeoutMs: 50 }),
- (err) => err instanceof Error && !(err instanceof ApiError) && /timed out/.test(err.message),
+ (err) => err instanceof NetworkError && /timed out/.test(err.message),
)
})
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 80b1e58..26cc675 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -38,6 +38,14 @@ export class AuthError extends ApiError {
}
}
+/** The request never got an HTTP response: a connection failure or a timeout. */
+export class NetworkError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'NetworkError'
+ }
+}
+
export interface ParsedErrorBody {
code: string | null
message: string | null
@@ -117,7 +125,7 @@ export interface RequestOptions {
*
* @throws AuthError on 401.
* @throws ApiError on any other non-2xx status.
- * @throws Error on a network failure or timeout. A caller's abort rethrows its signal's reason.
+ * @throws NetworkError on a connection failure or timeout. A caller's abort rethrows its signal's reason.
*/
export async function request(method: string, path: string, opts: RequestOptions): Promise {
const baseUrl = opts.baseUrl ?? API_BASE_URL
@@ -145,10 +153,10 @@ export async function request(method: string, path: string, opts: RequestOpti
} catch (err) {
if (opts.signal?.aborted) throw opts.signal.reason
if (timeoutSignal.aborted) {
- throw new Error(`${method} ${path} timed out after ${timeoutMs}ms contacting ${baseUrl}`)
+ throw new NetworkError(`${method} ${path} timed out after ${timeoutMs}ms contacting ${baseUrl}`)
}
const msg = err instanceof Error ? err.message : String(err)
- throw new Error(`network error contacting ${baseUrl}: ${msg}`)
+ throw new NetworkError(`network error contacting ${baseUrl}: ${msg}`)
}
const body = parseJsonOrText(text)
@@ -163,16 +171,18 @@ export async function request(method: string, path: string, opts: RequestOpti
return (body ?? undefined) as T
}
+/** `GET /v1/me`: the key's owner and the one organization the key is bound to. */
export interface WhoamiResponse {
user_id: string
email: string
- name?: string
- organizations?: Array<{ id: string; name: string }>
+ name: string | null
+ organization_id: string
+ organization_name: string | null
}
-export async function whoami(apiSecret: string): Promise {
+export async function whoami(apiSecret: string, baseUrl?: string): Promise {
try {
- return await request('GET', '/v1/me', { apiSecret })
+ return await request('GET', '/v1/me', { apiSecret, baseUrl })
} catch (err) {
// `/v1/me` answers 403 for a key bound to no user, which `promptless login` fixes.
if (err instanceof ApiError && err.status === 403) {
diff --git a/src/lib/auth/loopback.test.ts b/src/lib/auth/loopback.test.ts
index 2634ed8..df33f40 100644
--- a/src/lib/auth/loopback.test.ts
+++ b/src/lib/auth/loopback.test.ts
@@ -3,7 +3,7 @@ import { constants as cryptoConstants, createPublicKey, publicEncrypt } from 'no
import { request as httpRequest } from 'node:http'
import { describe, it } from 'node:test'
-import { CALLBACK_PATH, KEY_TYPE, buildAuthUrl, startLoopbackLogin } from './loopback'
+import { CALLBACK_PATH, KEY_TYPE, buildAuthUrl, buildSignUpUrl, startLoopbackLogin } from './loopback'
import type { LoopbackLogin } from './loopback'
const APP = 'https://app.example.test'
@@ -61,11 +61,25 @@ describe('buildAuthUrl', () => {
assert.equal(url.searchParams.has('intent'), false)
})
+ it('omits org_id by default and adds it when given', () => {
+ assert.equal(new URL(buildAuthUrl(base)).searchParams.has('org_id'), false)
+ assert.equal(new URL(buildAuthUrl({ ...base, orgId: 'org_1' })).searchParams.get('org_id'), 'org_1')
+ })
+
it('adds intent when given', () => {
assert.equal(new URL(buildAuthUrl({ ...base, intent: 'setup' })).searchParams.get('intent'), 'setup')
})
})
+describe('buildSignUpUrl', () => {
+ it('carries the full auth URL as redirect_url', () => {
+ const authUrl = `${APP}/cli/auth?public_key=pk&state=s&intent=setup`
+ const url = new URL(buildSignUpUrl(APP, authUrl))
+ assert.equal(url.origin + url.pathname, `${APP}/sign-up`)
+ assert.equal(url.searchParams.get('redirect_url'), authUrl)
+ })
+})
+
describe('startLoopbackLogin', () => {
it('binds 127.0.0.1 on a free port and passes intent through', async () => {
const login = await startLoopbackLogin({
@@ -183,4 +197,38 @@ describe('startLoopbackLogin', () => {
assert.equal(result.ok, false)
assert.match(result.ok ? '' : result.error, /timed out/)
})
+
+ it('settles with the auth page error code on a cancel callback', async () => {
+ const login = await start()
+ const { redirectUri, state } = params(login)
+ const response = await post(
+ redirectUri,
+ JSON.stringify({ error: 'user_cancelled', error_description: 'User cancelled CLI authorization.', state }),
+ APP,
+ )
+ assert.equal(response.status, 204)
+ assert.deepEqual(await login.result, {
+ ok: false,
+ error: 'User cancelled CLI authorization.',
+ code: 'user_cancelled',
+ })
+ })
+
+ it('falls back to the error code when a cancel callback has no description', async () => {
+ const login = await start()
+ const { redirectUri, state } = params(login)
+ assert.equal((await post(redirectUri, JSON.stringify({ error: 'issue_failed', state }), APP)).status, 204)
+ assert.deepEqual(await login.result, { ok: false, error: 'issue_failed', code: 'issue_failed' })
+ })
+
+ it('rejects a cancel callback with the wrong state', async () => {
+ const login = await start()
+ const { redirectUri } = params(login)
+ const response = await post(redirectUri, JSON.stringify({ error: 'user_cancelled', state: 'wrong' }), APP)
+ assert.equal(response.status, 400)
+ assert.equal(JSON.parse(response.body).error, 'state_mismatch')
+ const result = await login.result
+ assert.equal(result.ok, false)
+ assert.equal(result.ok ? 'ok' : result.code, null)
+ })
})
diff --git a/src/lib/auth/loopback.ts b/src/lib/auth/loopback.ts
index 6dd0374..cbf683c 100644
--- a/src/lib/auth/loopback.ts
+++ b/src/lib/auth/loopback.ts
@@ -30,7 +30,13 @@ export const CALLBACK_PATH = '/auth/callback'
const MAX_BODY_BYTES = 64 * 1024
-export type CallbackResult = { ok: true; apiKey: string } | { ok: false; error: string }
+/**
+ * The login outcome. On failure, `code` is the error code the auth page sent
+ * (for example `user_cancelled`), or null when the CLI detected the failure itself.
+ */
+export type CallbackResult =
+ | { ok: true; apiKey: string }
+ | { ok: false; error: string; code: string | null }
export interface LoopbackLoginOptions {
/** Milliseconds to wait for a callback or pasted code before failing. */
@@ -41,6 +47,8 @@ export interface LoopbackLoginOptions {
onInvalidPaste?: () => void
/** Tells the auth page which flow started the login (for example `setup`). */
intent?: string
+ /** The organization the auth page preselects. The user can still choose another. */
+ orgId?: string
/** Defaults to `APP_BASE_URL`. The callback accepts only this origin. */
appBaseUrl?: string
}
@@ -104,6 +112,21 @@ interface AuthUrlParams {
redirectUri: string
state: string
intent?: string
+ orgId?: string
+}
+
+/**
+ * Wrap an auth URL in the sign-up page, which sends a new user on to the auth
+ * page once their account exists.
+ *
+ * @example
+ * buildSignUpUrl('https://app.example', 'https://app.example/cli/auth?state=s')
+ * // → 'https://app.example/sign-up?redirect_url=https%3A%2F%2Fapp.example%2Fcli%2Fauth%3Fstate%3Ds'
+ */
+export function buildSignUpUrl(appBaseUrl: string, authUrl: string): string {
+ const signUpUrl = new URL(`${appBaseUrl}/sign-up`)
+ signUpUrl.searchParams.set('redirect_url', authUrl)
+ return signUpUrl.toString()
}
export function buildAuthUrl(params: AuthUrlParams): string {
@@ -113,6 +136,7 @@ export function buildAuthUrl(params: AuthUrlParams): string {
authUrl.searchParams.set('redirect_uri', params.redirectUri)
authUrl.searchParams.set('state', params.state)
if (params.intent) authUrl.searchParams.set('intent', params.intent)
+ if (params.orgId) authUrl.searchParams.set('org_id', params.orgId)
return authUrl.toString()
}
@@ -175,6 +199,7 @@ export async function startLoopbackLogin(opts: LoopbackLoginOptions): Promise
@@ -274,6 +300,24 @@ export async function startLoopbackLogin(opts: LoopbackLoginOptions): Promise 0
+ ? payload.error_description
+ : payload.error
+ applyCors(res, origin)
+ res.writeHead(204)
+ res.end()
+ finish({ ok: false, error: description, code: payload.error })
+ return
+ }
+
if (receivedKeyType !== KEY_TYPE) {
reject(
{ error: 'unsupported_key_type', expected: KEY_TYPE },
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
index 7dcd7c1..7afdde8 100644
--- a/src/lib/errors.ts
+++ b/src/lib/errors.ts
@@ -17,10 +17,14 @@ export const ERROR_CODES = [
'login_failed',
'network_error',
'api_error',
+ 'setup_unavailable',
'github_not_connected',
'cancelled',
'config_invalid',
'internal_error',
+ 'admin_required',
+ 'repos_not_installed',
+ 'doc_collection_required',
] as const
export type ErrorCode = (typeof ERROR_CODES)[number]
diff --git a/src/lib/setup-api.test.ts b/src/lib/setup-api.test.ts
new file mode 100644
index 0000000..84cfad9
--- /dev/null
+++ b/src/lib/setup-api.test.ts
@@ -0,0 +1,378 @@
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import type { Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { after, before, beforeEach, describe, it } from 'node:test'
+
+import { ApiError } from './api'
+import {
+ GITHUB_CONNECTED_RETURN_PATH,
+ beginGitHubInstall,
+ completeOnboarding,
+ configValidationIssues,
+ getBackfillStatus,
+ getSetupStatus,
+ installedRepos,
+ postApply,
+ postDocsCandidates,
+ previewBackfill,
+ pullGitHubInstallations,
+ reposNotInstalled,
+ startBackfill,
+} from './setup-api'
+import type { ApplyRequest, SetupClient, SetupStatus } from './setup-api'
+
+// Contract tests: each call against a local fake runtime that records the
+// request and answers with the shapes the runtime routes document.
+
+interface Recorded {
+ method: string
+ path: string
+ authorization: string | undefined
+ body: unknown
+}
+
+interface Reply {
+ status: number
+ body: unknown
+}
+
+const ORG = 'org_abc'
+
+const STATUS: SetupStatus = {
+ onboarding_completed: false,
+ billing: { status: 'trialing', trial_end: '2026-10-15T00:00:00+00:00' },
+ github: {
+ connected: true,
+ installations: [
+ {
+ owner: 'acme',
+ repository_selection: 'selected',
+ repos: [
+ { full_name: 'acme/docs', default_branch: 'main' },
+ { full_name: 'acme/api', default_branch: 'main' },
+ ],
+ },
+ { owner: 'acme-labs', repository_selection: 'all', repos: [{ full_name: 'Acme/Docs', default_branch: 'main' }] },
+ ],
+ pending_approval: [{ github_org_login: 'other', requested_at: '2026-10-01T12:00:00+00:00' }],
+ },
+ doc_collections: [
+ { repo: 'acme/docs', platform: 'github', docs_framework: 'mintlify', config_file_path: 'docs.json', filter: [] },
+ ],
+ triggers: [{ key: 'source-merges', trigger_type: 'github_pr', repos: ['acme/api'] }],
+}
+
+const APPLY_BODY: ApplyRequest = {
+ doc_collection: { repo: 'acme/docs', filter: ['docs'], docs_framework: 'mintlify', config_file_path: 'docs/docs.json' },
+ trigger: { repos: ['acme/api'], trigger_on: ['merge'], trigger_directories: [] },
+ dry_run: true,
+}
+
+describe('setup API contract', () => {
+ let server: Server
+ let client: SetupClient
+ let recorded: Recorded[] = []
+ let replies: Record = {}
+
+ before(async () => {
+ server = createServer((req, res) => {
+ let raw = ''
+ req.on('data', (chunk: Buffer) => (raw += chunk.toString('utf8')))
+ req.on('end', () => {
+ const key = `${req.method} ${req.url}`
+ recorded.push({
+ method: req.method ?? '',
+ path: req.url ?? '',
+ authorization: req.headers.authorization,
+ body: raw.length > 0 ? JSON.parse(raw) : undefined,
+ })
+ const reply = replies[key] ?? { status: 404, body: { error: 'not_found' } }
+ res.writeHead(reply.status, { 'Content-Type': 'application/json' })
+ res.end(reply.body === undefined ? '' : JSON.stringify(reply.body))
+ })
+ })
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ client = { apiSecret: 'sk-pl-test', baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }
+ })
+
+ after(() => {
+ server.closeAllConnections()
+ server.close()
+ })
+
+ beforeEach(() => {
+ recorded = []
+ replies = {}
+ })
+
+ function only(): Recorded {
+ assert.equal(recorded.length, 1)
+ return recorded[0]
+ }
+
+ it('reads setup status with the bearer key', async () => {
+ replies[`GET /organizations/${ORG}/setup/status`] = { status: 200, body: STATUS }
+ assert.deepEqual(await getSetupStatus(client, ORG), STATUS)
+ assert.equal(only().authorization, 'Bearer sk-pl-test')
+ })
+
+ it('encodes the organization id in the path', async () => {
+ replies['GET /organizations/org%2Fodd/setup/status'] = { status: 200, body: STATUS }
+ await getSetupStatus(client, 'org/odd')
+ assert.equal(only().path, '/organizations/org%2Fodd/setup/status')
+ })
+
+ it('explains when the setup routes have not been deployed', async () => {
+ replies[`GET /organizations/${ORG}/setup/status`] = { status: 404, body: { detail: 'Not Found' } }
+ await assert.rejects(getSetupStatus(client, ORG), (err) => {
+ assert.ok(err instanceof ApiError)
+ assert.equal(err.code, 'setup_unavailable')
+ assert.match(err.message, /backend must be deployed/)
+ return true
+ })
+ })
+
+ it('preserves a specific organization-not-found response', async () => {
+ replies[`GET /organizations/${ORG}/setup/status`] = { status: 404, body: { error: 'organization_not_found' } }
+ await assert.rejects(getSetupStatus(client, ORG), (err) => {
+ assert.ok(err instanceof ApiError)
+ assert.equal(err.code, 'organization_not_found')
+ return true
+ })
+ })
+
+ it('sends cwd_repo to docs-candidates, including null', async () => {
+ const body = {
+ cwd_repo_installed: true,
+ candidates: [
+ {
+ full_name: 'acme/docs',
+ default_branch: 'main',
+ framework: 'mintlify',
+ config_path: 'docs/docs.json',
+ doc_count: 214,
+ proposed_filter: ['docs'],
+ reason: 'name_match',
+ },
+ ],
+ analysis_failures: [{ full_name: 'acme/empty', status_code: 409 }],
+ }
+ replies[`POST /organizations/${ORG}/setup/docs-candidates`] = { status: 200, body }
+
+ assert.deepEqual(await postDocsCandidates(client, ORG, 'acme/api'), body)
+ assert.deepEqual(only().body, { cwd_repo: 'acme/api' })
+
+ recorded = []
+ await postDocsCandidates(client, ORG, null)
+ assert.deepEqual(only().body, { cwd_repo: null })
+ })
+
+ it('surfaces 409 github_not_connected from docs-candidates', async () => {
+ replies[`POST /organizations/${ORG}/setup/docs-candidates`] = {
+ status: 409,
+ body: { error: 'github_not_connected' },
+ }
+ await assert.rejects(postDocsCandidates(client, ORG, null), (err) => {
+ assert.ok(err instanceof ApiError)
+ assert.equal(err.status, 409)
+ assert.equal(err.code, 'github_not_connected')
+ return true
+ })
+ })
+
+ it('posts the apply body and returns the result', async () => {
+ const body = {
+ dry_run: true,
+ changed: true,
+ yaml: 'doc_collections:\n acme/docs: {}\n',
+ kb_commit_sha: null,
+ doc_collection: { key: 'acme/docs', added: true },
+ trigger: { key: 'source-merges', added: true },
+ }
+ replies[`POST /organizations/${ORG}/setup/apply`] = { status: 200, body }
+ assert.deepEqual(await postApply(client, ORG, APPLY_BODY), body)
+ assert.deepEqual(only().body, APPLY_BODY)
+ })
+
+ it('reads 422 repos_not_installed from apply', async () => {
+ replies[`POST /organizations/${ORG}/setup/apply`] = {
+ status: 422,
+ body: { error: 'repos_not_installed', missing_repos: ['acme/api', 'acme/web'] },
+ }
+ const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught)
+ assert.deepEqual(reposNotInstalled(err), ['acme/api', 'acme/web'])
+ assert.equal(configValidationIssues(err), null)
+ })
+
+ it('reads 422 promptless.yaml validation errors from apply', async () => {
+ replies[`POST /organizations/${ORG}/setup/apply`] = {
+ status: 422,
+ body: {
+ error: 'Validation failed',
+ validation_errors: [
+ { message: 'unknown framework', path: 'doc_collections.acme/docs.docs_framework', line: 3 },
+ { message: 'whole config is invalid', path: null, line: null },
+ ],
+ },
+ }
+ const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught)
+ assert.equal(reposNotInstalled(err), null)
+ assert.deepEqual(configValidationIssues(err), [
+ { message: 'unknown framework', path: 'doc_collections.acme/docs.docs_framework', line: 3 },
+ { message: 'whole config is invalid', path: null, line: null },
+ ])
+ })
+
+ it('reads 422 request-schema errors from apply', async () => {
+ replies[`POST /organizations/${ORG}/setup/apply`] = {
+ status: 422,
+ body: { detail: [{ loc: ['body', 'trigger', 'repos'], msg: 'List should have at least 1 item', type: 'too_short' }] },
+ }
+ const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught)
+ assert.deepEqual(configValidationIssues(err), [
+ { message: 'List should have at least 1 item', path: 'body.trigger.repos', line: null },
+ ])
+ })
+
+ it('surfaces 409 write_in_progress from apply as an ApiError', async () => {
+ replies[`POST /organizations/${ORG}/setup/apply`] = { status: 409, body: { error: 'write_in_progress' } }
+ const err = await postApply(client, ORG, APPLY_BODY).catch((caught: unknown) => caught)
+ assert.ok(err instanceof ApiError)
+ assert.equal(err.code, 'write_in_progress')
+ assert.equal(reposNotInstalled(err), null)
+ assert.equal(configValidationIssues(err), null)
+ })
+
+ it('begins a GitHub install with the CLI return page', async () => {
+ replies['POST /integrations/github/begin'] = {
+ status: 200,
+ body: { redirect_url: 'https://github.com/apps/promptless/installations/new?state=s' },
+ }
+ assert.equal(
+ await beginGitHubInstall(client, ORG),
+ 'https://github.com/apps/promptless/installations/new?state=s',
+ )
+ assert.deepEqual(only().body, { org_id: ORG, return_to: GITHUB_CONNECTED_RETURN_PATH })
+ })
+
+ it('refreshes GitHub installations', async () => {
+ replies['POST /integrations/github/pull'] = { status: 200, body: { installations: [] } }
+ await pullGitHubInstallations(client, ORG)
+ assert.deepEqual(only().body, { org_id: ORG })
+ })
+
+ it('completes onboarding with an empty body', async () => {
+ replies[`POST /organizations/${ORG}/onboarding/complete`] = { status: 200, body: { status: 'ok' } }
+ assert.deepEqual(await completeOnboarding(client, ORG), { status: 'ok' })
+ assert.equal(only().body, undefined)
+ })
+
+ it('surfaces 409 doc_collection_required from onboarding completion', async () => {
+ replies[`POST /organizations/${ORG}/onboarding/complete`] = {
+ status: 409,
+ body: { error: 'doc_collection_required', message: 'Connect at least one docs repository.' },
+ }
+ await assert.rejects(completeOnboarding(client, ORG), (err) => {
+ assert.ok(err instanceof ApiError)
+ assert.equal(err.code, 'doc_collection_required')
+ return true
+ })
+ })
+
+ it('previews, starts, and reads a backfill with the trigger key encoded', async () => {
+ const base = `/organizations/${ORG}/promptless_config/triggers/source-merges%202`
+ const preview = {
+ count: 2,
+ approximate: false,
+ unsupported_reason: null,
+ prs: [
+ {
+ repo: 'acme/api',
+ number: 7,
+ title: 'Add a flag',
+ url: 'https://github.com/acme/api/pull/7',
+ created_at: '2026-09-28T00:00:00+00:00',
+ merged_at: '2026-09-29T00:00:00+00:00',
+ status: 'merged',
+ },
+ ],
+ }
+ const status = {
+ latest_job: {
+ id: 'job-1',
+ state: 'dispatching',
+ lookback_days: 7,
+ requested_by: 'dev@acme.com',
+ matched_count: 2,
+ dispatched_count: 1,
+ processed_count: 0,
+ failed_dispatch_count: 0,
+ error: null,
+ created_at: '2026-10-01T00:00:00+00:00',
+ dispatch_active: true,
+ },
+ suggestion_activity: { total_count: 0, has_more: false, suggestions: [] },
+ }
+ replies[`POST ${base}/backfill-preview`] = { status: 200, body: preview }
+ replies[`POST ${base}/backfill`] = { status: 202, body: { accepted: true, matched: null, job_id: 'job-1' } }
+ replies[`GET ${base}/backfill-status`] = { status: 200, body: status }
+
+ const request = { lookback_days: 7, skip_processed: true } as const
+ assert.deepEqual(await previewBackfill(client, ORG, 'source-merges 2', request), preview)
+ assert.deepEqual(await startBackfill(client, ORG, 'source-merges 2', request), {
+ accepted: true,
+ matched: null,
+ job_id: 'job-1',
+ })
+ assert.deepEqual(await getBackfillStatus(client, ORG, 'source-merges 2'), status)
+ assert.deepEqual(
+ recorded.map((r) => [r.method, r.path, r.body]),
+ [
+ ['POST', `${base}/backfill-preview`, request],
+ ['POST', `${base}/backfill`, request],
+ ['GET', `${base}/backfill-status`, undefined],
+ ],
+ )
+ })
+
+ it('surfaces 409 backfill_already_active and 402 trial_expired from a backfill start', async () => {
+ const path = `/organizations/${ORG}/promptless_config/triggers/source-merges/backfill`
+ const request = { lookback_days: 7, skip_processed: true } as const
+
+ replies[`POST ${path}`] = { status: 409, body: { error: 'backfill_already_active' } }
+ await assert.rejects(startBackfill(client, ORG, 'source-merges', request), (err) => {
+ assert.ok(err instanceof ApiError)
+ assert.equal(err.code, 'backfill_already_active')
+ return true
+ })
+
+ replies[`POST ${path}`] = { status: 402, body: { error: 'trial_expired', message: 'Trial ended 15 Oct.' } }
+ await assert.rejects(startBackfill(client, ORG, 'source-merges', request), (err) => {
+ assert.ok(err instanceof ApiError)
+ assert.equal(err.status, 402)
+ assert.equal(err.code, 'trial_expired')
+ return true
+ })
+ })
+
+ it('reads an empty backfill status', async () => {
+ replies[`GET /organizations/${ORG}/promptless_config/triggers/source-merges/backfill-status`] = {
+ status: 200,
+ body: { latest_job: null, suggestion_activity: null },
+ }
+ assert.deepEqual(await getBackfillStatus(client, ORG, 'source-merges'), {
+ latest_job: null,
+ suggestion_activity: null,
+ })
+ })
+})
+
+describe('installedRepos', () => {
+ it('flattens installations and drops case-insensitive duplicates', () => {
+ assert.deepEqual(
+ installedRepos(STATUS).map((repo) => repo.full_name),
+ ['acme/docs', 'acme/api'],
+ )
+ })
+})
diff --git a/src/lib/setup-api.ts b/src/lib/setup-api.ts
new file mode 100644
index 0000000..90e9bc9
--- /dev/null
+++ b/src/lib/setup-api.ts
@@ -0,0 +1,334 @@
+// Typed calls for `promptless setup`. Every request and response shape the
+// setup command depends on lives here, so a runtime contract change touches
+// one file. The contracts are documented in the runtime repository:
+// runtime/onboarding/setup/README.md setup/status, setup/docs-candidates, setup/apply
+// runtime/cli/README.md GitHub begin/pull, onboarding/complete, backfill routes
+
+import { ApiError, request } from './api'
+
+/** The base URL and key every setup call sends. */
+export interface SetupClient {
+ apiSecret: string
+ /** Defaults to `API_BASE_URL`. */
+ baseUrl?: string
+}
+
+export type PrLifecycleEvent = 'opened' | 'first_approval' | 'merge' | 'updated'
+
+export const PR_LIFECYCLE_EVENTS: readonly PrLifecycleEvent[] = ['opened', 'first_approval', 'merge', 'updated']
+
+export interface BillingStatus {
+ status: 'pending' | 'trialing' | 'active' | 'exempt'
+ /** ISO 8601 timestamp, or null when no trial was started. */
+ trial_end: string | null
+}
+
+export interface InstalledRepo {
+ full_name: string
+ default_branch: string | null
+}
+
+export interface GitHubInstallation {
+ owner: string | null
+ repository_selection: 'all' | 'selected' | null
+ repos: InstalledRepo[]
+}
+
+export interface PendingApproval {
+ github_org_login: string
+ requested_at: string
+}
+
+export interface SetupDocCollection {
+ repo: string
+ platform: string
+ docs_framework: string | null
+ config_file_path: string | null
+ filter: string[]
+}
+
+export interface SetupTrigger {
+ key: string
+ trigger_type: string
+ /** `"all"`, the repositories its clauses name, or null for a trigger type that names none. */
+ repos: 'all' | string[] | null
+}
+
+/** `GET /organizations/{org}/setup/status`. */
+export interface SetupStatus {
+ onboarding_completed: boolean
+ /** Null when the organization has no commerce row. */
+ billing: BillingStatus | null
+ github: {
+ connected: boolean
+ installations: GitHubInstallation[]
+ pending_approval: PendingApproval[]
+ }
+ doc_collections: SetupDocCollection[]
+ triggers: SetupTrigger[]
+}
+
+export type CandidateReason = 'cwd_repo' | 'only_installed_repo' | 'name_match' | 'markdown_count'
+
+export interface DocsCandidate {
+ full_name: string
+ default_branch: string | null
+ framework: string | null
+ config_path: string | null
+ doc_count: number
+ /** The directory that holds the framework config; empty when it is the root or none was found. */
+ proposed_filter: string[]
+ reason: CandidateReason
+}
+
+/** `POST /organizations/{org}/setup/docs-candidates`. Candidates are in rank order. */
+export interface DocsCandidatesResponse {
+ cwd_repo_installed: boolean
+ candidates: DocsCandidate[]
+ analysis_failures: Array<{ full_name: string; status_code: number }>
+}
+
+export interface ApplyRequest {
+ doc_collection: {
+ repo: string
+ filter: string[]
+ docs_framework: string | null
+ config_file_path: string | null
+ }
+ trigger: {
+ repos: string[]
+ trigger_on: PrLifecycleEvent[]
+ trigger_directories: string[]
+ }
+ dry_run: boolean
+}
+
+/** `POST /organizations/{org}/setup/apply`. */
+export interface ApplyResponse {
+ dry_run: boolean
+ /** False when the config already holds the collection and an equivalent trigger. */
+ changed: boolean
+ yaml: string
+ /** Null for a dry run and for an apply that changed nothing. */
+ kb_commit_sha: string | null
+ doc_collection: { key: string; added: boolean }
+ trigger: { key: string; added: boolean }
+}
+
+/** One `promptless.yaml` validation failure from a 422. */
+export interface ConfigValidationIssue {
+ message: string
+ path: string | null
+ line: number | null
+}
+
+/** `POST /organizations/{org}/onboarding/complete`. The trial it starts is read back from `setup/status`. */
+export interface OnboardingCompleteResponse {
+ status: 'ok'
+}
+
+export type BackfillLookbackDays = 7 | 14 | 30
+
+export interface BackfillRequest {
+ lookback_days: BackfillLookbackDays
+ skip_processed: boolean
+}
+
+export interface BackfillPreviewPullRequest {
+ repo: string
+ number: number
+ title: string
+ url: string
+ created_at: string
+ merged_at: string | null
+ status: string
+}
+
+/** `POST .../triggers/{key}/backfill-preview`. */
+export interface BackfillPreview {
+ count: number
+ approximate: boolean
+ /** Set, with `count` 0, when the trigger cannot be replayed as pull request history. */
+ unsupported_reason: string | null
+ prs: BackfillPreviewPullRequest[]
+}
+
+/** `POST .../triggers/{key}/backfill` (202). */
+export interface BackfillStarted {
+ accepted: boolean
+ matched: number | null
+ job_id: string
+}
+
+export interface BackfillJob {
+ id: string
+ state: string
+ lookback_days: number
+ requested_by: string | null
+ matched_count: number | null
+ dispatched_count: number
+ processed_count: number
+ failed_dispatch_count: number
+ error: string | null
+ created_at: string
+ dispatch_active: boolean
+}
+
+/** `GET .../triggers/{key}/backfill-status`. */
+export interface BackfillStatus {
+ latest_job: BackfillJob | null
+ suggestion_activity: {
+ total_count: number
+ has_more: boolean
+ suggestions: Array<{
+ id: string
+ title: string
+ pr_status: string | null
+ created_at: string
+ created_by_backfill: boolean
+ }>
+ } | null
+}
+
+/** The return page the GitHub install callback sends the browser to. */
+export const GITHUB_CONNECTED_RETURN_PATH = '/cli/setup/github-connected'
+
+function orgPath(orgId: string): string {
+ return `/organizations/${encodeURIComponent(orgId)}`
+}
+
+function triggerPath(orgId: string, triggerKey: string): string {
+ return `${orgPath(orgId)}/promptless_config/triggers/${encodeURIComponent(triggerKey)}`
+}
+
+export async function getSetupStatus(client: SetupClient, orgId: string): Promise {
+ try {
+ return await request('GET', `${orgPath(orgId)}/setup/status`, client)
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 404 && recordOf(err.body)?.detail === 'Not Found') {
+ throw new ApiError(
+ err.status,
+ 'setup_unavailable',
+ 'This Promptless server does not provide CLI setup yet. ' +
+ 'The setup backend must be deployed before this command can continue. ' +
+ 'You can use the dashboard to finish onboarding.',
+ err.body,
+ )
+ }
+ throw err
+ }
+}
+
+export function postDocsCandidates(
+ client: SetupClient,
+ orgId: string,
+ cwdRepo: string | null,
+): Promise {
+ return request('POST', `${orgPath(orgId)}/setup/docs-candidates`, {
+ ...client,
+ body: { cwd_repo: cwdRepo },
+ })
+}
+
+export function postApply(client: SetupClient, orgId: string, body: ApplyRequest): Promise {
+ return request('POST', `${orgPath(orgId)}/setup/apply`, { ...client, body })
+}
+
+/** Start a GitHub App install and return the URL to open. */
+export async function beginGitHubInstall(client: SetupClient, orgId: string): Promise {
+ const response = await request<{ redirect_url: string }>('POST', '/integrations/github/begin', {
+ ...client,
+ body: { org_id: orgId, return_to: GITHUB_CONNECTED_RETURN_PATH },
+ })
+ return response.redirect_url
+}
+
+/** Ask the runtime to re-read the organization's GitHub installations from GitHub. */
+export async function pullGitHubInstallations(client: SetupClient, orgId: string): Promise {
+ await request('POST', '/integrations/github/pull', { ...client, body: { org_id: orgId } })
+}
+
+export function completeOnboarding(client: SetupClient, orgId: string): Promise {
+ return request('POST', `${orgPath(orgId)}/onboarding/complete`, client)
+}
+
+export function previewBackfill(
+ client: SetupClient,
+ orgId: string,
+ triggerKey: string,
+ body: BackfillRequest,
+): Promise {
+ return request('POST', `${triggerPath(orgId, triggerKey)}/backfill-preview`, { ...client, body })
+}
+
+export function startBackfill(
+ client: SetupClient,
+ orgId: string,
+ triggerKey: string,
+ body: BackfillRequest,
+): Promise {
+ return request('POST', `${triggerPath(orgId, triggerKey)}/backfill`, { ...client, body })
+}
+
+export function getBackfillStatus(client: SetupClient, orgId: string, triggerKey: string): Promise {
+ return request('GET', `${triggerPath(orgId, triggerKey)}/backfill-status`, client)
+}
+
+function recordOf(value: unknown): Record | null {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+/** The repositories a 422 `repos_not_installed` from `apply` names, or null for any other error. */
+export function reposNotInstalled(err: unknown): string[] | null {
+ if (!(err instanceof ApiError) || err.status !== 422 || err.code !== 'repos_not_installed') return null
+ const missing = recordOf(err.body)?.missing_repos
+ return Array.isArray(missing) ? missing.filter((repo): repo is string => typeof repo === 'string') : []
+}
+
+/**
+ * The validation failures a 422 from `apply` carries, or null for any other error.
+ * Reads both the `promptless.yaml` shape (`validation_errors[{message, path, line}]`)
+ * and the FastAPI request-schema shape (`detail[{loc, msg}]`).
+ */
+export function configValidationIssues(err: unknown): ConfigValidationIssue[] | null {
+ if (!(err instanceof ApiError) || err.status !== 422) return null
+ const body = recordOf(err.body)
+ if (body === null) return null
+
+ if (Array.isArray(body.validation_errors)) {
+ return body.validation_errors.flatMap((item) => {
+ const issue = recordOf(item)
+ if (issue === null || typeof issue.message !== 'string') return []
+ return [
+ {
+ message: issue.message,
+ path: typeof issue.path === 'string' && issue.path.length > 0 ? issue.path : null,
+ line: typeof issue.line === 'number' ? issue.line : null,
+ },
+ ]
+ })
+ }
+ if (Array.isArray(body.detail)) {
+ return body.detail.flatMap((item) => {
+ const issue = recordOf(item)
+ if (issue === null || typeof issue.msg !== 'string') return []
+ const loc = Array.isArray(issue.loc) ? issue.loc.map(String) : []
+ return [{ message: issue.msg, path: loc.length > 0 ? loc.join('.') : null, line: null }]
+ })
+ }
+ return null
+}
+
+/** Every repository across the organization's installations, without duplicates, in inventory order. */
+export function installedRepos(status: SetupStatus): InstalledRepo[] {
+ const seen = new Map()
+ for (const installation of status.github.installations) {
+ for (const repo of installation.repos) {
+ const key = repo.full_name.toLowerCase()
+ if (!seen.has(key)) seen.set(key, repo)
+ }
+ }
+ return [...seen.values()]
+}