@mmnto/cli 1.113.1 → 1.115.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/dist/commands/doctor-seat-identity.d.ts +71 -0
- package/dist/commands/doctor-seat-identity.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.js +352 -0
- package/dist/commands/doctor-seat-identity.js.map +1 -0
- package/dist/commands/doctor-seat-identity.test.d.ts +13 -0
- package/dist/commands/doctor-seat-identity.test.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.test.js +230 -0
- package/dist/commands/doctor-seat-identity.test.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +1 -0
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +18 -7
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +63 -1
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/eject.d.ts +62 -3
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +382 -47
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +557 -7
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gemini-beforetool-contract.test.d.ts +2 -0
- package/dist/commands/gemini-beforetool-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-beforetool-contract.test.js +106 -0
- package/dist/commands/gemini-beforetool-contract.test.js.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts +2 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.js +184 -0
- package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -0
- package/dist/commands/init-templates.d.ts +6 -6
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +271 -19
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts +40 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +357 -32
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +546 -10
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +5 -0
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +26 -8
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +51 -6
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/mail-degraded-e4.test.js +126 -0
- package/dist/commands/mail-degraded-e4.test.js.map +1 -1
- package/dist/commands/mail.d.ts +19 -1
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +195 -30
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +291 -3
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/orient.d.ts.map +1 -1
- package/dist/commands/orient.js +82 -0
- package/dist/commands/orient.js.map +1 -1
- package/dist/commands/orient.test.js +144 -1
- package/dist/commands/orient.test.js.map +1 -1
- package/dist/commands/seat-cli-wiring.test.d.ts +20 -0
- package/dist/commands/seat-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/seat-cli-wiring.test.js +147 -0
- package/dist/commands/seat-cli-wiring.test.js.map +1 -0
- package/dist/commands/seat-poll-chain.test.d.ts +13 -0
- package/dist/commands/seat-poll-chain.test.d.ts.map +1 -0
- package/dist/commands/seat-poll-chain.test.js +103 -0
- package/dist/commands/seat-poll-chain.test.js.map +1 -0
- package/dist/commands/seat.d.ts +92 -0
- package/dist/commands/seat.d.ts.map +1 -0
- package/dist/commands/seat.js +649 -0
- package/dist/commands/seat.js.map +1 -0
- package/dist/commands/seat.test.d.ts +19 -0
- package/dist/commands/seat.test.d.ts.map +1 -0
- package/dist/commands/seat.test.js +532 -0
- package/dist/commands/seat.test.js.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts +2 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.js +176 -0
- package/dist/commands/sessionstart-manifest-contract.test.js.map +1 -0
- package/dist/hooks/__tests__/auto-context.test.js +20 -0
- package/dist/hooks/__tests__/auto-context.test.js.map +1 -1
- package/dist/hooks/auto-context.d.ts +19 -0
- package/dist/hooks/auto-context.d.ts.map +1 -1
- package/dist/hooks/auto-context.js +22 -4
- package/dist/hooks/auto-context.js.map +1 -1
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/commands/init.js
CHANGED
|
@@ -188,7 +188,39 @@ export function wirePreparePackageJson(pkgPath) {
|
|
|
188
188
|
return { action: 'wired' };
|
|
189
189
|
}
|
|
190
190
|
// --- Gemini CLI hook installer ---
|
|
191
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Whether a consumer owns a custom `<stem>.js` sibling of a managed `<stem>.cjs` vendor
|
|
193
|
+
* hook (mmnto-ai/totem#2601 — the strategy#482 incident: the generic template dropped
|
|
194
|
+
* beside a repo's own SessionStart.js, double-firing or regressing orientation depending
|
|
195
|
+
* on Gemini's hook discovery). Ownership is the same positional gate `scaffoldFile` and
|
|
196
|
+
* `migrateLegacyGeminiHooks` use, so a marker-headed sibling — the legacy `.js`→`.cjs`
|
|
197
|
+
* migration case — never reports here; only an UNOWNED sibling does. A sibling that
|
|
198
|
+
* cannot be read is not provably totem-owned and reports (non-destructive posture,
|
|
199
|
+
* never a crash). Returns the sibling's basename when one is present.
|
|
200
|
+
*
|
|
201
|
+
* This is a PREDICATE, not the policy: whether a report withholds the scaffold or only
|
|
202
|
+
* discloses a coexistence depends on whether the managed `.cjs` already exists — see
|
|
203
|
+
* `installGeminiHooks`, the sole caller.
|
|
204
|
+
*/
|
|
205
|
+
export function findUnownedHookSibling(cjsPath) {
|
|
206
|
+
if (!cjsPath.endsWith('.cjs'))
|
|
207
|
+
return undefined;
|
|
208
|
+
const siblingPath = `${cjsPath.slice(0, -'.cjs'.length)}.js`;
|
|
209
|
+
if (!fs.existsSync(siblingPath))
|
|
210
|
+
return undefined;
|
|
211
|
+
// totem-context: intentional cleanup — an unreadable sibling cannot be proven totem-owned, so it withholds the scaffold (non-destructive) rather than aborting init.
|
|
212
|
+
try {
|
|
213
|
+
const existing = fs.readFileSync(siblingPath, 'utf-8');
|
|
214
|
+
if (markerOpensFile(existing, TOTEM_FILE_MARKER))
|
|
215
|
+
return undefined;
|
|
216
|
+
// totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// fall through to withhold
|
|
220
|
+
}
|
|
221
|
+
return path.basename(siblingPath);
|
|
222
|
+
}
|
|
223
|
+
export async function installGeminiHooks(cwd) {
|
|
192
224
|
const results = [];
|
|
193
225
|
// The two whole-file hooks carry the managed end marker (mmnto-ai/totem#2410) so a
|
|
194
226
|
// drifted-but-bounded artifact self-repairs on re-init; the skill is marker-block
|
|
@@ -218,13 +250,43 @@ async function installGeminiHooks(cwd) {
|
|
|
218
250
|
];
|
|
219
251
|
for (const { rel, content, marker, endMarker } of files) {
|
|
220
252
|
const filePath = path.join(cwd, rel);
|
|
253
|
+
// Vendor-hook managed-marker guard (mmnto-ai/totem#2601): a custom same-stem `.js`
|
|
254
|
+
// the consumer owns withholds the managed `.cjs` — the drop is disclosed, never
|
|
255
|
+
// silent, because a withheld hook otherwise reads as an installed one.
|
|
256
|
+
//
|
|
257
|
+
// The withhold is scoped to the FRESH path (no `.cjs` on disk yet), where it is what
|
|
258
|
+
// prevents the twin from being introduced. Once the `.cjs` EXISTS the twin is already
|
|
259
|
+
// live: withholding there would suppress drift-repair of a file that fires anyway and
|
|
260
|
+
// would report "not installed" about an installed hook. That case scaffolds normally
|
|
261
|
+
// and discloses the coexistence plus the two ways out.
|
|
262
|
+
const unownedSibling = findUnownedHookSibling(filePath);
|
|
263
|
+
if (unownedSibling !== undefined && !fs.existsSync(filePath)) {
|
|
264
|
+
results.push({
|
|
265
|
+
file: rel,
|
|
266
|
+
action: 'skipped',
|
|
267
|
+
summaryActionOverride: `Skipped — custom ${unownedSibling} present — totem hook not installed; remove or rename it to adopt the managed hook`,
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
221
271
|
const result = scaffoldFile(filePath, content, marker, endMarker);
|
|
222
272
|
// Map the scaffold action onto HookInstallerResult: a bounded drift-repair
|
|
223
273
|
// (`refreshed`) surfaces as `merged` (file mutated) for installer summary parity
|
|
224
274
|
// with scaffoldClaudeSkill's mapping.
|
|
275
|
+
const action = result.action === 'refreshed' ? 'merged' : result.action;
|
|
276
|
+
// The coexistence line is true only when the `.cjs` really is totem's — `exists`
|
|
277
|
+
// (canonical) or `merged` (drift-repaired). A USER-owned `.cjs` (no marker) takes
|
|
278
|
+
// scaffoldFile's preserve path: totem installed nothing, so claiming a "managed
|
|
279
|
+
// .cjs" and steering the user to delete their own file would be false both ways
|
|
280
|
+
// (scoped leg round 2, D3) — that state gets the truthful not-installed line.
|
|
281
|
+
const coexistenceOverride = unownedSibling === undefined
|
|
282
|
+
? undefined
|
|
283
|
+
: action === 'exists' || action === 'merged'
|
|
284
|
+
? `Custom ${unownedSibling} coexists with the managed ${path.basename(filePath)} — both may fire; remove one (delete the managed .cjs to keep yours, or your .js to keep totem's)`
|
|
285
|
+
: `Custom ${unownedSibling} and ${path.basename(filePath)} are both present and neither is totem's — totem hook not installed`;
|
|
225
286
|
results.push({
|
|
226
287
|
file: rel,
|
|
227
|
-
action
|
|
288
|
+
action,
|
|
289
|
+
...(coexistenceOverride !== undefined ? { summaryActionOverride: coexistenceOverride } : {}),
|
|
228
290
|
...(result.err ? { err: result.err } : {}),
|
|
229
291
|
});
|
|
230
292
|
}
|
|
@@ -236,7 +298,7 @@ async function installGeminiHooks(cwd) {
|
|
|
236
298
|
// Dynamic-imported to keep the migration co-located with the upgrade-path
|
|
237
299
|
// drift-repair machinery.
|
|
238
300
|
const { migrateGeminiHookRegistration, migrateLegacyGeminiHooks } = await import('./install-hooks.js');
|
|
239
|
-
for (const { file, action } of await migrateLegacyGeminiHooks(cwd)) {
|
|
301
|
+
for (const { file, action, reason } of await migrateLegacyGeminiHooks(cwd)) {
|
|
240
302
|
if (action === 'migrated') {
|
|
241
303
|
results.push({
|
|
242
304
|
file,
|
|
@@ -246,6 +308,23 @@ async function installGeminiHooks(cwd) {
|
|
|
246
308
|
summaryActionOverride: `Migrated legacy Gemini ${path.basename(file)} → .cjs`,
|
|
247
309
|
});
|
|
248
310
|
}
|
|
311
|
+
else if (action === 'skipped' && reason === 'unreadable-legacy') {
|
|
312
|
+
// Unreadable outcomes are the non-migrated ones init discloses: the path
|
|
313
|
+
// exists and could not be read at all, so the user is the only one who can
|
|
314
|
+
// resolve it — and "user-owned" must never render for them (#2601).
|
|
315
|
+
results.push({
|
|
316
|
+
file,
|
|
317
|
+
action: 'skipped',
|
|
318
|
+
summaryActionOverride: `Skipped — legacy ${path.basename(file)} could not be read; left in place (remove or rename it to complete the .cjs migration)`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
else if (action === 'skipped' && reason === 'unreadable-successor') {
|
|
322
|
+
results.push({
|
|
323
|
+
file,
|
|
324
|
+
action: 'skipped',
|
|
325
|
+
summaryActionOverride: `Skipped — the .cjs successor of ${path.basename(file)} could not be read; left both files untouched (remove or rename the successor to complete the migration)`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
249
328
|
}
|
|
250
329
|
const registration = migrateGeminiHookRegistration(cwd);
|
|
251
330
|
if (registration.err) {
|
|
@@ -487,7 +566,152 @@ if (claudeTool)
|
|
|
487
566
|
const geminiTool = AI_TOOLS.find((t) => t.name === 'Gemini CLI');
|
|
488
567
|
if (geminiTool)
|
|
489
568
|
geminiTool.hookInstaller = installGeminiHooks;
|
|
490
|
-
|
|
569
|
+
/** The npm package every Totem MCP registration invokes, under whatever entry name. */
|
|
570
|
+
const MCP_PACKAGE = '@mmnto/mcp';
|
|
571
|
+
/**
|
|
572
|
+
* Boundary-anchored name probe. A bare `includes('@mmnto/mcp')` also fires on
|
|
573
|
+
* `@mmnto/mcp-experimental` — a DIFFERENT package whose presence must not suppress the
|
|
574
|
+
* real registration. The lookahead rejects a following word char, hyphen, or dot
|
|
575
|
+
* (`@mmnto/mcp.js` is a different package name too); a `/`, an `@`, a quote, a space,
|
|
576
|
+
* or end-of-string all still match (`@mmnto/mcp/dist/index.js`, `@mmnto/mcp@1.2.3`).
|
|
577
|
+
* Non-global, so it carries no `lastIndex` state across calls.
|
|
578
|
+
*/
|
|
579
|
+
const MCP_PACKAGE_RE = /@mmnto\/mcp(?![\w.-])/;
|
|
580
|
+
/**
|
|
581
|
+
* Whether a `command`/`args` string names a JS-family SCRIPT FILE worth an identity
|
|
582
|
+
* walk. A suffix gate, not a separator gate: a separator-shaped non-script arg
|
|
583
|
+
* (`--cwd ./`) resolves to a DIRECTORY, and `path.dirname` on a directory would start
|
|
584
|
+
* the walk one level above it — outside the config, potentially outside the repo
|
|
585
|
+
* (scoped leg round 2, D2). Non-global, so `.test()` carries no `lastIndex` state.
|
|
586
|
+
*/
|
|
587
|
+
const SCRIPT_FILE_RE = /\.(?:cjs|mjs|js)$/i;
|
|
588
|
+
function looksLikeScriptPath(value) {
|
|
589
|
+
return SCRIPT_FILE_RE.test(value);
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Whether a `command` is a Node-family runtime — the only commands whose FIRST
|
|
593
|
+
* script-suffixed argument is, by the runtime's own semantics, the ENTRYPOINT.
|
|
594
|
+
* The path probe is scoped to this shape: probing every `.js` argument of every
|
|
595
|
+
* command would let an unrelated server's plugin/config argument (a file totem
|
|
596
|
+
* happens to own) masquerade as a totem registration and silently suppress the
|
|
597
|
+
* real install (Greptile P2 on #2606). Package-manager launchers (`npx`,
|
|
598
|
+
* `pnpm dlx`, `cmd /c npx …`) name the package instead — the name probe's job.
|
|
599
|
+
*/
|
|
600
|
+
function isNodeCommand(command) {
|
|
601
|
+
const base = path.basename(command).toLowerCase();
|
|
602
|
+
return base === 'node' || base === 'node.exe';
|
|
603
|
+
}
|
|
604
|
+
/** No-throw file probe: only an existing regular FILE qualifies for the identity walk. */
|
|
605
|
+
function isExistingFile(p) {
|
|
606
|
+
// totem-context: intentional cleanup — an unstat-able path is simply not an attributable registration; dedup must never abort the init scaffold.
|
|
607
|
+
try {
|
|
608
|
+
return fs.statSync(p).isFile();
|
|
609
|
+
// totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The package name owning a resolved script: walk from the script's directory to the
|
|
617
|
+
* filesystem root and read the nearest `package.json`'s `name`. A `package.json` that
|
|
618
|
+
* names nothing (the `{"type":"module"}` dual-package shim inside a `dist/`) is not an
|
|
619
|
+
* identity, so the walk continues past it.
|
|
620
|
+
*/
|
|
621
|
+
function packageNameForScript(scriptPath) {
|
|
622
|
+
let dir = path.dirname(scriptPath);
|
|
623
|
+
for (;;) {
|
|
624
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
625
|
+
if (fs.existsSync(pkgPath)) {
|
|
626
|
+
// totem-context: intentional cleanup — an unreadable/invalid package.json is not evidence of a DIFFERENT package, so the walk continues to the next parent; dedup must never abort the init scaffold.
|
|
627
|
+
try {
|
|
628
|
+
// `JSON.parse` also yields `null`, arrays, and scalars — checked explicitly
|
|
629
|
+
// (the wirePreparePackageJson posture) rather than left to throw into the catch.
|
|
630
|
+
const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
631
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
632
|
+
const { name } = parsed;
|
|
633
|
+
if (typeof name === 'string')
|
|
634
|
+
return name;
|
|
635
|
+
}
|
|
636
|
+
// totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// fall through to the next parent
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// `path.dirname` is a fixpoint at the filesystem root — that fixpoint ends the walk.
|
|
643
|
+
const parent = path.dirname(dir);
|
|
644
|
+
if (parent === dir)
|
|
645
|
+
return undefined;
|
|
646
|
+
dir = parent;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Find an existing registration of the Totem MCP package under ANY entry name
|
|
651
|
+
* (mmnto-ai/totem#2601). Dedup keys on the registered PACKAGE, not the entry name: a
|
|
652
|
+
* consumer that registered `@mmnto/mcp` as `totem-dev` / `totem-strategy` (a `--cwd`
|
|
653
|
+
* pin, a local path pin) must not collect a third duplicate `totem` entry on re-init.
|
|
654
|
+
*
|
|
655
|
+
* Two probes, both scoped to `command` and the string members of `args` — the only
|
|
656
|
+
* fields that can INVOKE anything. There is deliberately no whole-entry serialization
|
|
657
|
+
* scan: it false-positives on a prose mention in `env`, a field that cannot invoke
|
|
658
|
+
* anything.
|
|
659
|
+
*
|
|
660
|
+
* 1. Name probe — {@link MCP_PACKAGE_RE} on each candidate string.
|
|
661
|
+
* 2. Path probe — a registration need not spell the package at all. This repo's own
|
|
662
|
+
* configs run `node ./packages/mcp/dist/index.js`; for a Node-family command
|
|
663
|
+
* ({@link isNodeCommand}), the FIRST script-suffixed arg — the runtime's own
|
|
664
|
+
* entrypoint — is resolved against every candidate base and, when it lands on a
|
|
665
|
+
* real file, the owning package is read from the nearest `package.json` on the
|
|
666
|
+
* written path (lexical — an aliased/symlinked script attributes to its host
|
|
667
|
+
* package) via {@link packageNameForScript}.
|
|
668
|
+
*
|
|
669
|
+
* `baseDirs` carries the config's own directory AND the project root: hosts launch
|
|
670
|
+
* the server from the project root, and three of the four host configs live in
|
|
671
|
+
* subdirectories (`.gemini/`, `.cursor/`, `.junie/mcp/`) while their pins are written
|
|
672
|
+
* root-relative — the config's directory alone is the right base only for the
|
|
673
|
+
* root-level `.mcp.json` (scoped leg round 2, D1).
|
|
674
|
+
*
|
|
675
|
+
* A path that does not resolve to an existing file under any base is a stale pin, not
|
|
676
|
+
* a registration we can attribute — it falls through and init appends its own entry.
|
|
677
|
+
*/
|
|
678
|
+
function findMcpPackageRegistration(servers, baseDirs) {
|
|
679
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
680
|
+
if (entry === null || typeof entry !== 'object')
|
|
681
|
+
continue;
|
|
682
|
+
const { command, args } = entry;
|
|
683
|
+
const candidates = [];
|
|
684
|
+
if (typeof command === 'string')
|
|
685
|
+
candidates.push(command);
|
|
686
|
+
if (Array.isArray(args)) {
|
|
687
|
+
for (const arg of args)
|
|
688
|
+
if (typeof arg === 'string')
|
|
689
|
+
candidates.push(arg);
|
|
690
|
+
}
|
|
691
|
+
if (candidates.some((candidate) => MCP_PACKAGE_RE.test(candidate)))
|
|
692
|
+
return name;
|
|
693
|
+
// Path probe: only a Node-family command has a script ENTRYPOINT to attribute,
|
|
694
|
+
// and only its FIRST script-suffixed arg is that entrypoint (node's own CLI
|
|
695
|
+
// semantics). Later `.js` args are plugin/config inputs — attributing those
|
|
696
|
+
// would suppress a real registration over a file the server merely consumes.
|
|
697
|
+
if (typeof command !== 'string' || !isNodeCommand(command))
|
|
698
|
+
continue;
|
|
699
|
+
const entrypoint = Array.isArray(args)
|
|
700
|
+
? args.find((arg) => typeof arg === 'string' && looksLikeScriptPath(arg))
|
|
701
|
+
: undefined;
|
|
702
|
+
if (entrypoint === undefined)
|
|
703
|
+
continue;
|
|
704
|
+
for (const baseDir of baseDirs) {
|
|
705
|
+
const resolved = path.resolve(baseDir, entrypoint);
|
|
706
|
+
if (!isExistingFile(resolved))
|
|
707
|
+
continue;
|
|
708
|
+
if (packageNameForScript(resolved) === MCP_PACKAGE)
|
|
709
|
+
return name;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
export function scaffoldMcpConfig(filePath, serverEntry, opts) {
|
|
491
715
|
try {
|
|
492
716
|
if (!fs.existsSync(filePath)) {
|
|
493
717
|
const dir = path.dirname(filePath);
|
|
@@ -522,6 +746,15 @@ export function scaffoldMcpConfig(filePath, serverEntry) {
|
|
|
522
746
|
if ('totem' in servers) {
|
|
523
747
|
return { action: 'skipped' };
|
|
524
748
|
}
|
|
749
|
+
// Package-level dedup runs after the name check so the `totem`-key skip keeps its
|
|
750
|
+
// bare shape: `existingName` marks the different-name case the caller discloses.
|
|
751
|
+
const baseDirs = [path.dirname(filePath)];
|
|
752
|
+
if (opts?.projectRoot !== undefined)
|
|
753
|
+
baseDirs.push(opts.projectRoot);
|
|
754
|
+
const existingName = findMcpPackageRegistration(servers, baseDirs);
|
|
755
|
+
if (existingName !== undefined) {
|
|
756
|
+
return { action: 'skipped', existingName };
|
|
757
|
+
}
|
|
525
758
|
servers.totem = serverEntry;
|
|
526
759
|
parsed.mcpServers = servers;
|
|
527
760
|
fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8');
|
|
@@ -535,9 +768,11 @@ export function scaffoldMcpConfig(filePath, serverEntry) {
|
|
|
535
768
|
/**
|
|
536
769
|
* Install the Universal AI Developer Baseline lessons into the lessons file.
|
|
537
770
|
* Returns 'installed', 'exists' (already present), or 'skipped' (user declined).
|
|
538
|
-
* In non-
|
|
771
|
+
* In non-interactive mode (CI, `--yes`, piped input), defaults to installing without
|
|
772
|
+
* prompting. `interactive` is threaded from init's single non-interactive predicate
|
|
773
|
+
* (mmnto-ai/totem#2601) and falls back to the bare TTY probe for standalone callers.
|
|
539
774
|
*/
|
|
540
|
-
export async function installBaselineLessons(baselinePath, rl, ecosystems) {
|
|
775
|
+
export async function installBaselineLessons(baselinePath, rl, ecosystems, interactive = process.stdin.isTTY === true) {
|
|
541
776
|
const { UNIVERSAL_BASELINE_LESSONS, UNIVERSAL_BASELINE_MARKER } = await import('../assets/universal-baseline.js');
|
|
542
777
|
const { log } = await import('../ui.js');
|
|
543
778
|
try {
|
|
@@ -547,12 +782,15 @@ export async function installBaselineLessons(baselinePath, rl, ecosystems) {
|
|
|
547
782
|
existing.includes('<!-- totem:baseline -->'))
|
|
548
783
|
return 'exists';
|
|
549
784
|
}
|
|
550
|
-
//
|
|
785
|
+
// Non-interactive (CI, `--yes`, piped input): take the (Y) default — install.
|
|
551
786
|
let declined = false;
|
|
552
|
-
if (
|
|
787
|
+
if (interactive) {
|
|
553
788
|
const answer = await rl.question('Install baseline lessons? (Y/n): ');
|
|
554
789
|
declined = answer.trim().toLowerCase() === 'n' || answer.trim().toLowerCase() === 'no';
|
|
555
790
|
}
|
|
791
|
+
else {
|
|
792
|
+
log.info('Totem', 'Non-interactive mode — installing baseline lessons.');
|
|
793
|
+
}
|
|
556
794
|
if (declined)
|
|
557
795
|
return 'skipped';
|
|
558
796
|
const dir = path.dirname(baselinePath);
|
|
@@ -621,8 +859,31 @@ export function upgradeReflexes(content) {
|
|
|
621
859
|
const startIdx = content.indexOf(REFLEX_START);
|
|
622
860
|
const endIdx = content.indexOf(REFLEX_END);
|
|
623
861
|
if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
|
|
624
|
-
|
|
625
|
-
|
|
862
|
+
// Both seams must be owned by exactly one party for regen to be a
|
|
863
|
+
// byte-fixpoint. Before-seam: normalize the prefix to end with exactly one
|
|
864
|
+
// `\n` (AI_PROMPT_BLOCK's own leading `\n` supplies the separating blank
|
|
865
|
+
// line); a prefix that is nothing but that added newline drops to '' so a
|
|
866
|
+
// marker-at-byte-0 file cannot grow a leading blank line per regen.
|
|
867
|
+
let before = content.slice(0, startIdx).replace(/(?:\r?\n)*$/, '\n');
|
|
868
|
+
if (before === '\n')
|
|
869
|
+
before = '';
|
|
870
|
+
let after = content.slice(endIdx + REFLEX_END.length);
|
|
871
|
+
// After-seam: AI_PROMPT_BLOCK already ends with the end marker's own line
|
|
872
|
+
// terminator, so the OLD block's terminator must not re-enter through
|
|
873
|
+
// `after` — that duplication accreted one blank line per regen into the
|
|
874
|
+
// after-end span that is contractually user content (#1890; byte-verified
|
|
875
|
+
// v7→v9, #2599). A tail of nothing but ASCII whitespace is totem's own
|
|
876
|
+
// accretion residue, not user content: normalize it away entirely so
|
|
877
|
+
// already-damaged files heal instead of freezing their residue.
|
|
878
|
+
if (/^[ \t\r\n]*$/.test(after)) {
|
|
879
|
+
after = '';
|
|
880
|
+
}
|
|
881
|
+
else if (after.startsWith('\r\n')) {
|
|
882
|
+
after = after.slice(2);
|
|
883
|
+
}
|
|
884
|
+
else if (after.startsWith('\n')) {
|
|
885
|
+
after = after.slice(1);
|
|
886
|
+
}
|
|
626
887
|
return { content: before + AI_PROMPT_BLOCK + after, clean: true };
|
|
627
888
|
}
|
|
628
889
|
// Case 2: Legacy block (v1) — no boundaries, appended at end of file
|
|
@@ -666,6 +927,19 @@ function applyReflexUpgrade(filePath) {
|
|
|
666
927
|
fs.writeFileSync(filePath, updated, 'utf-8');
|
|
667
928
|
return clean;
|
|
668
929
|
}
|
|
930
|
+
/**
|
|
931
|
+
* Resolve the AI-tool selection prompt's answer. Anything that is not an explicit
|
|
932
|
+
* `none` / `select` is the Enter default, `all` — which is also the answer the
|
|
933
|
+
* non-interactive path takes without raising the prompt (mmnto-ai/totem#2601).
|
|
934
|
+
*/
|
|
935
|
+
export function resolveToolSelection(answer) {
|
|
936
|
+
const trimmed = answer.trim().toLowerCase();
|
|
937
|
+
if (trimmed === 'none')
|
|
938
|
+
return 'none';
|
|
939
|
+
if (trimmed === 'select')
|
|
940
|
+
return 'select';
|
|
941
|
+
return 'all';
|
|
942
|
+
}
|
|
669
943
|
export async function initCommand(options) {
|
|
670
944
|
// ─── Doctrine pin wiring (mmnto-ai/totem#2088, Proposal 292 S1) ───
|
|
671
945
|
// Self-contained non-interactive path: point orient.parityManifest at the
|
|
@@ -773,6 +1047,11 @@ export default {
|
|
|
773
1047
|
const existingConfig = CONFIG_FILES.map((f) => path.join(cwd, f)).find((p) => fs.existsSync(p));
|
|
774
1048
|
const configExists = !!existingConfig;
|
|
775
1049
|
const rl = readline.createInterface({ input, output });
|
|
1050
|
+
// The ONE non-interactive predicate (mmnto-ai/totem#2601). Every `rl.question` site
|
|
1051
|
+
// below is guarded by it: a prompt raised without a TTY never settles, and init's
|
|
1052
|
+
// mutations land BEFORE the prompts, so a half-run reads as a silent no-op. Each
|
|
1053
|
+
// guarded site logs the default it took.
|
|
1054
|
+
const interactive = process.stdin.isTTY === true && options?.yes !== true;
|
|
776
1055
|
const summary = [];
|
|
777
1056
|
try {
|
|
778
1057
|
printBanner();
|
|
@@ -823,6 +1102,11 @@ export default {
|
|
|
823
1102
|
else if (embeddingTier === 'gemini') {
|
|
824
1103
|
log.info('Totem', `Detected ${bold('GEMINI_API_KEY')} in environment. Using Gemini embeddings (single-key DX).`);
|
|
825
1104
|
}
|
|
1105
|
+
else if (!interactive) {
|
|
1106
|
+
// Non-interactive: take the Enter default (Lite tier). The other two answers
|
|
1107
|
+
// MUTATE (an .env write, an Ollama pin) and must not fire unattended.
|
|
1108
|
+
log.info('Totem', 'Non-interactive mode — no embedding key entered; starting in Lite tier.');
|
|
1109
|
+
}
|
|
826
1110
|
else {
|
|
827
1111
|
// No key detected — prompt the user
|
|
828
1112
|
const answer = await rl.question('Enter your OpenAI API key, type "ollama" for a local model, or press Enter for Lite tier: ');
|
|
@@ -929,7 +1213,7 @@ export default {
|
|
|
929
1213
|
// --- Baseline lessons (core + detected ecosystem packs) ---
|
|
930
1214
|
const baselinePath = path.join(lessonsDir, 'baseline.md');
|
|
931
1215
|
const detectedEcosystems = detectProject(cwd).ecosystems;
|
|
932
|
-
const baselineResult = await installBaselineLessons(baselinePath, rl, detectedEcosystems);
|
|
1216
|
+
const baselineResult = await installBaselineLessons(baselinePath, rl, detectedEcosystems, interactive);
|
|
933
1217
|
if (baselineResult === 'installed') {
|
|
934
1218
|
const extraPacks = detectedEcosystems.filter((e) => e !== 'javascript');
|
|
935
1219
|
const packLabel = extraPacks.length > 0 ? ` + ${extraPacks.join(', ')}` : '';
|
|
@@ -994,31 +1278,38 @@ export default {
|
|
|
994
1278
|
if (detectedTools.length > 0) {
|
|
995
1279
|
const toolNames = detectedTools.map((t) => t.name).join(', ');
|
|
996
1280
|
log.info('Totem', `Detected AI tools: ${bold(toolNames)}`);
|
|
997
|
-
const toolAnswer = await rl.question('Which tools should Totem configure? [all/none/select] (default: all): ');
|
|
998
1281
|
let selectedTools;
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1282
|
+
if (!interactive) {
|
|
1283
|
+
// Non-interactive: the Enter default is 'all'. The per-tool `select` loop is
|
|
1284
|
+
// unreachable from here — it is entered only by an interactive 'select'.
|
|
1285
|
+
selectedTools = detectedTools;
|
|
1286
|
+
log.info('Totem', `Non-interactive mode — configuring all detected tools (${toolNames}).`);
|
|
1002
1287
|
}
|
|
1003
|
-
else
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1288
|
+
else {
|
|
1289
|
+
const toolAnswer = await rl.question('Which tools should Totem configure? [all/none/select] (default: all): ');
|
|
1290
|
+
const selection = resolveToolSelection(toolAnswer);
|
|
1291
|
+
if (selection === 'none') {
|
|
1292
|
+
selectedTools = [];
|
|
1293
|
+
}
|
|
1294
|
+
else if (selection === 'select') {
|
|
1295
|
+
selectedTools = [];
|
|
1296
|
+
for (const tool of detectedTools) {
|
|
1297
|
+
const pick = await rl.question(` Configure ${tool.name}? (Y/n): `);
|
|
1298
|
+
if (pick.trim().toLowerCase() !== 'n' && pick.trim().toLowerCase() !== 'no') {
|
|
1299
|
+
selectedTools.push(tool);
|
|
1300
|
+
}
|
|
1009
1301
|
}
|
|
1010
1302
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
selectedTools = detectedTools;
|
|
1303
|
+
else {
|
|
1304
|
+
selectedTools = detectedTools;
|
|
1305
|
+
}
|
|
1015
1306
|
}
|
|
1016
1307
|
// --- MCP scaffolding for selected tools ---
|
|
1017
1308
|
for (const tool of selectedTools) {
|
|
1018
1309
|
if (!tool.mcpPath || !tool.serverEntry)
|
|
1019
1310
|
continue;
|
|
1020
1311
|
const filePath = path.join(cwd, tool.mcpPath);
|
|
1021
|
-
const result = scaffoldMcpConfig(filePath, tool.serverEntry);
|
|
1312
|
+
const result = scaffoldMcpConfig(filePath, tool.serverEntry, { projectRoot: cwd });
|
|
1022
1313
|
if (result.err) {
|
|
1023
1314
|
log.error('Totem Error', result.err); // totem-ignore — result.err is internal scaffolding error, not LLM output
|
|
1024
1315
|
console.error(`To fix this, add the following manually to your ${tool.mcpPath} under "mcpServers":\n`);
|
|
@@ -1030,6 +1321,15 @@ export default {
|
|
|
1030
1321
|
else if (result.action === 'merged') {
|
|
1031
1322
|
summary.push({ file: tool.mcpPath, action: `Added totem to mcpServers` });
|
|
1032
1323
|
}
|
|
1324
|
+
else if (result.existingName !== undefined) {
|
|
1325
|
+
// Package-level dedup hit (mmnto-ai/totem#2601): no append, but the skip is
|
|
1326
|
+
// disclosed rather than silent — the user's registration is under a name
|
|
1327
|
+
// they chose, and a silent no-op reads as init having done nothing.
|
|
1328
|
+
summary.push({
|
|
1329
|
+
file: tool.mcpPath,
|
|
1330
|
+
action: `Totem MCP already registered as \`${result.existingName}\` — no duplicate added`,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1033
1333
|
}
|
|
1034
1334
|
// --- Reflex injection & upgrade for selected tools ---
|
|
1035
1335
|
const outdatedFiles = [];
|
|
@@ -1056,13 +1356,14 @@ export default {
|
|
|
1056
1356
|
const fileList = outdatedFiles.map((f) => f.tool.reflexFile).join(', ');
|
|
1057
1357
|
log.warn('Totem', `Outdated reflexes found in: ${bold(fileList)}`);
|
|
1058
1358
|
let shouldUpgrade = false;
|
|
1059
|
-
if (
|
|
1359
|
+
if (interactive) {
|
|
1060
1360
|
const answer = await rl.question(`Upgrade reflexes to v${REFLEX_VERSION}? (Y/n): `);
|
|
1061
1361
|
shouldUpgrade =
|
|
1062
1362
|
answer.trim().toLowerCase() !== 'n' && answer.trim().toLowerCase() !== 'no';
|
|
1063
1363
|
}
|
|
1064
1364
|
else {
|
|
1065
|
-
// Non-
|
|
1365
|
+
// Non-interactive (CI/scripted/`--yes`): take the (Y) default, matching
|
|
1366
|
+
// baseline lessons behavior.
|
|
1066
1367
|
shouldUpgrade = true;
|
|
1067
1368
|
log.info('Totem', 'Non-interactive mode — auto-upgrading reflexes.');
|
|
1068
1369
|
}
|
|
@@ -1122,6 +1423,16 @@ export default {
|
|
|
1122
1423
|
action: result.summaryActionOverride ?? `Merged ${tool.name} hook into existing config`,
|
|
1123
1424
|
});
|
|
1124
1425
|
}
|
|
1426
|
+
else if ((result.action === 'skipped' || result.action === 'exists') &&
|
|
1427
|
+
result.summaryActionOverride) {
|
|
1428
|
+
// A no-op an installer chose to DISCLOSE (mmnto-ai/totem#2601 vendor-hook
|
|
1429
|
+
// guard). Bare `skipped`/`exists` stay silent; an override means the user
|
|
1430
|
+
// must know why the file was left alone. `exists` carries one too: a
|
|
1431
|
+
// custom `.js` coexisting with an already-canonical managed `.cjs` is the
|
|
1432
|
+
// same live double-fire hazard as the drift-repaired case, and the fact
|
|
1433
|
+
// that nothing needed rewriting must not silence it.
|
|
1434
|
+
summary.push({ file: result.file, action: result.summaryActionOverride });
|
|
1435
|
+
}
|
|
1125
1436
|
}
|
|
1126
1437
|
}
|
|
1127
1438
|
}
|
|
@@ -1164,7 +1475,10 @@ export default {
|
|
|
1164
1475
|
// 'exists' (already canonical) and 'missing' (no package.json) are silent no-ops.
|
|
1165
1476
|
// --- Always run: enforcement hooks (pre-commit + pre-push) ---
|
|
1166
1477
|
const hookTier = options?.strict ? 'strict' : undefined;
|
|
1167
|
-
const enforcement = await installEnforcementHooks(cwd, rl, {
|
|
1478
|
+
const enforcement = await installEnforcementHooks(cwd, rl, {
|
|
1479
|
+
tier: hookTier,
|
|
1480
|
+
interactive,
|
|
1481
|
+
});
|
|
1168
1482
|
if (enforcement.preCommit === 'installed' || enforcement.preCommit === 'appended') {
|
|
1169
1483
|
summary.push({
|
|
1170
1484
|
file: '.git/hooks/pre-commit',
|
|
@@ -1190,7 +1504,7 @@ export default {
|
|
|
1190
1504
|
});
|
|
1191
1505
|
}
|
|
1192
1506
|
// --- Always run: post-merge git hook ---
|
|
1193
|
-
await installPostMergeHook(cwd, rl);
|
|
1507
|
+
await installPostMergeHook(cwd, rl, { interactive });
|
|
1194
1508
|
// --- Always run: .gitignore ---
|
|
1195
1509
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
1196
1510
|
if (fs.existsSync(gitignorePath)) {
|
|
@@ -1220,8 +1534,19 @@ export default {
|
|
|
1220
1534
|
const { scanCursorInstructions } = await import('@mmnto/totem');
|
|
1221
1535
|
const cursorInstructions = scanCursorInstructions(cwd);
|
|
1222
1536
|
if (cursorInstructions.length > 0) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1537
|
+
// The one non-interactive default that is NOT the Enter default: compiling is
|
|
1538
|
+
// an LLM-driven rewrite of compiled-rules.json, so it stays opt-in and the
|
|
1539
|
+
// manual command is disclosed instead (mmnto-ai/totem#2601).
|
|
1540
|
+
let shouldCompile = false;
|
|
1541
|
+
if (interactive) {
|
|
1542
|
+
const answer = await rl.question(`\nFound ${cursorInstructions.length} existing AI rule(s) (.cursorrules / .mdc). Compile into deterministic invariants? (Y/n): `);
|
|
1543
|
+
shouldCompile =
|
|
1544
|
+
answer.trim().toLowerCase() !== 'n' && answer.trim().toLowerCase() !== 'no';
|
|
1545
|
+
}
|
|
1546
|
+
else {
|
|
1547
|
+
log.info('Totem', `Non-interactive mode — found ${cursorInstructions.length} existing AI rule(s) (.cursorrules / .mdc); not compiling. Run \`totem compile --from-cursor\` to compile them.`);
|
|
1548
|
+
}
|
|
1549
|
+
if (shouldCompile) {
|
|
1225
1550
|
try {
|
|
1226
1551
|
const { compileCommand } = await import('./compile.js');
|
|
1227
1552
|
await compileCommand({ fromCursor: true });
|