@dotdrelle/wiki-manager 0.6.17

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/wiki-workspace ADDED
@@ -0,0 +1,773 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_SOURCE="${BASH_SOURCE[0]}"
5
+ while [[ -L "$SCRIPT_SOURCE" ]]; do
6
+ SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"
7
+ SCRIPT_TARGET="$(readlink "$SCRIPT_SOURCE")"
8
+ if [[ "$SCRIPT_TARGET" = /* ]]; then
9
+ SCRIPT_SOURCE="$SCRIPT_TARGET"
10
+ else
11
+ SCRIPT_SOURCE="$SCRIPT_DIR/$SCRIPT_TARGET"
12
+ fi
13
+ done
14
+ ROOT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"
15
+ DEFAULT_MANAGER_DIR="$PWD"
16
+ WORKSPACES_DIR="${WIKI_WORKSPACES_DIR:-$DEFAULT_MANAGER_DIR/workspaces}"
17
+ MANAGER_ENV_FILE="${WIKI_MANAGER_ENV_FILE:-$DEFAULT_MANAGER_DIR/.env}"
18
+
19
+ usage() {
20
+ cat <<'EOF'
21
+ Usage:
22
+ wiki-workspace config <workspace> [path]
23
+ wiki-workspace up <workspace>
24
+ wiki-workspace wiki <workspace> <command> [args...]
25
+ wiki-workspace list
26
+
27
+ Commands:
28
+ config <workspace> [path] Create workspace folder, auto-select ports, run wiki init
29
+ up <workspace> [--open] Start workspace stack: wiki + production
30
+ list List configured workspaces
31
+
32
+ agents up [--pull] Start all external agents (cme, documents, mailer)
33
+ agents down Stop all external agents
34
+ agents logs [args...] Follow external agent logs
35
+ agents pull Pull latest external agent images
36
+ agents status Check reachability of configured MCP endpoints
37
+
38
+ wiki <workspace> init Initialize the workspace with wiki init
39
+ wiki <workspace> up Start llm-wiki serve + mcp-http + production-mcp
40
+ wiki <workspace> down Stop all services for this workspace
41
+ wiki <workspace> logs [args...] Follow workspace service logs (tail 100 by default)
42
+ wiki <workspace> serve [--open] Start the web UI in foreground (--open launches browser)
43
+ wiki <workspace> doctor Run wiki doctor
44
+ wiki <workspace> ingest Run wiki ingest
45
+ wiki <workspace> build Run wiki build
46
+ wiki <workspace> export Run wiki export
47
+ wiki <workspace> run <args...> Run arbitrary wiki CLI args
48
+
49
+ Configuration:
50
+ workspaces/<workspace>/.env
51
+ Override directory: WIKI_WORKSPACES_DIR=/path/to/dir
52
+ EOF
53
+ }
54
+
55
+ die() {
56
+ printf 'Error: %s\n' "$*" >&2
57
+ exit 1
58
+ }
59
+
60
+ normalize_path() {
61
+ local value="$1"
62
+ if command -v wslpath >/dev/null 2>&1 && [[ "$value" =~ ^[A-Za-z]:[\\/] ]]; then
63
+ wslpath -u "$value"
64
+ return
65
+ fi
66
+ printf '%s\n' "$value"
67
+ }
68
+
69
+ absolute_path() {
70
+ local value="$1"
71
+ local base="${2:-$ROOT_DIR}"
72
+ value="$(normalize_path "$value")"
73
+ if [[ "$value" = /* ]]; then
74
+ printf '%s\n' "$value"
75
+ else
76
+ printf '%s\n' "$base/$value"
77
+ fi
78
+ }
79
+
80
+ workspace_env_file() {
81
+ local workspace="$1"
82
+ printf '%s/%s/.env\n' "$WORKSPACES_DIR" "$workspace"
83
+ }
84
+
85
+ need_workspace_env() {
86
+ local workspace="$1"
87
+ local file
88
+ file="$(workspace_env_file "$workspace")"
89
+ [[ -f "$file" ]] || die "missing workspace env: $file — run: wiki-workspace config $workspace"
90
+ }
91
+
92
+ env_value() {
93
+ local file="$1"
94
+ local key="$2"
95
+ local default="${3-}"
96
+ local line value
97
+ [[ -f "$file" ]] || { printf '%s\n' "$default"; return 0; }
98
+ while IFS= read -r line || [[ -n "$line" ]]; do
99
+ line="${line%$'\r'}"
100
+ [[ "$line" == "$key="* ]] || continue
101
+ value="${line#*=}"
102
+ if [[ "$value" == \"*\" && "$value" == *\" ]]; then
103
+ value="${value:1:${#value}-2}"
104
+ elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
105
+ value="${value:1:${#value}-2}"
106
+ fi
107
+ printf '%s\n' "$value"
108
+ return 0
109
+ done < "$file"
110
+ printf '%s\n' "$default"
111
+ }
112
+
113
+ generate_token() {
114
+ if command -v openssl >/dev/null 2>&1; then
115
+ openssl rand -hex 32
116
+ return
117
+ fi
118
+ if [[ -r /dev/urandom ]]; then
119
+ od -An -N32 -tx1 /dev/urandom | tr -d ' \n'
120
+ printf '\n'
121
+ return
122
+ fi
123
+ die "cannot generate MCP auth token: openssl and /dev/urandom are unavailable"
124
+ }
125
+
126
+ set_env_value() {
127
+ local file="$1"
128
+ local key="$2"
129
+ local value="$3"
130
+ local tmp="${file}.tmp.$$"
131
+ local wrote=0 line
132
+
133
+ if [[ -f "$file" ]]; then
134
+ while IFS= read -r line || [[ -n "$line" ]]; do
135
+ line="${line%$'\r'}"
136
+ if [[ "$line" == "$key="* ]]; then
137
+ printf '%s=%s\n' "$key" "$value"
138
+ wrote=1
139
+ else
140
+ printf '%s\n' "$line"
141
+ fi
142
+ done < "$file" > "$tmp"
143
+ else
144
+ : > "$tmp"
145
+ fi
146
+
147
+ if [[ "$wrote" -eq 0 ]]; then
148
+ if [[ -s "$tmp" ]]; then
149
+ printf '\n' >> "$tmp"
150
+ fi
151
+ printf '%s=%s\n' "$key" "$value" >> "$tmp"
152
+ fi
153
+ mv "$tmp" "$file"
154
+ }
155
+
156
+ agents_compose() {
157
+ local subcommand="$1"
158
+ shift
159
+ ensure_manager_endpoints_file
160
+ local agents_compose_file="$ROOT_DIR/agents.docker-compose.yml"
161
+ [[ -f "$agents_compose_file" ]] || die "agents compose file not found: $agents_compose_file"
162
+
163
+ local agents_data_dir workspaces_root
164
+ agents_data_dir="$(absolute_path "${AGENTS_DATA_DIR:-.agents-data}" "$DEFAULT_MANAGER_DIR")"
165
+ workspaces_root="$(absolute_path "$WORKSPACES_DIR" "$DEFAULT_MANAGER_DIR")"
166
+
167
+ local compose_env_args=()
168
+ [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
169
+
170
+ _agents_dc() {
171
+ WORKSPACES_ROOT="$workspaces_root" AGENTS_DATA_DIR="$agents_data_dir" \
172
+ docker compose "${compose_env_args[@]}" -f "$agents_compose_file" -p wiki-agents "$@"
173
+ }
174
+
175
+ case "$subcommand" in
176
+ up)
177
+ local do_pull=false
178
+ if [[ "${1:-}" == "--pull" ]]; then
179
+ do_pull=true
180
+ shift
181
+ fi
182
+ [[ $# -eq 0 ]] || die "agents up accepts only --pull"
183
+ mkdir -p "$agents_data_dir/cme" "$agents_data_dir/documents/input" "$agents_data_dir/documents/output" "$agents_data_dir/documents/uploads"
184
+ mkdir -p "$workspaces_root"
185
+ $do_pull && _agents_dc pull
186
+ _agents_dc up -d
187
+ printf 'Workspaces root: %s\n' "$workspaces_root"
188
+ printf 'Agents data: %s\n' "$agents_data_dir"
189
+ printf 'Agents: cme=:%s documents=:%s mailer=:%s\n' \
190
+ "${CME_MCP_PORT:-3336}" "${DOCUMENTS_MCP_PORT:-3337}" "${MAILER_MCP_PORT:-3335}"
191
+ ;;
192
+ down)
193
+ [[ $# -eq 0 ]] || die "agents down does not take arguments"
194
+ _agents_dc down
195
+ ;;
196
+ logs)
197
+ local log_args=()
198
+ read_lines_into_array log_args logs_args "$@"
199
+ _agents_dc logs "${log_args[@]}"
200
+ ;;
201
+ pull)
202
+ [[ $# -eq 0 ]] || die "agents pull does not take arguments"
203
+ _agents_dc pull
204
+ ;;
205
+ status)
206
+ [[ $# -eq 0 ]] || die "agents status does not take arguments"
207
+ local endpoints_file="$ROOT_DIR/mcp.endpoints.json"
208
+ if [[ ! -f "$endpoints_file" ]]; then
209
+ printf 'No mcp.endpoints.json found at %s\n' "$endpoints_file" >&2
210
+ exit 1
211
+ fi
212
+ command -v node >/dev/null 2>&1 || die "agents status requires node"
213
+ node - "$endpoints_file" "$MANAGER_ENV_FILE" <<'NODE'
214
+ import fs from 'node:fs';
215
+
216
+ const [, , endpointsFile, envFile] = process.argv;
217
+
218
+ function parseEnvValue(value) {
219
+ const trimmed = value.trim();
220
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1);
221
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1);
222
+ return trimmed;
223
+ }
224
+
225
+ function loadEnv(filePath) {
226
+ const values = { ...process.env };
227
+ try {
228
+ for (const raw of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
229
+ const line = raw.trim();
230
+ if (!line || line.startsWith('#')) continue;
231
+ const index = line.indexOf('=');
232
+ if (index === -1) continue;
233
+ const key = line.slice(0, index).trim();
234
+ if (!(key in values)) values[key] = parseEnvValue(line.slice(index + 1));
235
+ }
236
+ } catch (err) {
237
+ if (err.code !== 'ENOENT') throw err;
238
+ }
239
+ return values;
240
+ }
241
+
242
+ function interpolate(value, env) {
243
+ return String(value).replace(/\$\{([^}]+)\}/g, (_match, expr) => {
244
+ const sep = expr.indexOf(':-');
245
+ if (sep !== -1) return env[expr.slice(0, sep)] ?? expr.slice(sep + 2);
246
+ return env[expr] ?? '';
247
+ });
248
+ }
249
+
250
+ function runtimeUrl(url) {
251
+ try {
252
+ const parsed = new URL(url);
253
+ if (parsed.hostname === 'host.docker.internal') parsed.hostname = 'localhost';
254
+ return parsed.toString();
255
+ } catch {
256
+ return url;
257
+ }
258
+ }
259
+
260
+ function normalizeHeaders(headers, env) {
261
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return {};
262
+ return Object.fromEntries(
263
+ Object.entries(headers)
264
+ .filter(([key, value]) => key && typeof value === 'string' && value)
265
+ .map(([key, value]) => [key.toLowerCase(), interpolate(value, env)]),
266
+ );
267
+ }
268
+
269
+ async function check(url, headers) {
270
+ const controller = new AbortController();
271
+ const timeout = setTimeout(() => controller.abort(), 3000);
272
+ try {
273
+ const response = await fetch(runtimeUrl(url), {
274
+ method: 'POST',
275
+ signal: controller.signal,
276
+ headers: {
277
+ accept: 'application/json, text/event-stream',
278
+ 'content-type': 'application/json',
279
+ ...headers,
280
+ },
281
+ body: JSON.stringify({
282
+ jsonrpc: '2.0',
283
+ id: 1,
284
+ method: 'initialize',
285
+ params: {
286
+ protocolVersion: '2025-06-18',
287
+ capabilities: {},
288
+ clientInfo: { name: 'wiki-workspace', version: 'status' },
289
+ },
290
+ }),
291
+ });
292
+ await response.text();
293
+ return response.ok;
294
+ } catch {
295
+ return false;
296
+ } finally {
297
+ clearTimeout(timeout);
298
+ }
299
+ }
300
+
301
+ const env = loadEnv(envFile);
302
+ const config = JSON.parse(fs.readFileSync(endpointsFile, 'utf8'));
303
+ const servers = config.mcpServers ?? config.servers ?? {};
304
+
305
+ for (const [name, endpoint] of Object.entries(servers)) {
306
+ const url = interpolate(endpoint?.url ?? '', env);
307
+ if (!url) continue;
308
+ const ok = await check(url, normalizeHeaders(endpoint.headers, env));
309
+ console.log(`${name.padEnd(14)} ${url} ${ok ? 'ok' : 'unreachable'}`);
310
+ }
311
+ NODE
312
+ ;;
313
+ *)
314
+ die "unknown agents command: $subcommand (expected: up, down, logs, pull, status)"
315
+ ;;
316
+ esac
317
+ }
318
+
319
+ ensure_manager_endpoints_file() {
320
+ local endpoints_file="$ROOT_DIR/mcp.endpoints.json"
321
+ if [[ ! -f "$endpoints_file" && -f "$ROOT_DIR/mcp.endpoints.example.json" ]]; then
322
+ cp "$ROOT_DIR/mcp.endpoints.example.json" "$endpoints_file"
323
+ printf 'Created %s (from mcp.endpoints.example.json — add headers/tokens as needed)\n' "$endpoints_file"
324
+ fi
325
+ }
326
+
327
+ workspace_value() {
328
+ local workspace="$1"
329
+ local key="$2"
330
+ local default="${3-}"
331
+ env_value "$(workspace_env_file "$workspace")" "$key" "$default"
332
+ }
333
+
334
+ used_workspace_ports() {
335
+ local entry file key val
336
+ for entry in "$WORKSPACES_DIR"/*; do
337
+ [[ -d "$entry" ]] || continue
338
+ file="$entry/.env"
339
+ [[ -f "$file" ]] || continue
340
+ for key in WIKI_SERVE_PORT WIKI_MCP_PORT PRODUCTION_MCP_PORT; do
341
+ val="$(env_value "$file" "$key" "")"
342
+ [[ "$val" =~ ^[0-9]+$ ]] && printf '%s\n' "$val"
343
+ done
344
+ done
345
+ }
346
+
347
+ is_port_in_use() {
348
+ local port="$1"
349
+ if command -v ss >/dev/null 2>&1; then
350
+ ss -tln 2>/dev/null | grep -q ":${port} "
351
+ elif command -v lsof >/dev/null 2>&1; then
352
+ lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
353
+ else
354
+ (echo >/dev/tcp/127.0.0.1/"$port") 2>/dev/null
355
+ fi
356
+ }
357
+
358
+ find_free_port() {
359
+ local start="${1:-3100}"
360
+ local port="$start"
361
+ local used_ports=()
362
+ read_lines_into_array used_ports used_workspace_ports
363
+ while true; do
364
+ local taken=0 p
365
+ if (( ${#used_ports[@]} > 0 )); then
366
+ for p in "${used_ports[@]}"; do
367
+ [[ "$p" == "$port" ]] && { taken=1; break; }
368
+ done
369
+ fi
370
+ if [[ "$taken" -eq 0 ]] && ! is_port_in_use "$port"; then
371
+ printf '%s\n' "$port"
372
+ return 0
373
+ fi
374
+ port=$((port + 1))
375
+ done
376
+ }
377
+
378
+ find_free_workspace_port_base() {
379
+ local base="${1:-3100}"
380
+ local used_ports=()
381
+ read_lines_into_array used_ports used_workspace_ports
382
+ while true; do
383
+ local taken=0 offset port p
384
+ for offset in 0 1 2; do
385
+ port=$((base + offset))
386
+ if (( ${#used_ports[@]} > 0 )); then
387
+ for p in "${used_ports[@]}"; do
388
+ [[ "$p" == "$port" ]] && { taken=1; break; }
389
+ done
390
+ fi
391
+ [[ "$taken" -eq 1 ]] && break
392
+ if is_port_in_use "$port"; then
393
+ taken=1
394
+ break
395
+ fi
396
+ done
397
+ if [[ "$taken" -eq 0 ]]; then
398
+ printf '%s\n' "$base"
399
+ return 0
400
+ fi
401
+ base=$((base + 100))
402
+ done
403
+ }
404
+
405
+ list_workspaces() {
406
+ mkdir -p "$WORKSPACES_DIR"
407
+ local found=0 entry file name path serve mcp prod
408
+
409
+ for entry in "$WORKSPACES_DIR"/*; do
410
+ [[ -d "$entry" ]] || continue
411
+ file="$entry/.env"
412
+ [[ -f "$file" ]] || continue
413
+ found=1
414
+ name="$(env_value "$file" WORKSPACE_NAME "$(basename "$entry")")"
415
+ path="$(env_value "$file" WIKI_WORKSPACE_PATH "-")"
416
+ serve="$(env_value "$file" WIKI_SERVE_PORT "-")"
417
+ mcp="$(env_value "$file" WIKI_MCP_PORT "-")"
418
+ prod="$(env_value "$file" PRODUCTION_MCP_PORT "-")"
419
+ printf '%s\tpath=%s\tserve=%s\tmcp=%s\tprod=%s\n' \
420
+ "$name" "$path" "$serve" "$mcp" "$prod"
421
+ done
422
+
423
+ [[ "$found" -eq 1 ]] || printf 'No workspaces configured in %s\n' "$WORKSPACES_DIR"
424
+ }
425
+
426
+ compose_project() {
427
+ local workspace="$1"
428
+ printf 'wiki-%s' "$workspace" | tr -c '[:alnum:]_-' '-'
429
+ }
430
+
431
+ compose_for_workspace() {
432
+ local workspace="$1"
433
+ shift
434
+ need_workspace_env "$workspace"
435
+ local workspace_env ws_path serve_port mcp_port prod_port project
436
+ workspace_env="$(workspace_env_file "$workspace")"
437
+ ws_path="$(normalize_path "$(workspace_value "$workspace" WIKI_WORKSPACE_PATH)")"
438
+ serve_port="$(workspace_value "$workspace" WIKI_SERVE_PORT 3100)"
439
+ mcp_port="$(workspace_value "$workspace" WIKI_MCP_PORT 3101)"
440
+ prod_port="$(workspace_value "$workspace" PRODUCTION_MCP_PORT 3102)"
441
+ project="$(compose_project "$workspace")"
442
+
443
+ local compose_env_args=()
444
+ [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
445
+ compose_env_args+=(--env-file "$workspace_env")
446
+
447
+ WIKI_WORKSPACE_PATH="$ws_path" \
448
+ WIKI_SERVE_PORT="$serve_port" \
449
+ WIKI_MCP_PORT="$mcp_port" \
450
+ PRODUCTION_MCP_PORT="$prod_port" \
451
+ docker compose "${compose_env_args[@]}" -f "$ROOT_DIR/docker-compose.yml" -p "$project" "$@"
452
+ }
453
+
454
+ logs_args() {
455
+ if [[ $# -eq 0 ]]; then
456
+ printf '%s\n' --tail=100 -f
457
+ else
458
+ printf '%s\n' "$@"
459
+ fi
460
+ }
461
+
462
+
463
+ read_lines_into_array() {
464
+ local array_name="$1"
465
+ shift
466
+ [[ "$array_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "invalid array name: $array_name"
467
+
468
+ eval "$array_name=()"
469
+ local line
470
+ while IFS= read -r line; do
471
+ eval "$array_name+=(\"\$line\")"
472
+ done < <("$@")
473
+ }
474
+
475
+ run_wiki() {
476
+ local workspace="$1"
477
+ shift
478
+ local ws_path
479
+ ws_path="$(normalize_path "$(workspace_value "$workspace" WIKI_WORKSPACE_PATH)")"
480
+ ensure_workspace_dirs "$ws_path"
481
+ compose_for_workspace "$workspace" run --rm wiki "$@"
482
+ }
483
+
484
+ open_app_mode() {
485
+ local url="$1"
486
+ case "$(uname -s)" in
487
+ Darwin)
488
+ open -na "Google Chrome" --args "--app=${url}" 2>/dev/null \
489
+ || open -na "Microsoft Edge" --args "--app=${url}" 2>/dev/null \
490
+ || open -a Safari "${url}" 2>/dev/null \
491
+ || open "${url}"
492
+ ;;
493
+ Linux)
494
+ # WSL2: Windows browsers are not on PATH — probe known Windows paths via wslpath.
495
+ if command -v wslpath >/dev/null 2>&1; then
496
+ local wsl_candidate wsl_path
497
+ for wsl_candidate in \
498
+ "C:/Program Files/Google/Chrome/Application/chrome.exe" \
499
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe" \
500
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" \
501
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe"; do
502
+ wsl_path="$(wslpath -u "$wsl_candidate" 2>/dev/null)" || continue
503
+ [[ -f "$wsl_path" ]] || continue
504
+ "$wsl_path" "--app=${url}" &
505
+ return
506
+ done
507
+ for wsl_candidate in \
508
+ "C:/Program Files/Mozilla Firefox/firefox.exe"; do
509
+ wsl_path="$(wslpath -u "$wsl_candidate" 2>/dev/null)" || continue
510
+ [[ -f "$wsl_path" ]] || continue
511
+ "$wsl_path" "${url}" &
512
+ return
513
+ done
514
+ fi
515
+ for candidate in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge microsoft-edge-stable; do
516
+ if command -v "$candidate" >/dev/null 2>&1; then
517
+ "$candidate" "--app=${url}" &
518
+ return
519
+ fi
520
+ done
521
+ xdg-open "${url}" 2>/dev/null || true
522
+ ;;
523
+ MINGW*|MSYS*|CYGWIN*)
524
+ for candidate in \
525
+ "C:/Program Files/Google/Chrome/Application/chrome.exe" \
526
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe" \
527
+ "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" \
528
+ "C:/Program Files/Microsoft/Edge/Application/msedge.exe"; do
529
+ if [[ -f "$candidate" ]]; then
530
+ "$candidate" "--app=${url}" &
531
+ return
532
+ fi
533
+ done
534
+ for candidate in \
535
+ "C:/Program Files/Mozilla Firefox/firefox.exe"; do
536
+ if [[ -f "$candidate" ]]; then
537
+ "$candidate" "${url}" &
538
+ return
539
+ fi
540
+ done
541
+ start "" "${url}" || true
542
+ ;;
543
+ esac
544
+ }
545
+
546
+ wait_and_open() {
547
+ local url="$1"
548
+ local max_attempts=30
549
+ local i=0
550
+ while (( i < max_attempts )); do
551
+ sleep 1
552
+ if curl -sf "${url}" >/dev/null 2>&1; then
553
+ open_app_mode "${url}"
554
+ return
555
+ fi
556
+ (( i++ )) || true
557
+ done
558
+ printf 'wiki-workspace: server did not become ready at %s after %ds, opening anyway\n' "${url}" "$max_attempts" >&2
559
+ open_app_mode "${url}"
560
+ }
561
+
562
+ ensure_workspace_dirs() {
563
+ local ws_path="$1"
564
+ mkdir -p "$ws_path/.wiki/vector-index"
565
+ mkdir -p "$ws_path/raw/untracked"
566
+ # The llm-wiki container runs as the unprivileged `node` user. On WSL2,
567
+ # Rancher Desktop, and bind mounts created by the host, runtime folders can
568
+ # end up owned by a different UID and become read-only from the container.
569
+ chmod -R a+rwX "$ws_path/.wiki" "$ws_path/raw" 2>/dev/null || true
570
+ }
571
+
572
+ register_workspace_path() {
573
+ local workspace="$1"
574
+ local target_path="$2"
575
+ local registry_path registry_abs
576
+ registry_path="$WORKSPACES_DIR/$workspace"
577
+ registry_abs="$(absolute_path "$registry_path")"
578
+
579
+ [[ "$target_path" == "$registry_abs" ]] && return 0
580
+
581
+ mkdir -p "$WORKSPACES_DIR"
582
+ if [[ -e "$registry_path" || -L "$registry_path" ]]; then
583
+ die "workspace registry path already exists: $registry_path"
584
+ fi
585
+ ln -s "$target_path" "$registry_path"
586
+ printf 'Registered %s -> %s\n' "$registry_path" "$target_path"
587
+ }
588
+
589
+ config_workspace() {
590
+ local workspace="$1"
591
+ local target_path="${2:-$WORKSPACES_DIR/$workspace}"
592
+ target_path="$(absolute_path "$target_path")"
593
+ ensure_manager_endpoints_file
594
+
595
+ local env_file="$target_path/.env"
596
+ [[ -f "$env_file" ]] && die "workspace already configured: $env_file"
597
+
598
+ local port_base serve_port mcp_port prod_port wiki_token prod_token
599
+ port_base="$(find_free_workspace_port_base 3100)"
600
+ serve_port="$port_base"
601
+ mcp_port=$((port_base + 1))
602
+ prod_port=$((port_base + 2))
603
+ wiki_token="$(generate_token)"
604
+ prod_token="$(generate_token)"
605
+
606
+ mkdir -p "$target_path"
607
+ register_workspace_path "$workspace" "$target_path"
608
+ ensure_workspace_dirs "$target_path"
609
+ # Ensure the container (node, UID 1000) can write to the workspace.
610
+ # Needed on WSL2 when the host UID differs from the container UID.
611
+ chmod -R a+rwX "$target_path" 2>/dev/null || true
612
+
613
+ printf 'WORKSPACE_NAME=%s\nWIKI_WORKSPACE_PATH=%s\nWIKI_SERVE_PORT=%s\nWIKI_MCP_PORT=%s\nPRODUCTION_MCP_PORT=%s\nPRODUCTION_REQUIRE_CONFIRMATION=false\nWIKI_MCP_AUTH_TOKEN=%s\nPRODUCTION_MCP_AUTH_TOKEN=%s\n' \
614
+ "$workspace" "$target_path" "$serve_port" "$mcp_port" "$prod_port" "$wiki_token" "$prod_token" \
615
+ > "$env_file"
616
+
617
+ printf 'Created %s\n' "$env_file"
618
+ printf 'Ports serve=%s mcp=%s production=%s\n' \
619
+ "$serve_port" "$mcp_port" "$prod_port"
620
+
621
+ local compose_env_args=()
622
+ [[ -f "$MANAGER_ENV_FILE" ]] && compose_env_args+=(--env-file "$MANAGER_ENV_FILE")
623
+ compose_env_args+=(--env-file "$env_file")
624
+
625
+ WIKI_WORKSPACE_PATH="$target_path" \
626
+ WIKI_SERVE_PORT="$serve_port" \
627
+ WIKI_MCP_PORT="$mcp_port" \
628
+ docker compose "${compose_env_args[@]}" -f "$ROOT_DIR/docker-compose.yml" run --rm wiki init
629
+
630
+ printf 'Workspace ready: %s\n' "$target_path"
631
+ printf 'Start with: wiki-workspace up %s\n' "$workspace"
632
+ }
633
+
634
+ up_workspace() {
635
+ local workspace="$1"
636
+ local open_browser="${2:-0}"
637
+ need_workspace_env "$workspace"
638
+ local ws_path
639
+ ws_path="$(normalize_path "$(workspace_value "$workspace" WIKI_WORKSPACE_PATH)")"
640
+ ensure_workspace_dirs "$ws_path"
641
+
642
+ compose_for_workspace "$workspace" up -d serve mcp-http production-mcp
643
+
644
+ local serve_port prod_port
645
+ serve_port="$(workspace_value "$workspace" WIKI_SERVE_PORT 3100)"
646
+ prod_port="$(workspace_value "$workspace" PRODUCTION_MCP_PORT 3102)"
647
+ printf 'Workspace UI: http://localhost:%s\n' "$serve_port"
648
+ printf 'Chat: http://localhost:%s/chat\n' "$serve_port"
649
+ printf 'Production: http://localhost:%s/mcp/\n' "$prod_port"
650
+ printf 'External MCPs: see %s\n' "$ROOT_DIR/mcp.endpoints.json"
651
+
652
+ if [[ "$open_browser" == "1" ]]; then
653
+ wait_and_open "http://localhost:${serve_port}/chat" &
654
+ fi
655
+ }
656
+
657
+ main() {
658
+ if [[ $# -eq 1 && "$1" =~ ^(-h|--help|help)$ ]]; then
659
+ usage
660
+ exit 0
661
+ fi
662
+
663
+ if [[ $# -eq 1 && "$1" == "list" ]]; then
664
+ list_workspaces
665
+ exit 0
666
+ fi
667
+
668
+ # config <workspace> [path]
669
+ if [[ $# -ge 2 && "$1" == "config" ]]; then
670
+ local cfg_workspace="$2"
671
+ local cfg_path="${3:-}"
672
+ if [[ -n "$cfg_path" ]]; then
673
+ config_workspace "$cfg_workspace" "$cfg_path"
674
+ else
675
+ config_workspace "$cfg_workspace"
676
+ fi
677
+ exit 0
678
+ fi
679
+
680
+ # up <workspace> [--open]
681
+ if [[ $# -ge 2 && "$1" == "up" ]]; then
682
+ local up_open=0
683
+ [[ "${3:-}" == "--open" ]] && up_open=1
684
+ up_workspace "$2" "$up_open"
685
+ exit 0
686
+ fi
687
+
688
+ # agents <subcommand> [args...]
689
+ if [[ $# -ge 1 && "$1" == "agents" ]]; then
690
+ local agents_sub="${2:-up}"
691
+ if [[ $# -ge 2 ]]; then
692
+ shift 2
693
+ else
694
+ shift 1
695
+ fi
696
+ agents_compose "$agents_sub" "$@"
697
+ exit 0
698
+ fi
699
+
700
+ [[ $# -ge 2 ]] || { usage; exit 2; }
701
+
702
+ local scope="$1"
703
+ [[ "$scope" == "wiki" ]] || die "unknown scope: $scope (expected: wiki, agents, up, config, or list)"
704
+ shift
705
+ [[ $# -ge 2 ]] || die "wiki requires a workspace and a command"
706
+
707
+ local workspace="$1"
708
+ local command="$2"
709
+ shift 2
710
+
711
+ case "$command" in
712
+ init)
713
+ run_wiki "$workspace" init "$@"
714
+ ;;
715
+ up)
716
+ compose_for_workspace "$workspace" up -d serve mcp-http production-mcp
717
+ local serve_port production_port
718
+ serve_port="$(workspace_value "$workspace" WIKI_SERVE_PORT 3100)"
719
+ production_port="$(workspace_value "$workspace" PRODUCTION_MCP_PORT 3102)"
720
+ printf 'Workspace UI: http://localhost:%s\n' "$serve_port"
721
+ printf 'Chat MCP: http://localhost:%s/chat\n' "$serve_port"
722
+ printf 'Production: http://localhost:%s/mcp/\n' "$production_port"
723
+ ;;
724
+ down)
725
+ compose_for_workspace "$workspace" down
726
+ ;;
727
+ logs)
728
+ local log_args=()
729
+ read_lines_into_array log_args logs_args "$@"
730
+ compose_for_workspace "$workspace" logs "${log_args[@]}" serve mcp-http production-mcp
731
+ ;;
732
+ serve)
733
+ local open_browser=0
734
+ if [[ $# -eq 1 && "$1" == "--open" ]]; then
735
+ open_browser=1
736
+ elif [[ $# -ne 0 ]]; then
737
+ die "serve accepts only --open as optional flag; port is configured in the workspace .env"
738
+ fi
739
+
740
+ printf 'Starting mcp-http...\n'
741
+ compose_for_workspace "$workspace" up -d mcp-http
742
+ printf 'Starting production-mcp...\n'
743
+ compose_for_workspace "$workspace" up -d production-mcp
744
+ local serve_port production_port
745
+ serve_port="$(workspace_value "$workspace" WIKI_SERVE_PORT 3100)"
746
+ production_port="$(workspace_value "$workspace" PRODUCTION_MCP_PORT 3102)"
747
+ printf 'Workspace UI: http://localhost:%s\n' "$serve_port"
748
+ printf 'Chat MCP: http://localhost:%s/chat\n' "$serve_port"
749
+ printf 'Production: http://localhost:%s/mcp/\n' "$production_port"
750
+ printf 'Note: compose logs may show http://localhost:3000, which is the container port.\n'
751
+ if [[ $open_browser -eq 1 ]]; then
752
+ wait_and_open "http://localhost:${serve_port}" &
753
+ fi
754
+
755
+ compose_for_workspace "$workspace" up serve
756
+ ;;
757
+ doctor|ingest|build|export)
758
+ run_wiki "$workspace" "$command" "$@"
759
+ ;;
760
+ run)
761
+ [[ $# -gt 0 ]] || die "run requires wiki CLI args"
762
+ run_wiki "$workspace" "$@"
763
+ ;;
764
+ -h|--help|help)
765
+ usage
766
+ ;;
767
+ *)
768
+ die "unknown command: $command"
769
+ ;;
770
+ esac
771
+ }
772
+
773
+ main "$@"