@dotdrelle/wiki-manager 0.6.27 → 0.6.31

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
@@ -141,16 +141,6 @@ external agents (CME & co.) live in [docs/usage.md](https://raw.githubuserconten
141
141
  story is in [The journey](#the-journey-from-first-launch-to-first-result); and
142
142
  installing from source is in [Initial Setup](#initial-setup).
143
143
 
144
- ## The 4 ways to use it & agent configuration
145
-
146
- The same system has four faces — the **web interface** (explore with the mouse),
147
- **scripting** (let it run on its own), the **`donna` shell** (talk in plain
148
- language), and the **shared external agents** (the common toolbox). Each external
149
- agent (Confluence export with **CME**, document conversion, mail) also needs a
150
- little setup the first time.
151
-
152
- Both are covered in **[docs/usage.md](https://raw.githubusercontent.com/dotdrelle/llm-wiki-manager/main/docs/usage.md)**.
153
-
154
144
  ## The journey: from first launch to first result
155
145
 
156
146
  Follow this little story in order. By the end you'll have seen it all: the
@@ -353,6 +343,30 @@ ingest -> build -> export -> polish
353
343
  The legacy copy step is only for deployments that explicitly configure external
354
344
  import mappings.
355
345
 
346
+ ## Configuration overview
347
+
348
+ wikiLLM is configured by **four files** held together by two families of keys:
349
+ **MCP keys** (Bearer tokens that authenticate *who connects to whom*) and **LLM
350
+ keys** (`apiKey` + `baseUrl` that *reach a model*).
351
+
352
+ ![wikiLLM configuration keys — MCP vs LLM, where each key is configured](https://raw.githubusercontent.com/dotdrelle/llm-wiki-manager/main/docs/config-keys.svg)
353
+
354
+ | File | Owner | Scope | Holds |
355
+ | --- | --- | --- | --- |
356
+ | `.env` | manager | global | shared secrets: agent MCP tokens, MailerSend, OCR LLM, port overrides |
357
+ | `mcp.endpoints.json` | manager | global | where each external agent lives + which `Bearer`/header to send |
358
+ | `workspaces/<name>/.env` | manager | per workspace | ports, workspace path, the wiki's own MCP tokens |
359
+ | `workspaces/<name>/.wikirc.yaml` (+ `.wikirc.yaml.<profile>`) | workspace | per workspace | LLM & vector keys (provider/model/apiKey/baseUrl/retrieval) |
360
+
361
+ Donna reaches the external agents and the internal wiki MCP through Bearer
362
+ tokens; the wiki then uses its `.wikirc.yaml` LLM keys to call models and
363
+ embeddings. Because every MCP server is an HTTP endpoint, remote MCP clients can
364
+ connect to the same surfaces with the same tokens. **MCP keys** are set in the
365
+ root `.env`; the wiki's **LLM keys** live in each workspace `.wikirc.yaml`.
366
+
367
+ See the full, field-by-field reference in
368
+ **[docs/configuration.md](https://raw.githubusercontent.com/dotdrelle/llm-wiki-manager/main/docs/configuration.md)**.
369
+
356
370
  ## Initial Setup
357
371
 
358
372
  ```bash
package/bin/wiki-manager CHANGED
@@ -1,6 +1,44 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
+ CACERT_PATH="${WIKI_MANAGER_CACERT_PATH:-}"
5
+ ARGS=()
6
+ while [[ $# -gt 0 ]]; do
7
+ case "$1" in
8
+ --cacert)
9
+ [[ $# -ge 2 ]] || { printf 'Error: --cacert requires a file path\n' >&2; exit 2; }
10
+ CACERT_PATH="$2"
11
+ shift 2
12
+ ;;
13
+ *)
14
+ ARGS+=("$1")
15
+ shift
16
+ ;;
17
+ esac
18
+ done
19
+
20
+ absolute_file_path() {
21
+ local value="$1"
22
+ local dir base
23
+ if [[ "$value" = /* ]]; then
24
+ printf '%s\n' "$value"
25
+ return
26
+ fi
27
+ dir="$(dirname "$value")"
28
+ base="$(basename "$value")"
29
+ printf '%s/%s\n' "$(cd "$dir" && pwd)" "$base"
30
+ }
31
+
32
+ if [[ -n "$CACERT_PATH" ]]; then
33
+ CACERT_PATH="$(absolute_file_path "$CACERT_PATH")"
34
+ [[ -f "$CACERT_PATH" ]] || { printf 'Error: --cacert file not found: %s\n' "$CACERT_PATH" >&2; exit 2; }
35
+ export WIKI_MANAGER_CACERT_PATH="$CACERT_PATH"
36
+ export NODE_EXTRA_CA_CERTS="$CACERT_PATH"
37
+ export SSL_CERT_FILE="$CACERT_PATH"
38
+ export REQUESTS_CA_BUNDLE="$CACERT_PATH"
39
+ export CURL_CA_BUNDLE="$CACERT_PATH"
40
+ fi
41
+
4
42
  BUN_BIN="$(command -v bun 2>/dev/null || true)"
5
43
  if [[ -z "$BUN_BIN" && -n "${BUN_INSTALL:-}" && -x "$BUN_INSTALL/bin/bun" ]]; then
6
44
  BUN_BIN="$BUN_INSTALL/bin/bun"
@@ -27,4 +65,4 @@ while [[ -L "$SCRIPT_SOURCE" ]]; do
27
65
  done
28
66
  BIN_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"
29
67
 
30
- exec "$BUN_BIN" "$BIN_DIR/wiki-manager.js" "$@"
68
+ exec "$BUN_BIN" "$BIN_DIR/wiki-manager.js" "${ARGS[@]}"
@@ -1,6 +1,59 @@
1
1
  #!/usr/bin/env bun
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
2
4
 
3
- import '@opentui/solid/preload';
5
+ function valueAfter(argv, flag) {
6
+ const index = argv.indexOf(flag);
7
+ if (index === -1) return undefined;
8
+ return argv[index + 1];
9
+ }
10
+
11
+ function stripOptionWithValue(argv, flag) {
12
+ const result = [];
13
+ for (let index = 0; index < argv.length; index += 1) {
14
+ if (argv[index] === flag) {
15
+ index += 1;
16
+ continue;
17
+ }
18
+ result.push(argv[index]);
19
+ }
20
+ return result;
21
+ }
22
+
23
+ function cacertEnvVars(cacert) {
24
+ return {
25
+ WIKI_MANAGER_CACERT_PATH: cacert,
26
+ NODE_EXTRA_CA_CERTS: cacert,
27
+ SSL_CERT_FILE: cacert,
28
+ REQUESTS_CA_BUNDLE: cacert,
29
+ CURL_CA_BUNDLE: cacert,
30
+ };
31
+ }
32
+
33
+ function resolveCacert(argv) {
34
+ const cacert = valueAfter(argv, '--cacert');
35
+ if (!cacert) return { argv, cacert: null };
36
+ const absolute = resolve(cacert);
37
+ if (!existsSync(absolute)) {
38
+ throw new Error(`--cacert file not found: ${absolute}`);
39
+ }
40
+ return {
41
+ argv: stripOptionWithValue(argv, '--cacert'),
42
+ cacert: absolute,
43
+ };
44
+ }
45
+
46
+ async function reexecWithCacertIfNeeded(argv, cacert) {
47
+ if (!cacert || process.env.WIKI_MANAGER_CACERT_BOOTSTRAPPED === '1') return;
48
+ const { spawnSync } = await import('node:child_process');
49
+ const env = { ...process.env, WIKI_MANAGER_CACERT_BOOTSTRAPPED: '1', ...cacertEnvVars(cacert) };
50
+ const result = spawnSync(process.execPath, [process.argv[1], ...argv], {
51
+ env,
52
+ stdio: 'inherit',
53
+ });
54
+ if (result.error) throw result.error;
55
+ process.exit(result.status ?? 1);
56
+ }
4
57
 
5
58
  function formatStartupError(err) {
6
59
  const message = err instanceof Error ? err.message : String(err);
@@ -21,7 +74,13 @@ function formatStartupError(err) {
21
74
  }
22
75
 
23
76
  async function main() {
24
- const argv = process.argv.slice(2);
77
+ const parsed = resolveCacert(process.argv.slice(2));
78
+ const argv = parsed.argv;
79
+ await reexecWithCacertIfNeeded(argv, parsed.cacert);
80
+ // Fallback for already-bootstrapped direct invocations; the shell wrapper
81
+ // exports these before Bun starts.
82
+ if (parsed.cacert) Object.assign(process.env, cacertEnvVars(parsed.cacert));
83
+ await import('@opentui/solid/preload');
25
84
  const interactive = process.stdout.isTTY && process.stdin.isTTY && !argv.includes('--headless') && !argv.includes('--once') && !argv.includes('--version') && !argv.includes('-v') && !argv.includes('--help') && !argv.includes('-h');
26
85
  if (interactive) process.stdout.write('Starting wiki-manager…\r');
27
86
  const { runCli } = await import('../src/cli/wiki-manager.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.6.27",
3
+ "version": "0.6.31",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -322,8 +322,8 @@ export function buildAgentSystemPrompt(state) {
322
322
  skills,
323
323
  'You can call MCP tools directly using the provided tool functions.',
324
324
  'When the user asks for an action that can be performed with connected MCP tools or safe primitives, do not answer with future intent such as "I will call...", "I am going to run...", or "launching..." unless you also call the tool in the same turn. Either call the tool now, ask for the exact missing required arguments, or explain the concrete blocker.',
325
- 'For CME configuration/setup/update requests, if a matching CME tool such as cme_setup is connected and the required arguments are known, call it immediately. If the CME server or tool is not connected, say which CME capability is missing and recommend the exact service/status primitive to inspect it. Do not invent a pending CME action in plain text.',
326
- 'For workspace-scoped external MCP tools, the orchestrator enforces workspace injection. When calling documents_* conversion tools or cme_* configuration/source/export tools, use the active workspace only. cme_export_status(job_id=...) and cme_export_cancel(job_id=...) can be used from any active workspace.',
325
+ 'For connector configuration/setup/update requests, if a matching setup/configuration tool is connected and the required arguments are known, call it immediately. If the connector or tool is not connected, say which concrete capability is missing and recommend the exact service/status primitive to inspect it. Do not invent a pending connector action in plain text.',
326
+ 'For workspace-scoped external MCP tools, the orchestrator enforces workspace injection. Use the active workspace for configuration, source, import, export, conversion, and generation tools unless a tool is explicitly job-scoped and only requires a job id.',
327
327
  'You can call shell__run_command for safe manager slash commands such as /workspaces, /new <name> [path], /use <workspace>, /config, /status, /services, /skills, /skills show <name>, and /skills run <name>.',
328
328
  'Skills are workflow instructions, not executable code. When a user asks to run a skill, inspect it, propose the concrete primitive/tool plan, and ask for confirmation before costly or mutating actions.',
329
329
  [
@@ -562,6 +562,7 @@ Usage:
562
562
  Options:
563
563
  -v, --version Print version
564
564
  -h, --help Print help
565
+ --cacert <path> Trust a local CA; Docker must be able to read this host path
565
566
  --once <prompt> Run one agent turn and exit
566
567
  --headless Run a workspace task non-interactively
567
568
  --workspace <name> Workspace for --headless
@@ -658,7 +659,11 @@ export async function handleSlashCommand(line, context) {
658
659
  context.session.workspacePath = workspace.workspacePath;
659
660
  context.session.workspaceEnv = workspace.env;
660
661
  context.session.workspaceEnvFile = workspace.envFile;
661
- context.session.mcp = buildMcpStatus(context.session);
662
+ context.session.wikirc = null;
663
+ context.session.wikircConfig = null;
664
+ context.session.language = null;
665
+ context.session.llm = null;
666
+ context.session.mcp = null;
662
667
  context.session.systemPrompt = loadWorkspaceSystemPrompt(workspace.workspacePath);
663
668
  try {
664
669
  step(`Workspace: loading ${workspace.name} config…`);
@@ -702,6 +707,7 @@ export async function handleSlashCommand(line, context) {
702
707
  }
703
708
  try {
704
709
  const summary = loadSessionWikirc(context.session, profileName);
710
+ await refreshMcpRuntimeStatus(context.session);
705
711
  return {
706
712
  output: [
707
713
  'Active wikirc:',
@@ -0,0 +1,66 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import YAML from 'yaml';
4
+ import { managerRuntimeDir } from './env.js';
5
+
6
+ export const CACERT_CONTAINER_PATH = '/wiki-manager-ca.pem';
7
+ export const CACERT_ENV_KEYS = [
8
+ 'NODE_EXTRA_CA_CERTS',
9
+ 'SSL_CERT_FILE',
10
+ 'REQUESTS_CA_BUNDLE',
11
+ 'CURL_CA_BUNDLE',
12
+ ];
13
+
14
+ export function activeCacertPath() {
15
+ const value = process.env.WIKI_MANAGER_CACERT_PATH;
16
+ return value ? resolve(value) : null;
17
+ }
18
+
19
+ export function cacertEnv(path = activeCacertPath(), targetPath = path) {
20
+ if (!path) return {};
21
+ return Object.fromEntries(CACERT_ENV_KEYS.map((key) => [key, targetPath]));
22
+ }
23
+
24
+ function parseComposeServices(composeFilePath) {
25
+ const parsed = YAML.parse(readFileSync(composeFilePath, 'utf8')) ?? {};
26
+ return Object.keys(parsed.services ?? {}).filter(Boolean);
27
+ }
28
+
29
+ function composeOverrideContent(cacertPath, services) {
30
+ const serviceEntries = Object.fromEntries(
31
+ services.map((service) => [
32
+ service,
33
+ {
34
+ volumes: [`${cacertPath}:${CACERT_CONTAINER_PATH}:ro`],
35
+ environment: cacertEnv(cacertPath, CACERT_CONTAINER_PATH),
36
+ },
37
+ ]),
38
+ );
39
+ const doc = {
40
+ services: serviceEntries,
41
+ };
42
+ return [
43
+ '# Generated by wiki-manager. Safe to delete.',
44
+ '# Rewritten when --cacert is used with a Docker Compose command.',
45
+ YAML.stringify(doc).trimEnd(),
46
+ '',
47
+ ].join('\n');
48
+ }
49
+
50
+ let _cacertOverrideCache;
51
+
52
+ export function ensureCacertComposeOverride(composeFilePath, fileName = 'cacert.compose.yml') {
53
+ if (_cacertOverrideCache !== undefined) return _cacertOverrideCache;
54
+ const cacertPath = activeCacertPath();
55
+ if (!cacertPath) return (_cacertOverrideCache = null);
56
+ if (!existsSync(cacertPath)) {
57
+ throw new Error(`--cacert file not found: ${cacertPath}`);
58
+ }
59
+ const services = parseComposeServices(composeFilePath);
60
+ if (services.length === 0) return null;
61
+ const runtimeDir = managerRuntimeDir();
62
+ mkdirSync(runtimeDir, { recursive: true });
63
+ const overridePath = join(runtimeDir, fileName);
64
+ writeFileSync(overridePath, composeOverrideContent(cacertPath, services), 'utf8');
65
+ return (_cacertOverrideCache = overridePath);
66
+ }
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import YAML from 'yaml';
6
+ import { cacertEnv, ensureCacertComposeOverride } from './cacert.js';
6
7
  import { managerEnvFile, readEnvFile } from './env.js';
7
8
  import { managerRoot } from './workspaces.js';
8
9
 
@@ -93,14 +94,14 @@ function projectName(session) {
93
94
  }
94
95
 
95
96
  function composeBaseArgs(session) {
96
- const args = ['compose', '-f', composeFile(), '-p', projectName(session)];
97
+ const compose = composeFile();
98
+ const cacertOverride = ensureCacertComposeOverride(compose);
99
+ const args = ['compose', '-f', compose];
100
+ if (cacertOverride) args.push('-f', cacertOverride);
101
+ args.push('-p', projectName(session));
97
102
  const managerEnvPath = managerEnvFile();
98
- if (existsSync(managerEnvPath)) {
99
- args.push('--env-file', managerEnvPath);
100
- }
101
- if (session.workspaceEnvFile && existsSync(session.workspaceEnvFile)) {
102
- args.push('--env-file', session.workspaceEnvFile);
103
- }
103
+ if (existsSync(managerEnvPath)) args.push('--env-file', managerEnvPath);
104
+ if (session.workspaceEnvFile && existsSync(session.workspaceEnvFile)) args.push('--env-file', session.workspaceEnvFile);
104
105
  return args;
105
106
  }
106
107
 
@@ -109,6 +110,7 @@ function composeEnv(session) {
109
110
  ...process.env,
110
111
  ...readManagerEnv(),
111
112
  ...(session.workspaceEnv ?? {}),
113
+ ...cacertEnv(),
112
114
  WORKSPACE_NAME: session.workspace,
113
115
  WIKI_WORKSPACE_PATH: session.workspacePath,
114
116
  };
package/src/core/env.js CHANGED
@@ -1,18 +1,28 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
2
+ import { dirname, join, resolve } from 'node:path';
3
3
 
4
4
  export function userManagerDir() {
5
5
  return process.cwd();
6
6
  }
7
7
 
8
+ export function managerStateDir() {
9
+ return process.env.WIKI_MANAGER_ENV_FILE
10
+ ? dirname(resolve(process.env.WIKI_MANAGER_ENV_FILE))
11
+ : userManagerDir();
12
+ }
13
+
14
+ export function managerRuntimeDir() {
15
+ return join(managerStateDir(), '.wiki-manager');
16
+ }
17
+
8
18
  export function managerEnvFile() {
9
19
  return process.env.WIKI_MANAGER_ENV_FILE
10
20
  ? resolve(process.env.WIKI_MANAGER_ENV_FILE)
11
- : join(userManagerDir(), '.env');
21
+ : join(managerStateDir(), '.env');
12
22
  }
13
23
 
14
24
  export function managerMcpEndpointsFile() {
15
- return join(userManagerDir(), 'mcp.endpoints.json');
25
+ return join(managerStateDir(), 'mcp.endpoints.json');
16
26
  }
17
27
 
18
28
  function parseEnvValue(value) {
package/src/core/mcp.js CHANGED
@@ -2,10 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
4
  function envValue(key) {
5
- if (key in process.env) return process.env[key];
6
5
  const filePath = managerEnvFile();
7
- if (!existsSync(filePath)) return undefined;
8
- return readEnvFile(filePath)[key];
6
+ if (existsSync(filePath)) {
7
+ const fileValue = readEnvFile(filePath)[key];
8
+ if (fileValue !== undefined) return fileValue;
9
+ }
10
+ return process.env[key];
9
11
  }
10
12
 
11
13
  function interpolateEnv(value) {
@@ -75,16 +77,20 @@ const MCP_SERVICE_MAP = {
75
77
 
76
78
  export function buildMcpStatus(session) {
77
79
  const workspaceEnv = session.workspaceEnv ?? {};
80
+ const wikiMcpToken = session.wikircConfig?.mcp?.accessKey;
81
+ const wikiMcpDetail = workspaceEnv.WIKI_MCP_PORT
82
+ ? (wikiMcpToken ? `:${workspaceEnv.WIKI_MCP_PORT}` : `:${workspaceEnv.WIKI_MCP_PORT} (mcp.accessKey missing in active wikirc)`)
83
+ : '';
78
84
  const external = readExternalMcpEndpoints();
79
85
 
80
86
  return {
81
87
  wiki: {
82
88
  ...endpointStatus(
83
- workspaceEnv.WIKI_MCP_PORT && workspaceEnv.WIKI_MCP_AUTH_TOKEN,
84
- workspaceEnv.WIKI_MCP_PORT ? `:${workspaceEnv.WIKI_MCP_PORT}` : '',
89
+ workspaceEnv.WIKI_MCP_PORT && wikiMcpToken,
90
+ wikiMcpDetail,
85
91
  ),
86
92
  url: workspaceEnv.WIKI_MCP_PORT ? `http://127.0.0.1:${workspaceEnv.WIKI_MCP_PORT}/mcp` : null,
87
- token: workspaceEnv.WIKI_MCP_AUTH_TOKEN || null,
93
+ token: wikiMcpToken || null,
88
94
  },
89
95
  production: {
90
96
  ...endpointStatus(
@@ -205,7 +211,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
205
211
  params: {
206
212
  protocolVersion: '2025-06-18',
207
213
  capabilities: {},
208
- clientInfo: { name: 'wiki-manager', version: '0.6.27' },
214
+ clientInfo: { name: 'wiki-manager', version: '0.6.31' },
209
215
  },
210
216
  }),
211
217
  });
@@ -295,6 +301,7 @@ export async function discoverMcpTools(mcpStatus) {
295
301
  const message = err instanceof Error ? err.message : String(err);
296
302
  next[name] = {
297
303
  ...value,
304
+ status: value.status === 'connected' ? 'configured' : value.status,
298
305
  tools: [],
299
306
  toolError: message,
300
307
  };
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, writeFile } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { buildMcpStatus, callMcpTool } from './mcp.js';
6
+ import { buildMcpStatus, callMcpTool, discoverMcpTools } from './mcp.js';
7
7
 
8
8
  test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
9
9
  const originalCwd = process.cwd();
@@ -87,6 +87,82 @@ test('buildMcpStatus interpolates external endpoints from manager .env', async (
87
87
  }
88
88
  });
89
89
 
90
+ test('buildMcpStatus reloads external endpoint keys changed in manager .env', async () => {
91
+ const originalCwd = process.cwd();
92
+ const originalToken = process.env.TEST_EXTERNAL_TOKEN;
93
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-mcp-env-reload-'));
94
+ await writeFile(
95
+ path.join(root, '.env'),
96
+ 'TEST_EXTERNAL_TOKEN=first-token\n',
97
+ 'utf8',
98
+ );
99
+ await writeFile(
100
+ path.join(root, 'mcp.endpoints.json'),
101
+ JSON.stringify({
102
+ mcpServers: {
103
+ external: {
104
+ url: 'http://127.0.0.1:9999/mcp/',
105
+ headers: {
106
+ Authorization: 'Bearer ${TEST_EXTERNAL_TOKEN}',
107
+ },
108
+ },
109
+ },
110
+ }),
111
+ 'utf8',
112
+ );
113
+ process.env.TEST_EXTERNAL_TOKEN = 'first-token';
114
+
115
+ try {
116
+ process.chdir(root);
117
+ await writeFile(
118
+ path.join(root, '.env'),
119
+ 'TEST_EXTERNAL_TOKEN=second-token\n',
120
+ 'utf8',
121
+ );
122
+ const status = buildMcpStatus({ workspaceEnv: {} });
123
+ assert.deepEqual(status.external.headers, {
124
+ authorization: 'Bearer second-token',
125
+ });
126
+ } finally {
127
+ process.chdir(originalCwd);
128
+ if (originalToken === undefined) delete process.env.TEST_EXTERNAL_TOKEN;
129
+ else process.env.TEST_EXTERNAL_TOKEN = originalToken;
130
+ }
131
+ });
132
+
133
+ test('buildMcpStatus does not use workspace env token for wiki MCP without active wikirc accessKey', () => {
134
+ const status = buildMcpStatus({
135
+ workspaceEnv: {
136
+ WIKI_MCP_PORT: '3101',
137
+ WIKI_MCP_AUTH_TOKEN: 'wiki-token-2',
138
+ PRODUCTION_MCP_PORT: '3102',
139
+ PRODUCTION_MCP_AUTH_TOKEN: 'production-token-2',
140
+ },
141
+ });
142
+
143
+ assert.equal(status.wiki.status, 'missing');
144
+ assert.equal(status.wiki.token, null);
145
+ assert.match(status.wiki.detail, /mcp\.accessKey missing/);
146
+ assert.equal(status.production.token, 'production-token-2');
147
+ });
148
+
149
+ test('buildMcpStatus uses active wikirc mcp.accessKey for wiki MCP', () => {
150
+ const status = buildMcpStatus({
151
+ workspaceEnv: {
152
+ WIKI_MCP_PORT: '3101',
153
+ WIKI_MCP_AUTH_TOKEN: 'env-wiki-token',
154
+ },
155
+ wikircConfig: {
156
+ mcp: {
157
+ accessKey: 'wikirc-wiki-token',
158
+ },
159
+ },
160
+ });
161
+
162
+ assert.equal(status.wiki.status, 'configured');
163
+ assert.equal(status.wiki.token, 'wikirc-wiki-token');
164
+ });
165
+
90
166
  test('callMcpTool injects active configPath for production_start_job', async () => {
91
167
  const originalFetch = globalThis.fetch;
92
168
  let requestBody = null;
@@ -193,6 +269,32 @@ test('callMcpTool sends configured endpoint headers', async () => {
193
269
  }
194
270
  });
195
271
 
272
+ test('discoverMcpTools downgrades connected endpoint when tool discovery fails', async () => {
273
+ const originalFetch = globalThis.fetch;
274
+ globalThis.fetch = async () => ({
275
+ ok: false,
276
+ status: 401,
277
+ headers: { get: () => null },
278
+ text: async () => '{"error":"invalid or missing bearer token"}',
279
+ });
280
+
281
+ try {
282
+ const status = await discoverMcpTools({
283
+ wiki: {
284
+ status: 'connected',
285
+ url: 'http://127.0.0.1:3201/mcp',
286
+ token: 'token',
287
+ },
288
+ });
289
+
290
+ assert.equal(status.wiki.status, 'configured');
291
+ assert.equal(status.wiki.tools.length, 0);
292
+ assert.match(status.wiki.toolError, /401/);
293
+ } finally {
294
+ globalThis.fetch = originalFetch;
295
+ }
296
+ });
297
+
196
298
  test('callMcpTool parses SSE responses after keepalive comments', async () => {
197
299
  const originalFetch = globalThis.fetch;
198
300
  globalThis.fetch = async () => ({
package/wiki-workspace CHANGED
@@ -16,10 +16,66 @@ DEFAULT_MANAGER_DIR="$PWD"
16
16
  WORKSPACES_DIR="${WIKI_WORKSPACES_DIR:-$DEFAULT_MANAGER_DIR/workspaces}"
17
17
  MANAGER_ENV_FILE="${WIKI_MANAGER_ENV_FILE:-$DEFAULT_MANAGER_DIR/.env}"
18
18
  MANAGER_ENDPOINTS_FILE="${WIKI_MANAGER_ENDPOINTS_FILE:-$DEFAULT_MANAGER_DIR/mcp.endpoints.json}"
19
+ MANAGER_STATE_DIR="$(cd "$(dirname "$MANAGER_ENV_FILE")" && pwd)"
20
+ MANAGER_RUNTIME_DIR="$MANAGER_STATE_DIR/.wiki-manager"
21
+ CACERT_PATH="${WIKI_MANAGER_CACERT_PATH:-}"
22
+
23
+ normalize_path() {
24
+ local value="$1"
25
+ if command -v wslpath >/dev/null 2>&1 && [[ "$value" =~ ^[A-Za-z]:[\\/] ]]; then
26
+ wslpath -u "$value"
27
+ return
28
+ fi
29
+ printf '%s\n' "$value"
30
+ }
31
+
32
+ absolute_path() {
33
+ local value="$1"
34
+ local base="${2:-$ROOT_DIR}"
35
+ value="$(normalize_path "$value")"
36
+ if [[ "$value" = /* ]]; then
37
+ printf '%s\n' "$value"
38
+ else
39
+ printf '%s\n' "$base/$value"
40
+ fi
41
+ }
42
+
43
+ parse_global_options() {
44
+ local parsed=()
45
+ while [[ $# -gt 0 ]]; do
46
+ case "$1" in
47
+ --cacert)
48
+ [[ $# -ge 2 ]] || { printf 'Error: --cacert requires a file path\n' >&2; exit 2; }
49
+ CACERT_PATH="$2"
50
+ shift 2
51
+ ;;
52
+ *)
53
+ parsed+=("$1")
54
+ shift
55
+ ;;
56
+ esac
57
+ done
58
+ if [[ -n "$CACERT_PATH" ]]; then
59
+ CACERT_PATH="$(absolute_path "$CACERT_PATH" "$PWD")"
60
+ [[ -f "$CACERT_PATH" ]] || { printf 'Error: --cacert file not found: %s\n' "$CACERT_PATH" >&2; exit 2; }
61
+ export WIKI_MANAGER_CACERT_PATH="$CACERT_PATH"
62
+ export NODE_EXTRA_CA_CERTS="$CACERT_PATH"
63
+ export SSL_CERT_FILE="$CACERT_PATH"
64
+ export REQUESTS_CA_BUNDLE="$CACERT_PATH"
65
+ export CURL_CA_BUNDLE="$CACERT_PATH"
66
+ fi
67
+ set -- "${parsed[@]}"
68
+ PARSED_ARGS=("$@")
69
+ }
70
+
71
+ PARSED_ARGS=()
72
+ parse_global_options "$@"
73
+ set -- "${PARSED_ARGS[@]}"
19
74
 
20
75
  usage() {
21
76
  cat <<'EOF'
22
77
  Usage:
78
+ wiki-workspace [--cacert <path>] <command> ...
23
79
  wiki-workspace config <workspace> [path]
24
80
  wiki-workspace up <workspace>
25
81
  wiki-workspace wiki <workspace> <command> [args...]
@@ -50,6 +106,7 @@ Commands:
50
106
  Configuration:
51
107
  workspaces/<workspace>/.env
52
108
  Override directory: WIKI_WORKSPACES_DIR=/path/to/dir
109
+ Local CA: --cacert /absolute/path/to/ca.pem (Docker must be able to read this host path)
53
110
  EOF
54
111
  }
55
112
 
@@ -58,26 +115,6 @@ die() {
58
115
  exit 1
59
116
  }
60
117
 
61
- normalize_path() {
62
- local value="$1"
63
- if command -v wslpath >/dev/null 2>&1 && [[ "$value" =~ ^[A-Za-z]:[\\/] ]]; then
64
- wslpath -u "$value"
65
- return
66
- fi
67
- printf '%s\n' "$value"
68
- }
69
-
70
- absolute_path() {
71
- local value="$1"
72
- local base="${2:-$ROOT_DIR}"
73
- value="$(normalize_path "$value")"
74
- if [[ "$value" = /* ]]; then
75
- printf '%s\n' "$value"
76
- else
77
- printf '%s\n' "$base/$value"
78
- fi
79
- }
80
-
81
118
  workspace_env_file() {
82
119
  local workspace="$1"
83
120
  printf '%s/%s/.env\n' "$WORKSPACES_DIR" "$workspace"
@@ -169,9 +206,12 @@ agents_compose() {
169
206
  [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
170
207
 
171
208
  _agents_dc() {
209
+ local cacert_args=()
210
+ WORKSPACES_ROOT="$workspaces_root" AGENTS_DATA_DIR="$agents_data_dir" \
211
+ read_lines_into_array cacert_args cacert_compose_args "$agents_compose_file" "agents.cacert.compose.yml"
172
212
  WORKSPACES_ROOT="$workspaces_root" AGENTS_DATA_DIR="$agents_data_dir" \
173
213
  docker compose --project-directory "$DEFAULT_MANAGER_DIR" \
174
- "${compose_env_args[@]}" -f "$agents_compose_file" -p wiki-agents "$@"
214
+ "${compose_env_args[@]}" -f "$agents_compose_file" "${cacert_args[@]}" -p wiki-agents "$@"
175
215
  }
176
216
 
177
217
  case "$subcommand" in
@@ -444,13 +484,19 @@ compose_for_workspace() {
444
484
  local compose_env_args=()
445
485
  [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
446
486
  compose_env_args+=(--env-file "$workspace_env")
487
+ local cacert_args=()
488
+ WIKI_WORKSPACE_PATH="$ws_path" \
489
+ WIKI_SERVE_PORT="$serve_port" \
490
+ WIKI_MCP_PORT="$mcp_port" \
491
+ PRODUCTION_MCP_PORT="$prod_port" \
492
+ read_lines_into_array cacert_args cacert_compose_args "$ROOT_DIR/docker-compose.yml" "cacert.compose.yml"
447
493
 
448
494
  WIKI_WORKSPACE_PATH="$ws_path" \
449
495
  WIKI_SERVE_PORT="$serve_port" \
450
496
  WIKI_MCP_PORT="$mcp_port" \
451
497
  PRODUCTION_MCP_PORT="$prod_port" \
452
498
  docker compose --project-directory "$DEFAULT_MANAGER_DIR" \
453
- "${compose_env_args[@]}" -f "$ROOT_DIR/docker-compose.yml" -p "$project" "$@"
499
+ "${compose_env_args[@]}" -f "$ROOT_DIR/docker-compose.yml" "${cacert_args[@]}" -p "$project" "$@"
454
500
  }
455
501
 
456
502
  logs_args() {
@@ -474,6 +520,48 @@ read_lines_into_array() {
474
520
  done < <("$@")
475
521
  }
476
522
 
523
+ cacert_compose_args() {
524
+ local compose_file="$1"
525
+ local override_name="$2"
526
+ [[ -n "$CACERT_PATH" ]] || return 0
527
+ command -v node >/dev/null 2>&1 || die "--cacert requires node to generate Docker Compose overrides"
528
+
529
+ local services
530
+ services="$(
531
+ ROOT_DIR="$ROOT_DIR" _WIKI_COMPOSE_FILE_PATH="$compose_file" node <<'NODE'
532
+ const { createRequire } = require('node:module');
533
+ const fs = require('node:fs');
534
+ const requireFromPackage = createRequire(`${process.env.ROOT_DIR}/package.json`);
535
+ const YAML = requireFromPackage('yaml');
536
+ const parsed = YAML.parse(fs.readFileSync(process.env._WIKI_COMPOSE_FILE_PATH, 'utf8')) ?? {};
537
+ for (const service of Object.keys(parsed.services ?? {})) {
538
+ console.log(service);
539
+ }
540
+ NODE
541
+ )"
542
+ [[ -n "$services" ]] || return 0
543
+
544
+ mkdir -p "$MANAGER_RUNTIME_DIR"
545
+ local override_path="$MANAGER_RUNTIME_DIR/$override_name"
546
+ {
547
+ printf '# Generated by wiki-manager. Safe to delete.\n'
548
+ printf '# Rewritten when --cacert is used with a Docker Compose command.\n'
549
+ printf 'services:\n'
550
+ while IFS= read -r service || [[ -n "$service" ]]; do
551
+ [[ -n "$service" ]] || continue
552
+ printf ' %s:\n' "$service"
553
+ printf ' volumes:\n'
554
+ printf ' - %s:/wiki-manager-ca.pem:ro\n' "$CACERT_PATH"
555
+ printf ' environment:\n'
556
+ printf ' NODE_EXTRA_CA_CERTS: /wiki-manager-ca.pem\n'
557
+ printf ' SSL_CERT_FILE: /wiki-manager-ca.pem\n'
558
+ printf ' REQUESTS_CA_BUNDLE: /wiki-manager-ca.pem\n'
559
+ printf ' CURL_CA_BUNDLE: /wiki-manager-ca.pem\n'
560
+ done <<< "$services"
561
+ } > "$override_path"
562
+ printf '%s\n' -f "$override_path"
563
+ }
564
+
477
565
  run_wiki() {
478
566
  local workspace="$1"
479
567
  shift
@@ -623,12 +711,17 @@ config_workspace() {
623
711
  local compose_env_args=()
624
712
  [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
625
713
  compose_env_args+=(--env-file "$env_file")
714
+ local cacert_args=()
715
+ WIKI_WORKSPACE_PATH="$target_path" \
716
+ WIKI_SERVE_PORT="$serve_port" \
717
+ WIKI_MCP_PORT="$mcp_port" \
718
+ read_lines_into_array cacert_args cacert_compose_args "$ROOT_DIR/docker-compose.yml" "cacert.compose.yml"
626
719
 
627
720
  WIKI_WORKSPACE_PATH="$target_path" \
628
721
  WIKI_SERVE_PORT="$serve_port" \
629
722
  WIKI_MCP_PORT="$mcp_port" \
630
723
  docker compose --project-directory "$DEFAULT_MANAGER_DIR" \
631
- "${compose_env_args[@]}" -f "$ROOT_DIR/docker-compose.yml" run --rm wiki init
724
+ "${compose_env_args[@]}" -f "$ROOT_DIR/docker-compose.yml" "${cacert_args[@]}" run --rm wiki init
632
725
 
633
726
  printf 'Workspace ready: %s\n' "$target_path"
634
727
  printf 'Start with: wiki-workspace up %s\n' "$workspace"