@brunobracaioli/aibrain 0.4.0-beta.4 → 0.4.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,15 @@ Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
40
40
  - **Code:** it ships in this package and runs locally. Nothing comes from the
41
41
  server.
42
42
 
43
+ Licensed sessions of `aisac claude` and `aisac codex` can also load reviewed MCP
44
+ servers: `github` (read-only, token in `GITHUB_PERSONAL_ACCESS_TOKEN`), `supabase`
45
+ (read-only), `vercel`, `playwright` (pinned, isolated) and `context7`.
46
+ - **Default:** every server is off. `aisac mcp` lists them, `aisac mcp enable|disable
47
+ <id>` or `aisac mcp reset` changes the next session, and `aisac mcp info <id>` says
48
+ what a server reaches and how to log in.
49
+ - **Login:** the host does it (`/mcp` in Claude Code, `aisac mcp login <id>` for Codex)
50
+ and keeps it in its own store. No token reaches `~/.aibrain` or AISAC.
51
+
43
52
  Codex capture also requires the `flock` utility from `util-linux`, normally
44
53
  included in Linux/WSL. The launcher and doctor check availability before a
45
54
  monitored Codex session starts. Capture coordination is released by the kernel
@@ -16,7 +16,7 @@ export const MAX_LOG_FILES = 3;
16
16
  const LOCK_WAIT_MS = 250;
17
17
  const LOCK_STALE_MS = 30_000;
18
18
  const ADMISSION_MS = 60 * 60 * 1000;
19
- export const ADAPTER_VERSION = '0.4.0-beta.4';
19
+ export const ADAPTER_VERSION = '0.4.0-beta.6';
20
20
  const MAX_DIAGNOSTIC_COUNT = 10_000_000;
21
21
  const MAX_DIAGNOSTIC_BYTES = 128 * 1024 * 1024;
22
22
  const CHECKPOINT_BACKENDS = new Set(['native_store', 'private_file']);
package/dist/cli.mjs CHANGED
@@ -36,10 +36,11 @@ import {
36
36
  resolvePackLocale,
37
37
  writeStoredLocale,
38
38
  } from './engineering-pack.mjs';
39
- import { extensionsSummary, hooksCommand } from './extensions-cli.mjs';
39
+ import { extensionsSummary, hooksCommand, mcpCommand } from './extensions-cli.mjs';
40
+ import { sessionServers } from './mcp-servers.mjs';
40
41
  import { anyFormatter } from './guardrails/format.mjs';
41
42
 
42
- const VERSION = '0.4.0-beta.4';
43
+ const VERSION = '0.4.0-beta.6';
43
44
  // The command is `aisac`; `aibrain` is an alias installed from the same entry.
44
45
  const COMMAND = 'aisac';
45
46
  const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
@@ -62,6 +63,9 @@ function usage() {
62
63
  console.log(
63
64
  'Guardrails: `aisac hooks` lists them (a checklist on a terminal); `aisac hooks enable|disable <id>...` and `aisac hooks reset` change the next `aisac claude` session.',
64
65
  );
66
+ console.log(
67
+ 'MCP catalog: `aisac mcp` lists the reviewed servers (GitHub, Supabase, Vercel, Playwright, Context7); `aisac mcp enable|disable <id>...`, `aisac mcp reset`, `aisac mcp info <id>` and `aisac mcp login <id>` (Codex) change or explain the next session.',
68
+ );
65
69
  }
66
70
 
67
71
  // Telemetry is opt-in; setup omits the flag to preserve the stored choice.
