@bakit-org/ai-sdlc-cli 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -7,10 +7,13 @@ Command-line installer for the ai-sdlc Claude Code harness. It puts the BA / Dev
7
7
  ```sh
8
8
  npm install -g @bakit-org/ai-sdlc-cli # installs the `ai-sdlc` command
9
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
10
+ mkdir my-project && cd my-project # or any existing project folder
11
+ ai-sdlc init # guided install into this folder: checks access, review, apply
12
+ ai-sdlc doctor # confirm the install is intact
12
13
  ```
13
14
 
15
+ Later, to bring a project up to date: `cd` into it and run `ai-sdlc update` (guided, with a review before anything is written). Bare `ai-sdlc` opens a menu for the current folder.
16
+
14
17
  Scripted, or without network access:
15
18
 
16
19
  ```sh
@@ -48,11 +51,11 @@ Failures are plain messages: an invalid or expired token, a repository you canno
48
51
 
49
52
  ## Choosing the project
50
53
 
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`.
54
+ Without `--project`, `init` and `update` in a terminal act on the current folder. Plain mode (`--no-tui`) does too for `update`, `doctor` and `uninstall` when ai-sdlc is installed there; otherwise it 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
55
 
53
56
  ## Interactive mode
54
57
 
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.
58
+ In a terminal, `ai-sdlc init` is a guided install into the current folder and `ai-sdlc update` is the same guided flow for an installed project: a gradient banner, an environment check (including read access to the kit), a review of what will change, a progress bar and a result panel with next steps. Run it from the home directory or a filesystem root, or pass `--root <dir>`, and it shows a filterable project list instead (git repositories next to you plus recent projects, with `installed` / `not installed` / `no .git` badges). Running `ai-sdlc` with no command opens a menu, and `doctor` and `uninstall` print coloured result views. Esc or Ctrl-C at any step writes nothing and restores the terminal.
56
59
 
57
60
  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
61
 
package/docs/tui.md CHANGED
@@ -8,7 +8,8 @@ When you run the CLI in a terminal it can guide you with interactive screens tha
8
8
  |---|---|---|
9
9
  | `ai-sdlc init` | guided install (below) | same as `install` |
10
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 |
11
+ | `ai-sdlc update` | the same guided flow as `init` (current folder, review, progress, result; "Up to date" when there is nothing to do) | plain text |
12
+ | `ai-sdlc doctor`, `uninstall` | coloured result views (checklist, panels); prompts as before | plain text |
12
13
  | `ai-sdlc install` | unchanged (numbered picker, `[y/N]`) | unchanged |
13
14
 
14
15
  "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:
@@ -22,7 +23,7 @@ When you run the CLI in a terminal it can guide you with interactive screens tha
22
23
  ## The guided install (`ai-sdlc init`)
23
24
 
24
25
  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
+ 2. **Project.** The current folder, unless you pass `--project`, or `--root` (picker below), or the current folder cannot be a project (home directory, filesystem root; the picker then opens with the reason). With the current folder a problem such as "already installed" (init) or "not installed" (update) ends the run with a message. The picker is 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
27
  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
28
  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
