atheneum-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.npmrc.ci ADDED
@@ -0,0 +1 @@
1
+ //registry.npmjs.org/:_authToken=${NPM_TOKEN}
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Multi-instance Wiki.js support with path scoping and access controls
13
+ - Google Drive folder prioritization with recursive search
14
+ - YAML configuration with environment variable interpolation
15
+ - CLI entry point with `auth` subcommand for Google OAuth
16
+ - Configurable read-only mode, strict path enforcement, and path hiding per wiki instance
17
+ - Paginated Drive search that fills results across API pages
18
+
19
+ ### Changed
20
+
21
+ - Generalized from single-instance to reusable, config-driven package
22
+ - Refactored wiki client from module functions to WikiClient class
23
+ - Refactored drive client from module functions to DriveClient class
24
+ - Google OAuth tokens now stored in `~/.config/atheneum-mcp/`
25
+ - Configuration via YAML file or environment variables
26
+
27
+ [Unreleased]: https://gitlab.com/morgul/atheneum-mcp/-/compare/v0.1.0...main
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2
+ Version 2, December 2004
3
+
4
+ Copyright (C) 2026 Christopher S. Case <chris.case@g33xnexus.com>
5
+
6
+ Everyone is permitted to copy and distribute verbatim or modified
7
+ copies of this license document, and changing it is allowed as long
8
+ as the name is changed.
9
+
10
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
11
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
12
+
13
+ 0. You just DO WHAT THE FUCK YOU WANT TO.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # atheneum-mcp
2
+
3
+ An MCP (Model Context Protocol) server that connects AI assistants to Wiki.js and Google Drive.
4
+ Supports multiple Wiki.js instances with path scoping and access controls, plus Google Drive with
5
+ folder prioritization. Runs as a standalone stdio server, designed to be invoked via `npx`.
6
+
7
+ **Requires Node.js 24+** (native TypeScript, no build step)
8
+
9
+ ## Quick Start
10
+
11
+ The fastest way to get running with a single Wiki.js instance:
12
+
13
+ ```bash
14
+ # Set environment variables
15
+ export WIKIJS_URL="https://wiki.example.com"
16
+ export WIKIJS_TOKEN="your-api-token"
17
+
18
+ # Run directly
19
+ npx atheneum-mcp
20
+ ```
21
+
22
+ Or add it to your MCP client config (see [MCP Client Configuration](#mcp-client-configuration)).
23
+
24
+ ## YAML Configuration
25
+
26
+ For multiple wikis, Drive integration, or path scoping, use a YAML config file.
27
+ The server checks `./atheneum.yaml` by default, or pass `--config <path>` explicitly.
28
+
29
+ ```yaml
30
+ wikis:
31
+ - name: docs
32
+ url: https://wiki.example.com
33
+ token: ${WIKI_TOKEN}
34
+ basePath: /docs
35
+ readOnly: true
36
+
37
+ - name: internal
38
+ url: https://internal-wiki.example.com
39
+ token: ${INTERNAL_WIKI_TOKEN}
40
+
41
+ drive:
42
+ folders:
43
+ - "1abc123def456"
44
+ recursive: true
45
+ strictFolders: false
46
+ ```
47
+
48
+ YAML values support `$VAR` and `${VAR}` interpolation, so you can keep secrets in environment
49
+ variables or `.env` files.
50
+
51
+ See `atheneum.example.yaml` for a full example with all options.
52
+
53
+ ## Google Drive Setup
54
+
55
+ ### 1. Create OAuth Credentials
56
+
57
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
58
+ 2. Create a project (or use existing)
59
+ 3. Enable the Google Drive API
60
+ 4. Create OAuth 2.0 credentials (Desktop app type)
61
+ 5. Download the JSON and save it (default location: `~/.config/atheneum-mcp/gcp-oauth.keys.json`)
62
+
63
+ ### 2. Authenticate
64
+
65
+ ```bash
66
+ npx atheneum-mcp auth
67
+ ```
68
+
69
+ This opens a browser for Google sign-in and saves credentials to
70
+ `~/.config/atheneum-mcp/tokens.json`. Custom paths can be set via YAML config (`oauthPath` and
71
+ `credentialsPath` under `drive:`).
72
+
73
+ ## MCP Client Configuration
74
+
75
+ ### Claude Code
76
+
77
+ Add to your project `.mcp.json`:
78
+
79
+ ```json
80
+ {
81
+ "mcpServers": {
82
+ "atheneum": {
83
+ "command": "npx",
84
+ "args": ["atheneum-mcp", "--config", "./atheneum.yaml"]
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Or add globally via the CLI:
91
+
92
+ ```bash
93
+ claude mcp add atheneum -s user -- npx atheneum-mcp --config ~/atheneum.yaml
94
+ ```
95
+
96
+ ### Simple Setup (env vars only, no YAML)
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "atheneum": {
102
+ "command": "npx",
103
+ "args": ["atheneum-mcp"],
104
+ "env": {
105
+ "WIKIJS_URL": "https://wiki.example.com",
106
+ "WIKIJS_TOKEN": "your-api-token"
107
+ }
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ## Configuration Reference
114
+
115
+ ### Config Resolution Order
116
+
117
+ 1. `--config <path>` CLI argument (must exist)
118
+ 2. `./atheneum.yaml` in the current working directory (if present)
119
+ 3. Environment variable fallback
120
+ 4. `.env` file (loaded automatically via dotenv)
121
+
122
+ ### Environment Variables (simple mode)
123
+
124
+ | Variable | Description |
125
+ |---|---|
126
+ | `WIKIJS_URL` | Wiki.js instance URL (creates a single "default" wiki) |
127
+ | `WIKIJS_TOKEN` | Wiki.js API token |
128
+ | `GDRIVE_OAUTH_PATH` | Path to Google OAuth keys JSON |
129
+ | `GDRIVE_CREDENTIALS_PATH` | Path to Google credentials JSON |
130
+
131
+ ### YAML: `wikis[]`
132
+
133
+ | Key | Type | Default | Description |
134
+ |---|---|---|---|
135
+ | `name` | string | **required** | Instance name (used in multi-wiki `instance` parameter) |
136
+ | `url` | string | **required** | Wiki.js base URL |
137
+ | `token` | string | **required** | Wiki.js API token |
138
+ | `basePath` | string | `/` | Restrict operations to a subtree |
139
+ | `readOnly` | boolean | `false` | Disable all write operations |
140
+ | `strictPath` | boolean | `true` | Hard reject paths outside basePath |
141
+ | `hidePaths` | boolean | `false` | Return "not found" instead of "access denied" for out-of-scope paths |
142
+
143
+ ### YAML: `drive`
144
+
145
+ | Key | Type | Default | Description |
146
+ |---|---|---|---|
147
+ | `folders` | string[] | `[]` | Folder IDs to prioritize or restrict to |
148
+ | `recursive` | boolean | `true` | Search subfolders of listed folders |
149
+ | `strictFolders` | boolean | `false` | Treat folders as a hard restriction (not just priority hints) |
150
+ | `oauthPath` | string | `~/.config/atheneum-mcp/gcp-oauth.keys.json` | Path to OAuth client keys |
151
+ | `credentialsPath` | string | `~/.config/atheneum-mcp/tokens.json` | Path to saved auth tokens |
152
+
153
+ ## Wiki Tools
154
+
155
+ All wiki tools accept an optional `instance` parameter when multiple wikis are configured.
156
+
157
+ | Tool | Description | Write |
158
+ |---|---|---|
159
+ | `search_wiki` | Full-text search, results filtered to basePath | No |
160
+ | `get_wiki_page` | Get page by path (relative to basePath) | No |
161
+ | `get_wiki_page_by_id` | Get page by numeric ID (checked against basePath) | No |
162
+ | `list_wiki_pages` | List recent pages under basePath | No |
163
+ | `get_wiki_tree` | Folder structure under basePath | No |
164
+ | `list_wiki_tags` | All tags (unscoped) | No |
165
+ | `get_pages_by_tag` | Pages with tag, filtered to basePath | No |
166
+ | `create_wiki_page` | Create page under basePath | Yes |
167
+ | `update_wiki_page` | Update page (must be under basePath) | Yes |
168
+ | `delete_wiki_page` | Delete page (must be under basePath) | Yes |
169
+ | `move_wiki_page` | Move page (source and dest must be under basePath) | Yes |
170
+
171
+ ## Drive Tools
172
+
173
+ | Tool | Description |
174
+ |---|---|
175
+ | `search_gdrive` | Search files with folder prioritization/restriction |
176
+ | `read_gdrive_file` | Read file by ID (checked against folders if strict) |
177
+ | `list_gdrive_folder` | List folder contents (checked against allowed folders if strict) |
178
+
179
+ ## Development
180
+
181
+ ```bash
182
+ npm install # Install dependencies
183
+ npm start # Run the server
184
+ npm run lint # Lint code
185
+ npm run lint:types # Type check
186
+ npm run release # Version bump + changelog + tag + push
187
+ ```
188
+
189
+ ## License
190
+
191
+ [WTFPL](http://www.wtfpl.net/)
@@ -0,0 +1,52 @@
1
+ //----------------------------------------------------------------------------------------------------------------------
2
+ // ESLint Configuration
3
+ //----------------------------------------------------------------------------------------------------------------------
4
+
5
+ import eslint from '@eslint/js';
6
+ import tseslint from 'typescript-eslint';
7
+ import globals from 'globals';
8
+
9
+ //----------------------------------------------------------------------------------------------------------------------
10
+
11
+ export default tseslint.config(
12
+ eslint.configs.recommended,
13
+ ...tseslint.configs.recommended,
14
+ {
15
+ languageOptions: {
16
+ globals: {
17
+ ...globals.node
18
+ }
19
+ },
20
+ rules: {
21
+ // Enforce Allman brace style
22
+ 'brace-style': [ 'error', 'allman', { allowSingleLine: true } ],
23
+
24
+ // 4 space indentation
25
+ 'indent': [ 'error', 4, { SwitchCase: 1 } ],
26
+
27
+ // 120 character line limit
28
+ 'max-len': [ 'warn', { code: 120, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true } ],
29
+
30
+ // Spacing preferences
31
+ 'array-bracket-spacing': [ 'error', 'always' ],
32
+ 'object-curly-spacing': [ 'error', 'always' ],
33
+ 'computed-property-spacing': [ 'error', 'never' ],
34
+ 'space-in-parens': [ 'error', 'never' ],
35
+ 'template-curly-spacing': [ 'error', 'always' ],
36
+
37
+ // TypeScript specific
38
+ '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' } ],
39
+ '@typescript-eslint/explicit-function-return-type': 'off',
40
+ '@typescript-eslint/no-explicit-any': 'warn'
41
+ }
42
+ },
43
+ {
44
+ ignores: [
45
+ '**/dist/**',
46
+ '**/node_modules/**',
47
+ '**/*.d.ts'
48
+ ]
49
+ }
50
+ );
51
+
52
+ //----------------------------------------------------------------------------------------------------------------------
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "atheneum-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Wiki.js and Google Drive",
5
+ "author": "Christopher S. Case <chris.case@g33xnexus.com>",
6
+ "license": "WTFPL",
7
+ "type": "module",
8
+ "main": "src/cli.ts",
9
+ "bin": {
10
+ "atheneum-mcp": "src/cli.ts"
11
+ },
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "scripts": {
16
+ "start": "node src/cli.ts",
17
+ "lint": "eslint src/",
18
+ "lint:types": "tsc --noEmit",
19
+ "release": "bash scripts/release.sh"
20
+ },
21
+ "dependencies": {
22
+ "@google-cloud/local-auth": "^3.0.1",
23
+ "@modelcontextprotocol/sdk": "^1.0.0",
24
+ "dotenv": "^16.3.1",
25
+ "googleapis": "^166.0.0",
26
+ "yaml": "^2.7.0",
27
+ "zod": "^3.25.76"
28
+ },
29
+ "devDependencies": {
30
+ "@eslint/js": "^9.0.0",
31
+ "@types/node": "^22.0.0",
32
+ "eslint": "^9.0.0",
33
+ "globals": "^15.0.0",
34
+ "typescript": "^5.3.0",
35
+ "typescript-eslint": "^8.0.0"
36
+ }
37
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,60 @@
1
+ //----------------------------------------------------------------------------------------------------------------------
2
+ // Atheneum MCP - Google OAuth Authentication Flow
3
+ //----------------------------------------------------------------------------------------------------------------------
4
+
5
+ import * as fs from 'fs';
6
+ import * as os from 'os';
7
+ import * as path from 'path';
8
+ import { authenticate } from '@google-cloud/local-auth';
9
+
10
+ // Config
11
+ import type { AtheneumConfig } from './config.ts';
12
+
13
+ //----------------------------------------------------------------------------------------------------------------------
14
+ // Constants
15
+ //----------------------------------------------------------------------------------------------------------------------
16
+
17
+ const DEFAULT_OAUTH_PATH = path.join(os.homedir(), '.config', 'atheneum-mcp', 'gcp-oauth.keys.json');
18
+ const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), '.config', 'atheneum-mcp', 'tokens.json');
19
+
20
+ //----------------------------------------------------------------------------------------------------------------------
21
+ // Auth Flow
22
+ //----------------------------------------------------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Runs the interactive Google OAuth flow, opening a browser for the user to authorize, then saves the resulting
26
+ * credentials to disk. Intended to be invoked via `npx atheneum-mcp auth`.
27
+ */
28
+ export async function runAuthFlow(config : AtheneumConfig) : Promise<void>
29
+ {
30
+ const oauthPath = config.drive?.oauthPath ?? DEFAULT_OAUTH_PATH;
31
+ const credentialsPath = config.drive?.credentialsPath ?? DEFAULT_CREDENTIALS_PATH;
32
+
33
+ // Bail early if the OAuth keys file is missing
34
+ if(!fs.existsSync(oauthPath))
35
+ {
36
+ process.stderr.write(
37
+ `OAuth keys not found at ${ oauthPath }. Download from Google Cloud Console.\n`
38
+ );
39
+ process.exit(1);
40
+ }
41
+
42
+ // Run the interactive OAuth flow (opens a browser)
43
+ const auth = await authenticate({
44
+ keyfilePath: oauthPath,
45
+ scopes: [
46
+ 'https://www.googleapis.com/auth/drive',
47
+ 'https://www.googleapis.com/auth/drive.readonly',
48
+ ],
49
+ });
50
+
51
+ // Ensure the config directory exists
52
+ fs.mkdirSync(path.dirname(credentialsPath), { recursive: true });
53
+
54
+ // Write credentials with restrictive permissions
55
+ fs.writeFileSync(credentialsPath, JSON.stringify(auth.credentials, null, 2), { mode: 0o600 });
56
+
57
+ process.stderr.write(`Authentication successful. Credentials saved to ${ credentialsPath }\n`);
58
+ }
59
+
60
+ //----------------------------------------------------------------------------------------------------------------------
package/src/cli.ts ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ //----------------------------------------------------------------------------------------------------------------------
3
+ // CLI Entry Point
4
+ //----------------------------------------------------------------------------------------------------------------------
5
+
6
+ import { loadConfig } from './config.ts';
7
+ import { runAuthFlow } from './auth.ts';
8
+ import { startServer } from './server.ts';
9
+
10
+ //----------------------------------------------------------------------------------------------------------------------
11
+
12
+ async function main() : Promise<void>
13
+ {
14
+ const args = process.argv.slice(2);
15
+ const config = loadConfig(args);
16
+
17
+ if(args[0] === 'auth')
18
+ {
19
+ await runAuthFlow(config);
20
+ return;
21
+ }
22
+
23
+ await startServer(config);
24
+ }
25
+
26
+ main().catch((err) =>
27
+ {
28
+ console.error(err);
29
+ process.exit(1);
30
+ });
31
+
32
+ //----------------------------------------------------------------------------------------------------------------------
package/src/config.ts ADDED
@@ -0,0 +1,248 @@
1
+ //----------------------------------------------------------------------------------------------------------------------
2
+ // Atheneum MCP - Configuration
3
+ //----------------------------------------------------------------------------------------------------------------------
4
+
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ import dotenv from 'dotenv';
8
+ import { parse as parseYaml } from 'yaml';
9
+ import { z } from 'zod';
10
+
11
+ //----------------------------------------------------------------------------------------------------------------------
12
+ // Schemas
13
+ //----------------------------------------------------------------------------------------------------------------------
14
+
15
+ const WikiConfigSchema = z.object({
16
+ name: z.string(),
17
+ url: z.string().url(),
18
+ token: z.string(),
19
+ basePath: z.string().default('/'),
20
+ readOnly: z.boolean().default(false),
21
+ strictPath: z.boolean().default(true),
22
+ hidePaths: z.boolean().default(false),
23
+ });
24
+
25
+ const DriveConfigSchema = z.object({
26
+ folders: z.array(z.string()).default([]),
27
+ recursive: z.boolean().default(true),
28
+ strictFolders: z.boolean().default(false),
29
+ oauthPath: z.string().optional(),
30
+ credentialsPath: z.string().optional(),
31
+ });
32
+
33
+ const AtheneumConfigSchema = z.object({
34
+ wikis: z.array(WikiConfigSchema).default([]),
35
+ drive: DriveConfigSchema.optional(),
36
+ });
37
+
38
+ //----------------------------------------------------------------------------------------------------------------------
39
+ // Types
40
+ //----------------------------------------------------------------------------------------------------------------------
41
+
42
+ export type WikiConfig = z.infer<typeof WikiConfigSchema>;
43
+ export type DriveConfig = z.infer<typeof DriveConfigSchema>;
44
+ export type AtheneumConfig = z.infer<typeof AtheneumConfigSchema>;
45
+
46
+ //----------------------------------------------------------------------------------------------------------------------
47
+ // Env Var Interpolation
48
+ //----------------------------------------------------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Replaces `$VAR` and `${VAR}` patterns in a string with the corresponding environment variable value.
52
+ * Throws if the referenced variable is not set.
53
+ */
54
+ export function interpolateEnvVars(value : string) : string
55
+ {
56
+ // Match both ${VAR} and $VAR patterns. The ${VAR} form is checked first to avoid partial matches.
57
+ const envVarPattern = /\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
58
+ return value.replace(envVarPattern, (
59
+ _match,
60
+ braced : string | undefined,
61
+ bare : string | undefined
62
+ ) =>
63
+ {
64
+ const varName = braced ?? bare;
65
+ if(!varName)
66
+ {
67
+ return _match;
68
+ }
69
+
70
+ const envValue = process.env[varName];
71
+ if(envValue === undefined)
72
+ {
73
+ throw new Error(`Environment variable '${ varName }' is not set (referenced in config)`);
74
+ }
75
+
76
+ return envValue;
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Recursively walks all string values in an object and applies env var interpolation.
82
+ */
83
+ function interpolateObject(obj : unknown) : unknown
84
+ {
85
+ if(typeof obj === 'string')
86
+ {
87
+ return interpolateEnvVars(obj);
88
+ }
89
+
90
+ if(Array.isArray(obj))
91
+ {
92
+ return obj.map((item) => interpolateObject(item));
93
+ }
94
+
95
+ if(obj !== null && typeof obj === 'object')
96
+ {
97
+ const result : Record<string, unknown> = {};
98
+ for(const [ key, val ] of Object.entries(obj as Record<string, unknown>))
99
+ {
100
+ result[key] = interpolateObject(val);
101
+ }
102
+ return result;
103
+ }
104
+
105
+ // Booleans, numbers, null, etc. pass through untouched
106
+ return obj;
107
+ }
108
+
109
+ //----------------------------------------------------------------------------------------------------------------------
110
+ // CLI Arg Parsing
111
+ //----------------------------------------------------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Extracts the `--config <path>` argument from a CLI args array. Returns undefined if not present.
115
+ */
116
+ function parseConfigPath(args : string[]) : string | undefined
117
+ {
118
+ const idx = args.indexOf('--config');
119
+ if(idx === -1)
120
+ {
121
+ return undefined;
122
+ }
123
+
124
+ const next = args[idx + 1];
125
+ if(!next || next.startsWith('--'))
126
+ {
127
+ throw new Error('--config requires a path argument');
128
+ }
129
+
130
+ return next;
131
+ }
132
+
133
+ //----------------------------------------------------------------------------------------------------------------------
134
+ // Config Loading
135
+ //----------------------------------------------------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Builds a fallback config from environment variables when no YAML config is available.
139
+ * Returns undefined if the minimum required env vars aren't set.
140
+ */
141
+ function buildEnvFallbackConfig() : Record<string, unknown> | undefined
142
+ {
143
+ const wikiUrl = process.env.WIKIJS_URL;
144
+ const wikiToken = process.env.WIKIJS_TOKEN;
145
+
146
+ const hasWiki = wikiUrl && wikiToken;
147
+ const oauthPath = process.env.GDRIVE_OAUTH_PATH;
148
+ const credentialsPath = process.env.GDRIVE_CREDENTIALS_PATH;
149
+ const hasDrive = oauthPath || credentialsPath;
150
+
151
+ if(!hasWiki && !hasDrive)
152
+ {
153
+ return undefined;
154
+ }
155
+
156
+ const config : Record<string, unknown> = {};
157
+
158
+ if(hasWiki)
159
+ {
160
+ config.wikis = [
161
+ {
162
+ name: 'default',
163
+ url: wikiUrl,
164
+ token: wikiToken,
165
+ basePath: '/',
166
+ }
167
+ ];
168
+ }
169
+
170
+ if(hasDrive)
171
+ {
172
+ const drive : Record<string, unknown> = {};
173
+ if(oauthPath) { drive.oauthPath = oauthPath; }
174
+ if(credentialsPath) { drive.credentialsPath = credentialsPath; }
175
+ config.drive = drive;
176
+ }
177
+
178
+ return config;
179
+ }
180
+
181
+ function parseAndValidate(raw : unknown) : AtheneumConfig
182
+ {
183
+ const result = AtheneumConfigSchema.safeParse(raw);
184
+ if(!result.success)
185
+ {
186
+ const issues = result.error.issues.map(i => ` - ${ i.path.join('.') }: ${ i.message }`).join('\n');
187
+ throw new Error(`Invalid configuration:\n${ issues }`);
188
+ }
189
+ return result.data;
190
+ }
191
+
192
+ /**
193
+ * Loads and validates the Atheneum configuration.
194
+ *
195
+ * Resolution order:
196
+ * 1. `--config <path>` CLI argument
197
+ * 2. `./atheneum.yaml` in the current working directory
198
+ * 3. Environment variable fallback (`WIKIJS_URL` + `WIKIJS_TOKEN`, `GDRIVE_*`)
199
+ */
200
+ export function loadConfig(args : string[]) : AtheneumConfig
201
+ {
202
+ // Load .env file into process.env (no-ops if .env doesn't exist)
203
+ dotenv.config();
204
+
205
+ const configPath = parseConfigPath(args);
206
+ let rawConfig : unknown;
207
+
208
+ if(configPath)
209
+ {
210
+ // Explicit --config path: must exist
211
+ const resolved = resolve(configPath);
212
+ if(!existsSync(resolved))
213
+ {
214
+ throw new Error(`Config file not found: ${ resolved }`);
215
+ }
216
+
217
+ const content = readFileSync(resolved, 'utf-8');
218
+ rawConfig = parseYaml(content);
219
+ }
220
+ else
221
+ {
222
+ // Try default location
223
+ const defaultPath = resolve('atheneum.yaml');
224
+ if(existsSync(defaultPath))
225
+ {
226
+ const content = readFileSync(defaultPath, 'utf-8');
227
+ rawConfig = parseYaml(content);
228
+ }
229
+ }
230
+
231
+ // yaml.parse returns null for empty/whitespace-only files
232
+ if(rawConfig !== undefined && rawConfig !== null)
233
+ {
234
+ return parseAndValidate(interpolateObject(rawConfig));
235
+ }
236
+
237
+ // No YAML found -- try env var fallback
238
+ const fallback = buildEnvFallbackConfig();
239
+ if(fallback)
240
+ {
241
+ return parseAndValidate(fallback);
242
+ }
243
+
244
+ // Nothing configured at all -- return empty (valid) config
245
+ return parseAndValidate({});
246
+ }
247
+
248
+ //----------------------------------------------------------------------------------------------------------------------