create-byan-agent 2.29.1 → 2.35.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/install/bin/create-byan-agent-v2.js +33 -2
  3. package/install/lib/gdoc-setup.js +210 -0
  4. package/install/lib/mcp-extensions/gdrive.js +27 -2
  5. package/install/lib/native-helper.js +68 -1
  6. package/install/lib/rtk-integration.js +193 -57
  7. package/install/package.json +1 -1
  8. package/install/packages/platform-config/lib/credentials.js +13 -1
  9. package/install/setup-gdoc.js +41 -0
  10. package/install/setup-rtk.js +7 -3
  11. package/install/templates/.claude/CLAUDE.md +15 -4
  12. package/install/templates/.claude/hooks/inject-soul.js +4 -3
  13. package/install/templates/.claude/hooks/inject-tao.js +65 -25
  14. package/install/templates/.claude/hooks/inject-voice-anchor.js +102 -0
  15. package/install/templates/.claude/rules/portable-core.md +81 -0
  16. package/install/templates/.claude/settings.json +5 -1
  17. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +16 -2
  18. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-client.js +203 -0
  19. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-content.js +203 -0
  20. package/install/templates/_byan/mcp/byan-mcp-server/lib/resolve-config.js +15 -2
  21. package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +1 -1
  22. package/install/templates/_byan/mcp/byan-mcp-server/package.json +2 -0
  23. package/install/templates/_byan/mcp/byan-mcp-server/server.js +70 -0
  24. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
  25. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  26. package/install/templates/docs/google-docs-publish.md +121 -0
  27. package/node_modules/byan-platform-config/lib/credentials.js +13 -1
  28. package/package.json +3 -1
@@ -1,38 +1,78 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * UserPromptSubmit hook — injects BYAN tao voice directives into every
4
- * user prompt so Claude's response respects the register even when BYAN
5
- * agent isn't explicitly invoked.
3
+ * SessionStart hook — injects BYAN's FULL tao (voice directives) ONCE into the
4
+ * session's initial context, so it lands in the stable, cacheable prefix instead
5
+ * of being re-sent on every turn.
6
6
  *
7
- * Reads _byan/tao.md. If absent or empty, emits empty additionalContext
8
- * (no-op). Always exits 0.
7
+ * Cache rationale: a UserPromptSubmit injection is appended at the growing edge
8
+ * each turn, so the full ~3.6k-token tao was re-billed N times over a session.
9
+ * Loaded once at SessionStart it sits in the stable prefix (cache read at 10%).
10
+ * The per-turn voice freshness is carried by the tiny inject-voice-anchor.js
11
+ * (UserPromptSubmit): full tao here, compact anchor there. The voice stays 100%
12
+ * present every turn — nothing about it becomes conditional.
13
+ *
14
+ * Reads _byan/agent/byan/tao.md (Gen3) then _byan/tao.md (Gen2). Missing/empty ->
15
+ * empty additionalContext (no-op). Always exits 0.
9
16
  */
10
17
 
11
18
  const fs = require('fs');
12
19
  const path = require('path');
13
20
 
14
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
15
- // Gen3 _byan/agent/byan/tao.md first, Gen2 _byan/tao.md fallback.
16
- const taoGen3 = path.join(projectDir, '_byan', 'agent', 'byan', 'tao.md');
17
- const taoPath = fs.existsSync(taoGen3) ? taoGen3 : path.join(projectDir, '_byan', 'tao.md');
21
+ // Gen3 puts tao under _byan/agent/byan/; Gen2 keeps it at the _byan/ root.
22
+ function taoFile(projectDir) {
23
+ const g3 = path.join(projectDir, '_byan', 'agent', 'byan', 'tao.md');
24
+ const g2 = path.join(projectDir, '_byan', 'tao.md');
25
+ return fs.existsSync(g3) ? g3 : g2;
26
+ }
18
27
 
