@bakit-org/ai-sdlc-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +93 -2
  2. package/bin/ai-sdlc.js +13 -0
  3. package/docs/cli.md +115 -0
  4. package/docs/tui.md +87 -0
  5. package/lib/apply-plan.js +61 -0
  6. package/lib/bundle.js +120 -0
  7. package/lib/cli-args.js +89 -0
  8. package/lib/cli-output.js +36 -0
  9. package/lib/command-plan.js +28 -0
  10. package/lib/commands.js +173 -0
  11. package/lib/config.js +42 -0
  12. package/lib/doctor.js +123 -0
  13. package/lib/errors.js +19 -0
  14. package/lib/fs-safe.js +133 -0
  15. package/lib/github-auth.js +115 -0
  16. package/lib/github-checks.js +43 -0
  17. package/lib/github-errors.js +39 -0
  18. package/lib/github-http.js +107 -0
  19. package/lib/github-release.js +156 -0
  20. package/lib/journal.js +117 -0
  21. package/lib/manifest.js +64 -0
  22. package/lib/merge-claude-md.js +63 -0
  23. package/lib/merge-settings.js +125 -0
  24. package/lib/path-rules.js +23 -0
  25. package/lib/plan-files.js +86 -0
  26. package/lib/plan.js +78 -0
  27. package/lib/project-picker.js +117 -0
  28. package/lib/release-source.js +30 -0
  29. package/lib/semver.js +27 -0
  30. package/lib/termination-cleanup.js +22 -0
  31. package/lib/tui/cancelled.js +16 -0
  32. package/lib/tui/colors.js +33 -0
  33. package/lib/tui/flows/init-flow.js +126 -0
  34. package/lib/tui/flows/intro.js +12 -0
  35. package/lib/tui/flows/menu-flow.js +51 -0
  36. package/lib/tui/flows/pick-project-step.js +42 -0
  37. package/lib/tui/flows/preflight.js +80 -0
  38. package/lib/tui/flows/project-choices.js +64 -0
  39. package/lib/tui/flows/recent-projects.js +76 -0
  40. package/lib/tui/flows/result-views.js +89 -0
  41. package/lib/tui/flows/review-view.js +45 -0
  42. package/lib/tui/flows/session-options.js +7 -0
  43. package/lib/tui/flows/terminal-views.js +17 -0
  44. package/lib/tui/keys.js +182 -0
  45. package/lib/tui/launch.js +19 -0
  46. package/lib/tui/logo-fonts.js +29 -0
  47. package/lib/tui/logo.js +98 -0
  48. package/lib/tui/sanitize.js +27 -0
  49. package/lib/tui/terminal-guards.js +58 -0
  50. package/lib/tui/terminal.js +196 -0
  51. package/lib/tui/text-width.js +122 -0
  52. package/lib/tui/theme.js +84 -0
  53. package/lib/tui/widgets/confirm.js +56 -0
  54. package/lib/tui/widgets/input-box.js +88 -0
  55. package/lib/tui/widgets/panel.js +31 -0
  56. package/lib/tui/widgets/path-input.js +110 -0
  57. package/lib/tui/widgets/progress-bar.js +17 -0
  58. package/lib/tui/widgets/select-list.js +89 -0
  59. package/lib/tui/widgets/spinner.js +41 -0
  60. package/lib/version-check.js +38 -0
  61. package/package.json +32 -4
