create-byan-agent 2.29.2 → 2.38.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 (48) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/install/bin/create-byan-agent-v2.js +44 -1
  3. package/install/lib/claude-native-setup.js +12 -38
  4. package/install/lib/gdoc-setup.js +210 -0
  5. package/install/lib/mcp-extensions/gdrive.js +27 -2
  6. package/install/lib/platforms/claude-code.js +28 -19
  7. package/install/lib/rtk-integration.js +18 -8
  8. package/install/package.json +1 -1
  9. package/install/packages/platform-config/lib/credentials.js +13 -1
  10. package/install/packages/platform-config/lib/mcp-config.js +71 -5
  11. package/install/setup-gdoc.js +41 -0
  12. package/install/setup-rtk.js +1 -1
  13. package/install/templates/.claude/CLAUDE.md +16 -4
  14. package/install/templates/.claude/hooks/inject-delivery-default.js +46 -0
  15. package/install/templates/.claude/hooks/inject-soul.js +4 -3
  16. package/install/templates/.claude/hooks/inject-tao.js +65 -25
  17. package/install/templates/.claude/hooks/inject-voice-anchor.js +102 -0
  18. package/install/templates/.claude/hooks/leantime-fd-sync.js +12 -1
  19. package/install/templates/.claude/hooks/lib/delivery-contract.js +143 -0
  20. package/install/templates/.claude/hooks/lib/punt-detect.js +143 -0
  21. package/install/templates/.claude/hooks/punt-guard.js +126 -0
  22. package/install/templates/.claude/hooks/strict-stop-guard.js +29 -6
  23. package/install/templates/.claude/rules/portable-core.md +81 -0
  24. package/install/templates/.claude/settings.json +13 -1
  25. package/install/templates/.claude/skills/byan-hermes-dispatch/SKILL.md +16 -2
  26. package/install/templates/_byan/_config/delivery-default.json +22 -0
  27. package/install/templates/_byan/mcp/byan-mcp-server/channel-entry.js +46 -0
  28. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-poll.js +128 -0
  29. package/install/templates/_byan/mcp/byan-mcp-server/lib/channel-server.js +234 -0
  30. package/install/templates/_byan/mcp/byan-mcp-server/lib/completeness-evidence.js +159 -0
  31. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-client.js +203 -0
  32. package/install/templates/_byan/mcp/byan-mcp-server/lib/gdoc-content.js +203 -0
  33. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-fd-core.js +60 -2
  34. package/install/templates/_byan/mcp/byan-mcp-server/lib/leantime-sync.js +4 -1
  35. package/install/templates/_byan/mcp/byan-mcp-server/lib/precommit-gate.js +68 -2
  36. package/install/templates/_byan/mcp/byan-mcp-server/lib/resolve-config.js +15 -2
  37. package/install/templates/_byan/mcp/byan-mcp-server/lib/strict-mode.js +78 -1
  38. package/install/templates/_byan/mcp/byan-mcp-server/lib/sync-rules.js +1 -1
  39. package/install/templates/_byan/mcp/byan-mcp-server/package.json +2 -0
  40. package/install/templates/_byan/mcp/byan-mcp-server/server.js +70 -0
  41. package/install/templates/_byan/mcp/byan-mcp-server/skill-bundles-manifest.json +1 -1
  42. package/install/templates/dist/skill-bundles/byan-hermes-dispatch.zip +0 -0
  43. package/install/templates/docs/google-docs-publish.md +121 -0
  44. package/install/templates/docs/leantime-integration.md +11 -1
  45. package/node_modules/byan-platform-config/lib/credentials.js +13 -1
  46. package/node_modules/byan-platform-config/lib/mcp-config.js +71 -5
  47. package/package.json +3 -1
  48. package/install/templates/.mcp.json.tmpl +0 -8