19
- let additionalContext = '';
20
- try {
21
- if (fs.existsSync(taoPath)) {
22
- const content = fs.readFileSync(taoPath, 'utf8').trim();
23
- if (content.length > 0) {
24
- additionalContext = `BYAN tao directives (active for this turn — follow register, signatures, forbidden vocabulary):\n\n${content}`;
28
+ function buildTaoContext(projectDir) {
29
+ try {
30
+ const p = taoFile(projectDir);
31
+ if (fs.existsSync(p)) {
32
+ const content = fs.readFileSync(p, 'utf8').trim();
33
+ if (content.length > 0) {
34
+ return `BYAN tao (voice directives, loaded once at session start — register, signatures, forbidden vocabulary):\n\n${content}`;
35
+ }
25
36
  }
37
+ } catch {
38
+ // Hook must never block session start.
39
+ }
40
+ return '';
41
+ }
42
+
43
+ // Per-session turn counter for the voice-anchor refresh cadence (inject-voice-anchor.js).
44
+ // It lives under _byan-output/ (gitignored). inject-tao OWNS the path and the reset
45
+ // because the full tao it injects at SessionStart (including source=compact) restarts
46
+ // the cadence: the next periodic full-tao refresh is then N turns later. The anchor hook
47
+ // reads/writes the same path via require('./inject-tao') -- single source, no drift.
48
+ function turnCounterPath(projectDir) {
49
+ return path.join(projectDir, '_byan-output', '.tao-refresh-turn');
50
+ }
51
+
52
+ function resetTurnCounter(projectDir) {
53
+ try {
54
+ const p = turnCounterPath(projectDir);
55
+ fs.mkdirSync(path.dirname(p), { recursive: true });
56
+ fs.writeFileSync(p, '0');
57
+ } catch {
58
+ // Never block session start.
59
+ }
60
+ }
61
+
62
+ if (require.main === module) {
63
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
64
+ // A fresh full tao is about to be injected -> restart the voice-anchor cadence.
65
+ resetTurnCounter(projectDir);
66
+ const additionalContext = buildTaoContext(projectDir);
67
+ if (additionalContext) {
68
+ process.stdout.write(
69
+ JSON.stringify({
70
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext },
71
+ })
72
+ );
73
+ } else {
74
+ process.stdout.write('{}');
26
75
  }
27
- } catch {
28
- // Hook must never block prompt submission.
29
76
  }
30
77
 
31
- process.stdout.write(
32
- JSON.stringify({
33
- hookSpecificOutput: {
34
- hookEventName: 'UserPromptSubmit',
35
- additionalContext: additionalContext || '',
36
- },
37
- })
38
- );
78
+ module.exports = { taoFile, buildTaoContext, turnCounterPath, resetTurnCounter };
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UserPromptSubmit hook — keeps BYAN's voice present near the live edge each turn.
4
+ *
5
+ * Most turns inject a COMPACT voice anchor (~95 tokens). Every Nth turn re-inject
6
+ * the FULL tao instead, so on a long session the heart is refreshed close to the
7
+ * live edge. N defaults to 12 (override with BYAN_TAO_REFRESH_EVERY; <= 0 disables
8
+ * the refresh, anchor every turn). The per-turn counter lives under _byan-output/
9
+ * (gitignored) and is reset at SessionStart by inject-tao.js, so the cadence
10
+ * restarts from each fresh full-tao load.
11
+ *
12
+ * Layered guarantee (best-effort, honest about its floor): each turn is a separate
13
+ * process, so the cadence needs a WRITABLE counter file to advance. If _byan-output/
14
+ * cannot be written, the counter cannot advance and this hook degrades to the anchor
15
+ * every turn -- it never crashes (exit 0), and the full heart still returns via
16
+ * inject-tao at SessionStart AND after every compaction (source=compact, pinned by
17
+ * the F1 test). So the periodic refresh is the in-window ENHANCEMENT; the
18
+ * SessionStart/compaction re-injection is the FLOOR that always holds. A persistent
19
+ * degradation means _byan-output/ is not writable -- check its permissions.
20
+ *
21
+ * The full tao is read via inject-tao.buildTaoContext (single source, no
22
+ * duplication; require is side-effect-free thanks to its require.main guard).
23
+ * Always exits 0 ; never blocks prompt submission.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const { buildTaoContext, turnCounterPath } = require('./inject-tao');
29
+
30
+ const ANCHOR = [
31
+ 'Voix BYAN (rappel par tour ; tao complet chargé au démarrage de session) :',
32
+ '- Tutoiement, registre artisan-senior, direct sans être brusque, concis.',
33
+ '- Challenge avant de confirmer ; questionne les absolus (Mantra IA-16).',
34
+ '- Signatures : "Attends — pourquoi ?", "OK. On construit.", "Ça, c\'est du générique.".',
35
+ '- Zéro emoji. Orienté solution : on cherche la meilleure option, pas le mur.',
36
+ ].join('\n');
37
+
38
+ const DEFAULT_REFRESH_EVERY = 12;
39
+
40
+ function buildVoiceAnchor() {
41
+ return ANCHOR;
42
+ }
43
+
44
+ // Turns between full-tao refreshes. Invalid/absent env -> default. A value <= 0
45
+ // disables the periodic refresh (anchor every turn) -- an explicit opt-out.
46
+ function refreshEvery(env = process.env) {
47
+ const n = parseInt(env.BYAN_TAO_REFRESH_EVERY, 10);
48
+ return Number.isInteger(n) ? n : DEFAULT_REFRESH_EVERY;
49
+ }
50
+
51
+ function readTurn(projectDir) {
52
+ try {
53
+ const n = parseInt(fs.readFileSync(turnCounterPath(projectDir), 'utf8').trim(), 10);
54
+ return Number.isInteger(n) && n >= 0 ? n : 0;
55
+ } catch {
56
+ return 0;
57
+ }
58
+ }
59
+
60
+ function writeTurn(projectDir, n) {
61
+ try {
62
+ const p = turnCounterPath(projectDir);
63
+ fs.mkdirSync(path.dirname(p), { recursive: true });
64
+ fs.writeFileSync(p, String(n));
65
+ } catch {
66
+ // Never block prompt submission.
67
+ }
68
+ }
69
+
70
+ // Pure cadence decision. Every Nth turn (N > 0) surfaces the full tao when it is
71
+ // available; otherwise the compact anchor. Kept pure so the cadence is unit-testable
72
+ // without fs.
73
+ function decideAnchor({ turn, every, fullTao }) {
74
+ if (every > 0 && turn % every === 0 && fullTao) {
75
+ return { mode: 'full', additionalContext: fullTao };
76
+ }
77
+ return { mode: 'anchor', additionalContext: ANCHOR };
78
+ }
79
+
80
+ if (require.main === module) {
81
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
82
+ const every = refreshEvery();
83
+ const turn = readTurn(projectDir) + 1;
84
+ writeTurn(projectDir, turn);
85
+ const fullTao = every > 0 && turn % every === 0 ? buildTaoContext(projectDir) : '';
86
+ const { additionalContext } = decideAnchor({ turn, every, fullTao });
87
+ process.stdout.write(
88
+ JSON.stringify({
89
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
90
+ })
91
+ );
92
+ }
93
+
94
+ module.exports = {
95
+ buildVoiceAnchor,
96
+ ANCHOR,
97
+ refreshEvery,
98
+ readTurn,
99
+ writeTurn,
100
+ decideAnchor,
101
+ DEFAULT_REFRESH_EVERY,
102
+ };
@@ -0,0 +1,81 @@
1
+ # BYAN Portable Core — Noyau portable, projection native
2
+
3
+ > BYAN est livre via npm et tourne sur deux plateformes (Claude Code, Codex).
4
+ > Son identite et son etat doivent survivre a l'absence de toute feature native.
5
+ > Les features natives sont un accelerateur opportuniste, pas une bequille.
6
+
7
+ ## Principe (hexagonal / ports-and-adapters)
8
+
9
+ Le noyau de BYAN — identite (soul, tao, soul-memory), etat (FD, strict, ELO),
10
+ connaissance — vit dans des artefacts **portables, in-repo**, sous `_byan/` et
11
+ `_byan-output/`, plus l'API `byan_web` quand le reseau est la. Ce noyau est la
12
+ **source de verite**. Il est shippe, il marche offline, il marche sur une machine
13
+ neuve, il marche sur Codex.
14
+
15
+ Les fonctionnalites natives de la plateforme (Claude Code) sont une **couche
16
+ d'acceleration en write-through** : BYAN ecrit *vers* elles pour en tirer le
17
+ benefice (cache, recall, isolation), mais ne les lit pas comme autorite. Si la
18
+ couche native est absente ou videe, BYAN se reconstruit depuis le noyau portable
19
+ sans rien perdre de son identite ni de son etat.
20
+
21
+ ```
22
+ +---------------------- noyau portable (source de verite) ----------------------+
23
+ | _byan/agent/byan/{soul,tao,soul-memory}.md _byan-output/fd-state.json |
24
+ | _byan/memoire/{elo,fact-graph}.json byan_web API (autorite reseau) |
25
+ +-------------------------------------------------------------------------------+
26
+ | (write-through, pas une dependance) ^ (reconstruction)
27
+ v |
28
+ +---------------------- adaptateurs natifs (accelerateurs) ---------------------+
29
+ | injection prefix-cache @-import memory files hooks subagent isolation |
30
+ +-------------------------------------------------------------------------------+
31
+ ```
32
+
33
+ ## Les trois frontieres
34
+
35
+ 1. **Source de verite portable.** Toute chose dont BYAN a besoin pour etre
36
+ lui-meme est un fichier sous `_byan/` (ou une entree `byan_web`). Rien
37
+ d'essentiel ne vit uniquement dans une couche native.
38
+ 2. **Le natif est un accelerateur write-through.** On projette l'etat portable
39
+ *vers* le natif pour la vitesse / le cache / le recall. On ne doit pas dependre
40
+ du natif en lecture pour une fonction essentielle. La projection est derivee et
41
+ regenerable depuis le noyau.
42
+ 3. **L'AutoMem est hors-perimetre.** Le repertoire AutoMem natif de Claude Code
43
+ (`~/.claude/projects/<hash>/memory/`) est local a l'assistant, par-machine,
44
+ indexe par un hash du chemin projet — **non shippable** via npm. Il n'est pas
45
+ une source de verite BYAN, et aucun chemin de lecture critique n'en depend. Ce
46
+ qui doit etre portable vit dans `soul-memory` / `_byan/`.
47
+
48
+ ## Table : feature native -> adaptateur -> chemin degrade
49
+
50
+ | Feature native Claude | Adaptateur BYAN (write-through) | Chemin degrade (natif absent / Codex) |
51
+ |-----------------------|--------------------------------|----------------------------------------|
52
+ | Prompt caching (prefixe stable) | `inject-soul.js` / `inject-tao.js` injectent soul+tao depuis `_byan/` au SessionStart | la voix reste lue depuis `_byan/` ; `inject-voice-anchor` re-pose l'ancre par tour |
53
+ | Memory files (`@-import`) | fichiers in-repo (`_byan/`, `.claude/rules/`) charges nativement par Claude | sur Codex, le bloc `AGENTS.md` porte l'equivalent ; les regles restent des fichiers lisibles |
54
+ | Hooks (SessionStart / PreCompact / Stop) | `.claude/hooks/*.js` | sur Codex (pas de hook), le contexte est porte par `AGENTS.md` ; le filet pre-commit reste actif |
55
+ | Subagent isolation | `byan-hermes-dispatch` delegue et ne ramene qu'un distillat | si pas de subagent, execution inline main-thread (meme resultat, plus de tokens) |
56
+ | Compaction | `pre-compact-save.js` ecrit un snapshot sous `_byan-output/` | l'etat FD vit dans `_byan-output/fd-state.json`, relisible sans la couche native |
57
+
58
+ L'AutoMem n'a **pas** de ligne : ce n'est pas un adaptateur, il est hors-perimetre.
59
+
60
+ ## Le test de degradation (litmus)
61
+
62
+ L'independance n'est pas une promesse, c'est un test. Wipe la couche native
63
+ (hooks off, `~/.claude/` absent, Codex) : l'identite (soul/tao/soul-memory) et
64
+ l'etat FD doivent se reconstruire depuis les artefacts portables `_byan/` seuls.
65
+ Rebranche le natif : BYAN tourne plus lean (cache, distillat). **Les deux doivent
66
+ tenir.** Garde-fou mecanique : `.claude/__tests__/portable-core.test.js` echoue
67
+ si un chemin critique se met a dependre de l'AutoMem natif.
68
+
69
+ ## Mantras
70
+
71
+ - `PORTABLE-1` Source de verite in-repo. Le noyau vit sous `_byan/`, pas dans le natif.
72
+ - `PORTABLE-2` Natif = accelerateur, pas une dependance. On ecrit vers, on ne lit pas comme autorite.
73
+ - `PORTABLE-3` AutoMem hors-perimetre. `~/.claude/projects/.../memory/` n'est pas une source BYAN.
74
+ - `PORTABLE-4` Projection derivee. Toute copie native est regenerable depuis le noyau.
75
+ - `PORTABLE-5` Degradation testee. L'independance passe un test, elle ne se decrete pas.
76
+
77
+ ## References
78
+
79
+ - Doctrine token (pourquoi un pointeur sans `@-import`) : CLAUDE.md, section `## Compact instructions` (chantier context-engineering).
80
+ - Isolation subagent (levier "plus avec moins") : `.claude/skills/byan-hermes-dispatch/SKILL.md`.
81
+ - Etat FD portable : `_byan/mcp/byan-mcp-server/lib/fd-state.js` -> `_byan-output/fd-state.json`.
@@ -8,6 +8,10 @@
8
8
  "type": "command",
9
9
  "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-soul.js"
10
10
  },
11
+ {
12
+ "type": "command",
13
+ "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-tao.js"
14
+ },
11
15
  {
12
16
  "type": "command",
13
17
  "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/soul-memory-check.js"
@@ -21,7 +25,7 @@
21
25
  "hooks": [
22
26
  {
23
27
  "type": "command",
24
- "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-tao.js"
28
+ "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/inject-voice-anchor.js"
25
29
  },
26
30
  {
27
31
  "type": "command",
@@ -73,10 +73,12 @@ prompt: |
73
73
  Load persona first : read <specialist stub path>.
74
74
  Task : <full goal>
75
75
  Deliverables : <list>
76
- When done, write a concise report (< 200 words).
76
+ When done, return ONLY a distilled summary (< 200 words, ~1-2k tokens) :
77
+ the verbose tool output, file contents and intermediate reasoning stay in
78
+ YOUR context and do not cross back -- only the distillate returns.
77
79
  ```
78
80
 
79
- **`mcp-worker`** : same Agent tool call but without `isolation`. Set the Agent's `model` to the returned `model` — `haiku` for exploration nature, otherwise omit `model` to inherit the session model. The tier follows the task nature, not its size.
81
+ **`mcp-worker`** : same Agent tool call but without `isolation` — including the SAME distilled-summary cap in the prompt (the subagent returns only the distillate, not its verbose work). Set the Agent's `model` to the returned `model` — `haiku` for exploration nature, otherwise omit `model` to inherit the session model. The tier follows the task nature, not its size.
80
82
 
81
83
  For any spawned strategy : pass `model` to the Agent tool when it is non-null; omit it when null so the subagent inherits the session model.
82
84
 
@@ -110,9 +112,21 @@ If the user (or calling agent) provides N independent subtasks and `parallelizab
110
112
  2. Dispatch all N Agent tool calls **in one message**.
111
113
  3. Aggregate via `coordination.aggregate` and `writeSummary`.
112
114
 
115
+ ## Subagent isolation (token leverage)
116
+
117
+ A subagent runs in its OWN context window — use that. Let it explore verbosely
118
+ (read files, run tools, reason at length) inside its own context, and have it
119
+ return ONLY a distilled summary (< 200 words, ~1-2k tokens) to the main thread.
120
+ The verbose work does not land in the lead context : this is the Anthropic
121
+ context-isolation principle (a subagent may burn ~9k tokens internally yet return
122
+ ~1-2k). It applies to BOTH spawn paths (worktree and mcp-worker), and it is why
123
+ heavy, verbose or exploratory work is worth delegating even when the lead could
124
+ do it inline — the delegation keeps the lead context lean ("plus avec moins").
125
+
113
126
  ## Hard rules
114
127
 
115
128
  - **Never ask for confirmation** before spawning. User opted into autonomous mode (Q1.b).
116
129
  - **Never execute the specialist's work yourself** unless strategy says `main-thread*`. You dispatch, you do not become the specialist.
117
130
  - **Never spawn with `isolation: "worktree"` for tasks < score 15** — the boot cost exceeds the gain.
118
131
  - **Never fabricate a specialist name**. If no match, say so and use `general-purpose`.
132
+ - **Cap every subagent return.** Both spawn paths (worktree AND mcp-worker) instruct the subagent to return only a distilled summary (< 200 words / ~1-2k tokens) — the raw exploration stays in the subagent's context.
@@ -0,0 +1,203 @@
1
+ // gdoc-client -- headless, byan-owned Google Docs publisher (service account).
2
+ //
3
+ // Turns a content object (see gdoc-content) into a branded Google Doc and returns
4
+ // its URL. Auth is a SERVICE ACCOUNT key (JWT, durable, no browser, no refresh
5
+ // token, no ~7-day expiry) -- distinct from the gw OAuth path. The SA key path is
6
+ // resolved with the same precedence as the rest of the server (env ->
7
+ // ~/.byan/credentials.json), via resolve-config.
8
+ //
9
+ // CONTRACT : never throws. Every failure (no key, dep not installed, API error)
10
+ // is returned as { ok:false, reason, message } so the MCP tool surfaces a clean
11
+ // message instead of crashing the server. On success : { ok:true, documentId,
12
+ // url }.
13
+ //
14
+ // TESTABILITY : googleapis is LAZY-loaded through an injectable `load()` so (a)
15
+ // the server boots even when googleapis is not installed, and (b) tests inject a
16
+ // mock and never touch the network or the real dependency.
17
+
18
+ import fs from 'node:fs';
19
+ import { resolveConfig } from './resolve-config.js';
20
+
21
+ // Narrowest scopes that let the SA create a Doc and share it : per-file Drive
22
+ // access + Docs editing. Not the broad .../auth/drive.
23
+ const SCOPES = [
24
+ 'https://www.googleapis.com/auth/documents',
25
+ 'https://www.googleapis.com/auth/drive.file',
26
+ ];
27
+
28
+ function oneLine(err) {
29
+ return String((err && err.message) || err).split('\n')[0];
30
+ }
31
+
32
+ // Default lazy loader : import googleapis only when a publish actually runs. A
33
+ // missing dependency is caught by the caller and turned into reason:'dep-missing'
34
+ // -- it must NOT crash the server at import time.
35
+ async function defaultLoad() {
36
+ const mod = await import('googleapis');
37
+ return { google: mod.google || (mod.default && mod.default.google) };
38
+ }
39
+
40
+ /**
41
+ * Read + parse the service-account JSON key at `keyPath`. Returns the parsed
42
+ * credentials object, or null on any failure (missing/unreadable/invalid).
43
+ */
44
+ function readServiceAccount(keyPath, readFileSync) {
45
+ try {
46
+ const raw = readFileSync(keyPath, 'utf8');
47
+ const parsed = JSON.parse(raw);
48
+ if (parsed && typeof parsed === 'object' && parsed.client_email && parsed.private_key) {
49
+ return parsed;
50
+ }
51
+ return null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * createPublisher(deps) -> { publish, status }. All side-effecting deps are
59
+ * injectable so the flow is fully unit-testable.
60
+ * @param {object} [deps]
61
+ * @param {Function} [deps.load] async () => ({ google }) ; default lazy-imports googleapis
62
+ * @param {Function} [deps.resolve] () => config ; default resolveConfig
63
+ * @param {Function} [deps.readFileSync] default fs.readFileSync (reads the SA key)
64
+ */
65
+ function createPublisher(deps = {}) {
66
+ const load = deps.load || defaultLoad;
67
+ const resolve = deps.resolve || resolveConfig;
68
+ const readFileSync = deps.readFileSync || fs.readFileSync;
69
+
70
+ // Read-only view of whether a SA key is configured (for a doctor/status surface).
71
+ function status() {
72
+ const cfg = resolve();
73
+ const keyPath = cfg.GOOGLE_APPLICATION_CREDENTIALS || '';
74
+ const sa = keyPath ? readServiceAccount(keyPath, readFileSync) : null;
75
+ return {
76
+ credentialsConfigured: Boolean(keyPath),
77
+ credentialsValid: Boolean(sa),
78
+ clientEmail: sa ? sa.client_email : null,
79
+ templateConfigured: Boolean(cfg.GDOC_TEMPLATE_ID),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * publish(content, opts) -> { ok, documentId?, url?, mode?, shared?, reason?, message? }.
85
+ * opts : { templateId?, shareWith?, role? }. Never throws.
86
+ */
87
+ async function publish(content, opts = {}) {
88
+ const cfg = resolve();
89
+
90
+ // 1. SA key present + valid ?
91
+ const keyPath = cfg.GOOGLE_APPLICATION_CREDENTIALS || '';
92
+ if (!keyPath) {
93
+ return {
94
+ ok: false,
95
+ reason: 'no-credentials',
96
+ message:
97
+ 'Aucune cle service account. Definis GOOGLE_APPLICATION_CREDENTIALS (chemin du JSON) dans l\'env ou ~/.byan/credentials.json. Voir docs/google-docs-publish.md.',
98
+ };
99
+ }
100
+ const credentials = readServiceAccount(keyPath, readFileSync);
101
+ if (!credentials) {
102
+ return {
103
+ ok: false,
104
+ reason: 'bad-credentials',
105
+ message: `La cle service account a ${keyPath} est introuvable ou invalide (client_email + private_key requis).`,
106
+ };
107
+ }
108
+
109
+ // 2. content shape (pure) -- import lazily to keep this module's top clean.
110
+ let content$;
111
+ let buildReplaceRequests;
112
+ let buildDocumentRequests;
113
+ try {
114
+ const gc = await import('./gdoc-content.js');
115
+ content$ = gc.normalizeContent(content);
116
+ buildReplaceRequests = gc.buildReplaceRequests;
117
+ buildDocumentRequests = gc.buildDocumentRequests;
118
+ } catch (err) {
119
+ return { ok: false, reason: 'invalid-content', message: oneLine(err) };
120
+ }
121
+
122
+ // 3. googleapis available ?
123
+ let google;
124
+ try {
125
+ ({ google } = await load());
126
+ if (!google) throw new Error('googleapis loaded but google export missing');
127
+ } catch {
128
+ return {
129
+ ok: false,
130
+ reason: 'dep-missing',
131
+ message:
132
+ 'googleapis non installe. Lance : npm install googleapis google-auth-library (dans _byan/mcp/byan-mcp-server).',
133
+ };
134
+ }
135
+
136
+ // 4. auth + API + publish. Any API failure -> graceful api-error.
137
+ try {
138
+ const auth = new google.auth.GoogleAuth({ credentials, scopes: SCOPES });
139
+ const docs = google.docs({ version: 'v1', auth });
140
+ const drive = google.drive({ version: 'v3', auth });
141
+
142
+ const templateId = opts.templateId || cfg.GDOC_TEMPLATE_ID || '';
143
+ let documentId;
144
+ let mode;
145
+
146
+ if (templateId) {
147
+ const copy = await drive.files.copy({
148
+ fileId: templateId,
149
+ requestBody: { name: content$.title },
150
+ fields: 'id',
151
+ });
152
+ documentId = copy.data.id;
153
+ mode = 'template';
154
+ await docs.documents.batchUpdate({
155
+ documentId,
156
+ requestBody: { requests: buildReplaceRequests(content) },
157
+ });
158
+ } else {
159
+ const created = await docs.documents.create({
160
+ requestBody: { title: content$.title },
161
+ });
162
+ documentId = created.data.documentId;
163
+ mode = 'programmatic';
164
+ await docs.documents.batchUpdate({
165
+ documentId,
166
+ requestBody: {
167
+ requests: buildDocumentRequests(content, {
168
+ logoPngUrl: opts.logoPngUrl || cfg.GDOC_LOGO_PNG_URL || '',
169
+ }),
170
+ },
171
+ });
172
+ }
173
+
174
+ let shared = false;
175
+ if (opts.shareWith) {
176
+ await drive.permissions.create({
177
+ fileId: documentId,
178
+ requestBody: {
179
+ type: 'user',
180
+ role: opts.role === 'commenter' || opts.role === 'writer' ? opts.role : 'reader',
181
+ emailAddress: opts.shareWith,
182
+ },
183
+ sendNotificationEmail: false,
184
+ });
185
+ shared = true;
186
+ }
187
+
188
+ return {
189
+ ok: true,
190
+ documentId,
191
+ url: `https://docs.google.com/document/d/${documentId}/edit`,
192
+ mode,
193
+ shared,
194
+ };
195
+ } catch (err) {
196
+ return { ok: false, reason: 'api-error', message: oneLine(err) };
197
+ }
198
+ }
199
+
200
+ return { publish, status };
201
+ }
202
+
203
+ export { createPublisher, readServiceAccount, SCOPES };