@@ -547,11 +551,16 @@ async function setup(args) {
547
551
  /** Extension state plus whether `format-on-edit` has a formatter in this folder (FR8). */
548
552
  async function doctorExtensions() {
549
553
  const summary = await extensionsSummary(stateRoot());
550
- if (!summary.hooks.includes('format-on-edit')) return { ...summary, formatOnEdit: 'off' };
551
- return {
552
- ...summary,
553
- formatOnEdit: (await anyFormatter(process.cwd())) ? 'ready' : 'no_formatter_found',
554
- };
554
+ // A token server is skipped at launch when its variable is unset (names only).
555
+ const mcpMissingEnv = sessionServers(summary.mcp, 'claude', process.env).missingEnv.map(
556
+ (skipped) => skipped.id,
557
+ );
558
+ const formatOnEdit = !summary.hooks.includes('format-on-edit')
559
+ ? 'off'
560
+ : (await anyFormatter(process.cwd()))
561
+ ? 'ready'
562
+ : 'no_formatter_found';
563
+ return { ...summary, formatOnEdit, mcpMissingEnv };
555
564
  }
556
565
 
557
566
  /** @param {boolean} json */
@@ -735,6 +744,7 @@ async function main(argv) {
735
744
  return;
736
745
  }
737
746
  if (command === 'hooks') return hooksCommand(rest);
747
+ if (command === 'mcp') return mcpCommand(rest);
738
748
  if (command === 'disable') {
739
749
  if (rest.length > 0) throw new Error('disable_does_not_accept_options');
740
750
  return removeOwnedSetup();
@@ -1,9 +1,10 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * The AISAC Engineering extension catalog (spec aisac-engineering-extensions,
4
- * FR1, FR2). It ships with the client and is the only source of hooks: the
5
- * server and the licensed pack can neither add nor change entries (ADR-0048).
6
- * The MCP catalog arrives in E6c.
4
+ * FR1, FR2, FR7). It ships with the client and is the only source of hooks
5
+ * and MCP servers: the server and the licensed pack can neither add nor change
6
+ * entries (ADR-0048). Every change to MCP_CATALOG repeats the FR7 review
7
+ * recorded in the spec, and CODEOWNERS covers this file.
7
8
  */
8
9
 
9
10
  /** @typedef {'pt-BR' | 'en'} CatalogLocale */
@@ -138,3 +139,161 @@ export function hookById(id) {
138
139
  export function hookIds() {
139
140
  return HOOK_CATALOG.map((entry) => entry.id);
140
141
  }
142
+
143
+ /**
144
+ * @typedef {object} McpHttpTransport
145
+ * @property {'http'} type
146
+ * @property {string} url Exact URL. Hosts key a stored login by server name and URL.
147
+ * @property {string} [bearerEnv] Environment variable that holds a bearer token. The
148
+ * catalog names it; its value is never stored or read by AISAC.
149
+ */
150
+ /**
151
+ * @typedef {object} McpStdioTransport
152
+ * @property {'stdio'} type
153
+ * @property {'npx'} command
154
+ * @property {readonly string[]} args Pinned to an exact package version (FR7).
155
+ */
156
+ /** @typedef {'host-oauth' | 'optional-oauth' | 'env-token' | 'none'} McpLogin */
157
+ /**
158
+ * @typedef {object} McpEntry
159
+ * @property {string} id
160
+ * @property {false} defaultOn Every catalog server starts off (FR7).
161
+ * @property {readonly ('claude' | 'codex')[]} clients
162
+ * @property {string} publisher
163
+ * @property {string} source The vendor's repository or documentation.
164
+ * @property {McpHttpTransport | McpStdioTransport} transport
165
+ * @property {McpLogin} login
166
+ * @property {string} [setup] A one-time command the user runs before the first use.
167
+ * @property {Readonly<Record<CatalogLocale, string>>} [loginNote] Why and how this login differs.
168
+ * @property {Readonly<Record<CatalogLocale, string>>} description
169
+ * @property {Readonly<Record<CatalogLocale, string>>} reaches What the server can reach (FR7).
170
+ */
171
+
172
+ /** Session name of a catalog server, so it never collides with the user's own. */
173
+ export const MCP_SERVER_PREFIX = 'aisac-';
174
+ const PLAYWRIGHT_PACKAGE = '@playwright/mcp@0.0.83';
175
+
176
+ /**
177
+ * MCP catalog v1 (spec FR7; the owner chose the list on 2026-11-03). The review of
178
+ * each entry (source, transport, login, tools, reach) is recorded in the spec.
179
+ * @type {readonly McpEntry[]}
180
+ */
181
+ export const MCP_CATALOG = Object.freeze([
182
+ {
183
+ id: 'github',
184
+ defaultOn: false,
185
+ clients: ['claude', 'codex'],
186
+ publisher: 'GitHub',
187
+ source: 'https://github.com/github/github-mcp-server',
188
+ transport: {
189
+ type: 'http',
190
+ url: 'https://api.githubcopilot.com/mcp/readonly',
191
+ bearerEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
192
+ },
193
+ login: 'env-token',
194
+ loginNote: {
195
+ en: 'GitHub offers no OAuth login for Claude Code or Codex, so this server takes a personal access token. Use a fine-grained token with read-only access to only the repositories you need, and an expiry date: in Claude Code the session’s shell commands can also read this variable.',
196
+ 'pt-BR':
197
+ 'O GitHub não oferece login OAuth para o Claude Code nem para o Codex, então este servidor usa um token de acesso pessoal. Use um token fine-grained, somente leitura, só nos repositórios de que você precisa e com data de validade: no Claude Code os comandos de shell da sessão também conseguem ler essa variável.',
198
+ },
199
+ description: {
200
+ en: "GitHub's hosted server, read-only: repositories, issues, pull requests and users.",
201
+ 'pt-BR':
202
+ 'Servidor hospedado do GitHub, somente leitura: repositórios, issues, pull requests e usuários.',
203
+ },
204
+ reaches: {
205
+ en: 'Reads what your token can see: repository files, commits, branches, releases, issues, pull requests, and user and team profiles. This read-only URL exposes no write tool.',
206
+ 'pt-BR':
207
+ 'Lê o que o seu token enxerga: arquivos, commits, branches e releases dos repositórios, issues, pull requests e perfis de usuários e times. Esta URL somente leitura não expõe ferramenta de escrita.',
208
+ },
209
+ },
210
+ {
211
+ id: 'supabase',
212
+ defaultOn: false,
213
+ clients: ['claude', 'codex'],
214
+ publisher: 'Supabase',
215
+ source: 'https://github.com/supabase-community/supabase-mcp',
216
+ transport: { type: 'http', url: 'https://mcp.supabase.com/mcp?read_only=true' },
217
+ login: 'host-oauth',
218
+ description: {
219
+ en: "Supabase's hosted server, read-only: tables, SQL, logs, advisors and docs.",
220
+ 'pt-BR':
221
+ 'Servidor hospedado da Supabase, somente leitura: tabelas, SQL, logs, advisors e documentação.',
222
+ },
223
+ reaches: {
224
+ en: 'Every organization and project of the Supabase account you log in with: database contents through read-only SQL, migrations, logs, advisors, Edge Functions, project URLs and publishable keys. read_only=true runs SQL as a read-only Postgres role and leaves out the write tools; the login grants the scopes you approve on Supabase.',
225
+ 'pt-BR':
226
+ 'Todas as organizações e projetos da conta Supabase em que você entrar: dados do banco por SQL somente leitura, migrations, logs, advisors, Edge Functions, URLs dos projetos e chaves publicáveis. read_only=true executa o SQL com um papel Postgres somente leitura e deixa de fora as ferramentas de escrita; o login concede os escopos que você aprovar na Supabase.',
227
+ },
228
+ },
229
+ {
230
+ id: 'vercel',
231
+ defaultOn: false,
232
+ clients: ['claude', 'codex'],
233
+ publisher: 'Vercel',
234
+ source: 'https://vercel.com/docs/agent-resources/vercel-mcp',
235
+ transport: { type: 'http', url: 'https://mcp.vercel.com' },
236
+ login: 'host-oauth',
237
+ description: {
238
+ en: "Vercel's hosted server: projects, deployments, logs, domains and environment variables.",
239
+ 'pt-BR':
240
+ 'Servidor hospedado da Vercel: projetos, deploys, logs, domínios e variáveis de ambiente.',
241
+ },
242
+ reaches: {
243
+ en: 'The teams you grant at login, with the access of your Vercel user: projects, deployments, build and runtime logs, domains and DNS, and environment variables, including decrypted values. It also has write tools (deploy, change settings, DNS records) and no read-only mode, so keep the confirmation of each call on.',
244
+ 'pt-BR':
245
+ 'Os times que você liberar no login, com o acesso do seu usuário Vercel: projetos, deploys, logs de build e de execução, domínios e DNS, e variáveis de ambiente, inclusive valores decifrados. Também tem ferramentas de escrita (deploy, configurações, registros DNS) e não tem modo somente leitura, então mantenha a confirmação de cada chamada ligada.',
246
+ },
247
+ },
248
+ {
249
+ id: 'playwright',
250
+ defaultOn: false,
251
+ clients: ['claude', 'codex'],
252
+ publisher: 'Microsoft',
253
+ source: 'https://github.com/microsoft/playwright-mcp',
254
+ transport: {
255
+ type: 'stdio',
256
+ command: 'npx',
257
+ args: ['-y', PLAYWRIGHT_PACKAGE, '--browser', 'chromium', '--headless', '--isolated'],
258
+ },
259
+ login: 'none',
260
+ setup: `npx -y ${PLAYWRIGHT_PACKAGE} install-browser chrome-for-testing`,
261
+ description: {
262
+ en: 'A local headless browser driven by Playwright, for testing web pages.',
263
+ 'pt-BR': 'Um navegador local sem janela, controlado pelo Playwright, para testar páginas.',
264
+ },
265
+ reaches: {
266
+ en: 'Runs on your machine with a fresh in-memory profile each session, so no saved logins. It opens any URL the agent asks for, including localhost and your local network, can run JavaScript in the page and upload files from the project. Page content is untrusted input.',
267
+ 'pt-BR':
268
+ 'Roda na sua máquina com um perfil novo em memória a cada sessão, sem logins salvos. Abre qualquer URL que o agente pedir, inclusive localhost e a sua rede local, pode executar JavaScript na página e enviar arquivos do projeto. O conteúdo das páginas é entrada não confiável.',
269
+ },
270
+ },
271
+ {
272
+ id: 'context7',
273
+ defaultOn: false,
274
+ clients: ['claude', 'codex'],
275
+ publisher: 'Upstash',
276
+ source: 'https://github.com/upstash/context7',
277
+ transport: { type: 'http', url: 'https://mcp.context7.com/mcp' },
278
+ login: 'optional-oauth',
279
+ description: {
280
+ en: 'Up-to-date documentation and code examples for libraries, from Context7.',
281
+ 'pt-BR': 'Documentação e exemplos de código atualizados de bibliotecas, do Context7.',
282
+ },
283
+ reaches: {
284
+ en: 'Public library documentation only. What the agent asks (library names and questions) goes to Upstash, so keep secrets and private code out of those questions. It works without login, with lower rate limits.',
285
+ 'pt-BR':
286
+ 'Só documentação pública de bibliotecas. O que o agente pergunta (nomes de bibliotecas e dúvidas) vai para a Upstash, então não coloque segredos nem código privado nessas perguntas. Funciona sem login, com limites de uso menores.',
287
+ },
288
+ },
289
+ ]);
290
+
291
+ /** @param {string} id @returns {McpEntry | undefined} */
292
+ export function mcpById(id) {
293
+ return MCP_CATALOG.find((entry) => entry.id === id);
294
+ }
295
+
296
+ /** @returns {string[]} */
297
+ export function mcpIds() {
298
+ return MCP_CATALOG.map((entry) => entry.id);
299
+ }
@@ -1,26 +1,105 @@
1
1
  // @ts-check
2
2
  /**
3
- * `aisac hooks` (spec aisac-engineering-extensions FR4): list, enable,
4
- * disable and reset guardrail hooks, offline. On a TTY without arguments it
5
- * opens a checklist. Choices apply to the next session; running sessions
6
- * keep their set.
3
+ * `aisac hooks` and `aisac mcp` (spec aisac-engineering-extensions FR4): list,
4
+ * enable, disable and reset catalog entries, offline. On a TTY without
5
+ * arguments each opens a checklist. `aisac mcp info` describes one server, and
6
+ * `aisac mcp login` runs Codex's own login for it. Choices apply to the next
7
+ * session; running sessions keep their set.
7
8
  */
9
+ import { spawn } from 'node:child_process';
8
10
  import { emitKeypressEvents } from 'node:readline';
9
- import { HOOK_CATALOG, hookById, hookIds } from './extensions-catalog.mjs';
11
+ import { hookById, hookIds, mcpById, mcpIds } from './extensions-catalog.mjs';
10
12
  import {
11
13
  readPreferences,
12
14
  resolveHooks,
15
+ resolveServers,
13
16
  unknownHookIds,
17
+ unknownServerIds,
14
18
  writePreferences,
15
19
  } from './extensions-preferences.mjs';
16
20
  import { readLastLaunch, readStoredLocale, resolvePackLocale } from './engineering-pack.mjs';
21
+ import { codexLoginArgs, serverName, supportsHostLogin } from './mcp-servers.mjs';
22
+ import { resolveNative } from './platform.mjs';
17
23
  import { stateRoot } from './state.mjs';
18
24
 
25
+ /** @typedef {import('./extensions-catalog.mjs').CatalogLocale} CatalogLocale */
26
+ /** @typedef {import('./extensions-catalog.mjs').McpEntry} McpEntry */
27
+ /** @typedef {import('./extensions-preferences.mjs').ExtensionPreferences} ExtensionPreferences */
28
+ /**
29
+ * @typedef {object} CatalogItem
30
+ * @property {{ id: string, description: Readonly<Record<CatalogLocale, string>> }} entry
31
+ * @property {boolean} enabled
32
+ * @property {'default' | 'choice'} source
33
+ */
34
+ /**
35
+ * @typedef {object} ExtensionKind
36
+ * @property {'hooks' | 'mcp'} key The command name and the preferences key.
37
+ * @property {string} title
38
+ * @property {() => string[]} ids
39
+ * @property {(preferences: ExtensionPreferences) => CatalogItem[]} resolve
40
+ * @property {(preferences: ExtensionPreferences) => string[]} unknown
41
+ * @property {(id: string) => string} column The second column: hook mode or server login.
42
+ * @property {string} nextSession
43
+ */
44
+
19
45
  const USAGE_EXIT_CODE = 2;
20
- const ID_WIDTH = Math.max(...HOOK_CATALOG.map((entry) => entry.id.length));
21
- const NEXT_SESSION =
22
- 'Applies to the next `aisac claude` session; running sessions keep their current set.';
23
- const LICENSE_NOTE = 'Hooks load only with an active AISAC Engineering license.';
46
+ const IO_EXIT_CODE = 1;
47
+ const LICENSE_NOTE = 'Extensions load only with an active AISAC Engineering license.';
48
+ const DEFAULT_COLUMNS = 80;
49
+ const MIN_COLUMNS = 20;
50
+ const ELLIPSIS = '…';
51
+ /** @type {Readonly<Record<McpEntry['login'], string>>} */
52
+ const LOGIN_LABEL = Object.freeze({
53
+ 'host-oauth': 'oauth',
54
+ 'optional-oauth': 'oauth-opt',
55
+ 'env-token': 'env-token',
56
+ none: 'no-login',
57
+ });
58
+
59
+ /** @type {ExtensionKind} */
60
+ const HOOKS = {
61
+ key: 'hooks',
62
+ title: 'AISAC Engineering hooks (Claude Code)',
63
+ ids: hookIds,
64
+ resolve: resolveHooks,
65
+ unknown: unknownHookIds,
66
+ column: (id) => hookById(id)?.mode ?? '',
67
+ nextSession:
68
+ 'Applies to the next `aisac claude` session; running sessions keep their current set.',
69
+ };
70
+
71
+ /** @type {ExtensionKind} */
72
+ const MCP = {
73
+ key: 'mcp',
74
+ title: 'AISAC Engineering MCP servers (Claude Code and Codex)',
75
+ ids: mcpIds,
76
+ resolve: resolveServers,
77
+ unknown: unknownServerIds,
78
+ column: (id) => {
79
+ const entry = mcpById(id);
80
+ return entry === undefined ? '' : LOGIN_LABEL[entry.login];
81
+ },
82
+ nextSession:
83
+ 'Applies to the next `aisac claude` or `aisac codex` session; running sessions keep their current set.',
84
+ };
85
+
86
+ /**
87
+ * Cut a checklist row to the terminal width. The redraw moves the cursor up one
88
+ * row per item, which only holds while no row wraps; one column stays free so
89
+ * no terminal wraps at the last column either.
90
+ * @param {string} line
91
+ * @param {number | undefined} columns
92
+ */
93
+ export function fitLine(line, columns) {
94
+ const total =
95
+ typeof columns === 'number' && Number.isInteger(columns) && columns > 0
96
+ ? columns
97
+ : DEFAULT_COLUMNS;
98
+ const width = Math.max(MIN_COLUMNS, total) - 1;
99
+ const characters = [...line];
100
+ if (characters.length <= width) return line;
101
+ return `${characters.slice(0, width - 1).join('')}${ELLIPSIS}`;
102
+ }
24
103
 
25
104
  /**
26
105
  * @typedef {object} ChecklistState
@@ -50,13 +129,19 @@ export function checklistStep(state, key) {
50
129
  return { state, done: null };
51
130
  }
52
131
 
53
- /** @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks @param {ChecklistState} [state] */
54
- function renderLines(locale, hooks, state) {
55
- return hooks.map((hook, index) => {
56
- const enabled = state === undefined ? hook.enabled : state.enabled[index];
132
+ /**
133
+ * @param {ExtensionKind} kind @param {CatalogLocale} locale
134
+ * @param {CatalogItem[]} items @param {ChecklistState} [state]
135
+ */
136
+ function renderLines(kind, locale, items, state) {
137
+ const idWidth = Math.max(...items.map((item) => item.entry.id.length));
138
+ const columnWidth = Math.max(...items.map((item) => kind.column(item.entry.id).length)) + 1;
139
+ return items.map((item, index) => {
140
+ const enabled = state === undefined ? item.enabled : state.enabled[index];
57
141
  const pointer = state === undefined ? ' ' : index === state.cursor ? '>' : ' ';
58
- const source = state === undefined ? ` ${hook.source.padEnd(7)}` : '';
59
- return `${pointer} [${enabled ? 'x' : ' '}] ${hook.entry.id.padEnd(ID_WIDTH)} ${hook.entry.mode.padEnd(8)}${source} ${hook.entry.description[locale]}`;
142
+ const source = state === undefined ? ` ${item.source.padEnd(7)}` : '';
143
+ const column = kind.column(item.entry.id).padEnd(columnWidth);
144
+ return `${pointer} [${enabled ? 'x' : ' '}] ${item.entry.id.padEnd(idWidth)} ${column}${source} ${item.entry.description[locale]}`;
60
145
  });
61
146
  }
62
147
 
@@ -67,41 +152,42 @@ async function licensedLastLaunch(root) {
67
152
  }
68
153
 
69
154
  /**
70
- * Store explicit choices, keeping MCP choices as they are.
71
- * @param {Record<string, boolean>} hooks
155
+ * Store one kind's explicit choices, keeping the other kind's as they are.
156
+ * @param {ExtensionKind} kind @param {Record<string, boolean>} choices
72
157
  */
73
- async function saveHooks(hooks) {
158
+ async function saveChoices(kind, choices) {
74
159
  const current = await readPreferences(stateRoot());
75
- await writePreferences({ hooks, mcp: current.status === 'valid' ? current.mcp : {} });
160
+ const valid = current.status === 'valid';
161
+ await writePreferences({
162
+ hooks: kind.key === 'hooks' ? choices : valid ? current.hooks : {},
163
+ mcp: kind.key === 'mcp' ? choices : valid ? current.mcp : {},
164
+ });
76
165
  if (current.status === 'invalid')
77
166
  console.log('The previous extensions file was invalid and was replaced.');
78
167
  }
79
168
 
80
- /** @param {string[]} ids */
81
- function unknownIds(ids) {
82
- return ids.filter((id) => hookById(id) === undefined);
83
- }
84
-
85
- /** @param {boolean} licensed */
86
- function afterChange(licensed) {
87
- console.log(NEXT_SESSION);
169
+ /** @param {ExtensionKind} kind @param {boolean} licensed */
170
+ function afterChange(kind, licensed) {
171
+ console.log(kind.nextSession);
88
172
  if (!licensed) console.log(LICENSE_NOTE);
89
173
  }
90
174
 
91
175
  /**
92
176
  * Interactive checklist. Resolves to the new enabled flags, or null on cancel.
93
- * @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks
177
+ * @param {ExtensionKind} kind @param {CatalogLocale} locale @param {CatalogItem[]} items
94
178
  * @returns {Promise<boolean[] | null>}
95
179
  */
96
- function runChecklist(locale, hooks) {
180
+ function runChecklist(kind, locale, items) {
97
181
  return new Promise((resolve) => {
98
- let state = { cursor: 0, enabled: hooks.map((hook) => hook.enabled) };
99
- const header = 'AISAC Engineering hooks: space toggles, enter saves, q cancels.';
182
+ let state = { cursor: 0, enabled: items.map((item) => item.enabled) };
100
183
  const draw = (/** @type {boolean} */ first) => {
101
- if (!first) process.stdout.write(`\u001b[${hooks.length}A\u001b[J`);
102
- process.stdout.write(`${renderLines(locale, hooks, state).join('\n')}\n`);
184
+ if (!first) process.stdout.write(`\u001b[${items.length}A\u001b[J`);
185
+ const rows = renderLines(kind, locale, items, state).map((line) =>
186
+ fitLine(line, process.stdout.columns),
187
+ );
188
+ process.stdout.write(`${rows.join('\n')}\n`);
103
189
  };
104
- console.log(header);
190
+ console.log(`${kind.title}: up/down moves, space toggles, enter saves, q cancels.`);
105
191
  draw(true);
106
192
  emitKeypressEvents(process.stdin);
107
193
  process.stdin.setRawMode(true);
@@ -121,17 +207,171 @@ function runChecklist(locale, hooks) {
121
207
  }
122
208
 
123
209
  /**
124
- * `aisac hooks [list|enable|disable|reset] [ids...] [--json]`.
125
- * @param {string[]} args
210
+ * The JSON row of one item.
211
+ * @param {ExtensionKind} kind @param {CatalogLocale} locale @param {CatalogItem} item
212
+ */
213
+ function jsonItem(kind, locale, item) {
214
+ const base = {
215
+ id: item.entry.id,
216
+ enabled: item.enabled,
217
+ source: item.source,
218
+ description: item.entry.description[locale],
219
+ };
220
+ if (kind.key === 'hooks') {
221
+ const hook = hookById(item.entry.id);
222
+ return { ...base, mode: hook?.mode, clients: hook?.clients };
223
+ }
224
+ const server = mcpById(item.entry.id);
225
+ return {
226
+ ...base,
227
+ server: serverName(item.entry.id),
228
+ login: server?.login,
229
+ transport: server?.transport.type === 'stdio' ? 'local' : 'remote',
230
+ clients: server?.clients,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * What one server reaches and how to log in (FR4 `aisac mcp info`).
236
+ * @param {McpEntry} entry @param {CatalogLocale} locale @param {CatalogItem | undefined} item
237
+ */
238
+ export function serverInfo(entry, locale, item) {
239
+ const name = serverName(entry.id);
240
+ const { transport } = entry;
241
+ const variable = transport.type === 'http' ? transport.bearerEnv : undefined;
242
+ /** @type {{ claude: string, codex: string }} */
243
+ const login =
244
+ entry.login === 'none'
245
+ ? { claude: 'No login.', codex: 'No login.' }
246
+ : variable !== undefined
247
+ ? {
248
+ claude: `Set ${variable} in the shell that runs aisac claude; without it the server is skipped.`,
249
+ codex: `Set ${variable} in the shell that runs aisac codex; without it the server is skipped.`,
250
+ }
251
+ : {
252
+ claude: `${entry.login === 'optional-oauth' ? 'Optional: ' : ''}in the session, /mcp, choose ${name}, then Authenticate.`,
253
+ codex: `${entry.login === 'optional-oauth' ? 'Optional: ' : ''}aisac mcp login ${entry.id} (runs Codex's own login with the session settings).`,
254
+ };
255
+ return {
256
+ id: entry.id,
257
+ server: name,
258
+ enabled: item?.enabled ?? false,
259
+ choice: item?.source ?? 'default',
260
+ publisher: entry.publisher,
261
+ sourceUrl: entry.source,
262
+ description: entry.description[locale],
263
+ transport:
264
+ transport.type === 'stdio'
265
+ ? {
266
+ type: 'local',
267
+ command: [transport.command, ...transport.args].join(' '),
268
+ note: 'Pinned to an exact package version; npx downloads it on first use.',
269
+ }
270
+ : {
271
+ type: 'remote',
272
+ url: transport.url,
273
+ note: 'Hosted by the vendor: its tools can change without a client release, so keep the confirmation of each call on.',
274
+ },
275
+ reaches: entry.reaches[locale],
276
+ login,
277
+ ...(variable === undefined ? {} : { tokenVariable: variable }),
278
+ ...(entry.loginNote === undefined ? {} : { loginNote: entry.loginNote[locale] }),
279
+ ...(entry.setup === undefined ? {} : { setup: entry.setup }),
280
+ credentials:
281
+ 'Logins stay in the host’s own credential store (Claude Code or Codex). AISAC never sees them: no token goes to ~/.aibrain or to AISAC.',
282
+ duplicate: `If you already run your own server for this service, keep one of them (aisac mcp disable ${entry.id}, or remove yours).`,
283
+ trifecta:
284
+ 'Private data (GitHub, Supabase, Vercel) plus untrusted content (web pages, issues) plus a way out in one session lets injected text reach your data: keep the confirmation of each call on.',
285
+ };
286
+ }
287
+
288
+ /** @param {ReturnType<typeof serverInfo>} info */
289
+ function printInfo(info) {
290
+ console.log(`${info.server}: ${info.description}`);
291
+ console.log(`State: ${info.enabled ? 'on' : 'off'} (${info.choice})`);
292
+ console.log(`Publisher: ${info.publisher} (${info.sourceUrl})`);
293
+ const where = info.transport.type === 'local' ? info.transport.command : info.transport.url;
294
+ console.log(`Transport: ${info.transport.type}, ${where}. ${info.transport.note}`);
295
+ console.log(`Reaches: ${info.reaches}`);
296
+ if (info.loginNote !== undefined) console.log(`Login: ${info.loginNote}`);
297
+ console.log(`Log in from Claude Code: ${info.login.claude}`);
298
+ console.log(`Log in from Codex: ${info.login.codex}`);
299
+ if (info.setup !== undefined) console.log(`Before the first use, run once: ${info.setup}`);
300
+ console.log(info.credentials);
301
+ console.log(info.trifecta);
302
+ console.log(info.duplicate);
303
+ }
304
+
305
+ /**
306
+ * `aisac mcp login <id>`: Codex's own login, with the session's settings, so
307
+ * the next `aisac codex` session finds it. AISAC never sees the credential.
308
+ * @param {McpEntry} entry
309
+ */
310
+ async function codexLogin(entry) {
311
+ const name = serverName(entry.id);
312
+ if (!supportsHostLogin(entry)) {
313
+ const variable = entry.transport.type === 'http' ? entry.transport.bearerEnv : undefined;
314
+ console.error(
315
+ variable === undefined
316
+ ? `aisac: ${name} needs no login.`
317
+ : `aisac: ${name} has no host login; set ${variable} instead (aisac mcp info ${entry.id}).`,
318
+ );
319
+ process.exitCode = USAGE_EXIT_CODE;
320
+ return;
321
+ }
322
+ const codex = resolveNative('codex');
323
+ if (codex === null) {
324
+ console.error('aisac: codex was not found on PATH.');
325
+ process.exitCode = IO_EXIT_CODE;
326
+ return;
327
+ }
328
+ console.log(
329
+ `Logging in to ${name} with Codex; the login stays in Codex's own store. Claude Code logs in inside the session: /mcp, choose ${name}, then Authenticate.`,
330
+ );
331
+ process.exitCode = await new Promise((resolve) => {
332
+ const child = spawn(codex, codexLoginArgs(entry), { stdio: 'inherit' });
333
+ child.on('error', () => resolve(IO_EXIT_CODE));
334
+ child.on('exit', (code) => resolve(code ?? IO_EXIT_CODE));
335
+ });
336
+ }
337
+
338
+ /**
339
+ * @param {ExtensionKind} kind @param {string[]} ids
340
+ * @returns {boolean} true when every id is in the catalog
341
+ */
342
+ function checkIds(kind, ids) {
343
+ const known = new Set(kind.ids());
344
+ const unknown = ids.filter((id) => !known.has(id));
345
+ if (unknown.length === 0) return true;
346
+ const noun = kind.key === 'hooks' ? 'hook' : 'MCP server';
347
+ console.error(
348
+ `aisac: unknown ${noun} id: ${unknown.join(', ')}. Valid: ${kind.ids().join(', ')}.`,
349
+ );
350
+ process.exitCode = USAGE_EXIT_CODE;
351
+ return false;
352
+ }
353
+
354
+ /** @param {ExtensionKind} kind */
355
+ function usage(kind) {
356
+ const extra = kind.key === 'mcp' ? ' | aisac mcp info <id> | aisac mcp login <id>' : '';
357
+ console.error(
358
+ `aisac: usage: aisac ${kind.key} [--json] | aisac ${kind.key} enable <id>... | aisac ${kind.key} disable <id>... | aisac ${kind.key} reset${extra}`,
359
+ );
360
+ process.exitCode = USAGE_EXIT_CODE;
361
+ }
362
+
363
+ /**
364
+ * `aisac hooks|mcp [list|enable|disable|reset|info|login] [ids...] [--json]`.
365
+ * @param {ExtensionKind} kind @param {string[]} args
126
366
  */
127
- export async function hooksCommand(args) {
367
+ async function extensionCommand(kind, args) {
128
368
  const json = args.includes('--json');
129
369
  const words = args.filter((arg) => arg !== '--json');
130
370
  const [action = 'list', ...ids] = words;
131
371
  const root = stateRoot();
132
372
  const locale = resolvePackLocale(await readStoredLocale(root));
133
373
  const preferences = await readPreferences(root);
134
- const hooks = resolveHooks(preferences);
374
+ const items = kind.resolve(preferences);
135
375
  const licensed = await licensedLastLaunch(root);
136
376
 
137
377
  if (action === 'list' && ids.length === 0) {
@@ -140,65 +380,79 @@ export async function hooksCommand(args) {
140
380
  JSON.stringify({
141
381
  preferences: preferences.status,
142
382
  licensedLastLaunch: licensed,
143
- hooks: hooks.map((hook) => ({
144
- id: hook.entry.id,
145
- enabled: hook.enabled,
146
- source: hook.source,
147
- mode: hook.entry.mode,
148
- clients: hook.entry.clients,
149
- description: hook.entry.description[locale],
150
- })),
151
- unknown: unknownHookIds(preferences),
383
+ [kind.key === 'hooks' ? 'hooks' : 'servers']: items.map((item) =>
384
+ jsonItem(kind, locale, item),
385
+ ),
386
+ unknown: kind.unknown(preferences),
152
387
  }),
153
388
  );
154
389
  return;
155
390
  }
156
391
  if (args.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
157
- const chosen = await runChecklist(locale, hooks);
392
+ const chosen = await runChecklist(kind, locale, items);
158
393
  if (chosen === null) return console.log('No change.');
159
394
  /** @type {Record<string, boolean>} */
160
- const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
161
- hooks.forEach((hook, index) => {
162
- if (chosen[index] !== hook.enabled) choices[hook.entry.id] = chosen[index] === true;
395
+ const choices = preferences.status === 'valid' ? { ...preferences[kind.key] } : {};
396
+ items.forEach((item, index) => {
397
+ if (chosen[index] !== item.enabled) choices[item.entry.id] = chosen[index] === true;
163
398
  });
164
- await saveHooks(choices);
165
- return afterChange(licensed);
399
+ await saveChoices(kind, choices);
400
+ return afterChange(kind, licensed);
166
401
  }
167
- console.log('AISAC Engineering hooks (Claude Code):');
168
- console.log(renderLines(locale, hooks).join('\n'));
402
+ console.log(`${kind.title}:`);
403
+ console.log(renderLines(kind, locale, items).join('\n'));
169
404
  if (preferences.status === 'invalid')
170
- console.log('The extensions file is invalid: the defaults apply. Run `aisac hooks reset`.');
405
+ console.log(
406
+ `The extensions file is invalid: ${kind.key === 'hooks' ? 'the defaults apply' : 'every server is off'}. Run \`aisac ${kind.key} reset\`.`,
407
+ );
171
408
  console.log(
172
- 'Change with `aisac hooks enable <id>`, `aisac hooks disable <id>` or `aisac hooks reset`.',
409
+ `Change with \`aisac ${kind.key} enable <id>\`, \`aisac ${kind.key} disable <id>\` or \`aisac ${kind.key} reset\`.${kind.key === 'mcp' ? ' Details: `aisac mcp info <id>`.' : ''}`,
173
410
  );
174
411
  if (!licensed) console.log(LICENSE_NOTE);
175
412
  return;
176
413
  }
177
414
  if (action === 'reset' && ids.length === 0) {
178
- await saveHooks({});
179
- console.log('Hooks reset to the defaults.');
180
- return afterChange(licensed);
415
+ await saveChoices(kind, {});
416
+ console.log(`${kind.key === 'hooks' ? 'Hooks' : 'MCP servers'} reset to the defaults.`);
417
+ return afterChange(kind, licensed);
181
418
  }
182
419
  if ((action === 'enable' || action === 'disable') && ids.length > 0) {
183
- const unknown = unknownIds(ids);
184
- if (unknown.length > 0) {
185
- console.error(
186
- `aisac: unknown hook id: ${unknown.join(', ')}. Valid: ${hookIds().join(', ')}.`,
187
- );
188
- process.exitCode = USAGE_EXIT_CODE;
189
- return;
190
- }
420
+ if (!checkIds(kind, ids)) return;
191
421
  /** @type {Record<string, boolean>} */
192
- const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
422
+ const choices = preferences.status === 'valid' ? { ...preferences[kind.key] } : {};
193
423
  for (const id of ids) choices[id] = action === 'enable';
194
- await saveHooks(choices);
424
+ await saveChoices(kind, choices);
195
425
  console.log(`${action === 'enable' ? 'Enabled' : 'Disabled'}: ${ids.join(', ')}.`);
196
- return afterChange(licensed);
426
+ return afterChange(kind, licensed);
197
427
  }
198
- console.error(
199
- 'aisac: usage: aisac hooks [--json] | aisac hooks enable <id>... | aisac hooks disable <id>... | aisac hooks reset',
200
- );
201
- process.exitCode = USAGE_EXIT_CODE;
428
+ const [id] = ids;
429
+ if (kind.key === 'mcp' && action === 'info' && ids.length === 1 && id !== undefined) {
430
+ if (!checkIds(kind, ids)) return;
431
+ const entry = /** @type {McpEntry} */ (mcpById(id));
432
+ const info = serverInfo(
433
+ entry,
434
+ locale,
435
+ items.find((item) => item.entry.id === id),
436
+ );
437
+ if (json) console.log(JSON.stringify(info));
438
+ else printInfo(info);
439
+ return;
440
+ }
441
+ if (kind.key === 'mcp' && action === 'login' && ids.length === 1 && id !== undefined && !json) {
442
+ if (!checkIds(kind, ids)) return;
443
+ return codexLogin(/** @type {McpEntry} */ (mcpById(id)));
444
+ }
445
+ usage(kind);
446
+ }
447
+
448
+ /** @param {string[]} args */
449
+ export function hooksCommand(args) {
450
+ return extensionCommand(HOOKS, args);
451
+ }
452
+
453
+ /** @param {string[]} args */
454
+ export function mcpCommand(args) {
455
+ return extensionCommand(MCP, args);
202
456
  }
203
457
 
204
458
  /** Offline extension summary for `status` and `doctor`. @param {string} root */
@@ -209,6 +463,9 @@ export async function extensionsSummary(root) {
209
463
  hooks: resolveHooks(preferences)
210
464
  .filter((hook) => hook.enabled)
211
465
  .map((hook) => hook.entry.id),
212
- unknown: unknownHookIds(preferences),
466
+ mcp: resolveServers(preferences)
467
+ .filter((server) => server.enabled)
468
+ .map((server) => server.entry.id),
469
+ unknown: [...unknownHookIds(preferences), ...unknownServerIds(preferences)],
213
470
  };
214
471
  }
@@ -9,7 +9,7 @@
9
9
  import { lstat, readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { atomicJson, ensureStateRoot } from './state.mjs';
12
- import { CATALOG_ID, HOOK_CATALOG, hookById } from './extensions-catalog.mjs';
12
+ import { CATALOG_ID, HOOK_CATALOG, MCP_CATALOG, hookById, mcpById } from './extensions-catalog.mjs';
13
13
 
14
14
  export const EXTENSIONS_FILE = 'extensions.json';
15
15
  const SCHEMA_VERSION = 1;
@@ -120,3 +120,32 @@ export function enabledHookIds(preferences) {
120
120
  export function unknownHookIds(preferences) {
121
121
  return Object.keys(preferences.hooks).filter((id) => hookById(id) === undefined);
122
122
  }
123
+
124
+ /**
125
+ * Each catalog MCP server with its effective state. Every server defaults to
126
+ * off, and an invalid file switches them all off (FR3).
127
+ * @param {ExtensionPreferences} preferences
128
+ */
129
+ export function resolveServers(preferences) {
130
+ const chosen = preferences.status === 'valid' ? preferences.mcp : {};
131
+ return MCP_CATALOG.map((entry) => {
132
+ const choice = chosen[entry.id];
133
+ return {
134
+ entry,
135
+ enabled: choice ?? entry.defaultOn,
136
+ source: /** @type {'default' | 'choice'} */ (choice === undefined ? 'default' : 'choice'),
137
+ };
138
+ });
139
+ }
140
+
141
+ /** @param {ExtensionPreferences} preferences @returns {string[]} */
142
+ export function enabledServerIds(preferences) {
143
+ return resolveServers(preferences)
144
+ .filter((server) => server.enabled)
145
+ .map((server) => server.entry.id);
146
+ }
147
+
148
+ /** Server ids stored in the file that this client's catalog does not know. @param {ExtensionPreferences} preferences */
149
+ export function unknownServerIds(preferences) {
150
+ return Object.keys(preferences.mcp).filter((id) => mcpById(id) === undefined);
151
+ }
@@ -36,7 +36,7 @@ const MAX_NESTING = 2;
36
36
  const AISAC_PROGRAMS = new Set(['aisac', 'aibrain']);
37
37
  const AISAC_PACKAGE = /^@(?:aisac\/cli|brunobracaioli\/aibrain)(?:@\S*)?$/u;
38
38
  const EXTENSION_COMMANDS = new Set(['hooks', 'mcp']);
39
- const EXTENSION_CHANGES = new Set(['enable', 'disable', 'reset']);
39
+ const EXTENSION_CHANGES = new Set(['enable', 'disable', 'reset', 'login']);
40
40
  const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace']);
41
41
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
42
42
  const GIT_TIMEOUT_MS = 2_000;
package/dist/launch.mjs CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  writeLaunchMarker,
20
20
  } from './engineering-pack.mjs';
21
21
  import { prepareGuardrails } from './guardrails-plugin.mjs';
22
+ import { CODEX_MCP_ENV, claudeMcpEntries, mcpLaunchLines, sessionServers } from './mcp-servers.mjs';
23
+ import { enabledServerIds, readPreferences } from './extensions-preferences.mjs';
22
24
  import {
23
25
  CODEX_ENGINEERING_ENV,
24
26
  CODEX_ENGINEERING_SKILLS_ROOT_ENV,
@@ -140,6 +142,7 @@ function childEnvironment(root) {
140
142
  delete environment[CODEX_MONITOR_CWD];
141
143
  delete environment[CODEX_ENGINEERING_ENV];
142
144
  delete environment[CODEX_ENGINEERING_SKILLS_ROOT_ENV];
145
+ delete environment[CODEX_MCP_ENV];
143
146
  environment.AIBRAIN_STATE_DIR = resolve(root);
144
147
  return environment;
145
148
  }
@@ -426,10 +429,22 @@ function waitForChild(child, directory) {
426
429
  });
427
430
  }
428
431
 
432
+ /**
433
+ * The catalog servers that join this session: the user's enabled choices from
434
+ * the preferences file. Called only after the license was confirmed.
435
+ * @param {string} root
436
+ * @param {'claude' | 'codex'} client
437
+ * @param {NodeJS.ProcessEnv} environment
438
+ */
439
+ export async function readSessionServers(root, client, environment) {
440
+ return sessionServers(enabledServerIds(await readPreferences(root)), client, environment);
441
+ }
442
+
429
443
  /**
430
444
  * @typedef {object} LaunchDependencies
431
445
  * @property {typeof prepareEngineeringPack} [preparePack] AISAC Engineering pack loader (E4b)
432
446
  * @property {typeof prepareGuardrails} [prepareGuardrails] Guardrail hooks plugin writer (E6a)
447
+ * @property {typeof readSessionServers} [readSessionServers] MCP catalog servers to load (E6c)
433
448
  */
434
449
 
435
450
  /**
@@ -441,6 +456,7 @@ function waitForChild(child, directory) {
441
456
  export async function launchClaude(config, root, args, dependencies = {}) {
442
457
  const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
443
458
  const writeGuardrails = dependencies.prepareGuardrails ?? prepareGuardrails;
459
+ const readServers = dependencies.readSessionServers ?? readSessionServers;
444
460
  const validated = assertConfig(config);
445
461
  const launchArgs = await parseClaudeLaunchArgs(args, process.cwd());
446
462
  assertClaudeArgs(launchArgs.args);
@@ -556,18 +572,8 @@ export async function launchClaude(config, root, args, dependencies = {}) {
556
572
  allow: mergedAllow,
557
573
  },
558
574
  };
559
- const mcp = {
560
- mcpServers: {
561
- [MCP_ALIAS]: {
562
- type: 'http',
563
- url: validated.endpoint,
564
- headers: { Authorization: `Bearer ${validated.token}` },
565
- },
566
- },
567
- };
568
575
  try {
569
576
  await privateJson(settingsPath, settings);
570
- await privateJson(mcpPath, mcp);
571
577
  } catch (error) {
572
578
  await rm(directory, { recursive: true, force: true });
573
579
  throw error;
@@ -590,6 +596,29 @@ export async function launchClaude(config, root, args, dependencies = {}) {
590
596
  () => null,
591
597
  );
592
598
  if (guardrails !== null) process.stderr.write(`${guardrails.line}\n`);
599
+ // Catalog servers need the same confirmed license, and never block the launch.
600
+ const servers =
601
+ engineering.pluginRoot === null
602
+ ? null
603
+ : await readServers(root, 'claude', process.env).catch(() => null);
604
+ for (const line of servers === null ? [] : mcpLaunchLines(servers, 'claude'))
605
+ process.stderr.write(`${line}\n`);
606
+ const mcp = {
607
+ mcpServers: {
608
+ ...(servers === null ? {} : claudeMcpEntries(servers.ready)),
609
+ [MCP_ALIAS]: {
610
+ type: 'http',
611
+ url: validated.endpoint,
612
+ headers: { Authorization: `Bearer ${validated.token}` },
613
+ },
614
+ },
615
+ };
616
+ try {
617
+ await privateJson(mcpPath, mcp);
618
+ } catch (error) {
619
+ await rm(directory, { recursive: true, force: true });
620
+ throw error;
621
+ }
593
622
  const environment = childEnvironment(root);
594
623
  environment.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = '1';
595
624
  /** @type {import('node:child_process').ChildProcess} */
@@ -630,6 +659,7 @@ export async function launchClaude(config, root, args, dependencies = {}) {
630
659
  */
631
660
  export async function launchCodex(config, root, args, dependencies = {}) {
632
661
  const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
662
+ const readServers = dependencies.readSessionServers ?? readSessionServers;
633
663
  const validated = assertConfig(config);
634
664
  assertCodexArgs(args);
635
665
  const captureLockPlatform = captureLockPlatformCheck();
@@ -691,8 +721,15 @@ export async function launchCodex(config, root, args, dependencies = {}) {
691
721
  hostSupported: supportsCodexEngineering(compatibility.version),
692
722
  }).catch(() => ({ pluginRoot: null, codex: null, line: null }));
693
723
  if (engineering.line !== null) process.stderr.write(`${engineering.line}\n`);
694
- if (engineering.codex != null)
724
+ if (engineering.codex != null) {
695
725
  environment[CODEX_ENGINEERING_ENV] = JSON.stringify(engineering.codex);
726
+ // Catalog servers need the license the pack confirmed (spec extensions FR6).
727
+ const servers = await readServers(root, 'codex', environment).catch(() => null);
728
+ for (const line of servers === null ? [] : mcpLaunchLines(servers, 'codex'))
729
+ process.stderr.write(`${line}\n`);
730
+ if (servers !== null && servers.ready.length > 0)
731
+ environment[CODEX_MCP_ENV] = JSON.stringify(servers.ready.map((entry) => entry.id));
732
+ }
696
733
  environment.AI_BRAIN_MCP_SERVER = MCP_ALIAS;
697
734
  environment.CODEX_BIN = executable;
698
735
  environment.CODEX_MONITOR_COMMAND = monitor;
@@ -0,0 +1,165 @@
1
+ // @ts-check
2
+ /**
3
+ * Session configuration for the MCP catalog (spec aisac-engineering-extensions
4
+ * FR5.6, FR6, FR7): entries for Claude Code's `--mcp-config` file, and
5
+ * `-c mcp_servers.*` flags for the app-server that `aisac codex` owns. Only
6
+ * catalog ids reach this module from outside; every URL, command and argument
7
+ * comes from the catalog in this package. AISAC never reads or stores a
8
+ * third-party credential: logins live in the host's own store, and a token
9
+ * variable is only named, never read here.
10
+ */
11
+ import { MCP_SERVER_PREFIX, mcpById } from './extensions-catalog.mjs';
12
+ import { tomlBasicString } from './codex-engineering.mjs';
13
+
14
+ /** @typedef {import('./extensions-catalog.mjs').McpEntry} McpEntry */
15
+ /** @typedef {'claude' | 'codex'} McpClient */
16
+ /**
17
+ * @typedef {object} SessionServers
18
+ * @property {McpEntry[]} ready Servers that join the session.
19
+ * @property {{ id: string, variable: string }[]} missingEnv Skipped: the token variable is unset.
20
+ */
21
+
22
+ /** The launcher hands the Codex monitor the ready ids in this variable. */
23
+ export const CODEX_MCP_ENV = 'AISAC_EXTENSIONS_CODEX_MCP';
24
+ const STDIO_STARTUP_TIMEOUT_SEC = 60;
25
+ const MAX_IDS = 16;
26
+ const MAX_IDS_BYTES = 1024;
27
+
28
+ /** @param {string} id */
29
+ export function serverName(id) {
30
+ return `${MCP_SERVER_PREFIX}${id}`;
31
+ }
32
+
33
+ /**
34
+ * Catalog entries for these ids that support the client. A token server whose
35
+ * variable is unset is skipped with a reason instead of failing in the host.
36
+ * @param {readonly string[]} ids
37
+ * @param {McpClient} client
38
+ * @param {NodeJS.ProcessEnv} environment
39
+ * @returns {SessionServers}
40
+ */
41
+ export function sessionServers(ids, client, environment) {
42
+ /** @type {SessionServers} */
43
+ const result = { ready: [], missingEnv: [] };
44
+ for (const id of new Set(ids)) {
45
+ const entry = mcpById(id);
46
+ if (entry === undefined || !entry.clients.includes(client)) continue;
47
+ const variable = entry.transport.type === 'http' ? entry.transport.bearerEnv : undefined;
48
+ if (variable !== undefined && !environment[variable]?.trim()) {
49
+ result.missingEnv.push({ id, variable });
50
+ continue;
51
+ }
52
+ result.ready.push(entry);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * `--mcp-config` entries. A token header uses Claude Code's `${VAR}` expansion,
59
+ * so the token never reaches the session file.
60
+ * @param {readonly McpEntry[]} entries
61
+ * @returns {Record<string, Record<string, unknown>>}
62
+ */
63
+ export function claudeMcpEntries(entries) {
64
+ /** @type {Record<string, Record<string, unknown>>} */
65
+ const servers = {};
66
+ for (const entry of entries) {
67
+ const { transport } = entry;
68
+ servers[serverName(entry.id)] =
69
+ transport.type === 'stdio'
70
+ ? { type: 'stdio', command: transport.command, args: [...transport.args] }
71
+ : {
72
+ type: 'http',
73
+ url: transport.url,
74
+ ...(transport.bearerEnv === undefined
75
+ ? {}
76
+ : { headers: { Authorization: `Bearer \${${transport.bearerEnv}}` } }),
77
+ };
78
+ }
79
+ return servers;
80
+ }
81
+
82
+ /**
83
+ * Session-only `-c` overrides for the owned Codex app-server.
84
+ * @param {readonly McpEntry[]} entries
85
+ * @returns {string[]}
86
+ */
87
+ export function codexMcpConfigArgs(entries) {
88
+ return entries.flatMap((entry) => {
89
+ const key = `mcp_servers.${serverName(entry.id)}`;
90
+ const { transport } = entry;
91
+ if (transport.type === 'stdio')
92
+ return [
93
+ '-c',
94
+ `${key}.command=${tomlBasicString(transport.command)}`,
95
+ '-c',
96
+ `${key}.args=[${transport.args.map(tomlBasicString).join(',')}]`,
97
+ '-c',
98
+ `${key}.startup_timeout_sec=${STDIO_STARTUP_TIMEOUT_SEC}`,
99
+ ];
100
+ return [
101
+ '-c',
102
+ `${key}.url=${tomlBasicString(transport.url)}`,
103
+ ...(transport.bearerEnv === undefined
104
+ ? []
105
+ : ['-c', `${key}.bearer_token_env_var=${tomlBasicString(transport.bearerEnv)}`]),
106
+ ];
107
+ });
108
+ }
109
+
110
+ /**
111
+ * The monitor's copy of the ready ids: a JSON array of catalog ids for Codex.
112
+ * Anything else throws, and the monitor then starts without catalog servers.
113
+ * @param {string | undefined} raw
114
+ * @returns {McpEntry[]}
115
+ */
116
+ export function parseCodexMcp(raw) {
117
+ if (raw === undefined || raw === '') return [];
118
+ if (Buffer.byteLength(raw, 'utf8') > MAX_IDS_BYTES) throw new Error('codex_mcp_too_large');
119
+ const value = JSON.parse(raw);
120
+ if (!Array.isArray(value) || value.length > MAX_IDS) throw new Error('codex_mcp_invalid');
121
+ return value.map((id) => {
122
+ const entry = typeof id === 'string' ? mcpById(id) : undefined;
123
+ if (entry === undefined || !entry.clients.includes('codex'))
124
+ throw new Error('codex_mcp_invalid');
125
+ return entry;
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Arguments for `codex` that log in to one catalog server with the same session
131
+ * flags `aisac codex` passes, so the next session finds the stored login.
132
+ * Spike Q5 (2026-11-03, Codex 0.164.0) measured this path end to end.
133
+ * @param {McpEntry} entry
134
+ */
135
+ export function codexLoginArgs(entry) {
136
+ return [...codexMcpConfigArgs([entry]), 'mcp', 'login', serverName(entry.id)];
137
+ }
138
+
139
+ /** @param {McpEntry} entry */
140
+ export function supportsHostLogin(entry) {
141
+ return entry.login === 'host-oauth' || entry.login === 'optional-oauth';
142
+ }
143
+
144
+ /**
145
+ * Launch lines: counts and names only, never a value.
146
+ * @param {SessionServers} servers
147
+ * @param {McpClient} client
148
+ * @returns {string[]}
149
+ */
150
+ export function mcpLaunchLines(servers, client) {
151
+ /** @type {string[]} */
152
+ const lines = [];
153
+ const count = servers.ready.length;
154
+ if (count > 0) {
155
+ const login = client === 'claude' ? 'log in with /mcp' : 'log in with `aisac mcp login <id>`';
156
+ lines.push(
157
+ `AISAC Engineering: ${count} MCP server${count === 1 ? '' : 's'} on (${login}; aisac mcp to change them).`,
158
+ );
159
+ }
160
+ for (const skipped of servers.missingEnv)
161
+ lines.push(
162
+ `AISAC Engineering: ${serverName(skipped.id)} skipped: set ${skipped.variable} (aisac mcp info ${skipped.id}).`,
163
+ );
164
+ return lines;
165
+ }
package/dist/monitor.mjs CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  codexEngineeringConfigArgs,
19
19
  parseCodexEngineering,
20
20
  } from './codex-engineering.mjs';
21
+ import { CODEX_MCP_ENV, codexMcpConfigArgs, parseCodexMcp } from './mcp-servers.mjs';
21
22
 
22
23
  const root = dirname(fileURLToPath(import.meta.url));
23
24
  const endpoint = process.env.AIBRAIN_MCP_URL?.trim();
@@ -49,6 +50,15 @@ try {
49
50
  } catch {
50
51
  engineering = null;
51
52
  }
53
+ // MCP catalog servers (E6c) follow the pack: no license, no servers. A malformed
54
+ // value starts the session without them.
55
+ /** @type {import('./mcp-servers.mjs').McpEntry[]} */
56
+ let catalogServers = [];
57
+ try {
58
+ catalogServers = engineering === null ? [] : parseCodexMcp(process.env[CODEX_MCP_ENV]);
59
+ } catch {
60
+ catalogServers = [];
61
+ }
52
62
  const policyText = process.env.CODEX_CONTEXT_POLICY_JSON;
53
63
  let policy;
54
64
  if (policyText === undefined) {
@@ -108,6 +118,7 @@ delete env[CODEX_LAUNCH_ENVELOPE];
108
118
  delete env[CODEX_APP_SERVER_PROFILE];
109
119
  delete env[CODEX_ENGINEERING_ENV];
110
120
  delete env[CODEX_ENGINEERING_SKILLS_ROOT_ENV];
121
+ delete env[CODEX_MCP_ENV];
111
122
  if (engineering !== null) env[CODEX_ENGINEERING_SKILLS_ROOT_ENV] = engineering.skillsRoot;
112
123
  if (launchEnvelope?.profile !== undefined) env[CODEX_APP_SERVER_PROFILE] = launchEnvelope.profile;
113
124
  if (policy !== null) env.CODEX_CONTEXT_POLICY_JSON = JSON.stringify(policy);
@@ -130,6 +141,7 @@ const child = spawn(
130
141
  'mcp_servers.aibrain_context.bearer_token_env_var="CODEX_RECOVERY_MCP_TOKEN"',
131
142
  ...(launchEnvelope === null ? [] : ownedAppServerConfigArgs(launchEnvelope)),
132
143
  ...(engineering === null ? [] : codexEngineeringConfigArgs(engineering)),
144
+ ...codexMcpConfigArgs(catalogServers),
133
145
  ],
134
146
  { cwd: process.cwd(), env, stdio: 'inherit' },
135
147
  );
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.4",
3
+ "version": "0.4.0-beta.6",
4
4
  "type": "module"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.4",
3
+ "version": "0.4.0-beta.6",
4
4
  "private": false,
5
5
  "description": "AISAC client for Claude Code and Codex: AI Brain memory, context management and AISAC Engineering",
6
6
  "license": "UNLICENSED",