package/README.md CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # @bakit-org/ai-sdlc-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command-line installer for the ai-sdlc Claude Code harness. It puts the BA / Dev / Test agents, skills and hooks into a project repository of your choice, keeps them up to date without overwriting your edits, and removes them cleanly.
4
+
5
+ ## Quick start
6
+
7
+ ```sh
8
+ npm install -g @bakit-org/ai-sdlc-cli # installs the `ai-sdlc` command
9
+ gh auth login # or export GH_TOKEN=... (needs read access to the kit repository)
10
+ cd ~/projects && ai-sdlc init # guided install: pick a project, review, apply
11
+ ai-sdlc doctor --project <path> # confirm the install is intact
12
+ ```
13
+
14
+ Scripted, or without network access:
15
+
16
+ ```sh
17
+ ai-sdlc install --project <path> --yes # latest kit release from GitHub
18
+ ai-sdlc install --project <path> --from-bundle ./ai-sdlc-0.1.0.bundle.json --yes
19
+ ```
20
+
21
+ Add `--dry-run` first to see exactly what a run would do; a real run performs the same list.
22
+
23
+ ## Requirements and access
24
+
25
+ - Prerequisites: Node 18 or newer, and read access to the GitHub repository that publishes the kit (`bakit-org/ai-sdlc-kit`; ask its owner if you do not have it). No runtime dependencies.
26
+ - GitHub credentials come from what you already use, first match wins: the `AI_SDLC_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN` environment variable, then `gh auth login` (the GitHub CLI), then your git credential helper for `github.com`. The token is only sent to GitHub's API and is never printed or stored. Tokens found automatically (`GH_TOKEN`, `GITHUB_TOKEN`, `gh`, git credential helper) are only used for the public API at `https://api.github.com`; if `AI_SDLC_GITHUB_API` points anywhere else (for example GitHub Enterprise) only an explicit `AI_SDLC_GITHUB_TOKEN` is used. The token needs read access to the kit repository's contents (for example a fine-grained token with `Contents: read` on it, or a classic token with the `repo` scope). For a repository behind SAML SSO, authorize the token for the organization.
27
+ - Downloads use Node's built-in `fetch`, which does not honour `HTTPS_PROXY` / `HTTP_PROXY`. Behind a proxy that GitHub is only reachable through, download the release bundle by other means and use `--from-bundle <file>`.
28
+ - Everything is scoped to the project you pick. Nothing is written to `~/.claude`.
29
+ - Works for both the Claude Code CLI and the VS Code extension (they share `.claude/`, `CLAUDE.md` and `.claude/settings.json`).
30
+
31
+ ## Commands
32
+
33
+ ```
34
+ ai-sdlc init [--project <path>] [--root <dir>] [--from-bundle <file> | --version <tag>] [--force] [--no-tui]
35
+ ai-sdlc install [--project <path>] [--root <dir>] [--from-bundle <file> | --version <tag>] [--dry-run] [--force] [--yes] [--json]
36
+ ai-sdlc update [--project <path>] [--root <dir>] [--from-bundle <file> | --version <tag>] [--dry-run] [--force] [--yes] [--json]
37
+ ai-sdlc doctor [--project <path>] [--from-bundle <file>] [--json]
38
+ ai-sdlc uninstall [--project <path>] [--dry-run] [--yes] [--json]
39
+ ai-sdlc version [--check] [--project <path>] [--json]
40
+ ai-sdlc # in a terminal: menu (Install / Update / Doctor / Uninstall / Quit)
41
+ ```
42
+
43
+ Exit codes: `0` success, `1` the operation failed or you cancelled (Esc / Ctrl-C), `2` wrong usage. If the process is stopped from outside, the terminal is restored first and the exit code is `130` (SIGINT) or `143` (SIGTERM).
44
+
45
+ Without `--from-bundle`, `install` and `update` download the latest release of the payload repository (or the one named by `--version <tag>`, for example `--version 1.2.0`), check its `.sha256` and the per-file hashes, and install it. With `--from-bundle <file>` they use a local release bundle instead (the `<file>.sha256` beside it is verified when present) and never touch the network. `ai-sdlc version --check` shows the latest release and, for the current or `--project` directory, whether the installed payload is behind it.
46
+
47
+ Failures are plain messages: an invalid or expired token, a repository you cannot read (the message names `https://github.com/<owner>/<repo>` and says to ask the repo owner to grant read access), a GitHub rate limit (with the reset time), or no network (use `--from-bundle`).
48
+
49
+ ## Choosing the project
50
+
51
+ Without `--project` the CLI lists the git repositories directly under the current directory (and its parent), or under `--root`, plus an "enter a path" option. It shows the full plan and asks before writing. In scripts and CI pass `--project <path> --yes`; without a terminal the CLI refuses to guess and exits with `2`.
52
+
53
+ ## Interactive mode
54
+
55
+ In a terminal, `ai-sdlc init` is a guided install: a gradient banner, an environment check, a filterable project list (git repositories next to you plus recent projects, with `installed` / `not installed` / `no .git` badges), a review of what will change, a progress bar and a result panel with next steps. Running `ai-sdlc` with no command opens a menu, and `update`, `doctor` and `uninstall` print coloured result views. Esc or Ctrl-C at any step writes nothing and restores the terminal.
56
+
57
+ It switches itself off for `--json`, `--yes`, `--no-tui`, `TERM=dumb` and whenever stdin or stdout is not a terminal; then the plain output and exit codes apply. Colours follow `NO_COLOR` and the terminal's capability, with an ASCII fallback. Details and key bindings: [docs/tui.md](docs/tui.md).
58
+
59
+ ## What gets installed
60
+
61
+ | Path | Behaviour |
62
+ |---|---|
63
+ | `.claude/agents`, `.claude/skills`, `.claude/hooks`, ... | Managed: replaced on `update` only while still identical to what was installed. A file you edited is kept and the new version is written next to it as `<file>.new`. |
64
+ | `CLAUDE.md` | Only the block between `<!-- ai-sdlc:begin -->` and `<!-- ai-sdlc:end -->` is managed. The rest of the file is never touched. |
65
+ | `.claude/settings.json` | Only the hook entries the CLI added are managed. Invalid JSON aborts the run untouched. |
66
+ | Starter files (project notes, design folder, ...) | Created if absent, never overwritten, never removed. |
67
+ | `.claude/ai-sdlc.manifest.json` | Records what was installed; written last. |
68
+
69
+ `uninstall` removes unmodified managed files, the block and the hooks it added, and gives `CLAUDE.md` and `settings.json` back byte-for-byte as they were before install. Files you modified and all starter files stay.
70
+
71
+ Note: `.claude/ai-sdlc.manifest.json` may hold a copy of your original `.claude/settings.json` text so uninstall can restore it exactly. Do not publish it.
72
+
73
+ See [docs/cli.md](docs/cli.md) for the details (update rules, doctor checks, bundle format, safety guarantees).
74
+
75
+ ## Update, uninstall and offline use
76
+
77
+ - `ai-sdlc update` compares the installed files with the new release and your disk. Unmodified managed files are replaced; a file you edited is kept and the new version is written next to it as `<file>.new`; starter files are never touched. `--dry-run` lists the result first.
78
+ - `ai-sdlc uninstall` removes what the CLI installed and nothing else (see above for what stays).
79
+ - `--from-bundle <file>` installs or updates from a local `ai-sdlc-<version>.bundle.json` with no network access. The `<file>.sha256` beside it is verified when present. Use it behind a proxy, offline, or to try a release before publishing it.
80
+ - `ai-sdlc doctor` is read-only: Node version, a leftover run journal, manifest, managed files (edited = warning, missing = failure), hook registration and scripts, the `CLAUDE.md` block, starter files, installed version, and a note when optional Python 3 is missing. It exits `1` if a check fails. `--json` for machine output.
81
+
82
+ ## Known limits
83
+
84
+ - Node's `fetch` ignores `HTTPS_PROXY` / `HTTP_PROXY` (use `--from-bundle`).
85
+ - Windows consoles have not been verified; the interactive screens fall back to ASCII on terminals without UTF-8.
86
+ - The bundle checksum comes from the same release as the bundle: it detects corruption, not tampering by someone who can edit the release.
87
+ - A symlinked `CLAUDE.md` or `settings.json` is refused.
88
+
89
+ ## Development
90
+
91
+ ```sh
92
+ npm test # node:test, no dependencies
93
+ npm pack --dry-run # shows the published file list
94
+ ```
package/bin/ai-sdlc.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { run } = require('../lib/commands');
4
+
5
+ run(process.argv.slice(2)).then(
6
+ (code) => {
7
+ process.exitCode = code;
8
+ },
9
+ (err) => {
10
+ process.stderr.write(`ai-sdlc: unexpected error: ${err && err.stack ? err.stack : err}\n`);
11
+ process.exitCode = 1;
12
+ },
13
+ );
package/docs/cli.md ADDED
@@ -0,0 +1,115 @@
1
+ # CLI reference
2
+
3
+ ## Global behaviour
4
+
5
+ - Options: `--project <path>`, `--root <dir>`, `--from-bundle <file>`, `--version <tag>` (install/update), `--check` (version), `--dry-run`, `--force`, `--yes` (`-y`), `--json`, `--no-tui`.
6
+ - Exit codes: `0` ok, `1` failure or a cancelled interactive screen, `2` usage error (unknown option, missing value, invalid target, missing `--project` / `--yes` without a terminal, `--version` that is not a release version or is combined with `--from-bundle`). Access and network failures of a download (no credentials, rejected token, no read access, rate limit, offline) exit `1`.
7
+ - `--json` prints one JSON document on stdout and never prompts; it therefore needs `--project`, and `--yes` for runs that write.
8
+ - Target rules: the directory must exist, must not be your home directory or a filesystem root. A directory without `.git` is accepted with a warning.
9
+
10
+ - `init` is `install` with a guided wizard when stdin and stdout are terminals and none of `--yes`, `--json`, `--no-tui`, `--dry-run` or `TERM=dumb` applies; in every other case it behaves exactly like `install`. Without a command, a terminal gets a menu (usage text and exit `2` otherwise). `update`, `doctor` and `uninstall` print coloured result views on a terminal. `--no-tui` always gives the plain text. See [tui.md](tui.md).
11
+ - Cancelling an interactive screen with Esc or Ctrl-C changes nothing and exits `1`. A `SIGINT` or `SIGTERM` that arrives from outside restores the terminal and exits `130` or `143`.
12
+
13
+ ## Choosing the project
14
+
15
+ 1. `--project <path>` wins.
16
+ 2. Otherwise, in a terminal: a numbered list of directories that contain `.git`, found one level under `--root` (default: the current directory, then its parent; the scanned directory itself is listed when it is a repository), plus `p` to type a path and `q` to cancel.
17
+ 3. The plan is printed, then `Apply these changes? [y/N]` unless `--yes` or `--dry-run`.
18
+
19
+ ## Where the bundle comes from
20
+
21
+ - `--from-bundle <file>`: a local bundle, no network.
22
+ - Otherwise `install` and `update` (and the `init` wizard) download it from the payload repository's GitHub release: `bakit-org/ai-sdlc-kit` by default; `AI_SDLC_PAYLOAD_REPO=<owner>/<repo>` points at another repository.
23
+ - `--version <tag>` pins a release (`1.2.0` and `v1.2.0` both work); without it the latest published release is used.
24
+
25
+ Steps: find credentials, check read access to the repository, look up the release, download `ai-sdlc-<version>.bundle.json` and its `.sha256` through the API (`Accept: application/octet-stream`) into a private temporary directory (mode `0600`), verify the checksum and the per-file hashes with the same checks as a local bundle, install, and delete the temporary files. A release without a checksum asset is refused.
26
+
27
+ Credentials, first match wins: environment `AI_SDLC_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`; the GitHub CLI (`gh auth token`, when `gh` is on the PATH); `git credential fill` for `github.com` (run with `core.askPass` and `credential.interactive` off and `GIT_ASKPASS`, `SSH_ASKPASS`, `GIT_TERMINAL_PROMPT` disabled, so no prompt or askpass dialog can appear; times out after 8 s). Only the name of the source is ever shown.
28
+
29
+ **Custom API base.** Tokens found on the machine (`GH_TOKEN`, `GITHUB_TOKEN`, `gh`, the git credential helper) belong to github.com and are used only when the API base is exactly `https://api.github.com`. With `AI_SDLC_GITHUB_API` set to anything else (GitHub Enterprise, a mirror, a local test server) only an explicit `AI_SDLC_GITHUB_TOKEN` is used, and neither `gh` nor `git` is started. The wizard shows the non-default base next to the kit source. The token is sent only to the API origin: when an asset download redirects to another host (GitHub's storage), the `Authorization` header is not forwarded. Only `https` is used (plain `http` only for a `localhost` API override). Requests time out after 20 s and are retried twice with backoff on network errors and 5xx.
30
+
31
+ | Situation | Message / exit |
32
+ |---|---|
33
+ | no credentials | lists the env vars, `gh auth login` and the git credential helper; exit `1` |
34
+ | 403 with an SSO header | the organization uses SAML SSO: authorize the token for it; exit `1` |
35
+ | 401 | token invalid or expired (names the source, not the value); exit `1` |
36
+ | 403 / 404 on the repository | `no read access to <owner>/<repo> — ask the repo owner to grant read access: https://github.com/<owner>/<repo>`, also pointing at `--from-bundle <file>`; exit `1` |
37
+ | rate limit (403/429) | shows the reset time (or the retry delay); exit `1` |
38
+ | offline, DNS, timeout | suggests `--from-bundle <file>`; exit `1` |
39
+ | release without bundle or checksum asset | names what is missing; exit `1` |
40
+
41
+ `ai-sdlc version --check` runs the access check and the release lookup (no download) and prints the latest payload version; with `--project <path>` (or an installed project in the current directory) it also compares the installed version. `--json` gives `{ version, repo, latest, installed, status }`.
42
+
43
+ Integrity: the `.sha256` asset comes from the same release as the bundle, so the check detects corruption or a partial download, not tampering by someone who can edit the release. The CLI also refuses a bundle whose payload version differs from its release tag. Redirect targets that carry a user name or password, or that are not https, are refused.
44
+
45
+ Proxies: Node's built-in `fetch` ignores `HTTPS_PROXY` / `HTTP_PROXY`. Where GitHub is reachable only through a proxy, get the bundle another way and use `--from-bundle <file>`.
46
+
47
+ Cancelling: in the guided install, Ctrl-C or Esc during the environment check or the download stops the in-flight request, removes the temporary files and ends with exit `1` (nothing written). In plain runs, `SIGINT` / `SIGTERM` during a download delete the temporary directory first and exit `130` / `143`.
48
+
49
+ Flag checks happen first, before any network access: `--version` only applies to `install`, `update` and `init`, cannot be combined with `--from-bundle` and must look like `1.2.0` or `v1.2.0`; `--check` only applies to `version`. Violations exit `2`.
50
+
51
+ Environment overrides meant for tests: `AI_SDLC_GITHUB_API` (API base URL).
52
+
53
+ ## install
54
+
55
+ Verifies the bundle, then plans and applies. Refuses when a manifest already exists (use `update`, or `--force` to reinstall). A file already present at a managed path with different content is kept and the bundle's version is written beside it as `<file>.new`; `--force` overwrites instead.
56
+
57
+ ## update
58
+
59
+ Three-way comparison per managed file: hash recorded at install, file on disk, file in the new bundle.
60
+
61
+ | Disk vs recorded | Bundle | Result |
62
+ |---|---|---|
63
+ | unchanged | changed | replaced |
64
+ | unchanged | same | nothing |
65
+ | edited | changed | kept, `<file>.new` written |
66
+ | edited | same | kept |
67
+ | missing | present | restored |
68
+ | unchanged | no longer shipped | deleted |
69
+ | edited | no longer shipped | kept, no longer tracked |
70
+ | not tracked | new in bundle | added (kept + `.new` if a different file is in the way) |
71
+
72
+ A renamed or moved folder (for example a skill folder that gains a prefix) is the last two "no longer shipped" rows plus the "new in bundle" row: the old files that you did not edit are deleted (empty folders are removed too), an old file you edited stays where it is and is no longer tracked, and the new files are added. A project that already has a different file at a new path keeps it and gets `<file>.new` beside it.
73
+
74
+ Starter files (`user-once`) are created only when they are new in the bundle. A starter file the previous install tracked and you have since deleted is not re-created by `update` (it is listed as `skip-deleted`; `doctor` still warns that it is missing). `install --force` does create it again. Existing starter files are never overwritten.
75
+
76
+ The `CLAUDE.md` block and the recorded hooks are re-rendered. A hook that is already registered identically stays where it is, so `settings.json` is not rewritten or reordered for it.
77
+
78
+ ## doctor
79
+
80
+ Read-only. Reports Node version, a leftover run journal (even when the manifest is missing), manifest validity, managed files (modified = warning, missing = failure), registered hooks and hook scripts, the `CLAUDE.md` block, starter files, installed version (compared with `--from-bundle` when given), and a note about optional Python 3 only when installed scripts need it. A check that cannot run (symlink, permissions, bad encoding) is reported as a failed check rather than aborting the report, in `--json` too. Exit `1` if any check fails.
81
+
82
+ ## uninstall
83
+
84
+ Deletes managed files that still match the manifest (never starter files), removes the exact hook entries and the block the CLI added, deletes files the CLI itself created if they are empty again (`CLAUDE.md`, `settings.json`), removes directories it emptied, and finally removes the manifest. Edited managed files, `<file>.new` leftovers and starter files stay.
85
+
86
+ ## Bundle format
87
+
88
+ One JSON file: `{ manifest, contents }`.
89
+
90
+ - `manifest`: `version`, `payload_schema` (must be `1`), `min_cli_version`, `files[]` (`path`, `sha256`, `class`, `encoding`), optional `hooks[]` (`event`, optional `matcher`, `command`).
91
+ - `contents[path]`: file text (`utf8`) or base64 string; `sha256` is over the raw bytes.
92
+ - Classes: `managed` (under `.claude/`), `block` (`CLAUDE.md`, body without markers), `user-once`.
93
+ - Hook commands may contain `$CLAUDE_PROJECT_DIR`; it is written as is, not expanded.
94
+ - Optional `<bundle>.sha256` sidecar (`<hex> <name>`): verified when present; when absent a warning is printed and listed under `warnings` in `--json` output.
95
+
96
+ Rejected before any write: a block body that contains the begin/end markers, checksum mismatch, per-file hash mismatch, `payload_schema` other than `1`, `min_cli_version` above the CLI version, unsafe paths (absolute, `..`, backslash, empty segment), managed files outside `.claude/`, a `CLAUDE.md` that is not a block, entries in `contents` the manifest does not list.
97
+
98
+ ## Safety guarantees
99
+
100
+ - Every write is a temporary file plus rename, and an existing file keeps its permission bits. The manifest is written last.
101
+ - Each real (non `--dry-run`) run takes a per-project lock by creating `.claude/ai-sdlc.journal.json` exclusively, before planning. A second run fails with "another ai-sdlc run is in progress". Just before applying, the journal records the previous content of every path the run will touch. A failure inside the process rolls all of them back.
102
+ - If the process is killed, the journal stays. `doctor` reports it, and the next `install` / `update` / `uninstall` refuses until you pass `--force`, which first rolls the interrupted run back from the journal (CLAUDE.md, settings.json, payload files and manifest return to their earlier state) and then proceeds. A journal owned by a still-running process is never recovered, not even with `--force`.
103
+ - Paths are resolved against the real project path; a symlink that leads outside it is refused. A symlinked `CLAUDE.md` or `settings.json` is refused too (replace it with a regular file, or run against the directory holding the real file). A `.claude` directory that is a symlink inside the project works and is never removed.
104
+ - The same path rules apply to the bundle and to the installed manifest before anything is deleted: no absolute paths, `..`, backslashes, nothing under `.git/`, managed files only under `.claude/`, and `.claude/settings.json`, `.claude/settings.local.json`, the manifest and the journal are reserved.
105
+ - `CLAUDE.md` and `settings.json` must be valid UTF-8; otherwise the run stops before changing anything.
106
+ - `settings.json` is parsed strictly; invalid JSON or an unexpected shape aborts before any file changes. Existing indentation and line endings are kept. The original text is recorded in the manifest so uninstall can restore it exactly (see the note below).
107
+ - `CLAUDE.md` insertion records the exact separator it added, so removal restores the original bytes (including a missing final newline or CRLF endings).
108
+
109
+ ## Code map for the download
110
+
111
+ `lib/release-source.js` exports `resolveBundle(opts, { fetchLatestBundle })` (resolving to `{ bundlePath }` or `{ bundleObject }`, plus an optional `cleanup()`) and `loadReleaseBundle`, which loads, verifies and then cleans up. `lib/github-release.js` is the default `fetchLatestBundle`; `github-auth.js` finds the token, `github-http.js` does timeouts, retries and redirects, `github-errors.js` maps responses to messages, `config.js` holds the repository and API settings.
112
+
113
+ ## Note on the manifest
114
+
115
+ `.claude/ai-sdlc.manifest.json` may contain a copy of the original `.claude/settings.json` text (base64) taken before hooks were merged in. Treat the manifest as local state: do not publish it or commit it to a public repository if your settings contain anything private.
package/docs/tui.md ADDED
@@ -0,0 +1,87 @@
1
+ # Interactive mode
2
+
3
+ When you run the CLI in a terminal it can guide you with interactive screens that redraw in place (no alternate screen): a gradient banner, a status line, a filterable project list, a review step, a progress bar and a result panel. There are no extra dependencies; everything is built on Node's raw terminal mode and ANSI sequences.
4
+
5
+ ## When it starts
6
+
7
+ | You run | In a terminal | Otherwise |
8
+ |---|---|---|
9
+ | `ai-sdlc init` | guided install (below) | same as `install` |
10
+ | `ai-sdlc` (no command) | menu: Install, Update, Doctor, Uninstall, Quit | usage text, exit `2` |
11
+ | `ai-sdlc update`, `doctor`, `uninstall` | coloured result views (checklist, panels); prompts as before | plain text |
12
+ | `ai-sdlc install` | unchanged (numbered picker, `[y/N]`) | unchanged |
13
+
14
+ "In a terminal" means stdin and stdout are both terminals. Interactive mode is never used when any of these is true, and the plain behaviour (output, prompts, exit codes `0`/`1`/`2`) is unchanged:
15
+
16
+ - `--json`, `--yes`/`-y` or `--no-tui` is given
17
+ - `TERM=dumb`
18
+ - `init --dry-run` (it prints the plan and exits, like `install --dry-run`)
19
+
20
+ `--project <path>` together with `init` skips the project list; the review step is still shown.
21
+
22
+ ## The guided install (`ai-sdlc init`)
23
+
24
+ 1. **Banner and checks.** A banner, a dim status line (`Using: node 24 | git ✔ | kit source: bundle file`) and one line per check: Node version, git, and the kit source. With `--from-bundle <file>` the source is that file. Without it the source is the GitHub release of the payload repository and two more checks run (with the spinner): `GitHub token` (shows only where it was found, for example `found via GH_TOKEN`) and `Access to <owner>/<repo>`. If either fails, the failure line explains it (owner contact for missing read access) and offers `--from-bundle <file>`; the run ends with exit `1` before the project list and nothing is written. The release is then downloaded behind a spinner, verified and read. Ctrl-C or Esc during either spinner cancels it immediately (the request and any helper process are stopped, temporary files removed; exit `1`, nothing written). Further checks plug in through the `extraChecks` list of `runPreflight` in `lib/tui/flows/preflight.js`.
25
+ 2. **Project.** A list of git repositories found one level under `--root` (default: the current directory and its parent) plus recently used projects, newest first. Each row has a badge: `not installed`, `installed vX → update`, or `no .git ⚠`. Type to filter; `Enter a path…` opens a path field with Tab / Shift-Tab completion. `~`, files, missing paths and filesystem roots are refused with an inline message.
26
+ 3. **Review.** What will happen by class (managed files, starter files, the `CLAUDE.md` block, hooks) with a note that only the marked block of `CLAUDE.md` is managed. `d` toggles the full file list. Nothing has been written at this point.
27
+ 4. **Install.** The project lock is taken, the plan is computed again under the lock, and the changes are applied with a progress bar showing the current file.
28
+ 5. **Result.** What was written and the next steps: open the folder in Claude Code, fill in `ai-sdlc/project.md`, run `ai-sdlc doctor`.
29
+
30
+ ### Keys
31
+
32
+ | Key | Action |
33
+ |---|---|
34
+ | Enter | choose / confirm / install |
35
+ | Esc | back one step; at the first step, cancel. In a filter or candidate list it first clears that |
36
+ | Ctrl-C | cancel immediately |
37
+ | Up / Down, Home / End, PgUp / PgDn | move in lists (lists wrap) |
38
+ | Tab / Shift-Tab | next / previous path completion |
39
+ | Left / Right, Home / End | move in text fields |
40
+ | Backspace, Delete | edit |
41
+ | Ctrl-A / Ctrl-E | start / end of line |
42
+ | Ctrl-U / Ctrl-W | delete to line start / previous word |
43
+ | `d` (review) | show or hide the full list |
44
+ | `1`-`5` (menu) | pick an entry |
45
+
46
+ Pasting works (bracketed paste); line breaks in pasted text are dropped. Enter sent as CRLF (Windows terminals) counts once. Resizing the window redraws the current screen.
47
+
48
+ ## What the screens guard against
49
+
50
+ - **Typed-ahead keys.** Keys pressed before a screen is on display (for example Enter during the environment check) are dropped. The review ignores Enter and `y` for the first 150 ms after it appears. Only Ctrl-C survives typing ahead.
51
+ - **Closed input.** If stdin ends (pipe closed, terminal gone) between screens, the next screen cancels: exit `1`, nothing written.
52
+ - **Hostile text.** Folder names, versions, paths and messages come from disk, so control characters in them are shown as visible escapes (`^[`, `\n`, `\u009b`) and never sent to the terminal. Text typed or pasted into a field loses escape sequences and control characters. A manifest whose version is not a plain semantic version is treated as untrustworthy (a warning in the menu and the list).
53
+ - **Stale review.** The plan is computed again after the project lock is taken. If it differs from what was reviewed, the review is shown again with a note instead of installing something else.
54
+ - **Limits.** Fields hold at most 4096 characters; bracketed pastes are capped at 1 MB and abandoned after 2 s without an end marker; the project list shows at most 200 projects. A window smaller than 20x8 shows a single line asking for more room.
55
+
56
+ ## Cancelling
57
+
58
+ Esc or Ctrl-C at any step writes nothing: the apply layer is only called after the final confirmation, and the project lock is not even taken before it. The command ends with `ai-sdlc: cancelled; nothing was changed` (or `interrupted; ...` for Ctrl-C) and exit code `1`, the same as declining the prompt in the plain flow. An external `SIGINT`/`SIGTERM` (for example `kill`) restores the terminal and exits `130`/`143`.
59
+
60
+ The terminal is always put back by one cleanup path (cursor shown, bracketed paste off, raw mode off), registered for normal exit, `SIGINT`, `SIGTERM` and uncaught exceptions, and run again from a `finally` when a screen ends or throws.
61
+
62
+ ## Looks
63
+
64
+ - **Colour:** truecolor when `COLORTERM` says so (also iTerm, Windows Terminal); 16 colours otherwise on a colour terminal; none for `NO_COLOR`, `FORCE_COLOR=0`, `TERM=dumb` or when output is not a terminal. `FORCE_COLOR=1` forces colour on.
65
+ - **Banner** (chosen by terminal width): 120+ columns thick 2-pixel letters, 100+ the 1-pixel letters, 50+ a compact half-block version, below 50 columns, without colour, or without unicode a single line `> AI-SDLC`. `node lib/tui/logo.js` previews the banner for the current terminal.
66
+ - **Unicode:** box drawing, `❯ ✔ ⚠ ✖ →` need a UTF-8 locale. With `TERM=linux`, `TERM=dumb` or a `LANG`/`LC_ALL` that is not UTF-8 the screens use ASCII (`+--+`, `|`, `>`, `+ ! x`).
67
+ - Long lines are cut with an ellipsis, never wrapped; long paths keep their start and end.
68
+
69
+ ## Recent projects
70
+
71
+ Project paths (nothing else) are remembered in `recent.json` under `$XDG_CONFIG_HOME/ai-sdlc/`, `%APPDATA%\ai-sdlc\` on Windows, or `~/.config/ai-sdlc/`. The file is replaced atomically, only paths of existing folders are shown, and any problem reading or writing it is ignored. Delete the file to forget everything.
72
+
73
+ ## Layout of the code
74
+
75
+ ```
76
+ lib/tui/
77
+ keys.js raw input -> key events (arrows, Home/End, Enter/CRLF, Esc, Tab, Ctrl-keys, paste)
78
+ terminal.js session: raw mode, live frame redraw, resize, single cleanup path
79
+ theme.js colour level, attribute support, unicode/ASCII symbols, gradient
80
+ text-width.js ANSI-aware width, truncation (end and middle)
81
+ logo.js banner variants; logo-fonts.js has the bitmap fonts
82
+ launch.js decides whether interactive mode may start
83
+ widgets/ input-box select-list path-input confirm spinner progress-bar panel
84
+ flows/ init-flow menu-flow result-views plus their helpers
85
+ ```
86
+
87
+ Frames are pure functions of `(state, theme, width)` returning lines; the session draws them by moving the cursor up over the previous frame and clearing it (no alternate screen). Tests drive widgets and flows with scripted key bytes over in-memory streams (`tests/tui-*.test.js`).
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { resolveInside, writeAtomic, pruneEmptyDirs } = require('./fs-safe');
5
+ const { MANIFEST_REL } = require('./path-rules');
6
+ const { rollbackEntries } = require('./journal');
7
+ const { CliError } = require('./errors');
8
+
9
+ // Disk changes in execution order, the manifest always last.
10
+ function orderedChanges(plan) {
11
+ const changes = plan.actions.filter((a) => a.write || a.remove);
12
+ return [...changes.filter((a) => a.path !== MANIFEST_REL), ...changes.filter((a) => a.path === MANIFEST_REL)];
13
+ }
14
+
15
+ // Previous content of every path about to change (base64, or null when absent).
16
+ function beforeImages(project, changes) {
17
+ return changes.map((a) => {
18
+ const abs = resolveInside(project, a.path);
19
+ return { path: a.path, before: fs.existsSync(abs) ? fs.readFileSync(abs).toString('base64') : null };
20
+ });
21
+ }
22
+
23
+ // Executes the planned writes/removals while holding `lock`. The before-images
24
+ // go into the journal first, so a failure here (or a crash, recovered later with
25
+ // --force) can put every path back exactly as it was.
26
+ // `onProgress({ done, total, path })` (optional) is called before the first
27
+ // change and after each one; it has no influence on what is written.
28
+ function applyPlan(project, plan, op, lock, { onProgress } = {}) {
29
+ const changes = orderedChanges(plan);
30
+ const entries = beforeImages(project, changes);
31
+ const report = (done, rel) => {
32
+ if (!onProgress) return;
33
+ try {
34
+ onProgress({ done, total: changes.length, path: rel });
35
+ } catch (_) {
36
+ /* a display problem must not undo or abort the install */
37
+ }
38
+ };
39
+ try {
40
+ lock.record(entries);
41
+ report(0, null);
42
+ changes.forEach((a, i) => {
43
+ const abs = resolveInside(project, a.path);
44
+ if (a.write) writeAtomic(abs, a.write);
45
+ else {
46
+ fs.rmSync(abs, { force: true });
47
+ pruneEmptyDirs(project, path.dirname(abs));
48
+ }
49
+ report(i + 1, a.path);
50
+ });
51
+ } catch (err) {
52
+ const problems = rollbackEntries(project, entries);
53
+ if (problems.length) {
54
+ lock.keep();
55
+ throw new CliError(`${op} failed: ${err.message} (rollback also failed: ${problems.join('; ')}; re-run with --force to retry the rollback)`);
56
+ }
57
+ throw new CliError(`${op} failed: ${err.message} (rolled back)`);
58
+ }
59
+ }
60
+
61
+ module.exports = { applyPlan, orderedChanges, beforeImages };
package/lib/bundle.js ADDED
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { CliError } = require('./errors');
5
+ const { sha256, assertRelPath } = require('./fs-safe');
6
+ const semver = require('./semver');
7
+ const { installPathProblem } = require('./path-rules');
8
+ const { BEGIN, END } = require('./merge-claude-md');
9
+
10
+ const SUPPORTED_SCHEMA = 1;
11
+ const CLASSES = new Set(['managed', 'block', 'user-once']);
12
+ const ENCODINGS = new Set(['utf8', 'base64']);
13
+
14
+ function fail(msg) {
15
+ throw new CliError(`invalid bundle: ${msg}`);
16
+ }
17
+
18
+ // Compares the bundle file against the "<hex> <name>" sidecar next to it.
19
+ function verifySidecar(file, bytes, warnings) {
20
+ const sidecar = `${file}.sha256`;
21
+ if (!fs.existsSync(sidecar)) {
22
+ warnings.push(`no checksum file beside the bundle (${path.basename(sidecar)}); integrity not verified`);
23
+ return;
24
+ }
25
+ const expected = fs.readFileSync(sidecar, 'utf8').trim().split(/\s+/)[0] || '';
26
+ if (!/^[0-9a-f]{64}$/i.test(expected)) fail(`checksum file ${path.basename(sidecar)} is malformed`);
27
+ if (expected.toLowerCase() !== sha256(bytes)) {
28
+ throw new CliError(`bundle checksum mismatch for ${path.basename(file)}: the file or its .sha256 was altered`);
29
+ }
30
+ }
31
+
32
+ function readSource(source, warnings) {
33
+ if (source.bundleObject) return source.bundleObject;
34
+ let bytes;
35
+ try {
36
+ bytes = fs.readFileSync(source.bundlePath);
37
+ } catch (err) {
38
+ throw new CliError(`cannot read bundle ${source.bundlePath}: ${err.code || err.message}`);
39
+ }
40
+ verifySidecar(source.bundlePath, bytes, warnings);
41
+ try {
42
+ return JSON.parse(bytes.toString('utf8'));
43
+ } catch (err) {
44
+ return fail(`not valid JSON (${err.message})`);
45
+ }
46
+ }
47
+
48
+ function checkHeader(manifest, cliVersion) {
49
+ if (!manifest || typeof manifest !== 'object') fail('missing manifest');
50
+ if (manifest.payload_schema !== SUPPORTED_SCHEMA) {
51
+ fail(`payload_schema ${manifest.payload_schema} is not supported (this CLI reads ${SUPPORTED_SCHEMA})`);
52
+ }
53
+ if (!semver.isStrict(manifest.version)) fail('manifest.version is not a version');
54
+ const cmp = semver.compare(cliVersion, manifest.min_cli_version);
55
+ if (cmp === null) fail('manifest.min_cli_version is not a version');
56
+ if (cmp < 0) {
57
+ throw new CliError(`this payload needs ai-sdlc-cli >= ${manifest.min_cli_version}, but this is ${cliVersion}; upgrade the CLI first`);
58
+ }
59
+ if (!Array.isArray(manifest.files) || manifest.files.length === 0) fail('manifest.files is empty');
60
+ }
61
+
62
+ function decodeFiles(manifest, contents) {
63
+ const seen = new Set();
64
+ const files = manifest.files.map((f) => {
65
+ if (!f || typeof f !== 'object') fail('manifest.files has a non-object entry');
66
+ assertRelPath(f.path, 'bundle path');
67
+ if (seen.has(f.path)) fail(`duplicate path ${f.path}`);
68
+ seen.add(f.path);
69
+ if (!CLASSES.has(f.class)) fail(`${f.path}: unknown class "${f.class}"`);
70
+ if (!ENCODINGS.has(f.encoding)) fail(`${f.path}: unknown encoding "${f.encoding}"`);
71
+ if (typeof f.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(f.sha256)) fail(`${f.path}: bad sha256`);
72
+ if (typeof contents[f.path] !== 'string') fail(`${f.path}: no content in bundle`);
73
+ const why = installPathProblem(f.path, f.class);
74
+ if (why) fail(`${f.path}: ${why}`);
75
+ const bytes = Buffer.from(contents[f.path], f.encoding);
76
+ if (sha256(bytes) !== f.sha256) throw new CliError(`bundle file hash mismatch: ${f.path}`);
77
+ if (f.class === 'block' && f.encoding !== 'utf8') fail('the CLAUDE.md block must be utf8');
78
+ return { path: f.path, class: f.class, sha256: f.sha256, encoding: f.encoding, bytes };
79
+ });
80
+ const extra = Object.keys(contents).filter((p) => !seen.has(p));
81
+ if (extra.length) fail(`contents has files not listed in the manifest: ${extra[0]}`);
82
+ return files;
83
+ }
84
+
85
+ function checkHooks(hooks) {
86
+ if (hooks === undefined) return [];
87
+ if (!Array.isArray(hooks)) fail('manifest.hooks must be an array');
88
+ return hooks.map((h) => {
89
+ if (!h || typeof h.event !== 'string' || !h.event || typeof h.command !== 'string' || !h.command) {
90
+ fail('each hook needs a non-empty event and command');
91
+ }
92
+ if (h.matcher !== undefined && typeof h.matcher !== 'string') fail('hook matcher must be a string');
93
+ return h.matcher === undefined ? { event: h.event, command: h.command } : { event: h.event, matcher: h.matcher, command: h.command };
94
+ });
95
+ }
96
+
97
+ // source: { bundlePath } or { bundleObject }. Returns a verified, decoded bundle.
98
+ function loadBundle(source, { cliVersion }) {
99
+ const warnings = [];
100
+ const raw = readSource(source, warnings);
101
+ if (!raw || typeof raw !== 'object' || !raw.manifest || !raw.contents || typeof raw.contents !== 'object') {
102
+ fail('expected { manifest, contents }');
103
+ }
104
+ checkHeader(raw.manifest, cliVersion);
105
+ const files = decodeFiles(raw.manifest, raw.contents);
106
+ const hooks = checkHooks(raw.manifest.hooks);
107
+ const blockFile = files.find((f) => f.class === 'block');
108
+ const body = blockFile ? blockFile.bytes.toString('utf8') : '';
109
+ if (body.includes(BEGIN) || body.includes(END)) fail('the CLAUDE.md block body must not contain the ai-sdlc begin/end markers');
110
+ return {
111
+ version: raw.manifest.version,
112
+ payloadSchema: raw.manifest.payload_schema,
113
+ files: files.filter((f) => f.class !== 'block'),
114
+ block: blockFile ? { sha256: blockFile.sha256, body } : null,
115
+ hooks,
116
+ warnings,
117
+ };
118
+ }
119
+
120
+ module.exports = { loadBundle, SUPPORTED_SCHEMA };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ const { UsageError } = require('./errors');
3
+ const semver = require('./semver');
4
+
5
+ const COMMANDS = ['init', 'install', 'update', 'doctor', 'uninstall', 'version', 'help'];
6
+ const VALUE_FLAGS = { '--project': 'project', '--root': 'root', '--from-bundle': 'fromBundle' };
7
+ const BOOL_FLAGS = { '--dry-run': 'dryRun', '--check': 'check', '--force': 'force', '--yes': 'yes', '-y': 'yes', '--json': 'json', '--no-tui': 'noTui', '--help': 'help', '-h': 'help' };
8
+
9
+ // Returns { command, flags }. Anything unrecognised is a usage error (exit 2).
10
+ function parseArgs(argv) {
11
+ const flags = {};
12
+ let command = null;
13
+ for (let i = 0; i < argv.length; i += 1) {
14
+ const arg = argv[i];
15
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
16
+ const name = eq === -1 ? arg : arg.slice(0, eq);
17
+ if (VALUE_FLAGS[name]) {
18
+ const value = eq === -1 ? argv[(i += 1)] : arg.slice(eq + 1);
19
+ if (value === undefined || value === '' || (eq === -1 && value.startsWith('--'))) throw new UsageError(`${name} needs a value`);
20
+ flags[VALUE_FLAGS[name]] = value;
21
+ } else if (BOOL_FLAGS[name] && eq === -1) {
22
+ flags[BOOL_FLAGS[name]] = true;
23
+ } else if (name === '--version' && eq !== -1) {
24
+ flags.version = arg.slice(eq + 1);
25
+ if (!flags.version) throw new UsageError('--version needs a value');
26
+ } else if (arg === '--version' && command && command !== 'version') {
27
+ // After a command, --version <tag> pins the release to install.
28
+ const next = argv[i + 1];
29
+ if (next === undefined || next.startsWith('-')) throw new UsageError('--version needs a value');
30
+ flags.version = next;
31
+ i += 1;
32
+ } else if (arg === '--version' || arg === '-v') {
33
+ command = command || 'version';
34
+ } else if (arg.startsWith('-')) {
35
+ throw new UsageError(`unknown option ${arg}`);
36
+ } else if (command === null) {
37
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
38
+ // init is install with a guided wizard when a terminal is available.
39
+ command = arg === 'init' ? 'install' : arg;
40
+ if (arg === 'init') flags.init = true;
41
+ } else {
42
+ throw new UsageError(`unexpected argument "${arg}"`);
43
+ }
44
+ }
45
+ if (flags.help) command = 'help';
46
+ return { command, flags };
47
+ }
48
+
49
+ // Rejects flag combinations that make no sense, before anything touches the network or the disk.
50
+ function validateFlags(command, flags) {
51
+ if (command === 'help') return;
52
+ if (flags.check && command !== 'version') throw new UsageError('--check only applies to the version command');
53
+ if (flags.version === undefined) return;
54
+ if (command !== 'install' && command !== 'update') throw new UsageError('--version only applies to install, update and init');
55
+ if (flags.fromBundle) throw new UsageError('--version cannot be combined with --from-bundle');
56
+ if (!semver.isReleaseTag(flags.version)) throw new UsageError('--version must be a release version such as 1.2.0 or v1.2.0');
57
+ }
58
+
59
+ const HELP = `ai-sdlc - install the ai-sdlc Claude Code harness into a project
60
+
61
+ Usage: ai-sdlc <command> [options]
62
+
63
+ Commands:
64
+ init guided install in a terminal; otherwise the same as install
65
+ install install into a project (refuses if already installed; see --force)
66
+ update bring an installed project up to the given bundle
67
+ doctor read-only health check of an installed project
68
+ uninstall remove what install added, keep anything you changed
69
+ version print the CLI version (--check: compare with the latest release)
70
+
71
+ Options:
72
+ --project <path> target project directory (skips the picker)
73
+ --root <dir> where the picker looks for git repositories
74
+ --from-bundle <file> use a local release bundle (<file>.sha256 is verified when present)
75
+ without it, install/update download the latest release from GitHub
76
+ --version <tag> with install/update: download this release instead of the latest
77
+ --check with version: also show the latest release and the installed version
78
+ --dry-run show exactly what would change, write nothing
79
+ --force reinstall over an existing install / overwrite modified managed files
80
+ --yes, -y do not ask for confirmation
81
+ --json machine-readable output (needs --project; mutating runs also need --yes)
82
+ --no-tui plain text output and prompts, no interactive screens
83
+
84
+ Run without a command in a terminal to open a menu.
85
+
86
+ Exit codes: 0 ok, 1 failure, 2 usage error.
87
+ `;
88
+
89
+ module.exports = { parseArgs, validateFlags, HELP };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ const { publicView, summarize } = require('./plan');
3
+
4
+ // Actions that change nothing are folded into a count in human output.
5
+ const QUIET = new Set(['unchanged', 'keep-existing', 'forget']);
6
+
7
+ function planLines(plan) {
8
+ const lines = [];
9
+ const quiet = plan.actions.filter((a) => QUIET.has(a.action)).length;
10
+ for (const a of plan.actions.filter((x) => !QUIET.has(x.action))) {
11
+ lines.push(` ${a.action.padEnd(13)} ${a.path}${a.detail ? ` (${a.detail})` : ''}`);
12
+ }
13
+ if (quiet) lines.push(` (${quiet} already in place or user-owned, untouched)`);
14
+ const counts = Object.entries(summarize(plan.actions)).map(([k, v]) => `${v} ${k}`);
15
+ lines.push(` summary: ${counts.join(', ')}`);
16
+ return lines;
17
+ }
18
+
19
+ function planResult(command, project, plan, extra) {
20
+ return {
21
+ ok: true,
22
+ command,
23
+ project,
24
+ payloadVersion: plan.payloadVersion,
25
+ actions: plan.actions.map(publicView),
26
+ summary: summarize(plan.actions),
27
+ ...extra,
28
+ };
29
+ }
30
+
31
+ function doctorLines(report) {
32
+ const tag = { ok: '[ok] ', warn: '[warn]', fail: '[FAIL]', info: '[info]' };
33
+ return report.checks.map((c) => `${tag[c.status]} ${c.name}: ${c.detail}`);
34
+ }
35
+
36
+ module.exports = { planLines, planResult, doctorLines, QUIET };