@frontfactory/brigade 1.0.0 → 1.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.
package/README.md CHANGED
@@ -42,15 +42,51 @@ detecting it (only `web` for now).
42
42
  Brigade never touches your `CLAUDE.md`: its instructions live in `.claude/rules/brigade.md`, which Claude Code loads
43
43
  at launch like a `CLAUDE.md`.
44
44
 
45
- When Brigade is already installed, `init` asks whether to keep the installed version or update to the one you run. On
46
- the same version it offers to change your options (the questions are asked again, files only change if an answer
47
- does) or to leave everything as it is. Without a terminal, it updates.
45
+ When Brigade is already installed, `init` asks whether to keep the installed version or update to the one you run.
46
+ Updating does not repeat the setup: your stored answers are reused and only the options that are new to the manifest
47
+ are asked. On the same version it offers to change your options (every question is asked again, files only change if
48
+ an answer does) or to leave everything as it is. Without a terminal, it updates.
48
49
 
49
50
  An existing file is never overwritten. If it differs from the kit, a `.proposed` file is written next to it; if it is
50
51
  identical, nothing happens. A file Brigade wrote and nobody touched is updated. `.gitignore` and `.gitattributes` only
51
52
  get the lines they miss. `.claude/context/decisions.md` is written once and then belongs to the team: it is never
52
53
  updated nor proposed over.
53
54
 
55
+ ## Update
56
+
57
+ Re-run `npx @frontfactory/brigade init`. Kit files you never touched are updated. A file you edited is left as it is
58
+ and a `<file>.proposed` is written next to it: diff it, merge what you want, then delete the `.proposed`.
59
+
60
+ An update does not ask your options again. It reuses the answers stored in `.claude/brigade.json` and asks only the
61
+ options the new version added (your personal reply language is not one of them). A downgrade does the same and keeps
62
+ the answers the older version does not know. To change your options on purpose, run `init` again on the same version
63
+ and choose "Change my options".
64
+
65
+ Brigade records what it did in `.claude/brigade.json`: its version, your answers and the sha256 of each file it wrote.
66
+ That is how it tells an untouched file from an edited one. Do not edit it by hand.
67
+
68
+ ## Troubleshooting
69
+
70
+ - `Unknown stack` or `Unknown option` from `init`: only `--yes` (`-y`) and `--stack <name>` exist, and `web` is the only
71
+ stack for now. Run `npx @frontfactory/brigade --help` to see the usage.
72
+ - `.proposed` files keep coming back: a proposal is rewritten only when the kit changed since the last one. Once you have
73
+ merged what you want, delete the `.proposed`.
74
+ - No terminal (CI, pipe): nothing is asked. `init` behaves like `--yes`: a new install accepts every detected answer, an
75
+ existing install is updated with its stored answers, and a new option gets its detected or default value. The run
76
+ prints one line naming the new options and the value used; it never fails for that.
77
+ - "No git repository" warning: run `git init` yourself. Brigade never creates a repository.
78
+ - A broken `.claude/settings.local.json` (invalid JSON) is left untouched, so your personal language is not written
79
+ there. Fix the file and run `init` again.
80
+
81
+ ## Security model of the git guard
82
+
83
+ The hook (`templates/web/.claude/hooks/git-guard.mjs`, PreToolUse on Bash) is a safety net against mistakes, not a
84
+ security boundary. It parses the text of the command, so it can miss indirect forms: subshells, `bash -c`, aliases,
85
+ scripts. The real protections are the `permissions` in `settings.json` and branch protection on the remote.
86
+
87
+ "Never read or write secrets" is a rule given to Claude, not an enforcement, beyond the `deny` `Read` rules in
88
+ `settings.json`.
89
+
54
90
  ## Git
55
91
 
56
92
  Only `git-operator` writes to git, enforced by a hook. Writing on the default or reference branch, `push`, `merge` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontfactory/brigade",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Sets up a Claude Code architecture in any web project: a main session, a Sonnet implementer, a Haiku git-operator and a git guard.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/cli.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import { CONVENTIONS } from './conventions.js';
2
2
  import { runInit } from './init.js';
3
3
  import { STACKS } from './stacks.js';
4
+ import { VERSION } from './version.js';
4
5
 
5
6
  const HELP = `Usage: brigade init [--yes] [--stack <name>]
7
+ brigade --version
6
8
 
7
9
  --yes accept every detected or default answer, ask nothing
10
+ (an installed project is updated with its stored answers; new options get their default)
8
11
  --stack <name> force the stack (${Object.keys(STACKS).join(', ')}); detected otherwise
12
+ -h, --help show this help
13
+ -v, --version show the version
9
14
  `;
10
15
 
11
16
  const WORDS = {
@@ -20,14 +25,18 @@ function parse(args) {
20
25
  const options = { yes: false, stack: undefined };
21
26
  for (let i = 0; i < args.length; i++) {
22
27
  if (args[i] === '--yes' || args[i] === '-y') options.yes = true;
23
- else if (args[i] === '--stack') options.stack = args[++i];
28
+ else if (args[i] === '--stack') {
29
+ const value = args[++i];
30
+ if (!value || value.startsWith('-')) throw new Error(`--stack needs a value (${Object.keys(STACKS).join(', ')})`);
31
+ options.stack = value;
32
+ }
24
33
  else throw new Error(`Unknown option: ${args[i]}`);
25
34
  }
26
35
  if (options.stack && !STACKS[options.stack]) throw new Error(`Unknown stack: ${options.stack}`);
27
36
  return options;
28
37
  }
29
38
 
30
- function summary({ version, answers, report, ignored, repo, personal, local }) {
39
+ function summary({ version, answers, report, ignored, repo, personal, local, asked = [] }) {
31
40
  const lines = report.map(({ path, status }) => `${WORDS[status].padEnd(28)} ${path}${status === 'proposed' ? '.proposed' : ''}`);
32
41
  for (const { path, status, added } of ignored) {
33
42
  if (status === 'updated') lines.push(`${'lines added'.padEnd(28)} ${path} (${added.join(', ')})`);
@@ -44,7 +53,8 @@ function summary({ version, answers, report, ignored, repo, personal, local }) {
44
53
  `Documentation ${answers.docsLanguage}`,
45
54
  `Replies for you ${personal.language && local?.status === 'written' ? `${personal.language} (your machine only)` : 'project default'}`
46
55
  ];
47
- return { files: lines.join('\n'), settings: settings.join('\n'), kept: report.some(({ status }) => status === 'proposed'), repo };
56
+ const added = asked.flat().map((key) => `${key}: ${answers[key]}`).join(', ');
57
+ return { files: lines.join('\n'), settings: settings.join('\n'), added, kept: report.some(({ status }) => status === 'proposed'), repo };
48
58
  }
49
59
 
50
60
  function print(result, interactive) {
@@ -55,25 +65,41 @@ function print(result, interactive) {
55
65
  return;
56
66
  }
57
67
 
58
- const { files, settings, kept, repo } = summary(result);
68
+ const { files, settings, added, kept, repo } = summary(result);
59
69
 
60
70
  if (!interactive) {
61
- console.log(`Brigade installed (stack: ${result.stack}).\n\n${files}\n\n${settings}`);
71
+ const news = added ? `\n\nNew options, default used: ${added}` : '';
72
+ console.log(`Brigade installed (stack: ${result.stack}).\n\n${files}\n\n${settings}${news}`);
62
73
  return;
63
74
  }
64
75
 
65
76
  prompter.note(files, 'Files');
66
77
  prompter.note(settings, 'Your setup');
78
+ if (added) prompter.info(`New options set up: ${added}`);
67
79
  if (kept) prompter.warn('Some files of yours differ from the kit: they were kept. Merge the .proposed version if you want it.');
68
80
  if (!repo) prompter.warn('Remember to run `git init` before your first commit.');
69
81
  prompter.outro('Done. Run `claude` here, use Plan Mode (Shift+Tab) to discuss, and /brigade-language and /brigade-effort for your own settings.');
70
82
  }
71
83
 
84
+ const isHelp = (arg) => arg === '--help' || arg === '-h';
85
+
72
86
  export async function main(argv) {
73
87
  const [command, ...rest] = argv;
88
+ if (command === undefined || command === 'help' || isHelp(command)) {
89
+ console.log(HELP);
90
+ return 0;
91
+ }
92
+ if (command === '--version' || command === '-v') {
93
+ console.log(VERSION);
94
+ return 0;
95
+ }
74
96
  if (command !== 'init') {
97
+ console.error(`brigade: Unknown command: ${command}\n\n${HELP}`);
98
+ return 1;
99
+ }
100
+ if (rest.some(isHelp)) {
75
101
  console.log(HELP);
76
- return command === undefined || command === '--help' ? 0 : 1;
102
+ return 0;
77
103
  }
78
104
 
79
105
  try {
@@ -1,22 +1,25 @@
1
+ // Shared by every convention text.
2
+ const LIMIT = '72 characters max.';
3
+
1
4
  export const CONVENTIONS = {
2
5
  conventional: {
3
6
  label: 'Conventional Commits',
4
7
  example: 'feat(auth): add login form',
5
- format: 'Conventional Commits: `type(scope): description` (`feat`, `fix`, `chore`, `refactor`, `docs`, `test`), imperative mood, 72 characters max.'
8
+ format: 'Conventional Commits: `type(scope): description` (`feat`, `fix`, `chore`, `refactor`, `docs`, `test`), imperative mood, ' + LIMIT
6
9
  },
7
10
  angular: {
8
11
  label: 'Angular',
9
12
  example: 'fix(core): handle empty list',
10
- format: 'Angular: `type(scope): description` with the types `build`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `test`, imperative mood, 72 characters max.'
13
+ format: 'Angular: `type(scope): description` with the types `build`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `test`, imperative mood, ' + LIMIT
11
14
  },
12
15
  gitmoji: {
13
16
  label: 'Gitmoji',
14
17
  example: '✨ Add login form',
15
- format: 'Gitmoji: `<emoji> Description` (e.g. `✨ Add login form`), imperative mood, 72 characters max.'
18
+ format: 'Gitmoji: `<emoji> Description` (e.g. `✨ Add login form`), imperative mood, ' + LIMIT
16
19
  },
17
20
  imperative: {
18
21
  label: 'Simple imperative',
19
22
  example: 'Add login form',
20
- format: 'A single imperative sentence without prefix (e.g. `Add login form`), 72 characters max.'
23
+ format: 'A single imperative sentence without prefix (e.g. `Add login form`), ' + LIMIT
21
24
  }
22
25
  };
package/src/init.js CHANGED
@@ -1,111 +1,10 @@
1
- import { CONVENTIONS } from './conventions.js';
2
- import {
3
- detectCommitConvention,
4
- detectDefaultBranch,
5
- detectLanguage,
6
- detectPackageManager,
7
- isGitRepo,
8
- localBranches
9
- } from './detect.js';
1
+ import { isGitRepo } from './detect.js';
10
2
  import { appendMissingLines, installFiles, readManifest, writeLocalSettings, writeManifest } from './install.js';
3
+ import { askOptions } from './options.js';
11
4
  import { createPrompter } from './prompt.js';
12
5
  import { DEFAULT_STACK, STACKS, detectStack } from './stacks.js';
13
6
  import { VERSION, compareVersions } from './version.js';
14
7
 
15
- const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
16
- const LANGUAGES = ['English', 'French'];
17
-
18
- // Every answer is detected first, then validated by the dev. A previous answer wins over detection.
19
- async function ask({ cwd, prompter, previous = {} }) {
20
- const { validate, choose, text } = prompter;
21
- const branches = localBranches(cwd);
22
-
23
- const defaultBranch = await validate(
24
- 'Default branch of the repository',
25
- previous.defaultBranch || detectDefaultBranch(cwd),
26
- () => text('Default branch of the repository', 'main')
27
- );
28
-
29
- const baseCandidates = [previous.baseBranch, defaultBranch, branches.includes('develop') && 'develop'].filter(Boolean);
30
- const baseBranch = await choose(
31
- 'Reference branch of the work',
32
- [...new Set(baseCandidates)].map((value) => ({
33
- value,
34
- label: value,
35
- hint: value === defaultBranch ? 'default branch' : 'feature branches start from it'
36
- }))
37
- );
38
-
39
- const manager = detectPackageManager(cwd);
40
- const managers = [...new Set([...manager.candidates, ...PACKAGE_MANAGERS])];
41
- const packageManager = await validate(
42
- 'Package manager',
43
- previous.packageManager || manager.value,
44
- () => choose(
45
- 'Package manager',
46
- managers.map((value) => ({ value, label: value, hint: manager.candidates.includes(value) ? 'found in the project' : undefined })),
47
- { other: false }
48
- )
49
- );
50
-
51
- const conventionKey = await validate(
52
- 'Commit convention',
53
- previous.commitConvention || detectCommitConvention(cwd) || 'conventional',
54
- () => choose(
55
- 'Commit convention',
56
- [
57
- ...Object.entries(CONVENTIONS).map(([value, { label, example }]) => ({ value, label, hint: example })),
58
- { value: 'custom', label: 'Custom', hint: 'you describe it in one sentence' }
59
- ],
60
- { other: false }
61
- ),
62
- CONVENTIONS[previous.commitConvention || detectCommitConvention(cwd) || 'conventional']?.label
63
- );
64
- const commitFormat = conventionKey === 'custom'
65
- ? await text('Describe your commit format in one sentence', previous.commitFormat)
66
- : CONVENTIONS[conventionKey].format;
67
-
68
- const languages = (current) => [...new Set([current, ...LANGUAGES].filter(Boolean))].map((value) => ({ value, label: value }));
69
-
70
- const replyLanguage = await validate(
71
- "Default language of Claude's replies for this project (each developer can use their own later)",
72
- previous.replyLanguage || detectLanguage(),
73
- () => choose("Default language of Claude's replies for this project", languages())
74
- );
75
- const mine = await choose(
76
- 'And for you? This stays on your machine',
77
- [
78
- { value: 'keep', label: `Keep the project language (${replyLanguage || LANGUAGES[0]})` },
79
- { value: 'own', label: 'Use another language for me' }
80
- ],
81
- { other: false }
82
- );
83
- const ownLanguage = mine === 'own'
84
- ? await choose('Your own reply language', languages().filter(({ value }) => value !== replyLanguage))
85
- : '';
86
-
87
- const codeLanguage = await choose(
88
- 'Language of the code, comments and commit messages (shared by the team)',
89
- languages(previous.codeLanguage)
90
- );
91
- const docsLanguage = await choose(
92
- 'Language of the documentation (shared by the team)',
93
- languages(previous.docsLanguage || codeLanguage)
94
- );
95
-
96
- const answers = {
97
- defaultBranch,
98
- baseBranch,
99
- packageManager,
100
- commitConvention: conventionKey,
101
- commitFormat,
102
- replyLanguage: replyLanguage || LANGUAGES[0],
103
- codeLanguage,
104
- docsLanguage
105
- };
106
- return { answers, personal: { language: ownLanguage } };
107
- }
108
-
109
8
  // Asked whenever Brigade is already installed. The first option is the answer when nothing can be asked.
110
9
  async function chooseVersion({ prompter, installed, version }) {
111
10
  if (installed === version) {
@@ -125,7 +24,7 @@ async function chooseVersion({ prompter, installed, version }) {
125
24
  return prompter.choose(
126
25
  newer ? 'A newer version is available' : 'This Brigade is older than the installed one',
127
26
  [
128
- { value: 'update', label: `${newer ? 'Update' : 'Switch'} to ${version}`, hint: 'new kit files, and the questions are asked again. Files you edited are kept' },
27
+ { value: 'update', label: `${newer ? 'Update' : 'Switch'} to ${version}`, hint: 'new kit files, your answers are kept and only new options are asked. Files you edited are kept' },
129
28
  { value: 'keep', label: `Keep ${installed}`, hint: 'nothing is written' }
130
29
  ],
131
30
  { other: false }
@@ -143,19 +42,23 @@ export async function runInit({ cwd, yes = false, stack, stackDir, version = VER
143
42
  prompter.info(`Stack: ${name}${stack ? '' : ' (the only one available for now)'}.`);
144
43
 
145
44
  const installed = previous?.version;
146
- if (installed && await chooseVersion({ prompter, installed, version }) === 'keep') {
45
+ const choice = installed && await chooseVersion({ prompter, installed, version });
46
+ if (choice === 'keep') {
147
47
  return { kept: installed, stack: previous.stack ?? name, answers: previous.answers, report: [], ignored: [], repo, prompter, personal: {}, local: null };
148
48
  }
149
49
 
150
50
  if (!repo) {
151
51
  prompter.warn('No git repository here. Brigade will not create one: run `git init` when you are ready. The branch names below are kept for then.');
152
52
  }
153
- const { answers, personal } = await ask({ cwd, prompter, previous: previous?.answers });
53
+ // A version change replays the kit with the stored answers and only asks the missing options. Otherwise (first
54
+ // install, same version) every question is asked.
55
+ const update = choice === 'update' && installed !== version;
56
+ const { answers, personal, asked } = await askOptions({ cwd, prompter, previous: previous?.answers ?? {}, full: !update });
154
57
 
155
58
  const { report, hashes } = installFiles({ cwd, stackDir: dir, answers, previous });
156
59
  writeManifest({ cwd, version, stack: name, answers, hashes });
157
60
  const ignored = appendMissingLines(cwd);
158
61
  const local = personal.language ? writeLocalSettings(cwd, { language: personal.language }) : null;
159
62
 
160
- return { version, stack: name, answers, report, ignored, repo, prompter, personal, local };
63
+ return { version, stack: name, answers, report, ignored, repo, prompter, personal, local, asked };
161
64
  }
package/src/install.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, relative } from 'node:path';
4
4
 
5
5
  export const MANIFEST = '.claude/brigade.json';
@@ -13,7 +13,8 @@ const APPENDS = {
13
13
  // Written once, then the team's own: never updated, never proposed over.
14
14
  const WRITE_ONCE = new Set(['.claude/context/decisions.md']);
15
15
 
16
- const sha = (content) => createHash('sha256').update(content).digest('hex');
16
+ // Line endings do not count: a CRLF checkout of a kit file is the same file.
17
+ const sha = (content) => createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
17
18
 
18
19
  function listFiles(dir) {
19
20
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
@@ -36,59 +37,73 @@ export function render(content, answers) {
36
37
  return content.replace(/__([A-Z_]+)__/g, (match, key) => values[key] ?? match);
37
38
  }
38
39
 
40
+ // A missing manifest means a first install. An unreadable one is an error: guessing would overwrite the install.
39
41
  export function readManifest(cwd) {
42
+ const file = join(cwd, MANIFEST);
43
+ if (!existsSync(file)) return null;
40
44
  try {
41
- return JSON.parse(readFileSync(join(cwd, MANIFEST), 'utf8'));
45
+ const manifest = JSON.parse(readFileSync(file, 'utf8'));
46
+ if (manifest && typeof manifest === 'object' && !Array.isArray(manifest)) return manifest;
42
47
  } catch {
43
- return null;
48
+ // Reported below, same as a valid JSON of the wrong shape.
44
49
  }
50
+ throw new Error(`${MANIFEST} is corrupted. Fix it or delete it to start over (Brigade will then treat this as a first install).`);
45
51
  }
46
52
 
47
53
  // Never overwrites a file that differs from what Brigade last wrote: that one gets a `.proposed` next to it.
54
+ // Everything is planned first (reads only), then written, so an error while planning leaves the project untouched.
48
55
  export function installFiles({ cwd, stackDir, answers, previous }) {
49
56
  const report = [];
50
57
  const hashes = {};
58
+ const writes = [];
59
+ const removals = [];
51
60
 
52
61
  for (const source of listFiles(stackDir)) {
53
62
  const path = relative(stackDir, source).split('\\').join('/');
54
63
  const target = join(cwd, path);
55
64
  const content = render(readFileSync(source, 'utf8'), answers);
56
65
  const mode = statSync(source).mode & 0o777;
57
-
58
- const write = (file) => {
59
- mkdirSync(dirname(file), { recursive: true });
60
- writeFileSync(file, content, { mode });
61
- chmodSync(file, mode);
62
- };
66
+ const wanted = sha(content);
67
+ const proposed = `${target}.proposed`;
68
+ const write = (file) => writes.push({ file, content, mode });
63
69
 
64
70
  if (!existsSync(target)) {
65
71
  write(target);
66
- hashes[path] = sha(content);
72
+ hashes[path] = wanted;
67
73
  report.push({ path, status: 'created' });
68
74
  continue;
69
75
  }
70
76
 
71
77
  const current = sha(readFileSync(target, 'utf8'));
72
78
  if (WRITE_ONCE.has(path)) {
73
- report.push({ path, status: current === sha(content) ? 'unchanged' : 'kept' });
79
+ report.push({ path, status: current === wanted ? 'unchanged' : 'kept' });
74
80
  continue;
75
81
  }
76
- if (current === sha(content)) {
82
+ if (current === wanted) {
83
+ // The target caught up with the kit: an old proposal is stale.
84
+ if (existsSync(proposed)) removals.push(proposed);
77
85
  hashes[path] = current;
78
86
  report.push({ path, status: 'unchanged' });
79
87
  } else if (previous?.files?.[path] === current) {
80
88
  write(target);
81
- hashes[path] = sha(content);
89
+ if (existsSync(proposed)) removals.push(proposed);
90
+ hashes[path] = wanted;
82
91
  report.push({ path, status: 'updated' });
83
92
  } else {
84
- const proposed = `${target}.proposed`;
85
- const same = existsSync(proposed) && sha(readFileSync(proposed, 'utf8')) === sha(content);
93
+ const same = existsSync(proposed) && sha(readFileSync(proposed, 'utf8')) === wanted;
86
94
  if (!same) write(proposed);
87
95
  if (previous?.files?.[path]) hashes[path] = previous.files[path];
88
96
  report.push({ path, status: 'proposed' });
89
97
  }
90
98
  }
91
99
 
100
+ for (const { file, content, mode } of writes) {
101
+ mkdirSync(dirname(file), { recursive: true });
102
+ writeFileSync(file, content, { mode });
103
+ chmodSync(file, mode);
104
+ }
105
+ for (const file of removals) rmSync(file, { force: true });
106
+
92
107
  return { report, hashes };
93
108
  }
94
109
 
@@ -98,14 +113,16 @@ export function appendMissingLines(cwd) {
98
113
  return Object.entries(APPENDS).map(([path, lines]) => {
99
114
  const file = join(cwd, path);
100
115
  const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
101
- const known = new Set(existing.split('\n').map(normalize));
116
+ const known = new Set(existing.split(/\r?\n/).map(normalize));
102
117
  const missing = lines.filter((line) => !known.has(normalize(line)));
103
118
 
104
119
  if (missing.length === 0) return { path, status: 'unchanged' };
105
120
 
106
- const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
107
- const heading = existing === '' ? '' : '\n';
108
- writeFileSync(file, `${existing}${separator}${heading}## Claude\n${missing.join('\n')}\n`);
121
+ // Keep the file's own line endings.
122
+ const eol = existing.includes('\r\n') ? '\r\n' : '\n';
123
+ const separator = existing === '' || existing.endsWith('\n') ? '' : eol;
124
+ const heading = existing === '' ? '' : eol;
125
+ writeFileSync(file, `${existing}${separator}${heading}## Claude${eol}${missing.join(eol)}${eol}`);
109
126
  return { path, status: 'updated', added: missing };
110
127
  });
111
128
  }
package/src/options.js ADDED
@@ -0,0 +1,153 @@
1
+ import { CONVENTIONS } from './conventions.js';
2
+ import {
3
+ detectCommitConvention,
4
+ detectDefaultBranch,
5
+ detectLanguage,
6
+ detectPackageManager,
7
+ localBranches
8
+ } from './detect.js';
9
+
10
+ const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
11
+ const LANGUAGES = ['English', 'French'];
12
+
13
+ const languages = (current) => [...new Set([current, ...LANGUAGES].filter(Boolean))].map((value) => ({ value, label: value }));
14
+
15
+ // The options of the kit, in the order they are asked. `keys` are the names stored in the manifest `answers`: a group
16
+ // is missing when any of its keys is. `ask(ctx)` returns the values of its keys. Every answer is detected first, then
17
+ // validated by the dev; a previous answer wins over detection.
18
+ // ctx: { cwd, prompter, previous (stored answers), answers (decided so far), full (asked from scratch), personal }
19
+ export const OPTIONS = [
20
+ {
21
+ keys: ['defaultBranch'],
22
+ ask: async ({ cwd, prompter: { validate, text }, previous }) => ({
23
+ defaultBranch: await validate(
24
+ 'Default branch of the repository',
25
+ previous.defaultBranch || detectDefaultBranch(cwd),
26
+ () => text('Default branch of the repository', 'main')
27
+ )
28
+ })
29
+ },
30
+ {
31
+ keys: ['baseBranch'],
32
+ ask: async ({ cwd, prompter: { choose }, previous, answers }) => {
33
+ const { defaultBranch } = answers;
34
+ const baseCandidates = [previous.baseBranch, defaultBranch, localBranches(cwd).includes('develop') && 'develop'].filter(Boolean);
35
+ return {
36
+ baseBranch: await choose(
37
+ 'Reference branch of the work',
38
+ [...new Set(baseCandidates)].map((value) => ({
39
+ value,
40
+ label: value,
41
+ hint: value === defaultBranch ? 'default branch' : 'feature branches start from it'
42
+ }))
43
+ )
44
+ };
45
+ }
46
+ },
47
+ {
48
+ keys: ['packageManager'],
49
+ ask: async ({ cwd, prompter: { validate, choose }, previous }) => {
50
+ const manager = detectPackageManager(cwd);
51
+ const managers = [...new Set([...manager.candidates, ...PACKAGE_MANAGERS])];
52
+ return {
53
+ packageManager: await validate(
54
+ 'Package manager',
55
+ previous.packageManager || manager.value,
56
+ () => choose(
57
+ 'Package manager',
58
+ managers.map((value) => ({ value, label: value, hint: manager.candidates.includes(value) ? 'found in the project' : undefined })),
59
+ { other: false }
60
+ )
61
+ )
62
+ };
63
+ }
64
+ },
65
+ {
66
+ keys: ['commitConvention', 'commitFormat'],
67
+ ask: async ({ cwd, prompter: { validate, choose, text }, previous }) => {
68
+ const start = previous.commitConvention || detectCommitConvention(cwd) || 'conventional';
69
+ const commitConvention = await validate(
70
+ 'Commit convention',
71
+ start,
72
+ () => choose(
73
+ 'Commit convention',
74
+ [
75
+ ...Object.entries(CONVENTIONS).map(([value, { label, example }]) => ({ value, label, hint: example })),
76
+ { value: 'custom', label: 'Custom', hint: 'you describe it in one sentence' }
77
+ ],
78
+ { other: false }
79
+ ),
80
+ CONVENTIONS[start]?.label
81
+ );
82
+ const commitFormat = commitConvention === 'custom'
83
+ ? await text('Describe your commit format in one sentence', previous.commitFormat)
84
+ : CONVENTIONS[commitConvention].format;
85
+ return { commitConvention, commitFormat };
86
+ }
87
+ },
88
+ {
89
+ keys: ['replyLanguage'],
90
+ ask: async (ctx) => {
91
+ const { prompter: { validate, choose }, previous } = ctx;
92
+ const replyLanguage = await validate(
93
+ "Default language of Claude's replies for this project (each developer can use their own later)",
94
+ previous.replyLanguage || detectLanguage(),
95
+ () => choose("Default language of Claude's replies for this project", languages())
96
+ );
97
+ // The personal choice lives in settings.local.json, not in the manifest: only asked from scratch.
98
+ if (ctx.full) {
99
+ const mine = await choose(
100
+ 'And for you? This stays on your machine',
101
+ [
102
+ { value: 'keep', label: `Keep the project language (${replyLanguage || LANGUAGES[0]})` },
103
+ { value: 'own', label: 'Use another language for me' }
104
+ ],
105
+ { other: false }
106
+ );
107
+ if (mine === 'own') {
108
+ ctx.personal.language = await choose(
109
+ 'Your own reply language',
110
+ languages().filter(({ value }) => value !== replyLanguage)
111
+ );
112
+ }
113
+ }
114
+ return { replyLanguage: replyLanguage || LANGUAGES[0] };
115
+ }
116
+ },
117
+ {
118
+ keys: ['codeLanguage', 'docsLanguage'],
119
+ ask: async ({ prompter: { choose }, previous }) => {
120
+ const codeLanguage = await choose(
121
+ 'Language of the code, comments and commit messages (shared by the team)',
122
+ languages(previous.codeLanguage)
123
+ );
124
+ const docsLanguage = await choose(
125
+ 'Language of the documentation (shared by the team)',
126
+ languages(previous.docsLanguage || codeLanguage)
127
+ );
128
+ return { codeLanguage, docsLanguage };
129
+ }
130
+ }
131
+ ];
132
+
133
+ // `previous`: the stored answers. Returns the options (groups of keys) that have no stored value yet.
134
+ export const missingOptions = (previous = {}) => OPTIONS.filter(({ keys }) => keys.some((key) => previous[key] === undefined));
135
+
136
+ // Asks the options in order. From scratch (`full`) every option is asked; otherwise only the missing ones and the
137
+ // others keep their stored answers. Keys the kit does not know (older version) are kept as they are.
138
+ export async function askOptions({ cwd, prompter, previous = {}, full }) {
139
+ const missing = new Set(missingOptions(previous));
140
+ const answers = {};
141
+ const personal = {};
142
+ const asked = [];
143
+
144
+ for (const option of OPTIONS) {
145
+ if (full || missing.has(option)) {
146
+ Object.assign(answers, await option.ask({ cwd, prompter, previous, answers, full, personal }));
147
+ if (!full) asked.push(option.keys);
148
+ } else {
149
+ for (const key of option.keys) answers[key] = previous[key];
150
+ }
151
+ }
152
+ return { answers: { ...previous, ...answers }, personal, asked };
153
+ }