@@ -0,0 +1,234 @@
1
+ /**
2
+ * lib/channel-server.js — Serveur MCP channel Claude Code pour byan_web.
3
+ *
4
+ * POURQUOI ce fichier est une factory et non un entrypoint direct :
5
+ * la même factory est importée par channel-entry.js (entrypoint stdio pour
6
+ * --dangerously-load-development-channels) ET par les tests, sans effet de
7
+ * bord transport au import-time.
8
+ *
9
+ * Protocole Channel (research preview CC v2.1.80+) :
10
+ * - capabilities.experimental['claude/channel'] = {}
11
+ * -> CC enregistre un listener de notification
12
+ * - mcp.notification({ method: 'notifications/claude/channel', params })
13
+ * -> event injecté dans la session CC comme <channel source="byan" ...>
14
+ * - reply tool "byan_session_reply" (capabilities.tools = {})
15
+ * -> CC appelle le tool quand Claude veut répondre
16
+ *
17
+ * Gate sender : le poll utilise BYAN_API_TOKEN (scope user). La barrière
18
+ * anti-injection est le RBAC owner côté serveur byan_web ; ce channel ne
19
+ * reçoit que les messages des sessions du user authentifié.
20
+ */
21
+
22
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
23
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
24
+ import {
25
+ ListToolsRequestSchema,
26
+ CallToolRequestSchema,
27
+ } from '@modelcontextprotocol/sdk/types.js';
28
+ import { startPollLoop } from './channel-poll.js';
29
+
30
+ /** Intervalle de poll par défaut (ms). Exposé pour override dans les tests. */
31
+ export const DEFAULT_POLL_INTERVAL_MS = 5_000;
32
+
33
+ /**
34
+ * Reply tool : Claude l'appelle pour renvoyer une réponse vers byan_web.
35
+ *
36
+ * Hypothèse F2b : POST /api/sessions/:session_id/reply { content }
37
+ * -> { ok: true }
38
+ * Le nom du tool côté CC sera mcp__byan-channel__byan_session_reply.
39
+ */
40
+ const REPLY_TOOL = {
41
+ name: 'byan_session_reply',
42
+ description:
43
+ 'Reply to a user message received from byan_web. Call this when a <channel source="byan"> event arrives and a response is expected. Pass the session_id from the event meta attribute and the text to send back.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {
47
+ session_id: {
48
+ type: 'string',
49
+ description: 'The session_id from the inbound <channel> event meta.',
50
+ },
51
+ content: {
52
+ type: 'string',
53
+ description: 'The reply text to send back to the user in byan_web.',
54
+ },
55
+ },
56
+ required: ['session_id', 'content'],
57
+ additionalProperties: false,
58
+ },
59
+ };
60
+
61
+ /**
62
+ * Crée un serveur MCP channel byan sans le connecter (transport séparé).
63
+ * Accepte un fetchImpl pour permettre le mock en test.
64
+ *
65
+ * @param {object} opts
66
+ * @param {string} opts.apiUrl Base URL byan_web
67
+ * @param {string} opts.apiToken Token du user (ApiKey scheme si préfixe byan_)
68
+ * @param {number} [opts.intervalMs] Override intervalle de poll (tests)
69
+ * @param {object} [opts.fetchImpl] Override fetch (tests)
70
+ * @returns {Server} Instance MCP Server (non connectée)
71
+ */
72
+ export function createChannelServer({ apiUrl, apiToken, intervalMs, fetchImpl } = {}) {
73
+ const fetchFn = fetchImpl ?? globalThis.fetch;
74
+ const interval = intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
75
+
76
+ const authHeader = () => {
77
+ if (!apiToken) return {};
78
+ const scheme = apiToken.startsWith('byan_') ? 'ApiKey' : 'Bearer';
79
+ return { Authorization: `${scheme} ${apiToken}` };
80
+ };
81
+
82
+ const mcp = new Server(
83
+ { name: 'byan-channel', version: '0.1.0' },
84
+ {
85
+ capabilities: {
86
+ // Clé qui fait de ce serveur un channel CC.
87
+ // CC enregistre un listener de notification sur cette capability.
88
+ experimental: { 'claude/channel': {} },
89
+ // tools: {} requis pour que CC découvre les tools de réponse.
90
+ tools: {},
91
+ },
92
+ // Instructions injectées dans le system prompt CC.
93
+ // Dis à Claude : les events arrivent comme <channel source="byan" ...>,
94
+ // il doit répondre via byan_session_reply en passant session_id.
95
+ instructions:
96
+ 'Events from byan_web arrive as <channel source="byan" session_id="..." msg_id="...">. ' +
97
+ 'When a user message arrives, read it and reply using the byan_session_reply tool, ' +
98
+ 'passing the session_id from the tag attribute. ' +
99
+ 'Do not reply if the event is a system notification (no reply expected).',
100
+ }
101
+ );
102
+
103
+ // Découverte des tools par CC.
104
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
105
+ tools: [REPLY_TOOL],
106
+ }));
107
+
108
+ // Appel du reply tool par Claude.
109
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
110
+ const { name, arguments: args = {} } = req.params;
111
+
112
+ if (name === 'byan_session_reply') {
113
+ const { session_id, content } = args;
114
+ if (!session_id || !content) {
115
+ return {
116
+ isError: true,
117
+ content: [{ type: 'text', text: 'Error: session_id and content are required.' }],
118
+ };
119
+ }
120
+ if (!apiToken) {
121
+ return {
122
+ isError: true,
123
+ content: [{ type: 'text', text: 'Error: BYAN_API_TOKEN is not configured.' }],
124
+ };
125
+ }
126
+
127
+ // Hypothèse F2b : POST /api/sessions/:session_id/reply { content }
128
+ try {
129
+ const ctrl = new AbortController();
130
+ const timeout = setTimeout(() => ctrl.abort(), 8_000);
131
+ let res;
132
+ try {
133
+ res = await fetchFn(
134
+ `${apiUrl}/api/sessions/${encodeURIComponent(session_id)}/reply`,
135
+ {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ ...authHeader(),
140
+ },
141
+ body: JSON.stringify({ content }),
142
+ signal: ctrl.signal,
143
+ }
144
+ );
145
+ } finally {
146
+ clearTimeout(timeout);
147
+ }
148
+
149
+ if (!res.ok) {
150
+ const text = await res.text().catch(() => '');
151
+ return {
152
+ isError: true,
153
+ content: [{ type: 'text', text: `Error: byan_web replied ${res.status} — ${text}` }],
154
+ };
155
+ }
156
+
157
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, session_id }) }] };
158
+ } catch (err) {
159
+ return {
160
+ isError: true,
161
+ content: [{ type: 'text', text: `Error sending reply: ${err.message}` }],
162
+ };
163
+ }
164
+ }
165
+
166
+ return {
167
+ isError: true,
168
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
169
+ };
170
+ });
171
+
172
+ // Boucle de poll : démarrée après la connexion transport pour que
173
+ // mcp.notification() ait un transport actif. La connexion est gérée par
174
+ // l'entrypoint ; ici on expose startPolling() pour que l'entrypoint
175
+ // démarre la boucle au bon moment.
176
+ let pollLoop = null;
177
+
178
+ /**
179
+ * Démarre la boucle de poll. Appeler APRÈS mcp.connect(transport).
180
+ * Retourne { stop } pour arrêter proprement.
181
+ */
182
+ function startPolling() {
183
+ if (!apiUrl || !apiToken) {
184
+ process.stderr.write(
185
+ '[byan-channel] BYAN_API_URL ou BYAN_API_TOKEN manquant — poll désactivé\n'
186
+ );
187
+ return { stop: () => {} };
188
+ }
189
+
190
+ pollLoop = startPollLoop({
191
+ apiUrl,
192
+ apiToken,
193
+ intervalMs: interval,
194
+ fetchImpl: fetchFn,
195
+ onMessage: async (msg) => {
196
+ // Émettre la notification CC pour chaque message outbox.
197
+ // meta : chaque clé devient un attribut sur le tag <channel>.
198
+ // Les clés contenant des tirets sont silencieusement ignorées par CC
199
+ // (constraint doc) ; on n'utilise que des underscores.
200
+ await mcp.notification({
201
+ method: 'notifications/claude/channel',
202
+ params: {
203
+ content: msg.content ?? '',
204
+ meta: {
205
+ session_id: String(msg.session_id),
206
+ msg_id: String(msg.id),
207
+ ...(msg.meta && typeof msg.meta === 'object' ? msg.meta : {}),
208
+ },
209
+ },
210
+ });
211
+ },
212
+ });
213
+
214
+ return pollLoop;
215
+ }
216
+
217
+ // Attache startPolling sur le serveur pour l'entrypoint.
218
+ mcp.startPolling = startPolling;
219
+
220
+ return mcp;
221
+ }
222
+
223
+ /**
224
+ * Lance le channel en mode stdio (entrypoint direct).
225
+ * Appelé uniquement depuis channel-entry.js, pas depuis les tests.
226
+ */
227
+ export async function runChannelStdio({ apiUrl, apiToken, intervalMs, fetchImpl } = {}) {
228
+ const mcp = createChannelServer({ apiUrl, apiToken, intervalMs, fetchImpl });
229
+ await mcp.connect(new StdioServerTransport());
230
+ // La boucle de poll démarre APRÈS connect pour que la notification
231
+ // ait un transport actif.
232
+ mcp.startPolling();
233
+ // Le process reste vivant grâce au transport stdio.
234
+ }
@@ -0,0 +1,159 @@
1
+ // BYAN completeness-evidence (F2).
2
+ //
3
+ // Strict mode already forces >= 3 self-verify passes whose verdict is the
4
+ // agent's word. This module adds an EVIDENCE layer on top: for each locked
5
+ // acceptance criterion it classifies the criterion (code-shaped vs prose) and
6
+ // collects whatever objective proof is available — a captured test-runner exit,
7
+ // a `git diff --stat` constrained to the allowedPaths, or the existence of a
8
+ // named file. The result is { perCriterion, missing }.
9
+ //
10
+ // It ships DISARMED. The strict complete() ATTACHES this report (so the agent
11
+ // and the audit trail see it) but only HARD-REJECTS when
12
+ // delivery-default.json completenessGate.armed === true (default false). With
13
+ // armed=false the report is pure observation: complete() behaves exactly as
14
+ // before. This is the same disarmed-net posture as the autobench Stop guard.
15
+ //
16
+ // The risky-but-pure half (classification, the report shape, the missing list)
17
+ // takes no I/O. The I/O half (running git, reading files) is injected via an
18
+ // `io` object so the unit tests pin behaviour without touching the real
19
+ // filesystem or spawning git — the same shape strict-sync / suitability-store use.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { execFileSync } from 'node:child_process';
24
+
25
+ // A criterion is "code-shaped" when it names a testable/file/command artifact:
26
+ // a path, an extension, a test/spec word, a backticked token, or a runner. Else
27
+ // it is prose (a human-judged outcome with no mechanical proof). Pure.
28
+ const CODE_SHAPED_PATTERNS = [
29
+ /`[^`]+`/, // a backticked token (file, command, symbol)
30
+ /\b[\w./-]+\.(js|ts|mjs|cjs|json|yaml|yml|md|py|rs|go|sh)\b/i, // a filename with a known ext
31
+ /\b(test|tests|spec|coverage|passe?s?|green|exit\s*0)\b/i, // test/run vocabulary
32
+ /\b(npm|node|jest|git|curl|build|lint)\b/i, // a runner / command
33
+ /\//, // a path separator
34
+ ];
35
+
36
+ export function classifyCriterion(criterion) {
37
+ const text = String(criterion || '');
38
+ const isCode = CODE_SHAPED_PATTERNS.some((re) => re.test(text));
39
+ return isCode ? 'code' : 'prose';
40
+ }
41
+
42
+ // Default I/O surface. Overridable for tests.
43
+ function defaultIo(projectRoot) {
44
+ const root = projectRoot || process.cwd();
45
+ return {
46
+ existsSync: (p) => fs.existsSync(path.isAbsolute(p) ? p : path.join(root, p)),
47
+ // git diff --stat constrained to the given paths, run at the project root.
48
+ gitDiffStat: (paths) => {
49
+ const argPaths = Array.isArray(paths) && paths.length ? ['--', ...paths] : [];
50
+ try {
51
+ const out = execFileSync('git', ['diff', '--stat', ...argPaths], {
52
+ cwd: root,
53
+ encoding: 'utf8',
54
+ stdio: ['ignore', 'pipe', 'ignore'],
55
+ });
56
+ return out.trim();
57
+ } catch {
58
+ return '';
59
+ }
60
+ },
61
+ };
62
+ }
63
+
64
+ // Pull the candidate file tokens out of a code-shaped criterion: backticked
65
+ // tokens and bare filenames-with-extension. Pure (string-only).
66
+ export function fileTokens(criterion) {
67
+ const text = String(criterion || '');
68
+ const tokens = new Set();
69
+ const backtick = /`([^`]+)`/g;
70
+ let m;
71
+ while ((m = backtick.exec(text))) {
72
+ const inner = m[1].trim();
73
+ // Only treat a backticked token as a file when it LOOKS like a path/file
74
+ // (has a separator or an extension); a backticked `npm test` is a command.
75
+ if (/[./]/.test(inner) && !/\s/.test(inner)) tokens.add(inner);
76
+ }
77
+ const bare = /\b[\w./-]+\.(?:js|ts|mjs|cjs|json|yaml|yml|md|py|rs|go|sh)\b/gi;
78
+ while ((m = bare.exec(text))) tokens.add(m[0]);
79
+ return [...tokens];
80
+ }
81
+
82
+ // Build the evidence report for a set of criteria.
83
+ //
84
+ // criteria : array of acceptance-criterion strings (the locked scope)
85
+ // allowedPaths : the locked allowed paths (scopes the git diff)
86
+ // context : { testRun } where testRun is an optional captured run
87
+ // { ran:bool, exitCode:number, summary?:string } — the agent
88
+ // passes what it observed; we never re-run the suite here.
89
+ // io : injected I/O (existsSync, gitDiffStat). Defaults to real fs/git.
90
+ //
91
+ // Returns { perCriterion:[{criterion, kind, hasEvidence, evidence}], missing:[...] }.
92
+ // A code criterion HAS evidence when a named file exists OR a constrained git
93
+ // diff is non-empty OR a green test run was captured. A prose criterion never
94
+ // claims mechanical evidence (it is human-judged) and is reported as such — it
95
+ // is NOT counted as missing, because prose criteria have no mechanical proof by
96
+ // nature; only code criteria without any evidence land in `missing`.
97
+ export function buildEvidence({
98
+ criteria = [],
99
+ allowedPaths = [],
100
+ context = {},
101
+ projectRoot,
102
+ io,
103
+ } = {}) {
104
+ const surface = io || defaultIo(projectRoot);
105
+ const testRun = context && context.testRun;
106
+ const diffStat =
107
+ typeof surface.gitDiffStat === 'function' ? surface.gitDiffStat(allowedPaths) : '';
108
+ const hasDiff = Boolean(diffStat && diffStat.length);
109
+ const greenTest = Boolean(testRun && testRun.ran && Number(testRun.exitCode) === 0);
110
+
111
+ const perCriterion = [];
112
+ const missing = [];
113
+
114
+ for (const c of Array.isArray(criteria) ? criteria : []) {
115
+ const kind = classifyCriterion(c);
116
+ if (kind === 'prose') {
117
+ perCriterion.push({
118
+ criterion: c,
119
+ kind,
120
+ hasEvidence: false,
121
+ evidence: { type: 'prose', note: 'human-judged outcome — no mechanical proof expected' },
122
+ });
123
+ continue;
124
+ }
125
+
126
+ const tokens = fileTokens(c);
127
+ const existing = tokens.filter((t) => {
128
+ try {
129
+ return surface.existsSync(t);
130
+ } catch {
131
+ return false;
132
+ }
133
+ });
134
+
135
+ let hasEvidence = false;
136
+ let evidence = null;
137
+ if (existing.length) {
138
+ hasEvidence = true;
139
+ evidence = { type: 'file', files: existing };
140
+ } else if (greenTest) {
141
+ hasEvidence = true;
142
+ evidence = {
143
+ type: 'test',
144
+ exitCode: 0,
145
+ summary: testRun.summary || 'test run captured green',
146
+ };
147
+ } else if (hasDiff) {
148
+ hasEvidence = true;
149
+ evidence = { type: 'diff', stat: diffStat };
150
+ } else {
151
+ evidence = { type: 'none', note: 'no file, no green test run, no diff in allowedPaths' };
152
+ }
153
+
154
+ perCriterion.push({ criterion: c, kind, hasEvidence, evidence });
155
+ if (!hasEvidence) missing.push(c);
156
+ }
157
+
158
+ return { perCriterion, missing };
159
+ }
@@ -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 };