@moxxy/config 0.27.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/LICENSE +21 -0
- package/dist/config-writer.d.ts +30 -0
- package/dist/config-writer.d.ts.map +1 -0
- package/dist/config-writer.js +57 -0
- package/dist/config-writer.js.map +1 -0
- package/dist/define.d.ts +17 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +18 -0
- package/dist/define.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +28 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +210 -0
- package/dist/loader.js.map +1 -0
- package/dist/merge.d.ts +9 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +45 -0
- package/dist/merge.js.map +1 -0
- package/dist/plugin-settings-schema.d.ts +24 -0
- package/dist/plugin-settings-schema.d.ts.map +1 -0
- package/dist/plugin-settings-schema.js +17 -0
- package/dist/plugin-settings-schema.js.map +1 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +329 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugins-tree-schema.d.ts +444 -0
- package/dist/plugins-tree-schema.d.ts.map +1 -0
- package/dist/plugins-tree-schema.js +93 -0
- package/dist/plugins-tree-schema.js.map +1 -0
- package/dist/schema.d.ts +1200 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +198 -0
- package/dist/schema.js.map +1 -0
- package/dist/user-config.d.ts +77 -0
- package/dist/user-config.d.ts.map +1 -0
- package/dist/user-config.js +265 -0
- package/dist/user-config.js.map +1 -0
- package/package.json +56 -0
- package/src/config-writer.test.ts +52 -0
- package/src/config-writer.ts +88 -0
- package/src/define.ts +19 -0
- package/src/index.ts +64 -0
- package/src/loader.test.ts +122 -0
- package/src/loader.ts +232 -0
- package/src/loader.yaml.test.ts +149 -0
- package/src/merge.test.ts +78 -0
- package/src/merge.ts +44 -0
- package/src/plugin-settings-schema.ts +18 -0
- package/src/plugin.test.ts +310 -0
- package/src/plugin.ts +360 -0
- package/src/plugins-tree-schema.test.ts +25 -0
- package/src/plugins-tree-schema.ts +103 -0
- package/src/schema.ts +218 -0
- package/src/security-config.test.ts +30 -0
- package/src/user-config.test.ts +113 -0
- package/src/user-config.ts +359 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { pluginSettingsSchema } from './plugin-settings-schema.js';
|
|
3
|
+
import { pluginsTreeSchema } from './plugins-tree-schema.js';
|
|
4
|
+
|
|
5
|
+
export const watcherModeSchema = z.enum(['auto', 'manual', 'off']);
|
|
6
|
+
|
|
7
|
+
export const permissionsConfigSchema = z.object({
|
|
8
|
+
policyPath: z.string().optional(),
|
|
9
|
+
allow: z
|
|
10
|
+
.array(
|
|
11
|
+
z.object({
|
|
12
|
+
name: z.string(),
|
|
13
|
+
inputMatches: z.record(z.string(), z.string()).optional(),
|
|
14
|
+
reason: z.string().optional(),
|
|
15
|
+
}),
|
|
16
|
+
)
|
|
17
|
+
.optional(),
|
|
18
|
+
deny: z
|
|
19
|
+
.array(
|
|
20
|
+
z.object({
|
|
21
|
+
name: z.string(),
|
|
22
|
+
inputMatches: z.record(z.string(), z.string()).optional(),
|
|
23
|
+
reason: z.string().optional(),
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
.optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const securityConfigSchema = z.object({
|
|
30
|
+
/**
|
|
31
|
+
* Master toggle. When omitted or false (the default), `@moxxy/plugin-security`
|
|
32
|
+
* is a no-op even if registered — every tool runs exactly as it does
|
|
33
|
+
* without the plugin. Per-tool `isolation: { ... }` declarations
|
|
34
|
+
* remain as documentation but are not enforced. Optional so a partial
|
|
35
|
+
* `security:` block (e.g. only `isolator`) validates and config_set can
|
|
36
|
+
* write one field at a time; consumers default it to false.
|
|
37
|
+
*/
|
|
38
|
+
enabled: z.boolean().optional(),
|
|
39
|
+
// The default isolator now lives in the unified tree at
|
|
40
|
+
// `plugins.isolator.default` (a registry kind like any other); it is no
|
|
41
|
+
// longer a bespoke `security.isolator` key.
|
|
42
|
+
/**
|
|
43
|
+
* Per-tool isolator overrides keyed by tool name. e.g.
|
|
44
|
+
* `{ bash: 'subprocess', memory_save: 'none' }`. Falls back to the
|
|
45
|
+
* default isolator above when a tool isn't listed.
|
|
46
|
+
*/
|
|
47
|
+
perTool: z.record(z.string(), z.string()).optional(),
|
|
48
|
+
/**
|
|
49
|
+
* Per-plugin isolator overrides keyed by plugin name. Applies to
|
|
50
|
+
* every tool the plugin contributes unless overridden in `perTool`.
|
|
51
|
+
*/
|
|
52
|
+
perPlugin: z.record(z.string(), z.string()).optional(),
|
|
53
|
+
/**
|
|
54
|
+
* When true, tools without a declared `isolation` field are denied
|
|
55
|
+
* outright (instead of falling through to the default isolator).
|
|
56
|
+
* Useful for hardening once every in-use tool has been audited.
|
|
57
|
+
*/
|
|
58
|
+
requireDeclaration: z.boolean().optional(),
|
|
59
|
+
/**
|
|
60
|
+
* The requireDeclaration ratchet for THIRD-PARTY plugins only — packages
|
|
61
|
+
* outside the trusted `@moxxy/` scope. Applies when a tool has no
|
|
62
|
+
* `isolation` declaration and the global `requireDeclaration` above did
|
|
63
|
+
* not already deny it:
|
|
64
|
+
* - 'off' — third-party tools are treated like first-party ones.
|
|
65
|
+
* - 'warn' — grace mode (the default while `security.enabled`): the
|
|
66
|
+
* call runs, but a structured warning is logged once per
|
|
67
|
+
* tool per process.
|
|
68
|
+
* - 'enforce' — the call is denied with a reason naming this flag.
|
|
69
|
+
* Tools with no resolvable owning plugin (e.g. runtime-attached MCP
|
|
70
|
+
* tools) are exempt — the npm-scope test is meaningless for them.
|
|
71
|
+
* Consumed by `@moxxy/plugin-security`
|
|
72
|
+
* (`SecurityPluginConfig.thirdPartyRequireDeclaration`).
|
|
73
|
+
*/
|
|
74
|
+
thirdPartyRequireDeclaration: z.enum(['off', 'warn', 'enforce']).optional(),
|
|
75
|
+
/**
|
|
76
|
+
* Tighten the in-process input cap-check from best-effort to fail-closed.
|
|
77
|
+
* By default the fs/net checks only inspect string values under a recognized
|
|
78
|
+
* key name (`file`, `path`, `url`, …), so a path/URL carried by an
|
|
79
|
+
* unrecognized field (`config`, `manifest`, `webhook`) is not checked. With
|
|
80
|
+
* `strict: true`, any string value that is unambiguously an absolute path or a
|
|
81
|
+
* bare http(s) URL is treated as in-scope-required regardless of key name, so
|
|
82
|
+
* an unrecognized carrier fails closed. Consumed by `@moxxy/plugin-security`
|
|
83
|
+
* (`SecurityPluginConfig.strict`); without this field the loader's schema would
|
|
84
|
+
* silently strip a user's `security.strict: true` and the hardening would be
|
|
85
|
+
* lost. Defaults to false at the consumer.
|
|
86
|
+
*/
|
|
87
|
+
strict: z.boolean().optional(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const embeddingsConfigSchema = z.object({
|
|
91
|
+
/**
|
|
92
|
+
* 'tfidf' (default, zero deps) | 'openai' (text-embedding-3-*)
|
|
93
|
+
* | 'transformers' (local, @huggingface/transformers) | 'none' (disable).
|
|
94
|
+
* Optional so a partial `embeddings:` block (e.g. only `model`) validates and
|
|
95
|
+
* config_set can write one field at a time; consumers default it to 'tfidf'.
|
|
96
|
+
*/
|
|
97
|
+
provider: z.enum(['tfidf', 'openai', 'transformers', 'none']).optional(),
|
|
98
|
+
model: z.string().optional(),
|
|
99
|
+
dimensions: z.number().int().positive().optional(),
|
|
100
|
+
apiKey: z.string().optional(),
|
|
101
|
+
batchSize: z.number().int().positive().optional(),
|
|
102
|
+
cacheDir: z.string().optional(),
|
|
103
|
+
/** Persist computed embeddings to ~/.moxxy/memory/.embeddings.json. */
|
|
104
|
+
persistIndex: z.boolean().optional(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Turn-boundary elision settings (context-on-demand). Off-by-floor safety:
|
|
109
|
+
* `keepRecentTurns` never drops below 2 and elision is skipped while the
|
|
110
|
+
* context is under `minContextRatioToElide` full.
|
|
111
|
+
*/
|
|
112
|
+
export const elisionConfigSchema = z.object({
|
|
113
|
+
enabled: z.boolean().optional(),
|
|
114
|
+
keepRecentTurns: z.number().int().min(2).optional(),
|
|
115
|
+
minContextRatioToElide: z.number().min(0).max(1).optional(),
|
|
116
|
+
/** Also collapse old user/assistant text turns (not just bulky tool results). */
|
|
117
|
+
elideConversational: z.boolean().optional(),
|
|
118
|
+
/** Auto-disable conversational elision after this many `recall({seq})` calls. */
|
|
119
|
+
conversationalRecallThreshold: z.number().int().positive().optional(),
|
|
120
|
+
maxRecallBytes: z.number().int().positive().optional(),
|
|
121
|
+
neverElideTools: z.array(z.string()).optional(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Context-window / token-efficiency settings. */
|
|
125
|
+
export const contextConfigSchema = z.object({
|
|
126
|
+
/** Master switch for prompt caching. Default true (lossless). */
|
|
127
|
+
caching: z.boolean().optional(),
|
|
128
|
+
// The active CacheStrategy now lives at `plugins.cacheStrategy.default`.
|
|
129
|
+
elision: elisionConfigSchema.optional(),
|
|
130
|
+
/** Lazy tool loading: send only core + loaded tool schemas, index the rest. Default false. */
|
|
131
|
+
lazyTools: z.boolean().optional(),
|
|
132
|
+
/**
|
|
133
|
+
* Reasoning/thinking preview. `true` enables it at the model's default depth;
|
|
134
|
+
* `{ effort }` sets the depth. Honored only by providers/models that support
|
|
135
|
+
* reasoning (Anthropic adaptive thinking, OpenAI/Codex reasoning). Default off.
|
|
136
|
+
*/
|
|
137
|
+
reasoning: z
|
|
138
|
+
.union([z.boolean(), z.object({ effort: z.enum(['low', 'medium', 'high']).optional() })])
|
|
139
|
+
.optional(),
|
|
140
|
+
/**
|
|
141
|
+
* Stuck-loop guard tuning. The guard bails a turn early when the model keeps
|
|
142
|
+
* making the same tool call; `maxIterations` is the hard backstop. Raise the
|
|
143
|
+
* thresholds if legitimately-repeated work is being cut short, or set
|
|
144
|
+
* `enabled: false` to disable the guard and rely on `maxIterations` alone.
|
|
145
|
+
*/
|
|
146
|
+
loopGuard: z
|
|
147
|
+
.object({
|
|
148
|
+
enabled: z.boolean().optional(),
|
|
149
|
+
windowSize: z.number().int().positive().optional(),
|
|
150
|
+
repeatThreshold: z.number().int().positive().optional(),
|
|
151
|
+
nearWindowSize: z.number().int().positive().optional(),
|
|
152
|
+
nearThreshold: z.number().int().positive().optional(),
|
|
153
|
+
})
|
|
154
|
+
.strict()
|
|
155
|
+
.optional(),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export const moxxyConfigSchema = z.object({
|
|
159
|
+
/**
|
|
160
|
+
* The unified plugins manifest — the single source of truth for what's
|
|
161
|
+
* installed/enabled (`plugins.packages`) and the active default per category
|
|
162
|
+
* (`plugins.<category>.default`). Replaces the legacy flat
|
|
163
|
+
* provider/mode/compactor/workflowExecutor keys, the old `plugins:` map, and
|
|
164
|
+
* `preferences.json`.
|
|
165
|
+
*/
|
|
166
|
+
plugins: pluginsTreeSchema.optional(),
|
|
167
|
+
context: contextConfigSchema.optional(),
|
|
168
|
+
systemPrompt: z.string().optional(),
|
|
169
|
+
maxIterations: z.number().int().positive().optional(),
|
|
170
|
+
hookTimeoutMs: z.number().int().positive().optional(),
|
|
171
|
+
watcher: watcherModeSchema.optional(),
|
|
172
|
+
skills: z
|
|
173
|
+
.object({
|
|
174
|
+
projectDir: z.string().optional(),
|
|
175
|
+
userDir: z.string().optional(),
|
|
176
|
+
extraDirs: z.array(z.string()).optional(),
|
|
177
|
+
})
|
|
178
|
+
.optional(),
|
|
179
|
+
security: securityConfigSchema.optional(),
|
|
180
|
+
channels: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
|
181
|
+
permissions: permissionsConfigSchema.optional(),
|
|
182
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
183
|
+
/** TUI presentation preferences (read at TUI boot; edited via /settings). */
|
|
184
|
+
tui: z
|
|
185
|
+
.object({
|
|
186
|
+
/** `mono` drops color like NO_COLOR; `default` is the standard palette. */
|
|
187
|
+
theme: z.enum(['default', 'mono']).optional(),
|
|
188
|
+
/** Hide the footer key-hint row when false. */
|
|
189
|
+
hints: z.boolean().optional(),
|
|
190
|
+
/**
|
|
191
|
+
* Ctrl+<letter> overrides for the input-editor command hotkeys. Values
|
|
192
|
+
* are single letters; unknown/conflicting letters are ignored at boot.
|
|
193
|
+
*/
|
|
194
|
+
keys: z
|
|
195
|
+
.object({
|
|
196
|
+
forceSend: z.string().length(1).optional(),
|
|
197
|
+
dropQueued: z.string().length(1).optional(),
|
|
198
|
+
toggleTools: z.string().length(1).optional(),
|
|
199
|
+
})
|
|
200
|
+
.strict()
|
|
201
|
+
.optional(),
|
|
202
|
+
})
|
|
203
|
+
.strict()
|
|
204
|
+
.optional(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
export type MoxxyConfig = z.infer<typeof moxxyConfigSchema>;
|
|
208
|
+
export type ContextConfig = z.infer<typeof contextConfigSchema>;
|
|
209
|
+
export type ElisionConfig = z.infer<typeof elisionConfigSchema>;
|
|
210
|
+
export type WatcherMode = z.infer<typeof watcherModeSchema>;
|
|
211
|
+
export type PermissionsConfig = z.infer<typeof permissionsConfigSchema>;
|
|
212
|
+
export type EmbeddingsConfig = z.infer<typeof embeddingsConfigSchema>;
|
|
213
|
+
export type SecurityConfig = z.infer<typeof securityConfigSchema>;
|
|
214
|
+
|
|
215
|
+
// `pluginSettingsSchema` is defined in ./plugin-settings-schema and re-exported
|
|
216
|
+
// here for back-references; `PluginSettings` lives there too.
|
|
217
|
+
export { pluginSettingsSchema };
|
|
218
|
+
export type { PluginSettings } from './plugin-settings-schema.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { moxxyConfigSchema, securityConfigSchema } from './schema.js';
|
|
3
|
+
|
|
4
|
+
describe('security.thirdPartyRequireDeclaration schema round-trip', () => {
|
|
5
|
+
it.each(['off', 'warn', 'enforce'] as const)('accepts and preserves %s', (mode) => {
|
|
6
|
+
const parsed = moxxyConfigSchema.parse({
|
|
7
|
+
security: { enabled: true, thirdPartyRequireDeclaration: mode },
|
|
8
|
+
});
|
|
9
|
+
expect(parsed.security?.thirdPartyRequireDeclaration).toBe(mode);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('stays absent when unset (consumer applies the warn default)', () => {
|
|
13
|
+
const parsed = moxxyConfigSchema.parse({ security: { enabled: true } });
|
|
14
|
+
expect(parsed.security?.thirdPartyRequireDeclaration).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects values outside the closed enum', () => {
|
|
18
|
+
expect(() =>
|
|
19
|
+
securityConfigSchema.parse({ thirdPartyRequireDeclaration: 'deny' }),
|
|
20
|
+
).toThrow();
|
|
21
|
+
expect(() =>
|
|
22
|
+
securityConfigSchema.parse({ thirdPartyRequireDeclaration: true }),
|
|
23
|
+
).toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('validates as a partial block (the field alone parses)', () => {
|
|
27
|
+
const parsed = securityConfigSchema.parse({ thirdPartyRequireDeclaration: 'enforce' });
|
|
28
|
+
expect(parsed).toEqual({ thirdPartyRequireDeclaration: 'enforce' });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { applyInitConfig, setPluginEnabled } from './user-config.js';
|
|
7
|
+
import { moxxyConfigSchema } from './schema.js';
|
|
8
|
+
|
|
9
|
+
let tmp: string;
|
|
10
|
+
let configPath: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-user-config-'));
|
|
14
|
+
configPath = path.join(tmp, 'config.yaml');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function readParsed(): Promise<Record<string, unknown>> {
|
|
22
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
23
|
+
return parseYaml(raw) as Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('applyInitConfig', () => {
|
|
27
|
+
it('writes the unified plugins tree the clean-slate schema reads', async () => {
|
|
28
|
+
const written = await applyInitConfig(
|
|
29
|
+
{ provider: 'anthropic', model: 'claude-sonnet-4-6', mode: 'goal', embedder: 'openai' },
|
|
30
|
+
{ configPath },
|
|
31
|
+
);
|
|
32
|
+
expect(written).toBe(configPath);
|
|
33
|
+
|
|
34
|
+
const parsed = await readParsed();
|
|
35
|
+
expect(parsed).toMatchObject({
|
|
36
|
+
plugins: {
|
|
37
|
+
provider: { default: 'anthropic', items: { anthropic: { model: 'claude-sonnet-4-6' } } },
|
|
38
|
+
mode: { default: 'goal' },
|
|
39
|
+
embedder: { default: 'openai' },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
// The result must validate against the real config schema.
|
|
43
|
+
expect(moxxyConfigSchema.safeParse(parsed).success).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('MERGES into the existing package ledger instead of clobbering it', async () => {
|
|
47
|
+
// Simulate ensureProvider/installPlugins enabling packages BEFORE the wizard
|
|
48
|
+
// persists the provider default + model (the exact init ordering).
|
|
49
|
+
await setPluginEnabled('@moxxy/plugin-provider-anthropic', true, { configPath });
|
|
50
|
+
await setPluginEnabled('@moxxy/plugin-telegram', true, { configPath });
|
|
51
|
+
|
|
52
|
+
await applyInitConfig(
|
|
53
|
+
{ provider: 'anthropic', model: 'claude-sonnet-4-6', mode: 'default', embedder: 'tfidf' },
|
|
54
|
+
{ configPath },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const parsed = (await readParsed()) as {
|
|
58
|
+
plugins: {
|
|
59
|
+
packages: Record<string, { enabled: boolean }>;
|
|
60
|
+
provider: { default: string };
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
// Ledger survived the merge.
|
|
64
|
+
expect(parsed.plugins.packages['@moxxy/plugin-provider-anthropic']).toEqual({ enabled: true });
|
|
65
|
+
expect(parsed.plugins.packages['@moxxy/plugin-telegram']).toEqual({ enabled: true });
|
|
66
|
+
// And the provider default landed alongside it.
|
|
67
|
+
expect(parsed.plugins.provider.default).toBe('anthropic');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('omits the tfidf floor, a null model, and the security block by default', async () => {
|
|
71
|
+
await applyInitConfig(
|
|
72
|
+
{ provider: 'anthropic', model: null, mode: 'default', embedder: 'tfidf' },
|
|
73
|
+
{ configPath },
|
|
74
|
+
);
|
|
75
|
+
const parsed = (await readParsed()) as { plugins: Record<string, unknown>; security?: unknown };
|
|
76
|
+
expect(parsed.plugins.embedder).toBeUndefined();
|
|
77
|
+
expect(parsed.plugins.provider).toEqual({ default: 'anthropic' });
|
|
78
|
+
expect(parsed.security).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('writes security.enabled only when opted in', async () => {
|
|
82
|
+
await applyInitConfig(
|
|
83
|
+
{ provider: 'anthropic', mode: 'default', embedder: 'tfidf', security: { enabled: true } },
|
|
84
|
+
{ configPath },
|
|
85
|
+
);
|
|
86
|
+
const parsed = (await readParsed()) as { security: { enabled: boolean } };
|
|
87
|
+
expect(parsed.security.enabled).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('records fallbacks (excluding the primary) and never references the vault key', async () => {
|
|
91
|
+
await applyInitConfig(
|
|
92
|
+
{ provider: 'anthropic', mode: 'default', embedder: 'tfidf', fallbacks: ['anthropic', 'openai'] },
|
|
93
|
+
{ configPath },
|
|
94
|
+
);
|
|
95
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
96
|
+
const parsed = parseYaml(raw) as { plugins: { provider: { fallbacks: string[] } } };
|
|
97
|
+
expect(parsed.plugins.provider.fallbacks).toEqual(['openai']);
|
|
98
|
+
// Like `moxxy provision`, the key lives in the vault under its canonical
|
|
99
|
+
// name — the config must not carry a ${vault:...} apiKey ref.
|
|
100
|
+
expect(raw).not.toContain('apiKey');
|
|
101
|
+
expect(raw).not.toContain('${vault:');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('preserves user comments across the merge (comment-preserving round-trip)', async () => {
|
|
105
|
+
await fs.writeFile(configPath, '# my hand-written config\nplugins:\n packages: {}\n');
|
|
106
|
+
await applyInitConfig(
|
|
107
|
+
{ provider: 'anthropic', mode: 'default', embedder: 'tfidf' },
|
|
108
|
+
{ configPath },
|
|
109
|
+
);
|
|
110
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
111
|
+
expect(raw).toContain('# my hand-written config');
|
|
112
|
+
});
|
|
113
|
+
});
|