Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<a href="example.com">Slack</a>
<p>

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

Expand Down Expand Up @@ -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

```
Expand All @@ -51,6 +60,49 @@ Run `promptless --help` for the command list and `promptless <command> --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 <id>` | 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 <owner/repo>` | Use this documentation repository instead of asking. |
| `--source-repo <owner/repo>` | Watch this repository instead of asking. Repeat the flag for several repositories. |
| `--trigger-on <event>` | 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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion src/commands/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
;;
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion src/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down
75 changes: 75 additions & 0 deletions src/commands/setup/args.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
105 changes: 105 additions & 0 deletions src/commands/setup/args.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading