@frontfactory/brigade 1.1.0 → 1.2.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 +48 -25
- package/package.json +2 -2
- package/src/cli.js +45 -27
- package/src/init.js +6 -2
- package/src/install.js +43 -3
- package/src/options.js +180 -35
- package/src/prompt.js +24 -9
- package/src/ui.js +71 -0
- package/templates/web/.claude/rules/brigade.md +6 -7
package/README.md
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@frontfactory/brigade)
|
|
5
5
|
[](https://www.npmjs.com/package/@frontfactory/brigade)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Claude Code, organized like a kitchen brigade.
|
|
8
|
+
|
|
9
|
+
Brigade sets up a Claude Code architecture in any web project, new or existing, without fuss: a main session that
|
|
10
|
+
thinks and orchestrates, an `implementer` that writes the code and a `git-operator` that handles git, each limited to
|
|
11
|
+
its mission, with guardrails built in.
|
|
8
12
|
|
|
9
13
|
```sh
|
|
10
14
|
npx @frontfactory/brigade init
|
|
@@ -14,18 +18,19 @@ npx @frontfactory/brigade init
|
|
|
14
18
|
|
|
15
19
|
Three roles, each limited to its mission:
|
|
16
20
|
|
|
17
|
-
| Role
|
|
18
|
-
|
|
19
|
-
| Main session
|
|
20
|
-
| `implementer`
|
|
21
|
-
| `git-operator` | Haiku (low)
|
|
21
|
+
| Role | Model (default effort) | Mission |
|
|
22
|
+
|----------------|----------------------------|-----------------------------------------------------------------------------------------------|
|
|
23
|
+
| Main session | Opus in Plan Mode (medium) | Discuss, think, orchestrate, verify, propose, answer. Goes back and forth with the subagents. |
|
|
24
|
+
| `implementer` | Sonnet (medium) | Development, nothing else. |
|
|
25
|
+
| `git-operator` | Haiku (low) | Git, nothing else, once the work is done. |
|
|
22
26
|
|
|
23
27
|
Plus base rules for work, security and efficiency, adapted to the stack (web for now). The setup is shared with the
|
|
24
28
|
project and committed with it. Each developer can override their own effort locally with `/brigade-effort`.
|
|
25
29
|
|
|
26
30
|
The rest of the kit, all under `.claude/`:
|
|
27
31
|
|
|
28
|
-
- Skills: `session-start` (where things stand before the first task), `feature` (branch + state file for multi-step
|
|
32
|
+
- Skills: `session-start` (where things stand before the first task), `feature` (branch + state file for multi-step
|
|
33
|
+
work),
|
|
29
34
|
`audit` (keep an audit file between sessions).
|
|
30
35
|
- Commands: `/brigade-effort` (your effort per role) and `/brigade-language` (your reply language), both local and
|
|
31
36
|
not committed.
|
|
@@ -35,41 +40,54 @@ The rest of the kit, all under `.claude/`:
|
|
|
35
40
|
|
|
36
41
|
## Install
|
|
37
42
|
|
|
38
|
-
`init` detects the default branch, the package manager, the commit convention and the language,
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
`init` detects the default branch, the package manager, the commit convention and the language, then shows what will
|
|
44
|
+
be set up in one card. Choose "Install as is" (the default) or "Customize" to answer every option one by one. An option
|
|
45
|
+
that cannot be detected is asked even in the express path. After "Customize", or when something had to be asked, a recap
|
|
46
|
+
is shown before anything is written: "Everything is right" installs, "Change something" lets you pick the groups to
|
|
47
|
+
redo (branches, package manager, commit format, languages) and shows the recap again. There are two language
|
|
48
|
+
questions: the language of Claude's replies, then the language of code and docs together, or "Different for code and
|
|
49
|
+
docs" to choose each apart. Customize also asks your own reply language, which stays on your machine. Cancelling writes
|
|
50
|
+
nothing. `--yes` (or `-y`) accepts everything detected. `--stack <name>` forces the stack instead of detecting it (only
|
|
51
|
+
`web` for now).
|
|
41
52
|
|
|
42
53
|
Brigade never touches your `CLAUDE.md`: its instructions live in `.claude/rules/brigade.md`, which Claude Code loads
|
|
43
54
|
at launch like a `CLAUDE.md`.
|
|
44
55
|
|
|
45
56
|
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
|
|
47
|
-
are asked
|
|
48
|
-
|
|
57
|
+
Updating does not repeat the setup: your answers stored in `.claude/brigade.json` are reused and only the options the
|
|
58
|
+
new version added are asked (your personal reply language is not one of them). A downgrade does the same and keeps the
|
|
59
|
+
answers the older version does not know. On the same version it offers to change your options (every question is asked
|
|
60
|
+
again, files only change if an answer does) or to leave everything as it is. Without a terminal, it updates.
|
|
49
61
|
|
|
50
62
|
An existing file is never overwritten. If it differs from the kit, a `.proposed` file is written next to it; if it is
|
|
51
63
|
identical, nothing happens. A file Brigade wrote and nobody touched is updated. `.gitignore` and `.gitattributes` only
|
|
52
|
-
get the lines they miss. `.claude/context/decisions.md`
|
|
53
|
-
|
|
64
|
+
get the lines they miss. `.claude/context/decisions.md` and `.claude/context/architecture.md` are written once and then
|
|
65
|
+
belong to the
|
|
66
|
+
project: they are never updated nor proposed over.
|
|
54
67
|
|
|
55
68
|
## Update
|
|
56
69
|
|
|
57
70
|
Re-run `npx @frontfactory/brigade init`. Kit files you never touched are updated. A file you edited is left as it is
|
|
58
71
|
and a `<file>.proposed` is written next to it: diff it, merge what you want, then delete the `.proposed`.
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
options
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
Older installs had your options written inside `.claude/rules/brigade.md`. They now live in the generated
|
|
74
|
+
`.claude/rules/brigade-options.md`, and `brigade.md` is pure kit. If you never edited your `brigade.md`, it is updated
|
|
75
|
+
silently. If you did (or the manifest is lost), a `brigade.md.proposed` is written with a note: merge it, or delete
|
|
76
|
+
your edits and re-run.
|
|
77
|
+
|
|
78
|
+
To change your options on purpose, run `init` again on the same version and choose "Change my options" (see Install).
|
|
64
79
|
|
|
65
80
|
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.
|
|
81
|
+
That is how it tells an untouched file from an edited one. Do not edit it by hand. Commit it with the rest of
|
|
82
|
+
`.claude/`:
|
|
83
|
+
the install is shared by the whole team, and anyone can then update it from any machine.
|
|
67
84
|
|
|
68
85
|
## Troubleshooting
|
|
69
86
|
|
|
70
87
|
- `Unknown stack` or `Unknown option` from `init`: only `--yes` (`-y`) and `--stack <name>` exist, and `web` is the only
|
|
71
88
|
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
|
|
89
|
+
- `.proposed` files keep coming back: a proposal is rewritten only when the kit changed since the last one. Once you
|
|
90
|
+
have
|
|
73
91
|
merged what you want, delete the `.proposed`.
|
|
74
92
|
- No terminal (CI, pipe): nothing is asked. `init` behaves like `--yes`: a new install accepts every detected answer, an
|
|
75
93
|
existing install is updated with its stored answers, and a new option gets its detected or default value. The run
|
|
@@ -93,18 +111,23 @@ Only `git-operator` writes to git, enforced by a hook. Writing on the default or
|
|
|
93
111
|
`rebase` ask for confirmation; destructive commands are denied. Brigade proposes a commit once a piece of work is
|
|
94
112
|
finished and never commits without your go.
|
|
95
113
|
|
|
96
|
-
A feature (work over several steps or sessions) gets its own branch and a state file
|
|
114
|
+
A feature (work over several steps or sessions) gets its own branch and a state file
|
|
115
|
+
`.claude/context/features/<branch>.md` (slashes become dashes), committed with the branch, so
|
|
97
116
|
the work survives between developers and machines. `CLAUDE.local.md` stays personal.
|
|
98
117
|
|
|
99
118
|
## Add a stack
|
|
100
119
|
|
|
101
|
-
Create `templates/<name>/` with the same layout as `templates/web/`, then add one entry in `src/stacks.js`.
|
|
120
|
+
Create `templates/<name>/` with the same layout as `templates/web/`, then add one entry in `src/stacks.js`.
|
|
121
|
+
Placeholders:
|
|
102
122
|
`__DEFAULT_BRANCH__`, `__BASE_BRANCH__`, `__PACKAGE_MANAGER__`, `__COMMIT_FORMAT__`, `__REPLY_LANGUAGE__`,
|
|
103
|
-
`__CODE_LANGUAGE__`, `__DOCS_LANGUAGE__`.
|
|
123
|
+
`__CODE_LANGUAGE__`, `__DOCS_LANGUAGE__`. Do not use them in `.claude/rules/brigade.md`: it stays pure kit, and the
|
|
124
|
+
answers reach it through `.claude/rules/brigade-options.md`, which Brigade generates from the answers (see
|
|
125
|
+
`renderOptions` in `src/install.js`).
|
|
104
126
|
|
|
105
127
|
## Develop
|
|
106
128
|
|
|
107
|
-
`npm test` runs the tests (`node --test`, no dependency). Commits follow Conventional Commits, checked by commitlint.
|
|
129
|
+
`npm test` runs the tests (`node --test`, no dependency). Commits follow Conventional Commits, checked by commitlint.
|
|
130
|
+
Node >= 24 is required.
|
|
108
131
|
|
|
109
132
|
Releases are automatic: on a push to `main` or `beta`, once the tests pass, the CI runs semantic-release, which
|
|
110
133
|
derives the version from the commits, updates the changelog and publishes `@frontfactory/brigade` to npm.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontfactory/brigade",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Claude Code, organized like a kitchen brigade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"prepare": "husky",
|
package/src/cli.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
|
|
2
3
|
import { runInit } from './init.js';
|
|
4
|
+
import { recapRows } from './options.js';
|
|
3
5
|
import { STACKS } from './stacks.js';
|
|
4
6
|
import { VERSION } from './version.js';
|
|
5
7
|
|
|
@@ -22,41 +24,51 @@ const WORDS = {
|
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
function parse(args) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
let values;
|
|
28
|
+
try {
|
|
29
|
+
({ values } = parseArgs({ args, options: { yes: { type: 'boolean', short: 'y' }, stack: { type: 'string' } } }));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const arg = /'([^']+)'/.exec(error.message)?.[1];
|
|
32
|
+
if (error.code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE' && arg?.startsWith('--stack')) {
|
|
33
|
+
throw new Error(`--stack needs a value (${Object.keys(STACKS).join(', ')})`);
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION' || error.code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL') {
|
|
36
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
34
39
|
}
|
|
40
|
+
const options = { yes: values.yes ?? false, stack: values.stack };
|
|
41
|
+
if (options.stack === '') throw new Error(`--stack needs a value (${Object.keys(STACKS).join(', ')})`);
|
|
35
42
|
if (options.stack && !STACKS[options.stack]) throw new Error(`Unknown stack: ${options.stack}`);
|
|
36
43
|
return options;
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
// Plain-text rows, the label padded to a common column.
|
|
47
|
+
const plain = (rows) => rows.map(([label, value]) => `${label.padEnd(17)}${value}`).join('\n');
|
|
48
|
+
|
|
39
49
|
function summary({ version, answers, report, ignored, repo, personal, local, asked = [] }) {
|
|
40
|
-
const
|
|
50
|
+
const files = report.map(({ path, status }) => ({
|
|
51
|
+
done: status === 'created' || status === 'updated',
|
|
52
|
+
label: `${path}${status === 'proposed' ? '.proposed' : ''}`,
|
|
53
|
+
detail: WORDS[status],
|
|
54
|
+
plain: `${WORDS[status].padEnd(28)} ${path}${status === 'proposed' ? '.proposed' : ''}`
|
|
55
|
+
}));
|
|
41
56
|
for (const { path, status, added } of ignored) {
|
|
42
|
-
if (status === 'updated')
|
|
57
|
+
if (status === 'updated') files.push({ done: true, label: path, detail: `lines added (${added.join(', ')})`, plain: `${'lines added'.padEnd(28)} ${path} (${added.join(', ')})` });
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`Commit format ${CONVENTIONS[answers.commitConvention]?.label ?? 'Custom'}`,
|
|
51
|
-
`Replies ${answers.replyLanguage} (project default)`,
|
|
52
|
-
`Code ${answers.codeLanguage}`,
|
|
53
|
-
`Documentation ${answers.docsLanguage}`,
|
|
54
|
-
`Replies for you ${personal.language && local?.status === 'written' ? `${personal.language} (your machine only)` : 'project default'}`
|
|
60
|
+
const notes = report.filter(({ note }) => note).map(({ path, note }) => `${path}: ${note}`);
|
|
61
|
+
const rows = [
|
|
62
|
+
['Brigade', version],
|
|
63
|
+
...recapRows(answers),
|
|
64
|
+
['Replies for you', personal.language && local?.status === 'written' ? `${personal.language} (your machine only)` : 'project default']
|
|
55
65
|
];
|
|
56
66
|
const added = asked.flat().map((key) => `${key}: ${answers[key]}`).join(', ');
|
|
57
|
-
return { files
|
|
67
|
+
return { files, notes, rows, added, kept: report.some(({ status }) => status === 'proposed'), repo };
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
const plainFiles = (files, notes) => [...files.map(({ plain }) => plain), ...(notes.length ? ['', ...notes] : [])].join('\n');
|
|
71
|
+
|
|
60
72
|
function print(result, interactive) {
|
|
61
73
|
const { prompter } = result;
|
|
62
74
|
if (result.kept) {
|
|
@@ -65,20 +77,26 @@ function print(result, interactive) {
|
|
|
65
77
|
return;
|
|
66
78
|
}
|
|
67
79
|
|
|
68
|
-
const { files,
|
|
80
|
+
const { files, notes, rows, added, kept, repo } = summary(result);
|
|
69
81
|
|
|
70
82
|
if (!interactive) {
|
|
71
83
|
const news = added ? `\n\nNew options, default used: ${added}` : '';
|
|
72
|
-
console.log(`Brigade installed (stack: ${result.stack}).\n\n${files}\n\n${
|
|
84
|
+
console.log(`Brigade installed (stack: ${result.stack}).\n\n${plainFiles(files, notes)}\n\n${plain(rows)}${news}\n\nCommit .claude/ including .claude/brigade.json so the install is shared with your team and can be updated from any machine.`);
|
|
73
85
|
return;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
prompter.
|
|
77
|
-
prompter.
|
|
88
|
+
prompter.step(3, 3, 'Installation');
|
|
89
|
+
prompter.files(files);
|
|
90
|
+
for (const note of notes) prompter.info(note);
|
|
91
|
+
prompter.card('Your setup', rows);
|
|
78
92
|
if (added) prompter.info(`New options set up: ${added}`);
|
|
79
93
|
if (kept) prompter.warn('Some files of yours differ from the kit: they were kept. Merge the .proposed version if you want it.');
|
|
80
94
|
if (!repo) prompter.warn('Remember to run `git init` before your first commit.');
|
|
81
|
-
prompter.
|
|
95
|
+
prompter.done('Done. Brigade is ready.', [
|
|
96
|
+
'Commit `.claude/` (with `brigade.json`) to share the install.',
|
|
97
|
+
'Run `claude` here, Shift+Tab for Plan Mode.',
|
|
98
|
+
'/brigade-language and /brigade-effort set your own preferences.'
|
|
99
|
+
]);
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
const isHelp = (arg) => arg === '--help' || arg === '-h';
|
package/src/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isGitRepo } from './detect.js';
|
|
2
|
-
import { appendMissingLines, installFiles, readManifest, writeLocalSettings, writeManifest } from './install.js';
|
|
2
|
+
import { appendMissingLines, hasKitFiles, installFiles, MANIFEST, readManifest, writeLocalSettings, writeManifest, writeOptionsFile } from './install.js';
|
|
3
3
|
import { askOptions } from './options.js';
|
|
4
4
|
import { createPrompter } from './prompt.js';
|
|
5
5
|
import { DEFAULT_STACK, STACKS, detectStack } from './stacks.js';
|
|
@@ -38,7 +38,7 @@ export async function runInit({ cwd, yes = false, stack, stackDir, version = VER
|
|
|
38
38
|
|
|
39
39
|
prompter ??= createPrompter({ interactive: Boolean(interactive) });
|
|
40
40
|
const repo = isGitRepo(cwd);
|
|
41
|
-
prompter.
|
|
41
|
+
prompter.banner(version);
|
|
42
42
|
prompter.info(`Stack: ${name}${stack ? '' : ' (the only one available for now)'}.`);
|
|
43
43
|
|
|
44
44
|
const installed = previous?.version;
|
|
@@ -50,12 +50,16 @@ export async function runInit({ cwd, yes = false, stack, stackDir, version = VER
|
|
|
50
50
|
if (!repo) {
|
|
51
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.');
|
|
52
52
|
}
|
|
53
|
+
if (!previous && hasKitFiles({ cwd, stackDir: dir })) {
|
|
54
|
+
prompter.warn(`${MANIFEST} is missing but Brigade files already exist here. Nothing is overwritten: files identical to the kit are adopted and files that differ get a .proposed. If the manifest exists in your team's repo, restore it from git first.`);
|
|
55
|
+
}
|
|
53
56
|
// A version change replays the kit with the stored answers and only asks the missing options. Otherwise (first
|
|
54
57
|
// install, same version) every question is asked.
|
|
55
58
|
const update = choice === 'update' && installed !== version;
|
|
56
59
|
const { answers, personal, asked } = await askOptions({ cwd, prompter, previous: previous?.answers ?? {}, full: !update });
|
|
57
60
|
|
|
58
61
|
const { report, hashes } = installFiles({ cwd, stackDir: dir, answers, previous });
|
|
62
|
+
report.push(writeOptionsFile({ cwd, answers }));
|
|
59
63
|
writeManifest({ cwd, version, stack: name, answers, hashes });
|
|
60
64
|
const ignored = appendMissingLines(cwd);
|
|
61
65
|
const local = personal.language ? writeLocalSettings(cwd, { language: personal.language }) : null;
|
package/src/install.js
CHANGED
|
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, st
|
|
|
3
3
|
import { dirname, join, relative } from 'node:path';
|
|
4
4
|
|
|
5
5
|
export const MANIFEST = '.claude/brigade.json';
|
|
6
|
+
export const OPTIONS_FILE = '.claude/rules/brigade-options.md';
|
|
6
7
|
|
|
7
8
|
// Lines Brigade appends to the project files when missing. Existing lines are never touched or duplicated.
|
|
8
9
|
const APPENDS = {
|
|
@@ -10,11 +11,14 @@ const APPENDS = {
|
|
|
10
11
|
'.gitattributes': ['.claude/hooks/* text eol=lf']
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
const RULES_PATH = '.claude/rules/brigade.md';
|
|
15
|
+
const MIGRATION_NOTE = "The kit's brigade.md no longer holds your options: they moved to .claude/rules/brigade-options.md. Merge brigade.md.proposed into your file, or delete your file's edits and re-run to take the new one.";
|
|
16
|
+
|
|
13
17
|
// Written once, then the team's own: never updated, never proposed over.
|
|
14
|
-
const WRITE_ONCE = new Set(['.claude/context/decisions.md']);
|
|
18
|
+
const WRITE_ONCE = new Set(['.claude/context/decisions.md', '.claude/context/architecture.md']);
|
|
15
19
|
|
|
16
20
|
// 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');
|
|
21
|
+
export const sha = (content) => createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
|
|
18
22
|
|
|
19
23
|
function listFiles(dir) {
|
|
20
24
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
@@ -24,6 +28,11 @@ function listFiles(dir) {
|
|
|
24
28
|
});
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
// True when the project already holds at least one file of the kit.
|
|
32
|
+
export function hasKitFiles({ cwd, stackDir }) {
|
|
33
|
+
return listFiles(stackDir).some((source) => existsSync(join(cwd, relative(stackDir, source))));
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
export function render(content, answers) {
|
|
28
37
|
const values = {
|
|
29
38
|
DEFAULT_BRANCH: answers.defaultBranch,
|
|
@@ -93,7 +102,9 @@ export function installFiles({ cwd, stackDir, answers, previous }) {
|
|
|
93
102
|
const same = existsSync(proposed) && sha(readFileSync(proposed, 'utf8')) === wanted;
|
|
94
103
|
if (!same) write(proposed);
|
|
95
104
|
if (previous?.files?.[path]) hashes[path] = previous.files[path];
|
|
96
|
-
|
|
105
|
+
const entry = { path, status: 'proposed' };
|
|
106
|
+
if (path === RULES_PATH && !readFileSync(target, 'utf8').includes('brigade-options.md')) entry.note = MIGRATION_NOTE;
|
|
107
|
+
report.push(entry);
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
110
|
|
|
@@ -107,6 +118,35 @@ export function installFiles({ cwd, stackDir, answers, previous }) {
|
|
|
107
118
|
return { report, hashes };
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
// The project's answers in plain English. Not a kit file: generated from the answers at every install, always overwritten.
|
|
122
|
+
export function renderOptions(answers) {
|
|
123
|
+
return `# Brigade options
|
|
124
|
+
|
|
125
|
+
<!-- Generated by Brigade from .claude/brigade.json. Do not edit: changes are overwritten at the next install or update. -->
|
|
126
|
+
|
|
127
|
+
Options of this project, referred to by \`.claude/rules/brigade.md\`.
|
|
128
|
+
|
|
129
|
+
- Default branch: \`${answers.defaultBranch}\`.
|
|
130
|
+
- Reference branch of the work: \`${answers.baseBranch}\`.
|
|
131
|
+
- Package manager: \`${answers.packageManager}\`.
|
|
132
|
+
- Commit format: ${answers.commitFormat}
|
|
133
|
+
- Code, comments and commit messages language: ${answers.codeLanguage}.
|
|
134
|
+
- Documentation language: ${answers.docsLanguage}.
|
|
135
|
+
- Reply language, project default: ${answers.replyLanguage}.
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function writeOptionsFile({ cwd, answers }) {
|
|
140
|
+
const file = join(cwd, OPTIONS_FILE);
|
|
141
|
+
const content = renderOptions(answers);
|
|
142
|
+
const exists = existsSync(file);
|
|
143
|
+
if (exists && sha(readFileSync(file, 'utf8')) === sha(content)) return { path: OPTIONS_FILE, status: 'unchanged' };
|
|
144
|
+
|
|
145
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
146
|
+
writeFileSync(file, content);
|
|
147
|
+
return { path: OPTIONS_FILE, status: exists ? 'updated' : 'created' };
|
|
148
|
+
}
|
|
149
|
+
|
|
110
150
|
const normalize = (line) => line.trim().replace(/^\//, '');
|
|
111
151
|
|
|
112
152
|
export function appendMissingLines(cwd) {
|
package/src/options.js
CHANGED
|
@@ -9,29 +9,41 @@ import {
|
|
|
9
9
|
|
|
10
10
|
const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
11
11
|
const LANGUAGES = ['English', 'French'];
|
|
12
|
+
const ENGLISH = LANGUAGES[0];
|
|
13
|
+
const DIFFERENT = '__different__';
|
|
14
|
+
const LANGUAGE_KEYS = ['replyLanguage', 'codeLanguage', 'docsLanguage'];
|
|
12
15
|
|
|
13
16
|
const languages = (current) => [...new Set([current, ...LANGUAGES].filter(Boolean))].map((value) => ({ value, label: value }));
|
|
14
17
|
|
|
15
18
|
// The options of the kit, in the order they are asked. `keys` are the names stored in the manifest `answers`: a group
|
|
16
19
|
// 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
|
-
//
|
|
20
|
+
// validated by the dev; a previous answer wins over detection. `detect(ctx)` returns the same values without asking
|
|
21
|
+
// (express install), or null when they cannot be told.
|
|
22
|
+
// ctx: { cwd, prompter, previous (stored answers), current (answers being edited), answers (decided so far), full (asked from scratch), personal }
|
|
19
23
|
export const OPTIONS = [
|
|
20
24
|
{
|
|
21
25
|
keys: ['defaultBranch'],
|
|
22
|
-
|
|
26
|
+
detect: ({ cwd, previous }) => {
|
|
27
|
+
const defaultBranch = previous.defaultBranch || detectDefaultBranch(cwd);
|
|
28
|
+
return defaultBranch ? { defaultBranch } : null;
|
|
29
|
+
},
|
|
30
|
+
ask: async ({ cwd, prompter: { validate, text }, previous, current }) => ({
|
|
23
31
|
defaultBranch: await validate(
|
|
24
32
|
'Default branch of the repository',
|
|
25
33
|
previous.defaultBranch || detectDefaultBranch(cwd),
|
|
26
|
-
() => text('Default branch of the repository', 'main')
|
|
34
|
+
() => text('Default branch of the repository', current.defaultBranch || 'main')
|
|
27
35
|
)
|
|
28
36
|
})
|
|
29
37
|
},
|
|
30
38
|
{
|
|
31
39
|
keys: ['baseBranch'],
|
|
32
|
-
|
|
40
|
+
detect: ({ previous, answers }) => {
|
|
41
|
+
const baseBranch = previous.baseBranch || answers.defaultBranch;
|
|
42
|
+
return baseBranch ? { baseBranch } : null;
|
|
43
|
+
},
|
|
44
|
+
ask: async ({ cwd, prompter: { choose }, previous, current, answers }) => {
|
|
33
45
|
const { defaultBranch } = answers;
|
|
34
|
-
const baseCandidates = [previous.baseBranch, defaultBranch, localBranches(cwd).includes('develop') && 'develop'].filter(Boolean);
|
|
46
|
+
const baseCandidates = [current.baseBranch, previous.baseBranch, defaultBranch, localBranches(cwd).includes('develop') && 'develop'].filter(Boolean);
|
|
35
47
|
return {
|
|
36
48
|
baseBranch: await choose(
|
|
37
49
|
'Reference branch of the work',
|
|
@@ -46,9 +58,13 @@ export const OPTIONS = [
|
|
|
46
58
|
},
|
|
47
59
|
{
|
|
48
60
|
keys: ['packageManager'],
|
|
49
|
-
|
|
61
|
+
detect: ({ cwd, previous }) => {
|
|
62
|
+
const packageManager = previous.packageManager || detectPackageManager(cwd).value;
|
|
63
|
+
return packageManager ? { packageManager } : null;
|
|
64
|
+
},
|
|
65
|
+
ask: async ({ cwd, prompter: { validate, choose }, previous, current }) => {
|
|
50
66
|
const manager = detectPackageManager(cwd);
|
|
51
|
-
const managers = [...new Set([...manager.candidates, ...PACKAGE_MANAGERS])];
|
|
67
|
+
const managers = [...new Set([current.packageManager, ...manager.candidates, ...PACKAGE_MANAGERS].filter(Boolean))];
|
|
52
68
|
return {
|
|
53
69
|
packageManager: await validate(
|
|
54
70
|
'Package manager',
|
|
@@ -64,8 +80,13 @@ export const OPTIONS = [
|
|
|
64
80
|
},
|
|
65
81
|
{
|
|
66
82
|
keys: ['commitConvention', 'commitFormat'],
|
|
67
|
-
|
|
68
|
-
const
|
|
83
|
+
detect: ({ cwd, previous }) => {
|
|
84
|
+
const commitConvention = previous.commitConvention || detectCommitConvention(cwd) || 'conventional';
|
|
85
|
+
if (commitConvention === 'custom') return previous.commitFormat ? { commitConvention, commitFormat: previous.commitFormat } : null;
|
|
86
|
+
return CONVENTIONS[commitConvention] ? { commitConvention, commitFormat: CONVENTIONS[commitConvention].format } : null;
|
|
87
|
+
},
|
|
88
|
+
ask: async ({ cwd, prompter: { validate, choose, text }, previous, current }) => {
|
|
89
|
+
const start = previous.commitConvention || current.commitConvention || detectCommitConvention(cwd) || 'conventional';
|
|
69
90
|
const commitConvention = await validate(
|
|
70
91
|
'Commit convention',
|
|
71
92
|
start,
|
|
@@ -74,32 +95,75 @@ export const OPTIONS = [
|
|
|
74
95
|
[
|
|
75
96
|
...Object.entries(CONVENTIONS).map(([value, { label, example }]) => ({ value, label, hint: example })),
|
|
76
97
|
{ value: 'custom', label: 'Custom', hint: 'you describe it in one sentence' }
|
|
77
|
-
],
|
|
98
|
+
].sort((a, b) => (b.value === current.commitConvention) - (a.value === current.commitConvention)),
|
|
78
99
|
{ other: false }
|
|
79
100
|
),
|
|
80
101
|
CONVENTIONS[start]?.label
|
|
81
102
|
);
|
|
82
103
|
const commitFormat = commitConvention === 'custom'
|
|
83
|
-
? await text('Describe your commit format in one sentence', previous.commitFormat)
|
|
104
|
+
? await text('Describe your commit format in one sentence', previous.commitFormat || current.commitFormat)
|
|
84
105
|
: CONVENTIONS[commitConvention].format;
|
|
85
106
|
return { commitConvention, commitFormat };
|
|
86
107
|
}
|
|
87
108
|
},
|
|
88
109
|
{
|
|
89
|
-
keys:
|
|
110
|
+
keys: LANGUAGE_KEYS,
|
|
111
|
+
// The recommended setup: stored values first, then the language found (none when unknown), English for code and docs.
|
|
112
|
+
detect: ({ previous }) => {
|
|
113
|
+
const replyLanguage = previous.replyLanguage || detectLanguage();
|
|
114
|
+
const codeLanguage = previous.codeLanguage || ENGLISH;
|
|
115
|
+
return { ...(replyLanguage && { replyLanguage }), codeLanguage, docsLanguage: previous.docsLanguage || codeLanguage };
|
|
116
|
+
},
|
|
90
117
|
ask: async (ctx) => {
|
|
91
|
-
const { prompter: {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
const { prompter: { choose }, previous, current } = ctx;
|
|
119
|
+
const known = { ...current, ...previous };
|
|
120
|
+
|
|
121
|
+
// First question: replies. The language found comes first, stored values lead.
|
|
122
|
+
const detected = detectLanguage();
|
|
123
|
+
const replyOptions = languages(known.replyLanguage || detected);
|
|
124
|
+
if (!known.replyLanguage) replyOptions[0].hint = 'recommended';
|
|
125
|
+
const replyLanguage = await choose(
|
|
126
|
+
"Language of Claude's replies for this project (each developer can use their own later)",
|
|
127
|
+
replyOptions,
|
|
128
|
+
{ other: false }
|
|
96
129
|
);
|
|
130
|
+
|
|
131
|
+
// Second question: code and docs together, or apart. A stored split keeps "Different" first.
|
|
132
|
+
const together = known.codeLanguage && known.codeLanguage === known.docsLanguage ? known.codeLanguage : undefined;
|
|
133
|
+
const split = known.codeLanguage && known.docsLanguage && !together;
|
|
134
|
+
const sameChoices = [...new Set([ENGLISH, replyLanguage])].map((value) => ({ value, label: value }));
|
|
135
|
+
if (together && !sameChoices.some(({ value }) => value === together)) sameChoices.push({ value: together, label: together });
|
|
136
|
+
const different = { value: DIFFERENT, label: 'Different for code and docs', hint: 'you choose each language' };
|
|
137
|
+
const first = split ? different : sameChoices.find(({ value }) => value === (together || ENGLISH));
|
|
138
|
+
const codeDocs = await choose(
|
|
139
|
+
'Language of the code, comments, commit messages and documentation',
|
|
140
|
+
[first, ...[...sameChoices, different].filter((option) => option !== first)],
|
|
141
|
+
{ other: false }
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
let languagesChosen;
|
|
145
|
+
if (codeDocs === DIFFERENT) {
|
|
146
|
+
const codeLanguage = await choose(
|
|
147
|
+
'Language of the code, comments and commit messages (shared by the team)',
|
|
148
|
+
languages(known.codeLanguage)
|
|
149
|
+
);
|
|
150
|
+
const docsLanguage = await choose(
|
|
151
|
+
'Language of the documentation (shared by the team)',
|
|
152
|
+
languages(known.docsLanguage || codeLanguage)
|
|
153
|
+
);
|
|
154
|
+
languagesChosen = { replyLanguage, codeLanguage, docsLanguage };
|
|
155
|
+
} else {
|
|
156
|
+
languagesChosen = { replyLanguage, codeLanguage: codeDocs, docsLanguage: codeDocs };
|
|
157
|
+
}
|
|
158
|
+
|
|
97
159
|
// The personal choice lives in settings.local.json, not in the manifest: only asked from scratch.
|
|
160
|
+
delete ctx.personal.language;
|
|
98
161
|
if (ctx.full) {
|
|
162
|
+
const { replyLanguage } = languagesChosen;
|
|
99
163
|
const mine = await choose(
|
|
100
164
|
'And for you? This stays on your machine',
|
|
101
165
|
[
|
|
102
|
-
{ value: 'keep', label: `Keep the project language (${replyLanguage
|
|
166
|
+
{ value: 'keep', label: `Keep the project language (${replyLanguage})` },
|
|
103
167
|
{ value: 'own', label: 'Use another language for me' }
|
|
104
168
|
],
|
|
105
169
|
{ other: false }
|
|
@@ -111,31 +175,112 @@ export const OPTIONS = [
|
|
|
111
175
|
);
|
|
112
176
|
}
|
|
113
177
|
}
|
|
114
|
-
return
|
|
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 };
|
|
178
|
+
return languagesChosen;
|
|
129
179
|
}
|
|
130
180
|
}
|
|
131
181
|
];
|
|
132
182
|
|
|
183
|
+
// The answers as the dev reads them, before and after the files are written. Missing ones are still to be chosen.
|
|
184
|
+
export function recapRows(answers) {
|
|
185
|
+
const show = (value, text = value) => (value === undefined ? 'to choose' : text);
|
|
186
|
+
return [
|
|
187
|
+
['Default branch', show(answers.defaultBranch)],
|
|
188
|
+
['Reference branch', show(answers.baseBranch)],
|
|
189
|
+
['Package manager', show(answers.packageManager)],
|
|
190
|
+
['Commit format', show(answers.commitConvention, CONVENTIONS[answers.commitConvention]?.label ?? 'Custom')],
|
|
191
|
+
['Replies', show(answers.replyLanguage, `${answers.replyLanguage} (project default)`)],
|
|
192
|
+
['Code', show(answers.codeLanguage)],
|
|
193
|
+
['Documentation', show(answers.docsLanguage)]
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
133
197
|
// `previous`: the stored answers. Returns the options (groups of keys) that have no stored value yet.
|
|
134
198
|
export const missingOptions = (previous = {}) => OPTIONS.filter(({ keys }) => keys.some((key) => previous[key] === undefined));
|
|
135
199
|
|
|
200
|
+
// What each option would be without asking. The ones that cannot be told are left out of the state.
|
|
201
|
+
function propose(ctx) {
|
|
202
|
+
const answers = {};
|
|
203
|
+
for (const option of OPTIONS) {
|
|
204
|
+
const found = option.detect({ ...ctx, answers });
|
|
205
|
+
for (const [key, value] of Object.entries(found ?? {})) if (value !== undefined) answers[key] = value;
|
|
206
|
+
}
|
|
207
|
+
return answers;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The groups the dev can re-open from the recap, each covering some options of OPTIONS.
|
|
211
|
+
const GROUPS = [
|
|
212
|
+
{ value: 'branches', label: 'Branches', options: [0, 1] },
|
|
213
|
+
{ value: 'packageManager', label: 'Package manager', options: [2] },
|
|
214
|
+
{ value: 'commit', label: 'Commit format', options: [3] },
|
|
215
|
+
{ value: 'languages', label: 'Languages', options: [4] }
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
// Asks the given options in order, each one seeing the answers decided so far.
|
|
219
|
+
async function askEach(options, ctx) {
|
|
220
|
+
for (const option of options) Object.assign(ctx.answers, await option.ask(ctx));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A first install with a dev to ask. Everything is detected and shown in one card; "Install as is" takes it (the
|
|
224
|
+
// options that cannot be told are still asked), "Customize" asks every option. After a customization, or when
|
|
225
|
+
// something had to be asked, the recap can be edited group by group until it is right. Nothing is written before it
|
|
226
|
+
// returns, so cancelling leaves the project untouched.
|
|
227
|
+
async function askInteractive({ cwd, prompter, previous }) {
|
|
228
|
+
prompter.step(1, 3, 'Detection');
|
|
229
|
+
const personal = {};
|
|
230
|
+
const answers = propose({ cwd, prompter, previous, current: {}, answers: {}, full: true, personal });
|
|
231
|
+
const unresolved = OPTIONS.filter(({ keys }) => keys.some((key) => answers[key] === undefined));
|
|
232
|
+
|
|
233
|
+
prompter.step(2, 3, 'Review');
|
|
234
|
+
prompter.card('Here is what will be set up', recapRows(answers));
|
|
235
|
+
const mode = await prompter.choose(
|
|
236
|
+
'How do you want to set up Brigade?',
|
|
237
|
+
[
|
|
238
|
+
{ value: 'asis', label: 'Install as is', hint: unresolved.length ? 'the options still to choose will be asked' : undefined },
|
|
239
|
+
{ value: 'customize', label: 'Customize', hint: 'every option, one by one' }
|
|
240
|
+
],
|
|
241
|
+
{ other: false }
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
let reviewed = false;
|
|
245
|
+
if (mode === 'customize') {
|
|
246
|
+
for (const key of Object.keys(answers)) delete answers[key];
|
|
247
|
+
await askEach(OPTIONS, { cwd, prompter, previous, current: {}, answers, full: true, personal });
|
|
248
|
+
reviewed = true;
|
|
249
|
+
} else if (unresolved.length) {
|
|
250
|
+
await askEach(unresolved, { cwd, prompter, previous, current: {}, answers, full: false, personal });
|
|
251
|
+
reviewed = true;
|
|
252
|
+
} else {
|
|
253
|
+
prompter.info('Installing as detected.');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
while (reviewed) {
|
|
257
|
+
prompter.card('Your setup', recapRows(answers));
|
|
258
|
+
const verdict = await prompter.choose(
|
|
259
|
+
'Is the setup right?',
|
|
260
|
+
[
|
|
261
|
+
{ value: 'ok', label: 'Everything is right' },
|
|
262
|
+
{ value: 'change', label: 'Change something' }
|
|
263
|
+
],
|
|
264
|
+
{ other: false }
|
|
265
|
+
);
|
|
266
|
+
if (verdict === 'ok') break;
|
|
267
|
+
|
|
268
|
+
const chosen = await prompter.multi('What do you want to change?', GROUPS.map(({ value, label }) => ({ value, label })));
|
|
269
|
+
// Re-asked options skip "Is that right?" and start from the current values.
|
|
270
|
+
const editor = { ...prompter, validate: async (_message, _detected, pick) => pick() };
|
|
271
|
+
const current = { ...answers };
|
|
272
|
+
const options = GROUPS.filter(({ value }) => chosen.includes(value)).flatMap(({ options }) => options.map((index) => OPTIONS[index]));
|
|
273
|
+
await askEach(options, { cwd, prompter: editor, previous: {}, current, answers, full: true, personal });
|
|
274
|
+
}
|
|
275
|
+
return { answers: { ...previous, ...answers }, personal, asked: [] };
|
|
276
|
+
}
|
|
277
|
+
|
|
136
278
|
// Asks the options in order. From scratch (`full`) every option is asked; otherwise only the missing ones and the
|
|
137
279
|
// others keep their stored answers. Keys the kit does not know (older version) are kept as they are.
|
|
280
|
+
// Without a dev to ask (`prompter.interactive` false) every question resolves to its default.
|
|
138
281
|
export async function askOptions({ cwd, prompter, previous = {}, full }) {
|
|
282
|
+
if (full && prompter.interactive) return askInteractive({ cwd, prompter, previous });
|
|
283
|
+
|
|
139
284
|
const missing = new Set(missingOptions(previous));
|
|
140
285
|
const answers = {};
|
|
141
286
|
const personal = {};
|
|
@@ -143,7 +288,7 @@ export async function askOptions({ cwd, prompter, previous = {}, full }) {
|
|
|
143
288
|
|
|
144
289
|
for (const option of OPTIONS) {
|
|
145
290
|
if (full || missing.has(option)) {
|
|
146
|
-
Object.assign(answers, await option.ask({ cwd, prompter, previous, answers, full, personal }));
|
|
291
|
+
Object.assign(answers, await option.ask({ cwd, prompter, previous, current: {}, answers, full, personal }));
|
|
147
292
|
if (!full) asked.push(option.keys);
|
|
148
293
|
} else {
|
|
149
294
|
for (const key of option.keys) answers[key] = previous[key];
|
package/src/prompt.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
|
|
3
|
+
import * as ui from './ui.js';
|
|
4
|
+
|
|
3
5
|
const OTHER = Symbol('other');
|
|
4
6
|
|
|
5
7
|
// Non-interactive (no TTY or `--yes`): every question resolves to its default and nothing is printed.
|
|
6
8
|
export function createPrompter({ interactive }) {
|
|
9
|
+
const cancelled = () => {
|
|
10
|
+
console.log(`\n${ui.dim('Cancelled. Nothing was written.')}`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
};
|
|
13
|
+
|
|
7
14
|
// Nothing is written before the last question, so cancelling leaves the project untouched.
|
|
8
15
|
const answer = async (pending) => {
|
|
9
16
|
const value = await pending;
|
|
10
|
-
if (p.isCancel(value))
|
|
11
|
-
p.cancel('Cancelled. Nothing was written.');
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
17
|
+
if (p.isCancel(value)) cancelled();
|
|
14
18
|
return value;
|
|
15
19
|
};
|
|
16
20
|
|
|
@@ -39,17 +43,28 @@ export function createPrompter({ interactive }) {
|
|
|
39
43
|
return keep ? detected : pick();
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
// Several values among `options` ([{ value, label, hint? }]), at least one. Nothing is asked without a dev.
|
|
47
|
+
async function multi(message, options) {
|
|
48
|
+
if (!interactive) return [];
|
|
49
|
+
return answer(p.multiselect({ message, options: options.map(({ value, label, hint }) => ({ value, label, hint })), required: true }));
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
const when = (fn) => (...args) => interactive && fn(...args);
|
|
43
53
|
|
|
44
54
|
return {
|
|
45
55
|
text,
|
|
56
|
+
interactive,
|
|
46
57
|
choose,
|
|
58
|
+
multi,
|
|
47
59
|
validate,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
banner: when((version) => console.log(ui.banner(version))),
|
|
61
|
+
step: when((n, total, label) => console.log(ui.step(n, total, label))),
|
|
62
|
+
card: when((title, rows) => console.log(ui.card(title, rows))),
|
|
63
|
+
files: when((items) => console.log(`\n${ui.checklist(items)}`)),
|
|
64
|
+
info: when((message) => console.log(`${ui.dim('·')} ${ui.dim(message)}`)),
|
|
65
|
+
warn: when((message) => console.log(`${ui.accent('!')} ${message}`)),
|
|
66
|
+
outro: when((message) => console.log(`\n${message}`)),
|
|
67
|
+
done: when((message, lines) => console.log(`\n${ui.success('✓')} ${ui.bright(message)}\n${ui.bullets(lines)}\n`)),
|
|
53
68
|
close: () => {}
|
|
54
69
|
};
|
|
55
70
|
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { styleText } from 'node:util';
|
|
2
|
+
|
|
3
|
+
// The only place with colors and layout. `styleText` drops the escape codes by itself when the output is not a
|
|
4
|
+
// terminal or when NO_COLOR is set, so every helper degrades to plain text.
|
|
5
|
+
// Palette: amber accent, bright (cream) text, dim secondary, green for success, red for errors only.
|
|
6
|
+
const paint = (format, text) => styleText(format, text);
|
|
7
|
+
|
|
8
|
+
export const accent = (text) => paint(['bold', 'yellow'], text);
|
|
9
|
+
export const bright = (text) => paint('whiteBright', text);
|
|
10
|
+
export const dim = (text) => paint('gray', text);
|
|
11
|
+
export const success = (text) => paint('green', text);
|
|
12
|
+
export const warning = (text) => paint('yellow', text);
|
|
13
|
+
export const danger = (text) => paint('red', text);
|
|
14
|
+
|
|
15
|
+
const TAGLINE = 'Claude Code, organized like a kitchen brigade.';
|
|
16
|
+
const FALLBACK_COLUMNS = 80;
|
|
17
|
+
|
|
18
|
+
// A terminal reporting 0 columns (or none) is treated as 80.
|
|
19
|
+
const columnsOf = (columns = process.stdout.columns) => (columns > 0 ? columns : FALLBACK_COLUMNS);
|
|
20
|
+
|
|
21
|
+
const truncate = (text, max) => (text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`);
|
|
22
|
+
|
|
23
|
+
// Compact welcome header: letter-spaced wordmark, dimmed version, a rule, the tagline.
|
|
24
|
+
export function banner(version) {
|
|
25
|
+
const wordmark = [...'BRIGADE'].join(' ');
|
|
26
|
+
return [
|
|
27
|
+
'',
|
|
28
|
+
` ${accent(wordmark)} ${dim(version)}`,
|
|
29
|
+
` ${dim('─'.repeat(TAGLINE.length))}`,
|
|
30
|
+
` ${dim(TAGLINE)}`,
|
|
31
|
+
''
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A section label such as "1/3 Detection".
|
|
36
|
+
export function step(n, total, label) {
|
|
37
|
+
return `\n${accent(`${n}/${total}`)} ${bright(label)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// An aligned key/value card with rounded borders, fitted to its content and capped to the terminal width.
|
|
41
|
+
// `rows`: [[label, value]].
|
|
42
|
+
export function card(title, rows, { columns } = {}) {
|
|
43
|
+
const keyWidth = Math.max(0, ...rows.map(([key]) => key.length));
|
|
44
|
+
const content = Math.max(title.length + 2, ...rows.map(([, value]) => keyWidth + 2 + value.length));
|
|
45
|
+
const inner = Math.max(12, Math.min(content, columnsOf(columns) - 4));
|
|
46
|
+
const valueWidth = Math.max(0, inner - keyWidth - 2);
|
|
47
|
+
|
|
48
|
+
const name = truncate(title, inner - 2);
|
|
49
|
+
const top = `${dim('╭─')} ${accent(name)} ${dim(`${'─'.repeat(inner - name.length - 2)}─╮`)}`;
|
|
50
|
+
const body = rows.map(([key, value]) => {
|
|
51
|
+
const shown = truncate(value, valueWidth);
|
|
52
|
+
const pad = ' '.repeat(Math.max(0, inner - keyWidth - 2 - shown.length));
|
|
53
|
+
return `${dim('│')} ${dim(key.padEnd(keyWidth))} ${bright(shown)}${pad} ${dim('│')}`;
|
|
54
|
+
});
|
|
55
|
+
const bottom = dim(`╰${'─'.repeat(inner + 2)}╯`);
|
|
56
|
+
return ['', top, ...body, bottom].join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A tidy list. `items`: [{ done, label, detail? }]. Done items get a green check, the others are dimmed.
|
|
60
|
+
export function checklist(items) {
|
|
61
|
+
const width = Math.max(0, ...items.map(({ label }) => label.length));
|
|
62
|
+
return items.map(({ done, label, detail = '' }) => {
|
|
63
|
+
const gap = ' '.repeat(width - label.length);
|
|
64
|
+
return done
|
|
65
|
+
? ` ${success('✓')} ${bright(label)}${gap} ${dim(detail)}`
|
|
66
|
+
: ` ${dim('-')} ${dim(label)}${gap} ${dim(detail)}`;
|
|
67
|
+
}).join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Short lines with accent bullets.
|
|
71
|
+
export const bullets = (lines) => lines.map((line) => ` ${accent('›')} ${bright(line)}`).join('\n');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Brigade
|
|
2
2
|
|
|
3
|
-
Rules of the Brigade setup of this web project. Work is split between three roles.
|
|
3
|
+
Rules of the Brigade setup of this web project. Work is split between three roles. The options of this project (branches, package manager, commit format, languages) are in `.claude/rules/brigade-options.md`, generated by Brigade.
|
|
4
4
|
|
|
5
5
|
## Roles
|
|
6
6
|
|
|
@@ -12,19 +12,19 @@ A brief to a subagent states the goal, the branch, the relevant files, the const
|
|
|
12
12
|
|
|
13
13
|
## Git
|
|
14
14
|
|
|
15
|
-
-
|
|
15
|
+
- The default branch and the reference branch of the work are protected: writing on them needs the user's confirmation. Their names are in `.claude/rules/brigade-options.md`.
|
|
16
16
|
- Check the current branch before any code.
|
|
17
17
|
- A feature (several steps, sessions or files) = its own branch + a state file. Small work (a fix, a few files, one sitting) has neither: it ends with a commit on the current branch. Before any code, judge which it is and follow the skill `feature`: propose, wait for the user's answer, then `git-operator` creates the branch (with the user's go).
|
|
18
18
|
- When the work is finished and its open questions are settled, propose the commit in one sentence. Never call `git-operator` without the user's go. The user can ask for a commit at any moment.
|
|
19
19
|
- The feature file `.claude/context/features/<branch-with-slashes-as-dashes>.md` is shared and travels with the branch. `implementer` updates it at important steps; mention it in the `git-operator` brief so it goes in the same commit.
|
|
20
20
|
- Pushing needs the user's confirmation. At the end of a session, or on pause/handoff, if commits are not pushed, propose the push in one sentence: without a push nothing survives.
|
|
21
|
-
- Commit format, for `git-operator`:
|
|
21
|
+
- Commit format, for `git-operator`: the one in `.claude/rules/brigade-options.md`.
|
|
22
22
|
|
|
23
23
|
## Rules
|
|
24
24
|
|
|
25
25
|
- At the start of a session, read `.claude/context/decisions.md` (team decisions, shared).
|
|
26
26
|
- Keep changes small and focused.
|
|
27
|
-
- Use the project's package manager
|
|
27
|
+
- Use the project's package manager, given in `.claude/rules/brigade-options.md`.
|
|
28
28
|
- Run lint, build or tests only when the user asks or the task needs them. Do not assume they exist.
|
|
29
29
|
- Never read or write secrets (`.env*`, keys, tokens).
|
|
30
30
|
- Say so before adding a dependency.
|
|
@@ -33,9 +33,8 @@ A brief to a subagent states the goal, the branch, the relevant files, the const
|
|
|
33
33
|
|
|
34
34
|
## Languages
|
|
35
35
|
|
|
36
|
-
- Code, comments
|
|
37
|
-
-
|
|
38
|
-
- Replies: set by the `language` setting. The project default is __REPLY_LANGUAGE__; each developer can use their own with `/brigade-language`.
|
|
36
|
+
- Code, comments, commit messages and documentation: the languages in `.claude/rules/brigade-options.md`.
|
|
37
|
+
- Replies: set by the `language` setting. The project default is in `.claude/rules/brigade-options.md`; each developer can use their own with `/brigade-language`.
|
|
39
38
|
|
|
40
39
|
## Local effort
|
|
41
40
|
|