29
  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`.
package/lib/commands.js CHANGED
@@ -12,6 +12,7 @@ const { buildCommandPlan } = require('./command-plan');
12
12
  const { applyPlan } = require('./apply-plan');
13
13
  const { createPrompter, confirm, pickProject } = require('./project-picker');
14
14
  const { runDoctor } = require('./doctor');
15
+ const { readManifest } = require('./manifest');
15
16
  const { planLines, planResult, doctorLines } = require('./cli-output');
16
17
  const { shouldUseTui, shouldRunWizard } = require('./tui/launch');
17
18
 
@@ -54,6 +55,16 @@ function requireNonInteractiveInputs(command, flags, io) {
54
55
  return false;
55
56
  }
56
57
 
58
+ // Without --project (or --root), update/doctor/uninstall act on the current folder when ai-sdlc is installed there.
59
+ function installedHere(command, flags, io) {
60
+ if (command === 'install' || flags.root) return undefined;
61
+ try {
62
+ return readManifest(io.cwd) ? io.cwd : undefined;
63
+ } catch (_) {
64
+ return io.cwd; // a manifest file is there but unreadable: let the command report why
65
+ }
66
+ }
67
+
57
68
  async function runProjectCommand(command, flags, io, say) {
58
69
  const interactive = requireNonInteractiveInputs(command, flags, io);
59
70
  const log = (line = '') => (flags.json ? undefined : say.stdout.write(`${line}\n`));
@@ -69,7 +80,7 @@ async function runProjectCommand(command, flags, io, say) {
69
80
  }
70
81
  const bundleWarnings = bundle ? [...bundle.warnings] : [];
71
82
  const target = await pickProject({
72
- project: flags.project, root: flags.root, cwd: io.cwd, home: io.home, prompter,
83
+ project: flags.project || installedHere(command, flags, io), root: flags.root, cwd: io.cwd, home: io.home, prompter,
73
84
  output: flags.json ? { write() {} } : say.stdout,
74
85
  });
75
86
  const project = target.dir;
@@ -127,13 +138,13 @@ function emitJson(flags, say, result, code) {
127
138
 
128
139
  // Messages can embed repo-controlled paths: on a terminal show control characters as text (line breaks kept).
129
140
  const forTerminal = (text, stream) => (stream && stream.isTTY ? String(text).split('\n').map((l) => require('./tui/sanitize').sanitize(l)).join('\n') : text);
130
- const runWizard = (flags, io) => require('./tui/flows/init-flow').runInitFlow({ flags, io, cliVersion: pkg.version });
141
+ const runWizard = (flags, io, command = 'install') => require('./tui/flows/init-flow').runInitFlow({ flags, io, cliVersion: pkg.version, command });
131
142
 
132
143
  // Bare `ai-sdlc` in a terminal: pick an action, then run it like the typed command.
133
144
  async function runMenu(flags, io, say) {
134
145
  const { command, installedHere } = await require('./tui/flows/menu-flow').chooseAction({ io });
135
146
  if (command === 'quit') return 0;
136
- if (command === 'install' && shouldRunWizard(flags, io)) return runWizard(flags, io);
147
+ if ((command === 'install' || command === 'update') && shouldRunWizard(flags, io)) return runWizard(flags, io, command);
137
148
  const target = installedHere && !flags.project && command !== 'install' ? { ...flags, project: io.cwd } : flags;
138
149
  return runProjectCommand(command, target, io, say);
139
150
  }
@@ -159,7 +170,7 @@ async function run(argv, env = {}) {
159
170
  say.stderr.write(HELP);
160
171
  return 2;
161
172
  default:
162
- if (flags.init && shouldRunWizard(flags, io)) return await runWizard(flags, io);
173
+ if ((flags.init || parsed.command === 'update') && shouldRunWizard(flags, io)) return await runWizard(flags, io, parsed.command === 'update' ? 'update' : 'install');
163
174
  return await runProjectCommand(parsed.command, flags, io, say);
164
175
  }
165
176
  } catch (err) {
@@ -1,8 +1,11 @@
1
1
  'use strict';
2
- // The guided install: banner, environment check, project, review, progress, result.
3
- // Nothing touches the project until the review is confirmed; the apply layer is
4
- // only called after that.
5
- const { CliError } = require('../../errors');
2
+ // The guided install and update: banner, environment check, project, review,
3
+ // progress, result. The project is the current folder unless --project or --root
4
+ // says otherwise. Nothing touches the project until the review is confirmed; the
5
+ // apply layer is only called after that.
6
+ const { CliError, UsageError } = require('../../errors');
7
+ const { readManifest } = require('../../manifest');
8
+ const { compare } = require('../../semver');
6
9
  const { loadReleaseBundle } = require('../../release-source');
7
10
  const { githubChecks } = require('../../github-checks');
8
11
  const { validateTarget } = require('../../project-picker');
@@ -23,11 +26,15 @@ const { bannerLines } = require('./intro');
23
26
  const { sessionOptions } = require('./session-options');
24
27
  const { pickProjectStep } = require('./pick-project-step');
25
28
  const { reviewBody } = require('./review-view');
26
- const { installedPanel } = require('./result-views');
29
+ const { installedPanel, updatedPanel, upToDatePanel } = require('./result-views');
27
30
  const { readRecent, rememberProject } = require('./recent-projects');
28
31
  const { shortPath } = require('./project-choices');
29
32
 
30
33
  const CHANGED_NOTE = 'The project changed after the plan was made; this is the updated plan.';
34
+ const COPY = {
35
+ install: { confirm: 'Install', progress: 'Installing', pick: 'Select the project to install into' },
36
+ update: { confirm: 'Update', progress: 'Updating', pick: 'Select the project to update' },
37
+ };
31
38
 
32
39
  function preflightLines(theme, items) {
33
40
  return items.map((i) => {
@@ -36,7 +43,32 @@ function preflightLines(theme, items) {
36
43
  });
37
44
  }
38
45
 
39
- async function wizard({ session, flags, io, cliVersion }) {
46
+ // The installed manifest of a folder, or null. A manifest that cannot be read counts as installed so planning reports why.
47
+ function installedManifest(dir) {
48
+ try {
49
+ return readManifest(dir);
50
+ } catch (_) {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ // The folder the user is standing in, when it can be the project. For update it must already hold an
56
+ // install (a subfolder of an installed repository gets the picker instead of a second, nested install).
57
+ function currentFolder(io, command) {
58
+ let target;
59
+ try {
60
+ target = validateTarget(io.cwd, { cwd: io.cwd, home: io.home });
61
+ } catch (err) {
62
+ if (!(err instanceof UsageError)) throw err;
63
+ return { target: null, notice: err.message };
64
+ }
65
+ if (command === 'update' && !installedManifest(target.dir)) {
66
+ return { target: null, notice: `ai-sdlc is not installed in ${target.dir}. Pick the project to update, or run "ai-sdlc init" here.` };
67
+ }
68
+ return { target, notice: '' };
69
+ }
70
+
71
+ async function wizard({ session, flags, io, cliVersion, command }) {
40
72
  const { theme } = session;
41
73
  const preflight = await withSpinner(session, 'Checking your environment...', (signal) => runPreflight({
42
74
  flags, cwd: io.cwd, execFile: io.execFile, env: io.env, signal, extraChecks: io.extraChecks || (flags.fromBundle ? [] : githubChecks(io.github)),
@@ -52,22 +84,37 @@ async function wizard({ session, flags, io, cliVersion }) {
52
84
  for (const w of bundle.warnings) session.commit([` ${theme.paint('warn', `${theme.sym.warn} ${sanitize(w)}`)}`]);
53
85
 
54
86
  const recents = readRecent({ env: io.env, home: io.home });
55
- let notice = '';
87
+ // --project and the current folder are fixed targets: a problem with them ends the run instead of re-asking.
88
+ const here = flags.project || flags.root ? { target: null, notice: '' } : currentFolder(io, command);
89
+ const fixed = flags.project ? validateTarget(flags.project, { cwd: io.cwd, home: io.home }) : here.target;
90
+ let notice = here.notice;
56
91
  for (;;) {
57
- const target = flags.project ? validateTarget(flags.project, { cwd: io.cwd, home: io.home }) : await pickProjectStep({ session, cwd: io.cwd, home: io.home, root: flags.root, recents, notice });
92
+ const target = fixed || await pickProjectStep({ session, cwd: io.cwd, home: io.home, root: flags.root, recents, notice, title: COPY[command].pick });
58
93
  notice = '';
59
94
  if (target === BACK) throw new Cancelled('escape');
60
- let plan;
95
+ if (command === 'update' && fixed && !installedManifest(target.dir)) {
96
+ throw new CliError(`ai-sdlc is not installed in ${target.dir}; run "ai-sdlc init" to install it first`);
97
+ }
98
+ let planned;
61
99
  try {
62
- ({ plan } = buildCommandPlan({ command: 'install', flags, project: target.dir, bundle, cliVersion }));
100
+ planned = buildCommandPlan({ command, flags, project: target.dir, bundle, cliVersion });
63
101
  } catch (err) {
64
102
  // For example "already installed": say so and let the user pick another project.
65
- if (flags.project || !(err instanceof CliError)) throw err;
103
+ if (fixed || !(err instanceof CliError)) throw err;
66
104
  notice = err.message;
67
105
  continue;
68
106
  }
69
- if ((await reviewAndInstall({ session, flags, io, cliVersion, bundle, target, plan })) === BACK) {
70
- if (flags.project) throw new Cancelled('escape');
107
+ const { plan, prior } = planned;
108
+ // The manifest is always rewritten; the same release with no other change is "nothing to do".
109
+ const sameRelease = command === 'update' && prior && prior.payload_version === plan.payloadVersion;
110
+ if (sameRelease && !plan.actions.some((a) => a.kind !== 'manifest' && (a.write || a.remove))) {
111
+ const stale = inspect(target.dir, JOURNAL_REL).exists ? [`${JOURNAL_REL} exists: an earlier run was interrupted or another run is active`] : [];
112
+ session.commit(upToDatePanel({ theme, width: session.ctx().width, project: shortPath(target.dir, io.home), version: bundle.version, warnings: stale }));
113
+ return 0;
114
+ }
115
+ const fromVersion = command === 'update' && prior ? prior.payload_version : '';
116
+ if ((await reviewAndApply({ session, flags, io, cliVersion, bundle, target, plan, command, fromVersion })) === BACK) {
117
+ if (fixed) throw new Cancelled('escape');
71
118
  continue;
72
119
  }
73
120
  return 0;
@@ -75,17 +122,18 @@ async function wizard({ session, flags, io, cliVersion }) {
75
122
  }
76
123
 
77
124
  // Review loop: if the project no longer matches the reviewed plan once the lock is held, review again.
78
- async function reviewAndInstall({ session, flags, io, cliVersion, bundle, target, plan: first }) {
125
+ async function reviewAndApply({ session, flags, io, cliVersion, bundle, target, plan: first, command, fromVersion }) {
79
126
  let plan = first;
80
127
  const warnings = inspect(target.dir, JOURNAL_REL).exists ? [`${JOURNAL_REL} exists: an earlier run was interrupted or another run is active`] : [];
128
+ if (fromVersion && compare(first.payloadVersion, fromVersion) === -1) warnings.push(`this release (${first.payloadVersion}) is older than the installed one (${fromVersion})`);
81
129
  for (;;) {
82
130
  const current = plan;
83
131
  const review = createConfirm({
84
- title: 'Review', confirmLabel: 'Install', detailsToggle: true, guardMs: io.confirmGuardMs,
85
- body: (state, ctx) => reviewBody({ theme: ctx.theme, project: truncateMiddle(sanitize(shortPath(target.dir, io.home)), ctx.width - 30), plan: current, bundle, hasGit: target.hasGit, warnings }, state),
132
+ title: 'Review', confirmLabel: COPY[command].confirm, detailsToggle: true, guardMs: io.confirmGuardMs,
133
+ body: (state, ctx) => reviewBody({ theme: ctx.theme, project: truncateMiddle(sanitize(shortPath(target.dir, io.home)), ctx.width - 30), plan: current, bundle, hasGit: target.hasGit, warnings, fromVersion }, state),
86
134
  });
87
135
  if ((await session.run(review)) === BACK) return BACK;
88
- const outcome = install({ session, flags, io, cliVersion, bundle, project: target.dir, reviewed: current });
136
+ const outcome = apply({ session, flags, io, cliVersion, bundle, project: target.dir, reviewed: current, command, fromVersion });
89
137
  if (!outcome.changed) return 0;
90
138
  plan = outcome.plan;
91
139
  if (!warnings.includes(CHANGED_NOTE)) warnings.push(CHANGED_NOTE);
@@ -94,29 +142,30 @@ async function reviewAndInstall({ session, flags, io, cliVersion, bundle, target
94
142
 
95
143
  // Lock, plan again under the lock, apply with a progress bar, release.
96
144
  // Returns { changed: true, plan } without writing when the fresh plan differs from the reviewed one.
97
- function install({ session, flags, io, cliVersion, bundle, project, reviewed }) {
98
- const lock = acquireLock(project, 'install', { force: Boolean(flags.force) });
145
+ function apply({ session, flags, io, cliVersion, bundle, project, reviewed, command, fromVersion }) {
146
+ const lock = acquireLock(project, command, { force: Boolean(flags.force) });
99
147
  try {
100
- const { plan } = buildCommandPlan({ command: 'install', flags, project, bundle, cliVersion });
148
+ const { plan } = buildCommandPlan({ command, flags, project, bundle, cliVersion });
101
149
  if (planSignature(plan) !== planSignature(reviewed)) return { changed: true, plan };
102
150
  const shown = truncateMiddle(sanitize(shortPath(project, io.home)), session.ctx().width - 14);
103
151
  session.commit([` ${session.theme.paint('ok', session.theme.sym.ok)} Project ${session.theme.paint('dim', shown)}`]);
104
152
  const show = ({ done, total, path: rel }) => session.draw((ctx) => [
105
- '', ` Installing ai-sdlc ${sanitize(bundle.version)}`, renderProgress({ theme: ctx.theme, width: ctx.width, done, total, label: sanitize(rel || 'preparing') }),
153
+ '', ` ${COPY[command].progress} ai-sdlc ${sanitize(bundle.version)}`, renderProgress({ theme: ctx.theme, width: ctx.width, done, total, label: sanitize(rel || 'preparing') }),
106
154
  ]);
107
- applyPlan(project, plan, 'install', lock, { onProgress: show });
155
+ applyPlan(project, plan, command, lock, { onProgress: show });
108
156
  rememberProject(project, { env: io.env, home: io.home });
109
- session.commit(installedPanel({ theme: session.theme, width: session.ctx().width, project: shortPath(project, io.home), plan, version: bundle.version }));
157
+ const panel = command === 'update' ? updatedPanel : installedPanel;
158
+ session.commit(panel({ theme: session.theme, width: session.ctx().width, project: shortPath(project, io.home), plan, version: bundle.version, fromVersion }));
110
159
  return { changed: false };
111
160
  } finally {
112
161
  lock.release();
113
162
  }
114
163
  }
115
164
 
116
- async function runInitFlow({ flags, io, cliVersion }) {
165
+ async function runInitFlow({ flags, io, cliVersion, command = 'install' }) {
117
166
  const theme = createTheme({ env: io.env, stream: io.stdout });
118
167
  try {
119
- return await withSession(sessionOptions(io, theme), (session) => wizard({ session, flags, io, cliVersion }));
168
+ return await withSession(sessionOptions(io, theme), (session) => wizard({ session, flags, io, cliVersion, command }));
120
169
  } catch (err) {
121
170
  if (err instanceof Cancelled) throw new CliError(err.reason === 'interrupt' ? 'interrupted; nothing was changed' : 'cancelled; nothing was changed');
122
171
  throw err;
@@ -9,7 +9,7 @@ const { createPathInput } = require('../widgets/path-input');
9
9
  const { buildProjectItems, PATH_CHOICE } = require('./project-choices');
10
10
 
11
11
  // Resolves to { dir, hasGit } or BACK.
12
- async function pickProjectStep({ session, cwd, home, root, recents, notice: initialNotice = '' }) {
12
+ async function pickProjectStep({ session, cwd, home, root, recents, notice: initialNotice = '', title = 'Select the project to install into' }) {
13
13
  const items = buildProjectItems({ cwd, root, home, recents, theme: session.theme });
14
14
  const maxVisible = Math.max(3, Math.min(10, session.rows - 10));
15
15
  const validate = (text) => validateTarget(text, { cwd, home });
@@ -17,7 +17,7 @@ async function pickProjectStep({ session, cwd, home, root, recents, notice: init
17
17
  let mode = 'list';
18
18
  for (;;) {
19
19
  if (mode === 'list') {
20
- const picked = await session.run(createSelectList({ title: 'Select the project to install into', items, maxVisible, notice, placeholder: 'Type to filter projects' }));
20
+ const picked = await session.run(createSelectList({ title, items, maxVisible, notice, placeholder: 'Type to filter projects' }));
21
21
  if (picked === BACK) return BACK;
22
22
  if (picked.item.value === PATH_CHOICE) {
23
23
  mode = 'path';
@@ -86,4 +86,17 @@ function installedPanel({ theme, width, project, plan, version }) {
86
86
  return ['', ...renderPanel({ theme, width, tone: 'ok', title: `${theme.sym.ok} Installed`, lines })];
87
87
  }
88
88
 
89
- module.exports = { doctorView, planView, planRows, doneView, installedPanel, counts };
89
+ // Closing panel of the guided update.
90
+ function updatedPanel({ theme, width, project, plan, version, fromVersion }) {
91
+ const written = plan.actions.filter((a) => a.write).length;
92
+ const lines = [`ai-sdlc ${fromVersion ? `${sanitize(fromVersion)} -> ` : ''}${sanitize(version)} in ${truncateMiddle(sanitize(project), width - 30)}`, `${written} file(s) written (${counts(plan.actions)})`, '', 'Run `ai-sdlc doctor` in the project to check it.'];
93
+ if (plan.actions.some((a) => a.action === 'write-new')) lines.push('', 'Review the *.new files next to files you modified and merge by hand.');
94
+ return ['', ...renderPanel({ theme, width, tone: 'ok', title: `${theme.sym.ok} Updated`, lines })];
95
+ }
96
+
97
+ function upToDatePanel({ theme, width, project, version, warnings = [] }) {
98
+ const lines = [`ai-sdlc ${sanitize(version)} is already installed in ${truncateMiddle(sanitize(project), width - 40)}`, ...warnings.map((w) => `${theme.sym.warn} ${sanitize(w)}`)];
99
+ return ['', ...renderPanel({ theme, width, tone: warnings.length ? 'warn' : 'ok', title: `${theme.sym.ok} Up to date`, lines })];
100
+ }
101
+
102
+ module.exports = { updatedPanel, upToDatePanel, doctorView, planView, planRows, doneView, installedPanel, counts };
@@ -20,8 +20,8 @@ function tallyByClass(plan, bundle) {
20
20
 
21
21
  const breakdown = (by) => Object.entries(by).map(([k, v]) => `${sanitize(k)} ${v}`).join(', ');
22
22
 
23
- // ctx: { theme, project, plan, bundle, hasGit, warnings }
24
- function reviewBody({ theme, project, plan, bundle, hasGit, warnings = [] }, state) {
23
+ // ctx: { theme, project, plan, bundle, hasGit, warnings, fromVersion }
24
+ function reviewBody({ theme, project, plan, bundle, hasGit, warnings = [], fromVersion = '' }, state) {
25
25
  const dim = (s) => theme.paint('dim', s);
26
26
  const tally = tallyByClass(plan, bundle);
27
27
  const block = plan.actions.find((a) => a.kind === 'block');
@@ -29,7 +29,8 @@ function reviewBody({ theme, project, plan, bundle, hasGit, warnings = [] }, sta
29
29
  const hookCount = bundle ? bundle.hooks.length : 0;
30
30
  const row = (name, text, extra) => ` ${theme.paint('accent', padEnd(name, 10))} ${padEnd(text, 18)} ${dim(extra)}`;
31
31
 
32
- const lines = [`${theme.paint('bold', `ai-sdlc ${sanitize(plan.payloadVersion)}`)} ${theme.sym.arrow} ${sanitize(project)}`, ''];
32
+ const version = fromVersion ? `${sanitize(fromVersion)} ${theme.sym.arrow} ${sanitize(plan.payloadVersion)}` : sanitize(plan.payloadVersion);
33
+ const lines = [`${theme.paint('bold', `ai-sdlc ${version}`)} ${theme.sym.arrow} ${sanitize(project)}`, ''];
33
34
  lines.push(row('managed', plural(tally.managed.total, 'file'), breakdown(tally.managed.by)));
34
35
  lines.push(row('user-once', plural(tally['user-once'].total, 'starter file'), breakdown(tally['user-once'].by)));
35
36
  lines.push(row('block', 'CLAUDE.md block', block ? sanitize(block.action) : 'unchanged'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakit-org/ai-sdlc-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Installs, updates, checks and removes the ai-sdlc Claude Code harness in a project repository.",
5
5
  "license": "MIT",
6
6
  "bin": {