@klhapp/skillmux 1.9.2 → 1.10.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/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/docs/README.md +1 -1
- package/docs/cli.md +74 -3
- package/docs/concepts.md +1 -1
- package/docs/configuration.md +52 -1
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +1 -1
- package/docs/skill-management.md +49 -0
- package/package.json +1 -1
- package/src/adapters.ts +148 -2
- package/src/cli.ts +302 -1291
- package/src/clients.ts +17 -0
- package/src/commands/audit.ts +54 -51
- package/src/commands/config.ts +11 -12
- package/src/commands/context.ts +103 -0
- package/src/commands/core.ts +5 -1
- package/src/commands/doctor.ts +76 -0
- package/src/commands/eval.ts +10 -13
- package/src/commands/init.ts +621 -0
- package/src/commands/install.ts +132 -0
- package/src/commands/local-vault.ts +60 -0
- package/src/commands/models.ts +10 -0
- package/src/commands/outdated.ts +8 -5
- package/src/commands/project.ts +37 -11
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/skill.ts +32 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +18 -6
- package/src/commands/update.ts +11 -5
- package/src/concurrency-limiter.ts +61 -0
- package/src/config-service.ts +1 -51
- package/src/config.ts +5 -0
- package/src/context.ts +8 -3
- package/src/db-audit.ts +286 -0
- package/src/db-index.ts +238 -0
- package/src/db.ts +3 -413
- package/src/global-flags.ts +46 -0
- package/src/install.ts +15 -0
- package/src/logger.ts +26 -0
- package/src/output.ts +30 -5
- package/src/redact.ts +52 -0
- package/src/router-core.ts +8 -27
- package/src/server.ts +594 -267
- package/src/toml-writer.ts +51 -0
- package/src/types.ts +7 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { expandHome, loadConfig } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
parseManifest,
|
|
6
|
+
resolveManifestPath,
|
|
7
|
+
validateManifest,
|
|
8
|
+
} from "../manifest";
|
|
9
|
+
import { emitSuccess, isInteractive, warn } from "../output";
|
|
10
|
+
import {
|
|
11
|
+
installPostMergeHook,
|
|
12
|
+
resolveProjectPinDir,
|
|
13
|
+
restoreMonolith as restoreMonolithTarget,
|
|
14
|
+
syncProjectTargets,
|
|
15
|
+
syncTarget,
|
|
16
|
+
type ProjectGroupInput,
|
|
17
|
+
} from "../sync";
|
|
18
|
+
import { confirmAction } from "./shared";
|
|
19
|
+
|
|
20
|
+
function parseSyncArgs(args: string[]): {
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
restoreMonolith: boolean;
|
|
23
|
+
installHook: boolean;
|
|
24
|
+
yes: boolean;
|
|
25
|
+
isJson: boolean;
|
|
26
|
+
} {
|
|
27
|
+
let dryRun = false;
|
|
28
|
+
let restoreMonolith = false;
|
|
29
|
+
let installHook = false;
|
|
30
|
+
let yes = false;
|
|
31
|
+
let isJson = false;
|
|
32
|
+
for (const arg of args) {
|
|
33
|
+
if (arg === "--dry-run") dryRun = true;
|
|
34
|
+
else if (arg === "--restore-monolith") restoreMonolith = true;
|
|
35
|
+
else if (arg === "--install-hook") installHook = true;
|
|
36
|
+
else if (arg === "--yes") yes = true;
|
|
37
|
+
else if (arg === "--json") isJson = true;
|
|
38
|
+
else throw new Error(`unknown sync option: ${arg}`);
|
|
39
|
+
}
|
|
40
|
+
return { dryRun, restoreMonolith, installHook, yes, isJson };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A target directory that doesn't exist yet is about to be created by `sync`.
|
|
45
|
+
* `manifest.targets[*].dir` is vault content — readable and writable by whatever
|
|
46
|
+
* populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
|
|
47
|
+
* `sync` can run unattended via the `--install-hook` post-merge hook. Without this
|
|
48
|
+
* gate, a tampered manifest naming a brand-new path gets that directory silently
|
|
49
|
+
* created (and populated with symlinks) the next time anyone pulls. Creation for
|
|
50
|
+
* an as-yet-unseen directory therefore requires either `--yes` or an interactive
|
|
51
|
+
* confirmation; once the directory exists, later syncs never hit this path again.
|
|
52
|
+
*/
|
|
53
|
+
async function confirmNewSyncTarget(
|
|
54
|
+
label: string,
|
|
55
|
+
dir: string,
|
|
56
|
+
yes: boolean,
|
|
57
|
+
isJson: boolean,
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
if (yes) return true;
|
|
60
|
+
if (!isInteractive()) {
|
|
61
|
+
if (!isJson) {
|
|
62
|
+
console.log(
|
|
63
|
+
`${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return confirmAction(`${label}: create new target directory ${dir}?`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SyncTargetSummary {
|
|
72
|
+
target: string;
|
|
73
|
+
status:
|
|
74
|
+
| "synced"
|
|
75
|
+
| "skipped_host_mismatch"
|
|
76
|
+
| "restored"
|
|
77
|
+
| "not_owned"
|
|
78
|
+
| "skipped_not_approved";
|
|
79
|
+
added?: string[];
|
|
80
|
+
removed?: string[];
|
|
81
|
+
skipped?: string[];
|
|
82
|
+
projects?: {
|
|
83
|
+
group: string;
|
|
84
|
+
pin_dir: string;
|
|
85
|
+
added: string[];
|
|
86
|
+
removed: string[];
|
|
87
|
+
skipped: string[];
|
|
88
|
+
}[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runSync(args: string[]): Promise<void> {
|
|
92
|
+
const { dryRun, restoreMonolith, installHook, yes, isJson } = parseSyncArgs(args);
|
|
93
|
+
const config = await loadConfig();
|
|
94
|
+
const vaultPath = expandHome(config.vault_path);
|
|
95
|
+
const log = (line: string) => {
|
|
96
|
+
if (!isJson) console.log(line);
|
|
97
|
+
};
|
|
98
|
+
const warnLine = (line: string) => {
|
|
99
|
+
if (!isJson) warn(line);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let hookInstalled: boolean | undefined;
|
|
103
|
+
if (installHook) {
|
|
104
|
+
const result = installPostMergeHook(vaultPath);
|
|
105
|
+
hookInstalled = result.installed;
|
|
106
|
+
log(result.installed ? "installed post-merge hook" : "post-merge hook already installed");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
110
|
+
if (!manifestPath) {
|
|
111
|
+
emitSuccess({ isJson }, { hook_installed: hookInstalled ?? null, targets: [] }, () =>
|
|
112
|
+
console.log("no skillmux.toml found at vault root — nothing to sync"),
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
118
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
119
|
+
const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
|
|
120
|
+
for (const note of notes) log(`note: ${note}`);
|
|
121
|
+
|
|
122
|
+
const currentHost = hostname();
|
|
123
|
+
const targetSummaries: SyncTargetSummary[] = [];
|
|
124
|
+
for (const [targetName, target] of Object.entries(manifest.targets)) {
|
|
125
|
+
if (target.host !== undefined && target.host !== currentHost) {
|
|
126
|
+
log(
|
|
127
|
+
`${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
|
|
128
|
+
);
|
|
129
|
+
targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const targetDir = expandHome(target.dir);
|
|
133
|
+
|
|
134
|
+
if (restoreMonolith) {
|
|
135
|
+
const result = restoreMonolithTarget(targetDir, vaultPath);
|
|
136
|
+
log(
|
|
137
|
+
result.restored
|
|
138
|
+
? `${targetName}: restored to a vault symlink`
|
|
139
|
+
: `${targetName}: not owned by skillmux, left untouched`,
|
|
140
|
+
);
|
|
141
|
+
targetSummaries.push({
|
|
142
|
+
target: targetName,
|
|
143
|
+
status: result.restored ? "restored" : "not_owned",
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!dryRun && !existsSync(targetDir)) {
|
|
149
|
+
const approved = await confirmNewSyncTarget(targetName, targetDir, yes, isJson);
|
|
150
|
+
if (!approved) {
|
|
151
|
+
if (isInteractive()) {
|
|
152
|
+
log(`${targetName}: skipped — creating ${targetDir} was not approved`);
|
|
153
|
+
}
|
|
154
|
+
targetSummaries.push({ target: targetName, status: "skipped_not_approved" });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const suffix = dryRun ? " (dry-run)" : "";
|
|
160
|
+
const result = syncTarget(
|
|
161
|
+
{
|
|
162
|
+
vaultPath,
|
|
163
|
+
targetDir,
|
|
164
|
+
targetName,
|
|
165
|
+
coreSkillIds: manifest.core.skills,
|
|
166
|
+
localVaultPaths,
|
|
167
|
+
},
|
|
168
|
+
{ dryRun },
|
|
169
|
+
);
|
|
170
|
+
log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
|
|
171
|
+
if (result.skipped.length > 0) {
|
|
172
|
+
warnLine(`refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`);
|
|
173
|
+
}
|
|
174
|
+
const summary: SyncTargetSummary = {
|
|
175
|
+
target: targetName,
|
|
176
|
+
status: "synced",
|
|
177
|
+
added: result.added,
|
|
178
|
+
removed: result.removed,
|
|
179
|
+
skipped: result.skipped,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (target.project_groups.length > 0) {
|
|
183
|
+
const allGroups = manifest.project ?? {};
|
|
184
|
+
const projectGroups: Record<string, ProjectGroupInput> = {};
|
|
185
|
+
for (const groupName of target.project_groups) {
|
|
186
|
+
const group = allGroups[groupName]!;
|
|
187
|
+
const approvedPaths: string[] = [];
|
|
188
|
+
for (const path of group.paths) {
|
|
189
|
+
// Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
|
|
190
|
+
// never prompt for a project path it would silently skip anyway.
|
|
191
|
+
if (!existsSync(path)) continue;
|
|
192
|
+
const pinDir = resolveProjectPinDir(targetDir, path);
|
|
193
|
+
if (dryRun || existsSync(pinDir)) {
|
|
194
|
+
approvedPaths.push(path);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes, isJson);
|
|
198
|
+
if (approved) approvedPaths.push(path);
|
|
199
|
+
}
|
|
200
|
+
projectGroups[groupName] = { ...group, paths: approvedPaths };
|
|
201
|
+
}
|
|
202
|
+
const projectResults = syncProjectTargets(
|
|
203
|
+
{ vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
|
|
204
|
+
{ dryRun },
|
|
205
|
+
);
|
|
206
|
+
summary.projects = projectResults.map((projectResult) => ({
|
|
207
|
+
group: projectResult.group,
|
|
208
|
+
pin_dir: projectResult.pinDir,
|
|
209
|
+
added: projectResult.added,
|
|
210
|
+
removed: projectResult.removed,
|
|
211
|
+
skipped: projectResult.skipped,
|
|
212
|
+
}));
|
|
213
|
+
for (const projectResult of projectResults) {
|
|
214
|
+
log(
|
|
215
|
+
` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
|
|
216
|
+
);
|
|
217
|
+
if (projectResult.skipped.length > 0) {
|
|
218
|
+
warnLine(
|
|
219
|
+
`refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
targetSummaries.push(summary);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
emitSuccess(
|
|
228
|
+
{ isJson },
|
|
229
|
+
{ hook_installed: hookInstalled ?? null, notes, targets: targetSummaries },
|
|
230
|
+
() => {},
|
|
231
|
+
);
|
|
232
|
+
}
|
package/src/commands/target.ts
CHANGED
|
@@ -63,14 +63,16 @@ export async function runTarget(
|
|
|
63
63
|
!(await confirmIfNeeded({
|
|
64
64
|
confirmed: args.includes("--yes"),
|
|
65
65
|
isJson: options.isJson,
|
|
66
|
-
prompt: `
|
|
66
|
+
prompt: `adopt target ${name} at ${path}?`,
|
|
67
67
|
nonInteractiveError:
|
|
68
68
|
"skillmux target add requires --yes when run non-interactively",
|
|
69
69
|
}))
|
|
70
70
|
)
|
|
71
71
|
return;
|
|
72
72
|
applyInit(vaultPath, [{ name, dir: path }]);
|
|
73
|
-
|
|
73
|
+
emitSuccess({ isJson: options.isJson }, { name, dir: path }, () =>
|
|
74
|
+
console.log(`target "${name}" added at ${path}`),
|
|
75
|
+
);
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -84,24 +86,34 @@ export async function runTarget(
|
|
|
84
86
|
);
|
|
85
87
|
}
|
|
86
88
|
if (options.dryRun) {
|
|
87
|
-
|
|
89
|
+
emitSuccess(
|
|
90
|
+
{ isJson: options.isJson },
|
|
91
|
+
{ name, preserved_dir: manifest.targets[name]!.dir },
|
|
92
|
+
() => console.log(`target remove: ${name} (files preserved, dry-run)`),
|
|
93
|
+
);
|
|
88
94
|
return;
|
|
89
95
|
}
|
|
90
96
|
if (
|
|
91
97
|
!(await confirmIfNeeded({
|
|
92
98
|
confirmed: args.includes("--yes"),
|
|
93
99
|
isJson: options.isJson,
|
|
94
|
-
prompt: `
|
|
100
|
+
prompt: `remove target ${name} from the manifest and preserve its files?`,
|
|
95
101
|
nonInteractiveError:
|
|
96
102
|
"skillmux target remove requires --yes when run non-interactively",
|
|
97
103
|
}))
|
|
98
104
|
)
|
|
99
105
|
return;
|
|
100
106
|
const targets = { ...manifest.targets };
|
|
107
|
+
const removedDir = manifest.targets[name]!.dir;
|
|
101
108
|
delete targets[name];
|
|
102
109
|
writeManifestAtomic(manifestPath, { ...manifest, targets });
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
emitSuccess(
|
|
111
|
+
{ isJson: options.isJson },
|
|
112
|
+
{ name, preserved_dir: removedDir },
|
|
113
|
+
() =>
|
|
114
|
+
console.log(
|
|
115
|
+
`target "${name}" removed from the manifest; files preserved at ${removedDir}`,
|
|
116
|
+
),
|
|
105
117
|
);
|
|
106
118
|
return;
|
|
107
119
|
}
|
package/src/commands/update.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { rmSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { expandHome, loadConfig } from "../config";
|
|
4
4
|
import {
|
|
5
|
+
assertHostAllowed,
|
|
5
6
|
cloneToTemp,
|
|
6
7
|
installIntoVault,
|
|
7
8
|
isLocalFileUrl,
|
|
@@ -17,6 +18,7 @@ import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
|
|
|
17
18
|
import { SKILL_ID_PATTERN } from "../vault";
|
|
18
19
|
import { confirmIfNeeded } from "./shared";
|
|
19
20
|
import { checkOutdated } from "./outdated";
|
|
21
|
+
import { isGlobalFlag } from "../global-flags";
|
|
20
22
|
|
|
21
23
|
type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
|
|
22
24
|
|
|
@@ -37,6 +39,7 @@ async function resolveCandidateOrigins(
|
|
|
37
39
|
vaultPath: string,
|
|
38
40
|
skillId: string | undefined,
|
|
39
41
|
allowLocalSource: boolean,
|
|
42
|
+
allowedHosts: string[] | undefined,
|
|
40
43
|
): Promise<{ skillId: string; origin: SkillOrigin }[]> {
|
|
41
44
|
if (skillId) {
|
|
42
45
|
// skillId (the CLI's positional <skill-id>) is joined straight into vaultPath
|
|
@@ -65,7 +68,7 @@ async function resolveCandidateOrigins(
|
|
|
65
68
|
}
|
|
66
69
|
return [{ skillId, origin }];
|
|
67
70
|
}
|
|
68
|
-
const outdated = await checkOutdated(vaultPath, { allowLocalSource });
|
|
71
|
+
const outdated = await checkOutdated(vaultPath, { allowLocalSource, allowedHosts });
|
|
69
72
|
return outdated
|
|
70
73
|
.filter((result) => result.status === "outdated")
|
|
71
74
|
.map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
|
|
@@ -76,6 +79,7 @@ async function buildPlan(
|
|
|
76
79
|
candidates: { skillId: string; origin: SkillOrigin }[],
|
|
77
80
|
failOn: ScanSeverity | undefined,
|
|
78
81
|
force: boolean,
|
|
82
|
+
allowedHosts: string[] | undefined,
|
|
79
83
|
): Promise<UpdatePlanItem[]> {
|
|
80
84
|
const plan: UpdatePlanItem[] = [];
|
|
81
85
|
for (const { skillId, origin } of candidates) {
|
|
@@ -118,6 +122,7 @@ async function buildPlan(
|
|
|
118
122
|
continue;
|
|
119
123
|
}
|
|
120
124
|
|
|
125
|
+
assertHostAllowed(origin.source_url, allowedHosts);
|
|
121
126
|
const cloneDir = await cloneToTemp(origin.source_url);
|
|
122
127
|
const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
|
|
123
128
|
const base = {
|
|
@@ -191,7 +196,7 @@ function parseUpdateArgs(args: string[]): {
|
|
|
191
196
|
throw new Error("--fail-on must be low, medium, or high");
|
|
192
197
|
}
|
|
193
198
|
failOn = value;
|
|
194
|
-
} else if (arg
|
|
199
|
+
} else if (isGlobalFlag(arg, "--json")) {
|
|
195
200
|
// handled globally
|
|
196
201
|
} else if (arg?.startsWith("--")) {
|
|
197
202
|
throw new Error(`unknown update option: ${arg}`);
|
|
@@ -206,10 +211,11 @@ function parseUpdateArgs(args: string[]): {
|
|
|
206
211
|
|
|
207
212
|
export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
|
|
208
213
|
const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
|
|
209
|
-
const
|
|
214
|
+
const config = await loadConfig();
|
|
215
|
+
const vaultPath = expandHome(config.vault_path);
|
|
210
216
|
|
|
211
|
-
const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
|
|
212
|
-
const plan = await buildPlan(vaultPath, candidates, failOn, force);
|
|
217
|
+
const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource, config.egress?.allowed_hosts);
|
|
218
|
+
const plan = await buildPlan(vaultPath, candidates, failOn, force, config.egress?.allowed_hosts);
|
|
213
219
|
try {
|
|
214
220
|
const toWrite = plan.filter((item) => item.kind === "update");
|
|
215
221
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export class ConcurrencyLimiter {
|
|
2
|
+
private inFlight = 0;
|
|
3
|
+
|
|
4
|
+
constructor(private readonly max: number) {}
|
|
5
|
+
|
|
6
|
+
tryAcquire(): boolean {
|
|
7
|
+
if (this.inFlight >= this.max) return false;
|
|
8
|
+
this.inFlight++;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
release(): void {
|
|
13
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps a response body so `release` fires when the stream actually finishes
|
|
19
|
+
* (fully drained or cancelled by a client disconnect) instead of as soon as
|
|
20
|
+
* the Response object is constructed. For a buffered body this happens almost
|
|
21
|
+
* immediately; for an open SSE stream it defers release until the connection
|
|
22
|
+
* really closes, so a concurrency limiter reflects true connection lifetime.
|
|
23
|
+
*/
|
|
24
|
+
export function releaseOnStreamClose(
|
|
25
|
+
body: ReadableStream<Uint8Array> | null,
|
|
26
|
+
release: () => void,
|
|
27
|
+
): ReadableStream<Uint8Array> | null {
|
|
28
|
+
if (!body) {
|
|
29
|
+
release();
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const reader = body.getReader();
|
|
34
|
+
let released = false;
|
|
35
|
+
const releaseOnce = () => {
|
|
36
|
+
if (released) return;
|
|
37
|
+
released = true;
|
|
38
|
+
release();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return new ReadableStream<Uint8Array>({
|
|
42
|
+
async pull(controller) {
|
|
43
|
+
try {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done) {
|
|
46
|
+
controller.close();
|
|
47
|
+
releaseOnce();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
controller.enqueue(value);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
controller.error(error);
|
|
53
|
+
releaseOnce();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
cancel(reason) {
|
|
57
|
+
releaseOnce();
|
|
58
|
+
return reader.cancel(reason);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/config-service.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
|
|
5
5
|
import { describeDeployment } from "./deployment";
|
|
6
6
|
import type { Config } from "./types";
|
|
7
|
+
import { stringifyToml } from "./toml-writer";
|
|
7
8
|
|
|
8
9
|
export type ConfigSource = "default" | "toml" | "environment";
|
|
9
10
|
export type ConfigSourceMap = Record<string, ConfigSource>;
|
|
@@ -373,57 +374,6 @@ export async function setDottedKey(
|
|
|
373
374
|
};
|
|
374
375
|
}
|
|
375
376
|
|
|
376
|
-
export function stringifyToml(obj: Record<string, any>): string {
|
|
377
|
-
let out = "";
|
|
378
|
-
const topLevel: Record<string, any> = {};
|
|
379
|
-
const sections: Record<string, any> = {};
|
|
380
|
-
|
|
381
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
382
|
-
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
383
|
-
sections[k] = v;
|
|
384
|
-
} else {
|
|
385
|
-
topLevel[k] = v;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
for (const [k, v] of Object.entries(topLevel)) {
|
|
390
|
-
out += `${k} = ${formatTomlVal(v)}\n`;
|
|
391
|
-
}
|
|
392
|
-
if (Object.keys(topLevel).length > 0) out += "\n";
|
|
393
|
-
|
|
394
|
-
for (const [secName, secObj] of Object.entries(sections)) {
|
|
395
|
-
out += stringifyTomlSection([secName], secObj);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
return out;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function stringifyTomlSection(path: string[], obj: Record<string, any>): string {
|
|
402
|
-
let out = `[${path.join(".")}]\n`;
|
|
403
|
-
const subSections: Record<string, any> = {};
|
|
404
|
-
|
|
405
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
406
|
-
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
407
|
-
subSections[k] = v;
|
|
408
|
-
} else {
|
|
409
|
-
out += `${k} = ${formatTomlVal(v)}\n`;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
out += "\n";
|
|
413
|
-
|
|
414
|
-
for (const [subName, subObj] of Object.entries(subSections)) {
|
|
415
|
-
out += stringifyTomlSection([...path, subName], subObj);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return out;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function formatTomlVal(v: unknown): string {
|
|
422
|
-
if (typeof v === "string") return JSON.stringify(v);
|
|
423
|
-
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
|
424
|
-
if (Array.isArray(v)) return JSON.stringify(v);
|
|
425
|
-
return JSON.stringify(v);
|
|
426
|
-
}
|
|
427
377
|
|
|
428
378
|
export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
|
|
429
379
|
const { effective } = await getEffectiveConfig(configPath);
|
package/src/config.ts
CHANGED
|
@@ -87,10 +87,15 @@ const configSchema = z.object({
|
|
|
87
87
|
enabled: z.boolean(),
|
|
88
88
|
token_env: z.string().min(1),
|
|
89
89
|
}).strict().optional(),
|
|
90
|
+
max_body_bytes: z.number().int().positive().optional(),
|
|
91
|
+
max_concurrent_requests: z.number().int().min(0).optional(),
|
|
90
92
|
}).strict().optional(),
|
|
91
93
|
audit: z.object({
|
|
92
94
|
retention_days: z.number().int().min(0).default(90),
|
|
93
95
|
}).strict().default({ retention_days: 90 }),
|
|
96
|
+
egress: z.object({
|
|
97
|
+
allowed_hosts: z.array(z.string().min(1)).optional(),
|
|
98
|
+
}).strict().optional(),
|
|
94
99
|
}).strict().refine((cfg) => {
|
|
95
100
|
const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
|
|
96
101
|
if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
|
package/src/context.ts
CHANGED
|
@@ -12,7 +12,12 @@ export interface ContextConfig {
|
|
|
12
12
|
contexts: Record<string, ContextRecord>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Context resolution: `local` = this CLI process has the Skillmux runtime (vault,
|
|
17
|
+
* index, audit db, embeddings/reranker clients) loaded in-process; `remote` = this
|
|
18
|
+
* CLI process is a thin network client to a separate process elsewhere that owns that runtime.
|
|
19
|
+
*/
|
|
20
|
+
export type ResolvedContext =
|
|
16
21
|
| { type: "local"; name: "local" }
|
|
17
22
|
| { type: "remote"; name: string; server: string; token_env?: string };
|
|
18
23
|
|
|
@@ -127,10 +132,10 @@ export async function useContext(name: string, filePath?: string): Promise<void>
|
|
|
127
132
|
await saveContextConfig(config, filePath);
|
|
128
133
|
}
|
|
129
134
|
|
|
130
|
-
export async function
|
|
135
|
+
export async function resolveContext(
|
|
131
136
|
flags: { context?: string; server?: string },
|
|
132
137
|
filePath?: string
|
|
133
|
-
): Promise<
|
|
138
|
+
): Promise<ResolvedContext> {
|
|
134
139
|
// Precedence 1: Explicit flags
|
|
135
140
|
if (flags.context && flags.server) {
|
|
136
141
|
throw new Error("Cannot specify both --context and --server");
|