tadforce 0.3.0 → 0.4.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -4
- package/package.json +1 -1
- package/skills/tadforce/references/questions.md +8 -9
- package/skills/tadforce/references/workflow.md +8 -3
- package/src/commands/doctor.js +287 -119
- package/src/util/agents.js +35 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"name": "tadforce",
|
|
12
12
|
"source": "./",
|
|
13
13
|
"description": "AI-Powered Salesforce Technical Documentation: generate, review, validate and update the Technical Architecture Document of any Salesforce org (/tadforce, /tadforce-review, /tadforce-update).",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"author": { "name": "Jihad AZAMI HASSANI" },
|
|
16
16
|
"license": "Apache-2.0",
|
|
17
17
|
"category": "documentation"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tadforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "AI-Powered Salesforce Technical Documentation — the Technical Architecture Document of any Salesforce org, written from the org's own metadata: evidence-based, reproducible, reviewable in the browser, kept up to date. All Salesforce products and industries.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Jihad AZAMI HASSANI",
|
package/README.md
CHANGED
|
@@ -47,11 +47,9 @@ From your Salesforce DX project (or an empty folder):
|
|
|
47
47
|
npx tadforce doctor
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
That one command installs everything: it downloads TADForce, installs the agent skills in your project, connects to the org, collects the metadata and asks you the few questions only a human can answer. When everything is green, it says so and **offers to hand over to your AI agent** (Claude Code, Codex, Gemini CLI or Cursor, detected on your machine), which writes, builds and validates the document — nothing else to type.
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
/tadforce
|
|
54
|
-
```
|
|
52
|
+
Already in your agent? Type `/tadforce`: it runs the same checks, then writes.
|
|
55
53
|
|
|
56
54
|
<table>
|
|
57
55
|
<tr><th width="33%">A — Claude Code plugin</th><th width="33%">B — Any agent (Agent Skills)</th><th width="33%">C — Deterministic / CI</th></tr>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tadforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "AI-Powered Salesforce Technical Documentation — the Technical Architecture Document of any Salesforce org, written from the org's own metadata: evidence-based, reproducible, reviewable in the browser, kept up to date.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -4,23 +4,22 @@ Ask in **blocks**, each in one message, with your proposed answer for every item
|
|
|
4
4
|
|
|
5
5
|
## Block A — startup (before or right after authorization)
|
|
6
6
|
|
|
7
|
-
`npx tadforce doctor --json`
|
|
7
|
+
`npx tadforce doctor --json` asks, in this order and one step at a time: the **project** (is this the Salesforce project to document?), the **org** (is this the org?), then these framing questions — six, each with a `recommended` answer. Ask them all in one message and let the human accept them all at once:
|
|
8
8
|
|
|
9
9
|
1. Document language — recommended: the org default language. English and French are built in; any other language works (the headings and labels are translated once, see `workflow.md › Other languages`).
|
|
10
|
-
2. Client name
|
|
11
|
-
3. Integrator — **optional**. Blank: the document names the client only (no empty « Integrator » line, no badge).
|
|
10
|
+
2. Client name, as it must appear in the document — recommended: `Organization.Name`. The short code (file names) and the reference are derived from it.
|
|
11
|
+
3. Integrator — **optional**. Blank: the document names the client only (no empty « Integrator » line, no badge). The Improvement Axes then carry the client's brand.
|
|
12
12
|
4. Author — **optional**. Blank: no author is shown anywhere. Never put your own name or the agent's.
|
|
13
|
-
5.
|
|
13
|
+
5. Functional specifications — **optional**: paths to PDF / DOCX / MD files (copied into `knowledge/inputs/specs/`).
|
|
14
14
|
6. Scope — **optional**: name patterns of the components the project owns (`NWB_*,Loan*`), when the org is shared with other teams. Blank: the whole org.
|
|
15
|
-
7. Client logo — **optional**: a file or a URL. Blank: TADForce searches the org (static resources, content assets, site assets), then falls back to an initials badge. The document colours follow the org's Lightning theme, else the logo. If none is found and the client has a public website, you may *offer* to look for the logo there — fetch it only after the human says yes.
|
|
16
|
-
8. Functional specifications — **optional**: paths to PDF / DOCX / MD files (copied into `knowledge/inputs/specs/`).
|
|
17
|
-
9. Metadata — `org` (recommended: retrieve everything from the org), `local` (the sources of this project), or `both`.
|
|
18
15
|
|
|
19
|
-
Answer with `npx tadforce doctor --json --confirm --lang fr --client "…" --
|
|
16
|
+
Answer with `npx tadforce doctor --json --confirm --lang fr --client "…" --integrator none --author none [--specs … | --no-specs] [--scope "…"]`. The title stays « Salesforce CRM Platform » unless the human gives one (`--title`).
|
|
17
|
+
|
|
18
|
+
Then come the metadata choice (`source`, which is also the go for the retrieve), the **branding** (logo and colours found in the org — never fetch a logo from the web without the human's go-ahead), and Blocks B to D.
|
|
20
19
|
|
|
21
20
|
FR example:
|
|
22
21
|
|
|
23
|
-
> Pour démarrer, je propose : langue **français** (langue par défaut de l'org : fr_FR) · client **« Banque Exemple »**
|
|
22
|
+
> Pour démarrer, je propose : langue **français** (langue par défaut de l'org : fr_FR) · client **« Banque Exemple »** · intégrateur : **aucun** · auteur : **aucun** · spécifications fonctionnelles : **aucune** · périmètre : **toute l'org**. Je valide tout ? Indiquez seulement ce qui change.
|
|
24
23
|
|
|
25
24
|
## Block B — external systems (from `facts/questions.json › systems`)
|
|
26
25
|
|
|
@@ -13,11 +13,16 @@ npx tadforce doctor --json
|
|
|
13
13
|
| `next` says | You do |
|
|
14
14
|
|---|---|
|
|
15
15
|
| install the missing tools | `sf-cli` missing: you may run `npm install --global @salesforce/cli` after asking. Node or a browser missing: human action (or `npx @puppeteer/browsers install chrome-headless-shell@stable` + `TADFORCE_CHROME`) |
|
|
16
|
-
|
|
|
17
|
-
|
|
|
18
|
-
|
|
|
16
|
+
| question `project` | Confirm with the human that this Salesforce project is the one to document → `--project yes`. Not in a project: `doctor` creates nothing; it proposes the projects found nearby → `--project <path>` (then always pass `--cwd <path>`), or `--project create` only if the human asks for a new project here |
|
|
17
|
+
| question `org` | Show the org (alias, user, URL) and ask whether the TAD is about it → `--org <alias>`. Another org, or none connected: the human logs in (`sf org login web --alias <a> [--instance-url https://test.salesforce.com]`, it opens the browser), then ask again and pass `--org <a>`. No org at all: `--source local` |
|
|
18
|
+
| framing questions | Block A of `questions.md`: one message, the recommended answer for each, « accept all » allowed → `--confirm --lang … --client "…" --integrator …|none --author …|none [--specs …|--no-specs] [--scope …]` |
|
|
19
|
+
| question `source` | org (retrieve everything, read-only, several minutes), local, or self (the human retrieves first). The answer **is the go**: ask before passing `--source org` — the retrieve starts right away. `self`: wait until the human says the retrieve is done, then `--source local` |
|
|
20
|
+
| question `branding` | Show the logo and colours found → `--branding keep`, or `--logo <file|url>` |
|
|
21
|
+
| systems / exclusions / features | Blocks B, C and D of `questions.md`, recommended answers shown → write `knowledge/*.yml`, `npx tadforce facts`, then `--facts-reviewed` |
|
|
19
22
|
| `tadforce build` | Ready: write and build |
|
|
20
23
|
|
|
24
|
+
Run by a person in a terminal, a green `doctor` starts the AI agent found on the machine on `/tadforce` (`--agent claude|codex|gemini|cursor`, `--no-launch` to stop there). Run by you (`--json`, or inside an agent session), it never starts another agent: carry on with the writing.
|
|
25
|
+
|
|
21
26
|
What `doctor` does by itself, read-only:
|
|
22
27
|
|
|
23
28
|
- creates the SFDX project and the `tadforce/` workspace if missing, installs the skills for the agents found;
|
package/src/commands/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@ import { sfVersion, sfJson, sfInteractive, soql } from '../sf/sf.js';
|
|
|
13
13
|
import { findBrowser, closeBrowser } from '../build/browser.js';
|
|
14
14
|
import { findLogoCandidates, orgThemeColors, imageColors, paletteFrom } from '../build/brand.js';
|
|
15
15
|
import { log, c, setQuiet } from '../util/log.js';
|
|
16
|
+
import { installedAgents, insideAgent, launchAgent } from '../util/agents.js';
|
|
16
17
|
import { ensureWorkspace, installSkills } from './init.js';
|
|
17
18
|
import * as org from './org.js';
|
|
18
19
|
import * as docs from './docs.js';
|
|
@@ -70,6 +71,12 @@ async function proposals(alias) {
|
|
|
70
71
|
return out;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** Short code for file names: initials of a multi-word name (Northwind Bank → NB), else the name cut to 8. */
|
|
75
|
+
export function shortCode(name) {
|
|
76
|
+
const words = name.normalize('NFD').replace(/[̀-ͯ]/g, '').split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
77
|
+
return (words.length > 1 ? words.map((w) => w[0]).join('') : words[0] || 'CLIENT').slice(0, 8).toUpperCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
const listArg = (v) => (typeof v !== 'string' ? undefined : v.split(',').map((x) => x.trim()).filter(Boolean));
|
|
74
81
|
|
|
75
82
|
/** Answers given as flags (the agent's way of answering) are written to the config. */
|
|
@@ -77,7 +84,7 @@ async function applyAnswers(ctx, args) {
|
|
|
77
84
|
const cfg = ctx.cfg;
|
|
78
85
|
const set = (cond, fn) => { if (cond) fn(); };
|
|
79
86
|
set(args.lang, () => { cfg.language = normalizeLang(args.lang); });
|
|
80
|
-
set(args.client, () => { cfg.client.name = String(args.client); if (!args.short && (cfg.client.short === 'CLIENT' || !cfg.client.short)) cfg.client.short = String(args.client)
|
|
87
|
+
set(args.client, () => { cfg.client.name = String(args.client); if (!args.short && (cfg.client.short === 'CLIENT' || !cfg.client.short)) cfg.client.short = shortCode(String(args.client)); });
|
|
81
88
|
set(args.short, () => { cfg.client.short = String(args.short); });
|
|
82
89
|
set(args.integrator !== undefined, () => { cfg.integrator.name = args.integrator === true || args.integrator === '' || args.integrator === 'none' ? null : String(args.integrator); });
|
|
83
90
|
set(args.author !== undefined, () => { cfg.document.author = args.author === true || args.author === '' || args.author === 'none' ? null : String(args.author); });
|
|
@@ -103,6 +110,8 @@ async function applyAnswers(ctx, args) {
|
|
|
103
110
|
if (args.specs !== undefined) cfg.setup.specsAsked = true; // --no-specs: none provided
|
|
104
111
|
if (args.confirm || args.yes) cfg.setup.confirmed = true;
|
|
105
112
|
if (args.factsReviewed) cfg.setup.factsReviewed = true;
|
|
113
|
+
if (args.branding === 'keep') cfg.setup.brandingConfirmed = true;
|
|
114
|
+
if (cfg.document.reference === 'TAD-CLIENT-CRM' && cfg.client.short !== 'CLIENT') cfg.document.reference = `TAD-${cfg.client.short}-CRM`;
|
|
106
115
|
ctx.save(cfg);
|
|
107
116
|
}
|
|
108
117
|
|
|
@@ -159,34 +168,13 @@ async function brand(ctx) {
|
|
|
159
168
|
return notes;
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
/** Questions only a human can answer, each with a recommended answer. */
|
|
163
|
-
function framingQuestions(cfg, prop, alias, hasLocal) {
|
|
164
|
-
const q = [];
|
|
165
|
-
const ask = (id, question, recommended, extra = {}) => q.push({ id, question, recommended, ...extra });
|
|
166
|
-
if (!cfg.setup.confirmed) {
|
|
167
|
-
ask('lang', `Document language — ${LANGUAGES.join(', ')} built in, or any language code (es, de, it, pt, nl, ar, ja…): the agent then translates the headings and labels once`, prop.language || cfg.language, { options: [...LANGUAGES, 'other'] });
|
|
168
|
-
ask('client', 'Client name, as it must appear in the document', prop.client || cfg.client.name);
|
|
169
|
-
ask('short', 'Client short name (file names, references)', cfg.client.short !== 'CLIENT' ? cfg.client.short : String(prop.client || cfg.client.name).replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toUpperCase());
|
|
170
|
-
ask('integrator', 'Integrator, if any (blank: the document only names the client)', cfg.integrator.name || '', { optional: true });
|
|
171
|
-
ask('author', 'Author of the document (blank: no author shown)', cfg.document.author || '', { optional: true });
|
|
172
|
-
ask('title', 'Document title', cfg.document.title);
|
|
173
|
-
ask('scope', 'Part of the org to document — name patterns such as NWB_*,Loan* (blank: the whole org)', (cfg.scope.include || []).join(','), { optional: true });
|
|
174
|
-
ask('logo', `Client logo — a file or a URL${cfg.client.logo ? ` (current: ${cfg.client.logo})` : ' (blank: searched in the org, then a badge with initials)'}`, cfg.client.logo || '', { optional: true });
|
|
175
|
-
}
|
|
176
|
-
if (!cfg.setup.specsAsked && !(cfg.inputs.specs || []).length) ask('specs', 'Functional specifications or other project documents (PDF, DOCX, MD) — comma-separated paths (blank: none)', '', { optional: true });
|
|
177
|
-
if (!cfg.source.mode) {
|
|
178
|
-
ask('source', 'Metadata to document', alias ? 'org' : 'local', { options: ['org', 'local', 'both'], help: 'org: retrieve every metadata type from the org (recommended — the org is the truth); local: the force-app sources already here' + (hasLocal ? '' : ' (none found)') + '; both: org first, local sources added' });
|
|
179
|
-
}
|
|
180
|
-
return q;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
171
|
/** Systems to name and exclusions to confirm, from facts/questions.json. */
|
|
184
172
|
function factQuestions(ctx) {
|
|
185
173
|
const p = ctx.p('facts', 'questions.json');
|
|
186
174
|
if (!exists(p) || ctx.cfg.setup.factsReviewed) return [];
|
|
187
175
|
const qs = readJson(p);
|
|
188
176
|
const out = [];
|
|
189
|
-
for (const s of qs.systems || []) out.push({ id: `system:${s.key}`, kind: 'system', question: `Which system is ${s.key}? (name as the client calls it
|
|
177
|
+
for (const s of qs.systems || []) out.push({ id: `system:${s.key}`, kind: 'system', question: `Which system is ${s.key}? (name as the client calls it)`, recommended: '', endpoints: s.endpoints, writeTo: 'knowledge/glossary.yml › systems' });
|
|
190
178
|
for (const g of (qs.exclusions && qs.exclusions.groups) || []) out.push({ id: `exclude:${g.reason}`, kind: 'exclusion', question: `${g.count} component(s): ${g.reason}. Exclude from the document? (all / none / list of names)`, recommended: 'none', items: g.items, writeTo: 'knowledge/exclusions.yml › exclude' });
|
|
191
179
|
out.push({ id: 'features', kind: 'features', question: 'Any feature decommissioned or switched off that the document must not describe? (blank: none)', recommended: '', writeTo: 'knowledge/exclusions.yml › features' });
|
|
192
180
|
return out;
|
|
@@ -203,6 +191,10 @@ function editYaml(file, key, items) {
|
|
|
203
191
|
/** Interactive answers to the facts questions, written to the knowledge files. */
|
|
204
192
|
async function askFacts(ctx, rl, questions) {
|
|
205
193
|
const systems = [], exclude = [], features = [];
|
|
194
|
+
log.step('What the metadata cannot tell:');
|
|
195
|
+
questions.forEach((q, i) => console.log(` ${i + 1}. ${q.question}`));
|
|
196
|
+
console.log(` ${c.dim('recommended: systems « to be confirmed » in the document, nothing excluded, no decommissioned feature')}`);
|
|
197
|
+
if (yes((await rl.question(' Accept the recommended answers? [Y/n] ')).trim())) return 0;
|
|
206
198
|
for (const q of questions) {
|
|
207
199
|
const a = (await rl.question(` ${q.question}${q.items ? '\n ' + c.dim(q.items.slice(0, 12).join(', ') + (q.items.length > 12 ? ` … +${q.items.length - 12}` : '')) : ''}${q.endpoints ? '\n ' + c.dim(q.endpoints.join(', ')) : ''}\n > `)).trim();
|
|
208
200
|
if (!a) continue;
|
|
@@ -219,144 +211,320 @@ async function askFacts(ctx, rl, questions) {
|
|
|
219
211
|
return systems.length + exclude.length + features.length;
|
|
220
212
|
}
|
|
221
213
|
|
|
214
|
+
/** Framing questions (5 to 6), each with a recommended answer. Title and short code are derived. */
|
|
215
|
+
function framingQuestions(cfg, prop) {
|
|
216
|
+
if (cfg.setup.confirmed) return [];
|
|
217
|
+
const q = [];
|
|
218
|
+
const ask = (id, question, recommended, extra = {}) => q.push({ id, question, recommended, ...extra });
|
|
219
|
+
ask('lang', `Document language (${LANGUAGES.join(', ')} built in, or any code: es, de, it, pt, ar…)`, prop.language || cfg.language, { options: [...LANGUAGES, 'other'] });
|
|
220
|
+
ask('client', 'Client name, as it must appear in the document', prop.client || (cfg.client.name !== 'Client' ? cfg.client.name : ''));
|
|
221
|
+
ask('integrator', 'Integrator (blank: the document only names the client)', cfg.integrator.name || '', { optional: true });
|
|
222
|
+
ask('author', 'Author of the document (blank: no author shown)', cfg.document.author || '', { optional: true });
|
|
223
|
+
ask('specs', 'Functional specifications to use, comma-separated paths (blank: none)', (cfg.inputs.specs || []).join(','), { optional: true });
|
|
224
|
+
ask('scope', 'Part of the org to document, name patterns such as NWB_* (blank: the whole org)', (cfg.scope.include || []).join(','), { optional: true });
|
|
225
|
+
return q;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Interactive batch: the list with its recommendations, then « accept all » or one by one. */
|
|
229
|
+
async function askBatch(rl, title, questions) {
|
|
230
|
+
log.step(title);
|
|
231
|
+
questions.forEach((q, i) => console.log(` ${i + 1}. ${q.question}\n ${c.dim('recommended:')} ${q.recommended === '' || q.recommended == null ? c.dim('(blank)') : c.bold(String(q.recommended))}`));
|
|
232
|
+
const all = (await rl.question(' Accept all the recommended answers? [Y/n] ')).trim();
|
|
233
|
+
const answers = {};
|
|
234
|
+
if (/^(|y|o|yes|oui)$/i.test(all)) { for (const q of questions) answers[q.id] = q.recommended ?? ''; return answers; }
|
|
235
|
+
for (const q of questions) {
|
|
236
|
+
const a = (await rl.question(` ${q.question}${q.options ? ` (${q.options.join('/')})` : ''}${q.recommended ? ` [${q.recommended}]` : ''}: `)).trim();
|
|
237
|
+
answers[q.id] = a || q.recommended || '';
|
|
238
|
+
if (q.id === 'lang' && /^(other|autre)$/i.test(a)) answers.lang = (await rl.question(' Language code (es, de, it, pt, nl, ar, ja…): ')).trim() || 'en';
|
|
239
|
+
}
|
|
240
|
+
return answers;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const yes = (a, def = true) => (a === '' ? def : /^(y|o|yes|oui)$/i.test(a));
|
|
244
|
+
|
|
245
|
+
/** Salesforce DX projects in this folder's sub-folders and next to it (two levels), for the user to pick from. */
|
|
246
|
+
function findProjects(start) {
|
|
247
|
+
const out = new Set();
|
|
248
|
+
const visit = (dir, depth) => {
|
|
249
|
+
let entries = [];
|
|
250
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
251
|
+
if (entries.some((e) => e.isFile() && e.name === 'sfdx-project.json')) out.add(dir);
|
|
252
|
+
if (depth <= 0) return;
|
|
253
|
+
for (const e of entries) if (e.isDirectory() && !/^(node_modules|\.|force-app$)/.test(e.name)) visit(path.join(dir, e.name), depth - 1);
|
|
254
|
+
};
|
|
255
|
+
visit(start, 2);
|
|
256
|
+
visit(path.dirname(start), 1);
|
|
257
|
+
out.delete(start);
|
|
258
|
+
return [...out].slice(0, 9);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const hasLocalSources = (project) => (readJson(path.join(project, 'sfdx-project.json')).packageDirectories || [])
|
|
262
|
+
.some((d) => exists(path.join(project, d.path)) && fs.readdirSync(path.join(project, d.path), { recursive: true }).some((f) => String(f).endsWith('-meta.xml')));
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Step 2 — which Salesforce project. Never creates anything without a yes. Returns the project folder,
|
|
266
|
+
* or { stop } with the question to hand back.
|
|
267
|
+
*/
|
|
268
|
+
async function resolveProject(start, args, rl) {
|
|
269
|
+
const ws = findWorkspace(start);
|
|
270
|
+
const confirmed = ws && defaultConfig(readJson(path.join(ws, CONFIG_FILE))).setup.projectConfirmed;
|
|
271
|
+
if (exists(path.join(start, 'sfdx-project.json'))) {
|
|
272
|
+
if (confirmed || args.project === 'yes' || args.yes) return start;
|
|
273
|
+
const name = readJson(path.join(start, 'sfdx-project.json')).name || path.basename(start);
|
|
274
|
+
if (!rl) return { stop: { id: 'project', question: `Generate the TAD for the Salesforce project "${name}" (${start})?`, recommended: 'yes', options: ['yes', '<path of another project>'] } };
|
|
275
|
+
if (yes((await rl.question(` Salesforce project found: ${c.bold(name)} (${start}).\n Generate the TAD for this project? [Y/n] `)).trim())) return start;
|
|
276
|
+
const other = (await rl.question(' Path of the project to document (blank: stop): ')).trim();
|
|
277
|
+
return other ? resolveProject(path.resolve(other), { ...args, project: 'yes' }, rl) : { stop: null };
|
|
278
|
+
}
|
|
279
|
+
if (args.project === 'create') return start;
|
|
280
|
+
if (args.project && args.project !== 'yes') return resolveProject(path.resolve(String(args.project)), { ...args, project: 'yes' }, rl);
|
|
281
|
+
const found = findProjects(start);
|
|
282
|
+
if (!rl) return { stop: { id: 'project', question: `No Salesforce DX project in ${start}. Which project should the TAD document? A path, or "create" to create a new project here.`, recommended: found[0] || '', options: [...found, 'create'] } };
|
|
283
|
+
console.log(` ${c.yellow('⚠')} ${start} is not a Salesforce DX project (no sfdx-project.json).`);
|
|
284
|
+
found.forEach((p, i) => console.log(` ${i + 1}. ${p}`));
|
|
285
|
+
console.log(` ${c.dim('or a path · c = create a new project here · Enter = stop')}`);
|
|
286
|
+
const a = (await rl.question(` Project to document${found.length ? ' [1]' : ''}: `)).trim();
|
|
287
|
+
if (!a && found.length) return found[0];
|
|
288
|
+
if (/^\d+$/.test(a) && found[Number(a) - 1]) return found[Number(a) - 1];
|
|
289
|
+
if (/^c$/i.test(a)) return start;
|
|
290
|
+
if (a) return resolveProject(path.resolve(a), { ...args, project: 'yes' }, rl);
|
|
291
|
+
console.log(' Open your Salesforce project folder (cd <path>), then run tadforce doctor again.');
|
|
292
|
+
return { stop: null };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Step 3 — which org. The default org is proposed, never taken silently; « no » opens the Salesforce
|
|
297
|
+
* login and asks again once an org is connected. Returns { ok } or { stop } with the question to hand back.
|
|
298
|
+
*/
|
|
299
|
+
async function confirmOrg(project, ctx, args, rl) {
|
|
300
|
+
const cfg = ctx.cfg;
|
|
301
|
+
if (cfg.source.mode === 'local') return { ok: true };
|
|
302
|
+
const explicit = typeof args.org === 'string' ? args.org : null;
|
|
303
|
+
let alias = explicit || (cfg.setup.orgConfirmed ? cfg.org.alias : await resolveAlias(project, {}, { org: {} }));
|
|
304
|
+
let status = alias ? await orgStatus(alias) : { ok: false, reason: 'none' };
|
|
305
|
+
const keep = () => { cfg.org.alias = alias; cfg.setup.orgConfirmed = true; ctx.save(cfg); return { ok: true }; };
|
|
306
|
+
if (status.ok && (explicit || cfg.setup.orgConfirmed)) return keep();
|
|
307
|
+
const describe = () => `${c.bold(alias)} (${status.username}, ${status.instanceUrl})`;
|
|
308
|
+
if (!rl) {
|
|
309
|
+
return { stop: alias && status.ok
|
|
310
|
+
? { id: 'org', question: `Generate the TAD from the org "${alias}" (${status.username}, ${status.instanceUrl})?`, recommended: alias, options: [alias, 'another org: the human runs `sf org login web --alias <a>` (opens the browser), then --org <a>', 'local: document the local sources only (--source local)'] }
|
|
311
|
+
: { id: 'org', question: alias ? `The org "${alias}" is not connected (session expired). The human logs in again: \`sf org login web --alias ${alias}\`, then --org ${alias}.` : 'No org connected. The human logs in: `sf org login web --alias <a>` (opens the browser), then --org <a>; or --source local.', recommended: alias || null, human: true } };
|
|
312
|
+
}
|
|
313
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
314
|
+
if (alias && status.ok) {
|
|
315
|
+
const a = (await rl.question(` ${attempt ? 'Connected' : 'Default org'}: ${describe()}\n Generate the TAD from this org? [Y/n/l = local sources only] `)).trim();
|
|
316
|
+
if (yes(a)) return keep();
|
|
317
|
+
if (/^l/i.test(a)) { cfg.source.mode = 'local'; ctx.save(cfg); return { ok: true }; }
|
|
318
|
+
} else if (alias) console.log(` ${c.yellow('⚠')} The org "${alias}" is not connected (session expired): log in again.`);
|
|
319
|
+
// « no », or no usable org: open the Salesforce CLI login and wait for it.
|
|
320
|
+
const def = alias && !status.ok ? alias : 'tad-org';
|
|
321
|
+
const next = (await rl.question(` Alias for the org to connect [${def}]: `)).trim() || def;
|
|
322
|
+
const sandbox = yes((await rl.question(' Is it a sandbox? [y/N] ')).trim(), false);
|
|
323
|
+
log.human('a browser window opens: log in to the org, then click « Allow ». Waiting for the login…');
|
|
324
|
+
if (!sfInteractive(['org', 'login', 'web', '--alias', next, ...(sandbox ? ['--instance-url', 'https://test.salesforce.com'] : [])])) { log.warn('Login did not complete.'); continue; }
|
|
325
|
+
alias = next; status = await orgStatus(alias);
|
|
326
|
+
}
|
|
327
|
+
return { stop: null };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Step 5 — which metadata, and the go before a long retrieve. Returns { mode } or { stop }. */
|
|
331
|
+
async function chooseSource(project, ctx, args, rl) {
|
|
332
|
+
const cfg = ctx.cfg;
|
|
333
|
+
const local = hasLocalSources(project);
|
|
334
|
+
// local sources need no go: there is no long retrieve
|
|
335
|
+
if (cfg.source.mode === 'local' || (cfg.source.mode && cfg.setup.sourceGo)) return { mode: cfg.source.mode };
|
|
336
|
+
if (typeof args.source === 'string') { cfg.source.mode = args.source === 'self' ? 'local' : args.source; cfg.setup.sourceGo = args.source !== 'self'; ctx.save(cfg); if (args.source !== 'self') return { mode: cfg.source.mode }; }
|
|
337
|
+
const org = cfg.source.mode !== 'local' && cfg.org.alias;
|
|
338
|
+
const options = [
|
|
339
|
+
...(org ? [{ id: 'org', text: `retrieve all the metadata of ${cfg.org.alias}, read-only (several minutes)` }] : []),
|
|
340
|
+
...(local ? [{ id: 'local', text: 'use only the metadata already in this project (force-app…)' }] : []),
|
|
341
|
+
{ id: 'self', text: 'you retrieve the metadata yourself first, then give the go' },
|
|
342
|
+
];
|
|
343
|
+
const recommended = org ? 'org' : local ? 'local' : 'self';
|
|
344
|
+
if (!rl) return { stop: { id: 'source', question: 'Which metadata should the TAD be written from? (--source answers it and is the go: the retrieve starts right away)', recommended, options: options.map((o) => `${o.id}: ${o.text}`) } };
|
|
345
|
+
log.step('Metadata for the document:');
|
|
346
|
+
options.forEach((o, i) => console.log(` ${i + 1}. ${o.text}${o.id === recommended ? c.dim(' (recommended)') : ''}`));
|
|
347
|
+
const a = (await rl.question(` Your choice [${options.findIndex((o) => o.id === recommended) + 1}]: `)).trim();
|
|
348
|
+
const pick = options[Number(a) - 1] || options.find((o) => o.id === recommended);
|
|
349
|
+
if (pick.id === 'self') {
|
|
350
|
+
console.log(` Retrieve the metadata into this project, for example:\n ${c.cyan('sf project retrieve start --manifest manifest/package.xml')}`);
|
|
351
|
+
const done = (await rl.question(' Press Enter when your retrieve is done (s = stop for now): ')).trim();
|
|
352
|
+
if (/^s/i.test(done)) return { stop: null };
|
|
353
|
+
cfg.source.mode = 'local'; cfg.setup.sourceGo = true; ctx.save(cfg);
|
|
354
|
+
return { mode: 'local' };
|
|
355
|
+
}
|
|
356
|
+
if (pick.id === 'org' && !yes((await rl.question(' The retrieve takes several minutes, read-only. Start now? [Y/n] ')).trim())) {
|
|
357
|
+
console.log(' Run tadforce doctor again when you are ready: your answers are kept.');
|
|
358
|
+
return { stop: null };
|
|
359
|
+
}
|
|
360
|
+
cfg.source.mode = pick.id; cfg.setup.sourceGo = true; ctx.save(cfg);
|
|
361
|
+
return { mode: pick.id };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Step 6a — logo and colours found from evidence, confirmed in one question. */
|
|
365
|
+
async function confirmBranding(ctx, args, rl, say) {
|
|
366
|
+
for (const n of await brand(ctx)) say(() => log.ok(n));
|
|
367
|
+
const cfg = loadWorkspace(ctx.project).cfg;
|
|
368
|
+
if (cfg.setup.brandingConfirmed) return null;
|
|
369
|
+
const logo = cfg.client.logo || 'none (a badge with the initials)';
|
|
370
|
+
const colours = cfg.brand.source ? `${cfg.brand.gradient[0]} (from the ${cfg.brand.source})` : 'TADForce default colours';
|
|
371
|
+
if (!rl) return { id: 'branding', question: `Branding: logo ${logo}; colours ${colours}. Keep it? (--branding keep, or --logo <file|url>)`, recommended: 'keep' };
|
|
372
|
+
const a = (await rl.question(` Logo: ${c.bold(logo)} · colours: ${c.bold(colours)}\n Keep it? [Y / path or URL of another logo] `)).trim();
|
|
373
|
+
const c2 = loadWorkspace(ctx.project);
|
|
374
|
+
if (a && !yes(a, false) && !/^n/i.test(a)) {
|
|
375
|
+
try { c2.cfg.client.logo = await importAsset(c2, a, 'client-logo'); c2.save(c2.cfg); for (const n of await brand(loadWorkspace(ctx.project))) say(() => log.ok(n)); } catch (e) { log.warn(e.message); }
|
|
376
|
+
}
|
|
377
|
+
const c3 = loadWorkspace(ctx.project); c3.cfg.setup.brandingConfirmed = true; c3.save(c3.cfg);
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
222
381
|
/** One pass: where the workspace stands, and what remains. */
|
|
223
382
|
async function evaluate(project, args) {
|
|
224
383
|
const tools = await checks(project);
|
|
225
384
|
const ws = findWorkspace(project);
|
|
226
385
|
const cfg = ws ? defaultConfig(readJson(path.join(ws, CONFIG_FILE))) : defaultConfig();
|
|
227
|
-
const alias = tools.find((t) => t.id === 'sf-cli').ok ? await resolveAlias(project, args, cfg) : null;
|
|
228
|
-
const status = alias ? await orgStatus(alias) : { ok: false, reason: 'none' };
|
|
229
386
|
const list = [...tools];
|
|
230
387
|
const add = (id, ok, message, fix = null, human = false, level = ok ? 'ok' : 'error') => list.push({ id, ok, level, message, fix, human });
|
|
231
|
-
add('workspace',
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
else if (status.reason === 'none') add('org', false, 'No org connected to this project', 'log in once in the browser (doctor opens it), or `--source local` to document the local sources only', true);
|
|
236
|
-
else add('org', false, `Org "${alias}" is not connected (${status.detail || 'session expired'})`, 'log in again in the browser (doctor opens it)', true);
|
|
388
|
+
if (ws) add('workspace', true, `Workspace: ${path.relative(project, ws) || '.'}`);
|
|
389
|
+
if (cfg.source.mode !== 'local' && cfg.setup.orgConfirmed && cfg.org.alias) {
|
|
390
|
+
const status = await orgStatus(cfg.org.alias);
|
|
391
|
+
add('org', status.ok, status.ok ? `Org: ${cfg.org.alias} (${status.instanceUrl})` : `Org "${cfg.org.alias}" is not connected`, status.ok ? null : 'log in again (doctor opens the browser)', !status.ok);
|
|
237
392
|
}
|
|
238
|
-
let facts = false
|
|
393
|
+
let facts = false;
|
|
239
394
|
if (ws) {
|
|
240
395
|
const ctx = loadWorkspace(project);
|
|
241
|
-
snapshot = exists(ctx.p(ctx.cfg.source.dir));
|
|
242
396
|
facts = exists(ctx.p('facts', 'facts.json'));
|
|
243
397
|
const lang = normalizeLang(ctx.cfg.language);
|
|
244
398
|
const own = ctx.p('i18n', `${lang}.json`);
|
|
245
399
|
if (!LANGUAGES.includes(lang) && exists(own) && readJson(own)._translate) add('language', true, `Document language "${lang}": headings and labels still to translate (once)`, `the agent translates the headings of src/*/*.md (keep the {#anchors}) and the values of tadforce/i18n/${lang}.json, then removes its "_translate" line`, false, 'warn');
|
|
246
|
-
add('metadata',
|
|
400
|
+
if (facts) add('metadata', true, `Metadata: ${exists(ctx.p(ctx.cfg.source.dir)) && ctx.cfg.source.mode !== 'local' ? 'retrieved from the org' : 'local sources'} → facts/facts.json`);
|
|
247
401
|
}
|
|
248
|
-
return { tools, ws, cfg,
|
|
402
|
+
return { tools, ws, cfg, list, facts };
|
|
249
403
|
}
|
|
250
404
|
|
|
251
405
|
export async function run(args) {
|
|
252
|
-
const
|
|
406
|
+
const start = path.resolve(args.cwd || process.cwd());
|
|
253
407
|
const interactive = process.stdin.isTTY && !args.json && !args.yes;
|
|
254
408
|
const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
255
409
|
const say = (fn) => { if (!args.json) fn(); };
|
|
256
410
|
if (args.json) setQuiet(true);
|
|
257
411
|
say(() => log.banner('doctor'));
|
|
258
412
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
const blocking = st.tools.filter((t) => t.level === 'error');
|
|
268
|
-
if (blocking.length) return finish(args, st, [], [], 'install the missing tools, then run tadforce doctor again');
|
|
269
|
-
|
|
270
|
-
// 2. Workspace (with the skills for the agents of this project)
|
|
271
|
-
if (!st.ws) {
|
|
272
|
-
await quietly(args.json, () => { ensureWorkspace(project, defaultConfig({ org: { alias: st.alias || null } })); installSkills(project, null); });
|
|
273
|
-
say(() => log.ok('Workspace created: tadforce/ (skills installed for your agents)'));
|
|
274
|
-
st = await evaluate(project, args);
|
|
275
|
-
}
|
|
276
|
-
const ctx = loadWorkspace(project);
|
|
277
|
-
ctx.cfg.setup = ctx.cfg.setup || {};
|
|
278
|
-
const hasLocal = (readJson(path.join(project, 'sfdx-project.json')).packageDirectories || []).some((d) => exists(path.join(project, d.path)) && fs.readdirSync(path.join(project, d.path), { recursive: true }).some((f) => String(f).endsWith('-meta.xml')));
|
|
279
|
-
await applyAnswers(ctx, args);
|
|
280
|
-
|
|
281
|
-
// 3. Org connection: reuse the project's target-org, log in only when needed.
|
|
282
|
-
if (ctx.cfg.source.mode !== 'local' && !st.status.ok) {
|
|
283
|
-
if (!rl) return finish(args, st, [], [], 'the human logs in: run `tadforce doctor` in a terminal (it opens the browser), or `sf org login web --alias <alias> --set-default`');
|
|
284
|
-
if (hasLocal && /^l/i.test((await rl.question(' No org connected. Log in now, or document the local sources only? [Login/local] ')).trim())) { ctx.cfg.source.mode = 'local'; ctx.save(ctx.cfg); continue; }
|
|
285
|
-
const def = st.alias || 'tadforce-org';
|
|
286
|
-
const alias = (await rl.question(` Org alias to log in [${def}]: `)).trim() || def;
|
|
287
|
-
const sandbox = /^(y|o|yes|oui)$/i.test((await rl.question(' Is it a sandbox? [y/N] ')).trim());
|
|
288
|
-
log.human('a browser window opens: log in to the org, then click « Allow ».');
|
|
289
|
-
if (sfInteractive(['org', 'login', 'web', '--alias', alias, '--set-default', ...(sandbox ? ['--instance-url', 'https://test.salesforce.com'] : [])])) { ctx.cfg.org.alias = alias; ctx.save(ctx.cfg); }
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
if (st.alias && ctx.cfg.org.alias !== st.alias && st.status.ok) { ctx.cfg.org.alias = st.alias; ctx.save(ctx.cfg); }
|
|
293
|
-
|
|
294
|
-
// 4. Framing questions (one batch, each with a recommendation)
|
|
295
|
-
const prop = st.status.ok ? await proposals(st.alias) : {};
|
|
296
|
-
const framing = framingQuestions(ctx.cfg, prop, st.status.ok ? st.alias : null, hasLocal);
|
|
297
|
-
if (framing.length) {
|
|
298
|
-
if (!rl) return finish(args, st, framing, [], 'ask the human these questions in one message, then run `tadforce doctor --json --confirm --<id> "<answer>"…`');
|
|
299
|
-
log.step('A few facts only you can confirm (Enter keeps the recommendation):');
|
|
300
|
-
const answers = {};
|
|
301
|
-
for (const q of framing) {
|
|
302
|
-
const a = (await rl.question(` ${q.question}${q.options ? ` (${q.options.join('/')})` : ''}${q.recommended ? ` [${q.recommended}]` : ''}: `)).trim();
|
|
303
|
-
answers[q.id] = a || q.recommended || (q.optional ? '' : undefined);
|
|
304
|
-
if (q.id === 'lang' && /^(other|autre)$/i.test(a)) answers.lang = (await rl.question(' Language code (es, de, it, pt, nl, ar, ja…): ')).trim() || 'en';
|
|
305
|
-
}
|
|
306
|
-
const flags = { confirm: true, specs: answers.specs || false };
|
|
307
|
-
for (const [k, v] of Object.entries(answers)) if (v !== undefined) flags[k === 'logo' ? 'logo' : k] = v === '' && ['integrator', 'author'].includes(k) ? 'none' : v === '' ? undefined : v;
|
|
308
|
-
await applyAnswers(loadWorkspace(project), flags);
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
413
|
+
// 1. Tools: TADForce installs what it may, the rest is a human action.
|
|
414
|
+
let st = await evaluate(start, args);
|
|
415
|
+
if (st.tools.find((t) => t.id === 'sf-cli' && !t.ok) && rl && yes((await rl.question(' Salesforce CLI missing. Install it now with npm? [Y/n] ')).trim())) {
|
|
416
|
+
spawnSync('npm', ['install', '--global', '@salesforce/cli'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
417
|
+
st = await evaluate(start, args);
|
|
418
|
+
}
|
|
419
|
+
if (st.tools.some((t) => t.level === 'error' && t.id !== 'sfdx-project')) return finish(args, st, [], 'install the missing tools, then run tadforce doctor again');
|
|
311
420
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
421
|
+
// 2. The Salesforce project the TAD is about (confirmed, never created without a yes).
|
|
422
|
+
const p = await resolveProject(start, args, rl);
|
|
423
|
+
if (typeof p !== 'string') return p.stop ? finish(args, st, [p.stop], 'ask the human, then run `tadforce doctor --json --project <yes|path|create>`') : 1;
|
|
424
|
+
const project = p;
|
|
425
|
+
if (project !== start) say(() => log.ok(`Project: ${project}`));
|
|
426
|
+
if (!findWorkspace(project)) {
|
|
427
|
+
await quietly(args.json, () => { ensureWorkspace(project, defaultConfig()); installSkills(project, null); });
|
|
428
|
+
say(() => log.ok('Workspace created: tadforce/ (skills installed for your agents)'));
|
|
429
|
+
}
|
|
430
|
+
let ctx = loadWorkspace(project);
|
|
431
|
+
ctx.cfg.setup.projectConfirmed = true; ctx.save(ctx.cfg);
|
|
432
|
+
await applyAnswers(ctx, args);
|
|
433
|
+
ctx = loadWorkspace(project);
|
|
434
|
+
|
|
435
|
+
// 3. The org: the default one is proposed; « no » opens the login and asks again.
|
|
436
|
+
const o = await confirmOrg(project, ctx, args, rl);
|
|
437
|
+
if (!o.ok) return o.stop ? finish(args, await evaluate(project, args), [o.stop], 'ask the human, then run `tadforce doctor --json --org <alias>` (or --source local)') : 1;
|
|
438
|
+
ctx = loadWorkspace(project);
|
|
325
439
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
440
|
+
// 4. Framing: 6 questions, each with a recommended answer; « accept all » in one keystroke.
|
|
441
|
+
const prop = ctx.cfg.org.alias && ctx.cfg.source.mode !== 'local' ? await proposals(ctx.cfg.org.alias) : {};
|
|
442
|
+
const framing = framingQuestions(ctx.cfg, prop);
|
|
443
|
+
if (framing.length) {
|
|
444
|
+
if (!rl) return finish(args, await evaluate(project, args), framing, 'ask the human in one message (recommended answers shown; « accept all » is fine), then run `tadforce doctor --json --confirm --lang … --client "…" --integrator …|none --author …|none [--specs …|--no-specs] [--scope …]`');
|
|
445
|
+
const a = await askBatch(rl, 'A few facts only you can confirm:', framing);
|
|
446
|
+
await applyAnswers(loadWorkspace(project), {
|
|
447
|
+
confirm: true, lang: a.lang, client: a.client || undefined,
|
|
448
|
+
integrator: a.integrator || 'none', author: a.author || 'none',
|
|
449
|
+
specs: a.specs || false, scope: a.scope || undefined,
|
|
450
|
+
});
|
|
451
|
+
ctx = loadWorkspace(project);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 5. Metadata: org, local or retrieved by the user — and a go before a long retrieve.
|
|
455
|
+
if (!exists(ctx.p('facts', 'facts.json')) || args.refresh) {
|
|
456
|
+
const s = await chooseSource(project, ctx, args, rl);
|
|
457
|
+
if (!s.mode) return s.stop ? finish(args, await evaluate(project, args), [s.stop], 'ask the human; the answer is the go: `tadforce doctor --json --source org|local|self`') : 1;
|
|
458
|
+
const a = { ...args, cwd: project, org: s.mode === 'local' ? false : ctx.cfg.org.alias, quiet: args.json };
|
|
459
|
+
if (s.mode !== 'local') {
|
|
460
|
+
say(() => log.step('Retrieving the metadata from the org (read-only, into tadforce/.snapshot)…'));
|
|
461
|
+
for (const fn of [org.manifest, org.retrieve]) { const code = await quietly(args.json, () => fn(a)); if (code) return finish(args, await evaluate(project, args), [], 'the retrieve failed: see the messages above'); }
|
|
338
462
|
}
|
|
463
|
+
await quietly(args.json, () => docs.facts(a));
|
|
464
|
+
await quietly(args.json, () => docs.scaffold(a));
|
|
465
|
+
ctx = loadWorkspace(project);
|
|
466
|
+
}
|
|
339
467
|
|
|
340
|
-
|
|
341
|
-
|
|
468
|
+
// 6. Branding (logo, colours) and what the metadata cannot tell — recommended answers again.
|
|
469
|
+
const b = await confirmBranding(ctx, args, rl, say);
|
|
470
|
+
if (b) return finish(args, await evaluate(project, args), [b], 'ask the human, then run `tadforce doctor --json --branding keep` (or --logo <file|url>)');
|
|
471
|
+
const fq = factQuestions(loadWorkspace(project));
|
|
472
|
+
if (fq.length) {
|
|
473
|
+
if (!rl) return finish(args, await evaluate(project, args), fq, 'ask the human in one message (recommended answers shown), write the answers to the knowledge files, then run `tadforce doctor --json --facts-reviewed`');
|
|
474
|
+
const changed = await askFacts(loadWorkspace(project), rl, fq);
|
|
475
|
+
const c2 = loadWorkspace(project); c2.cfg.setup.factsReviewed = true; c2.save(c2.cfg);
|
|
476
|
+
if (changed) await quietly(args.json, () => docs.facts({ cwd: project, org: c2.cfg.source.mode === 'local' ? false : c2.cfg.org.alias }));
|
|
342
477
|
}
|
|
343
|
-
|
|
344
|
-
|
|
478
|
+
|
|
479
|
+
// 7. All green: say it, and offer to start the writing.
|
|
480
|
+
const code = finish(args, await evaluate(project, args), [], null);
|
|
481
|
+
// CI, agents (--json) and agent sessions stop here; a person in a terminal is offered the writing.
|
|
482
|
+
if (code || args.json || args.launch === false || !process.stdin.isTTY || insideAgent()) return code;
|
|
483
|
+
return handOver(project, args, rl);
|
|
345
484
|
} finally {
|
|
346
485
|
if (rl) rl.close();
|
|
347
486
|
}
|
|
348
487
|
}
|
|
349
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Everything is green: offer to start the AI agent on the writing, in this terminal. The agent is chosen
|
|
491
|
+
* once (--agent claude|codex|gemini|cursor) and remembered; --no-launch stops at the prerequisites.
|
|
492
|
+
*/
|
|
493
|
+
async function handOver(project, args, rl) {
|
|
494
|
+
const ctx = loadWorkspace(project);
|
|
495
|
+
const found = installedAgents();
|
|
496
|
+
const wanted = typeof args.agent === 'string' ? args.agent : ctx.cfg.setup.agent;
|
|
497
|
+
let agent = found.find((a) => a.id === wanted) || (wanted ? null : found[0]);
|
|
498
|
+
if (!agent) {
|
|
499
|
+
log.human(found.length ? `agent "${wanted}" not found; installed: ${found.map((a) => a.id).join(', ')} (--agent <id>).` : 'open your AI agent (Claude Code, Codex, Cursor, Copilot…) in this folder and type /tadforce to write the document.');
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
if (rl) {
|
|
503
|
+
if (!yes((await rl.question(` Launch the writing of the document now with ${agent.name}? [Y/n] `)).trim())) {
|
|
504
|
+
log.info(`Later: open your agent in ${project} and type /tadforce.`);
|
|
505
|
+
rl.close(); return 0;
|
|
506
|
+
}
|
|
507
|
+
if (found.length > 1 && !wanted) {
|
|
508
|
+
const pick = (await rl.question(` Agent (${found.map((a) => a.id).join('/')}) [${agent.id}]: `)).trim();
|
|
509
|
+
agent = found.find((a) => a.id === pick) || agent;
|
|
510
|
+
}
|
|
511
|
+
rl.close();
|
|
512
|
+
}
|
|
513
|
+
ctx.cfg.setup.agent = agent.id; ctx.save(ctx.cfg);
|
|
514
|
+
log.step(`Handing over to ${agent.name}: it writes the document, then builds and validates it…`);
|
|
515
|
+
return launchAgent(agent, project);
|
|
516
|
+
}
|
|
517
|
+
|
|
350
518
|
async function quietly(json, fn) {
|
|
351
519
|
if (!json) return fn();
|
|
352
520
|
const orig = console.log; console.log = () => {};
|
|
353
521
|
try { return await fn(); } finally { console.log = orig; }
|
|
354
522
|
}
|
|
355
523
|
|
|
356
|
-
function finish(args, st,
|
|
524
|
+
function finish(args, st, questions, next) {
|
|
357
525
|
const ok = !next && st.list.every((x) => x.level !== 'error');
|
|
358
526
|
if (args.json) {
|
|
359
|
-
console.log(JSON.stringify({ ok, checks: st.list, questions
|
|
527
|
+
console.log(JSON.stringify({ ok, checks: st.list, questions, next: next || 'tadforce build' }, null, 2));
|
|
360
528
|
return ok ? 0 : 1;
|
|
361
529
|
}
|
|
362
530
|
for (const x of st.list) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// AI agents TADForce can hand over to once `doctor` is green: the agent writes the document.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
const RUN = '/tadforce';
|
|
5
|
+
const PROMPT = 'Use the tadforce skill: the prerequisites are green (tadforce doctor). Write every section of the Technical Architecture Document and the Improvement Axes, then build and validate them.';
|
|
6
|
+
|
|
7
|
+
/** Command-line agents, in order of preference: the argument list that starts an interactive session on the task. */
|
|
8
|
+
export const AGENTS = [
|
|
9
|
+
{ id: 'claude', name: 'Claude Code', bin: 'claude', args: [RUN] },
|
|
10
|
+
{ id: 'codex', name: 'Codex', bin: 'codex', args: [PROMPT] },
|
|
11
|
+
{ id: 'gemini', name: 'Gemini CLI', bin: 'gemini', args: ['-i', PROMPT] },
|
|
12
|
+
{ id: 'cursor', name: 'Cursor Agent', bin: 'cursor-agent', args: [PROMPT] },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const onPath = (bin) => spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' }).status === 0;
|
|
16
|
+
|
|
17
|
+
/** Agents installed on this machine. */
|
|
18
|
+
export function installedAgents() {
|
|
19
|
+
return AGENTS.filter((a) => onPath(a.bin));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True when TADForce already runs inside an agent session: never start another agent from there. */
|
|
23
|
+
export function insideAgent() {
|
|
24
|
+
const e = process.env;
|
|
25
|
+
return !!(e.CLAUDECODE || e.CLAUDE_CODE_ENTRYPOINT || e.CODEX_SANDBOX || e.CODEX_HOME && e.CODEX_SESSION || e.GEMINI_CLI || e.CURSOR_AGENT || e.TADFORCE_NO_LAUNCH);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Start the agent in this terminal, in the project folder. Returns its exit code. */
|
|
29
|
+
export function launchAgent(agent, cwd) {
|
|
30
|
+
const quote = (a) => (/^[\w./-]+$/.test(a) ? a : `"${a.replace(/"/g, '\\"')}"`);
|
|
31
|
+
const r = process.platform === 'win32'
|
|
32
|
+
? spawnSync([agent.bin, ...agent.args].map(quote).join(' '), [], { cwd, stdio: 'inherit', shell: true })
|
|
33
|
+
: spawnSync(agent.bin, agent.args, { cwd, stdio: 'inherit' });
|
|
34
|
+
return r.status ?? 1;
|
|
35
|
+
}
|