@blogic-cz/agent-tools 0.13.0 → 0.14.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/README.md CHANGED
@@ -173,10 +173,20 @@ bun run agent-tools/example-tool/index.ts ping
173
173
  retentionDays: 90,
174
174
  dbPath: "~/.agent-tools/audit.sqlite",
175
175
  },
176
+ vpns: {
177
+ blogic: {
178
+ // auto defaults to true:
179
+ // darwin -> macos-scutil, linux -> linux-nmcli, win32 -> windows-rasdial
180
+ name: "BLVPN",
181
+ },
182
+ },
176
183
  kubernetes: {
177
184
  default: {
178
185
  clusterId: "your-cluster-id",
179
186
  namespaces: { test: "your-ns-test", prod: "your-ns-prod" },
187
+ prerequisites: [{ type: "vpn", key: "blogic" }],
188
+ // Prerequisites are currently decoded and validated as config metadata;
189
+ // automatic VPN connect/disconnect execution is planned for a follow-up release.
180
190
  },
181
191
  },
182
192
  logs: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -72,6 +72,13 @@
72
72
  "additionalProperties": {
73
73
  "$ref": "#/definitions/GitHubRepoConfig"
74
74
  }
75
+ },
76
+ "vpns": {
77
+ "description": "Named VPN definitions referenced by profile prerequisites.",
78
+ "type": "object",
79
+ "additionalProperties": {
80
+ "$ref": "#/definitions/VpnConfig"
81
+ }
75
82
  }
76
83
  },
77
84
  "definitions": {
@@ -81,8 +88,14 @@
81
88
  "additionalProperties": false,
82
89
  "required": ["owner", "repo"],
83
90
  "properties": {
84
- "owner": { "type": "string", "description": "GitHub organization or user name." },
85
- "repo": { "type": "string", "description": "Repository name." }
91
+ "owner": {
92
+ "type": "string",
93
+ "description": "GitHub organization or user name."
94
+ },
95
+ "repo": {
96
+ "type": "string",
97
+ "description": "Repository name."
98
+ }
86
99
  }
87
100
  },
88
101
  "AzureConfig": {
@@ -125,6 +138,17 @@
125
138
  "timeoutMs": {
126
139
  "description": "Optional command timeout in milliseconds.",
127
140
  "type": "number"
141
+ },
142
+ "prerequisites": {
143
+ "description": "Ordered prerequisite references required before remote tool execution.",
144
+ "type": "array",
145
+ "items": {
146
+ "$ref": "#/definitions/Prerequisite"
147
+ }
148
+ },
149
+ "vpn": {
150
+ "type": "string",
151
+ "description": "Convenience sugar for a VPN prerequisite key. Runtime normalizes this to prerequisites."
128
152
  }
129
153
  },
130
154
  "required": ["clusterId", "namespaces"]
@@ -251,6 +275,21 @@
251
275
  "remotePath": {
252
276
  "description": "Remote logs path used in container or host environments.",
253
277
  "type": "string"
278
+ },
279
+ "prerequisites": {
280
+ "description": "Ordered prerequisite references required before remote tool execution.",
281
+ "type": "array",
282
+ "items": {
283
+ "$ref": "#/definitions/Prerequisite"
284
+ }
285
+ },
286
+ "vpn": {
287
+ "type": "string",
288
+ "description": "Convenience sugar for a VPN prerequisite key. Runtime normalizes this to prerequisites."
289
+ },
290
+ "kubernetesProfile": {
291
+ "type": "string",
292
+ "description": "Kubernetes profile used for remote logs."
254
293
  }
255
294
  },
256
295
  "required": ["localDir", "remotePath"]
@@ -336,6 +375,138 @@
336
375
  }
337
376
  }
338
377
  }
378
+ },
379
+ "VpnPrerequisite": {
380
+ "description": "VPN prerequisite reference used by tool profiles.",
381
+ "type": "object",
382
+ "additionalProperties": false,
383
+ "properties": {
384
+ "type": {
385
+ "const": "vpn",
386
+ "description": "Prerequisite type."
387
+ },
388
+ "key": {
389
+ "type": "string",
390
+ "description": "Key from the top-level vpns registry."
391
+ },
392
+ "cleanup": {
393
+ "description": "VPN cleanup policy.",
394
+ "type": "string",
395
+ "enum": ["leave-running", "stop-if-started"]
396
+ }
397
+ },
398
+ "required": ["type", "key"]
399
+ },
400
+ "Prerequisite": {
401
+ "oneOf": [
402
+ {
403
+ "$ref": "#/definitions/VpnPrerequisite"
404
+ }
405
+ ]
406
+ },
407
+ "MacosScutilVpnDriverConfig": {
408
+ "type": "object",
409
+ "additionalProperties": false,
410
+ "properties": {
411
+ "type": {
412
+ "const": "macos-scutil"
413
+ },
414
+ "serviceName": {
415
+ "type": "string"
416
+ }
417
+ },
418
+ "required": ["type"]
419
+ },
420
+ "LinuxNmcliVpnDriverConfig": {
421
+ "type": "object",
422
+ "additionalProperties": false,
423
+ "properties": {
424
+ "type": {
425
+ "const": "linux-nmcli"
426
+ },
427
+ "connectionName": {
428
+ "type": "string"
429
+ }
430
+ },
431
+ "required": ["type"]
432
+ },
433
+ "WindowsRasdialVpnDriverConfig": {
434
+ "type": "object",
435
+ "additionalProperties": false,
436
+ "properties": {
437
+ "type": {
438
+ "const": "windows-rasdial"
439
+ },
440
+ "entryName": {
441
+ "type": "string"
442
+ }
443
+ },
444
+ "required": ["type"]
445
+ },
446
+ "VpnDriverConfig": {
447
+ "oneOf": [
448
+ {
449
+ "$ref": "#/definitions/MacosScutilVpnDriverConfig"
450
+ },
451
+ {
452
+ "$ref": "#/definitions/LinuxNmcliVpnDriverConfig"
453
+ },
454
+ {
455
+ "$ref": "#/definitions/WindowsRasdialVpnDriverConfig"
456
+ }
457
+ ]
458
+ },
459
+ "VpnConfig": {
460
+ "description": "VPN configuration. auto defaults to true and maps name to a deterministic OS-specific driver.",
461
+ "type": "object",
462
+ "additionalProperties": false,
463
+ "properties": {
464
+ "name": {
465
+ "type": "string",
466
+ "description": "VPN connection/service name used by auto driver mapping."
467
+ },
468
+ "auto": {
469
+ "type": "boolean",
470
+ "default": true,
471
+ "description": "Defaults to true. Maps by process.platform without heuristic discovery."
472
+ },
473
+ "defaultCleanup": {
474
+ "description": "VPN cleanup policy.",
475
+ "type": "string",
476
+ "enum": ["leave-running", "stop-if-started"]
477
+ },
478
+ "connectTimeoutMs": {
479
+ "type": "number"
480
+ },
481
+ "disconnectTimeoutMs": {
482
+ "type": "number"
483
+ },
484
+ "cooldownMs": {
485
+ "type": "number"
486
+ },
487
+ "leaseTtlMs": {
488
+ "type": "number"
489
+ },
490
+ "drivers": {
491
+ "type": "object",
492
+ "additionalProperties": false,
493
+ "properties": {
494
+ "darwin": {
495
+ "$ref": "#/definitions/MacosScutilVpnDriverConfig"
496
+ },
497
+ "linux": {
498
+ "$ref": "#/definitions/LinuxNmcliVpnDriverConfig"
499
+ },
500
+ "win32": {
501
+ "$ref": "#/definitions/WindowsRasdialVpnDriverConfig"
502
+ }
503
+ }
504
+ },
505
+ "driver": {
506
+ "$ref": "#/definitions/VpnDriverConfig"
507
+ }
508
+ },
509
+ "required": ["name"]
339
510
  }
340
511
  },
341
512
  "examples": [
@@ -16,6 +16,56 @@ const CredentialGuardConfigSchema = Schema.Struct({
16
16
  additionalDangerousBashPatterns: Schema.optionalKey(Schema.Array(Schema.String)),
17
17
  });
18
18
 
19
+ const CleanupPolicySchema = Schema.Literals(["leave-running", "stop-if-started"]);
20
+
21
+ const VpnPrerequisiteSchema = Schema.Struct({
22
+ type: Schema.Literal("vpn"),
23
+ key: Schema.String,
24
+ cleanup: Schema.optionalKey(CleanupPolicySchema),
25
+ });
26
+
27
+ const PrerequisiteSchema = VpnPrerequisiteSchema;
28
+ const PrerequisitesSchema = Schema.Array(PrerequisiteSchema);
29
+
30
+ const MacosScutilVpnDriverConfigSchema = Schema.Struct({
31
+ type: Schema.Literal("macos-scutil"),
32
+ serviceName: Schema.optionalKey(Schema.String),
33
+ });
34
+
35
+ const LinuxNmcliVpnDriverConfigSchema = Schema.Struct({
36
+ type: Schema.Literal("linux-nmcli"),
37
+ connectionName: Schema.optionalKey(Schema.String),
38
+ });
39
+
40
+ const WindowsRasdialVpnDriverConfigSchema = Schema.Struct({
41
+ type: Schema.Literal("windows-rasdial"),
42
+ entryName: Schema.optionalKey(Schema.String),
43
+ });
44
+
45
+ const VpnDriverConfigSchema = Schema.Union([
46
+ MacosScutilVpnDriverConfigSchema,
47
+ LinuxNmcliVpnDriverConfigSchema,
48
+ WindowsRasdialVpnDriverConfigSchema,
49
+ ]);
50
+
51
+ const VpnConfigSchema = Schema.Struct({
52
+ name: Schema.String,
53
+ auto: Schema.optionalKey(Schema.Boolean),
54
+ defaultCleanup: Schema.optionalKey(CleanupPolicySchema),
55
+ connectTimeoutMs: Schema.optionalKey(Schema.Number),
56
+ disconnectTimeoutMs: Schema.optionalKey(Schema.Number),
57
+ cooldownMs: Schema.optionalKey(Schema.Number),
58
+ leaseTtlMs: Schema.optionalKey(Schema.Number),
59
+ drivers: Schema.optionalKey(
60
+ Schema.Struct({
61
+ darwin: Schema.optionalKey(MacosScutilVpnDriverConfigSchema),
62
+ linux: Schema.optionalKey(LinuxNmcliVpnDriverConfigSchema),
63
+ win32: Schema.optionalKey(WindowsRasdialVpnDriverConfigSchema),
64
+ }),
65
+ ),
66
+ driver: Schema.optionalKey(VpnDriverConfigSchema),
67
+ });
68
+
19
69
  const AzureConfigSchema = Schema.Struct({
20
70
  organization: Schema.String,
21
71
  defaultProject: Schema.String,
@@ -26,6 +76,8 @@ const K8sConfigSchema = Schema.Struct({
26
76
  clusterId: Schema.String,
27
77
  namespaces: Schema.Record(Schema.String, Schema.String),
28
78
  timeoutMs: Schema.optionalKey(Schema.Number),
79
+ prerequisites: Schema.optionalKey(PrerequisitesSchema),
80
+ vpn: Schema.optionalKey(Schema.String),
29
81
  });
30
82
 
31
83
  const DbEnvConfigSchema = Schema.Struct({
@@ -52,6 +104,9 @@ const DatabaseConfigSchema = Schema.Struct({
52
104
  const LogsConfigSchema = Schema.Struct({
53
105
  localDir: Schema.String,
54
106
  remotePath: Schema.String,
107
+ kubernetesProfile: Schema.optionalKey(Schema.String),
108
+ prerequisites: Schema.optionalKey(PrerequisitesSchema),
109
+ vpn: Schema.optionalKey(Schema.String),
55
110
  });
56
111
 
57
112
  const ObservabilityEnvTargetSchema = Schema.Struct({
@@ -78,6 +133,7 @@ const GitHubRepoConfigSchema = Schema.Struct({
78
133
  const KNOWN_TOP_LEVEL_KEYS = new Set([
79
134
  "$schema",
80
135
  "azure",
136
+ "vpns",
81
137
  "kubernetes",
82
138
  "database",
83
139
  "observability",
@@ -92,6 +148,7 @@ const KNOWN_TOP_LEVEL_KEYS = new Set([
92
148
  const AgentToolsConfigSchema = Schema.Struct({
93
149
  $schema: Schema.optionalKey(Schema.String),
94
150
  azure: Schema.optionalKey(Schema.Record(Schema.String, AzureConfigSchema)),
151
+ vpns: Schema.optionalKey(Schema.Record(Schema.String, VpnConfigSchema)),
95
152
  kubernetes: Schema.optionalKey(Schema.Record(Schema.String, K8sConfigSchema)),
96
153
  database: Schema.optionalKey(Schema.Record(Schema.String, DatabaseConfigSchema)),
97
154
  observability: Schema.optionalKey(Schema.Record(Schema.String, ObservabilityConfigSchema)),
@@ -5,8 +5,60 @@ export type AzureConfig = {
5
5
  timeoutMs?: number;
6
6
  };
7
7
 
8
+ export type CleanupPolicy = "leave-running" | "stop-if-started";
9
+
10
+ export type VpnPrerequisite = {
11
+ type: "vpn";
12
+ key: string;
13
+ cleanup?: CleanupPolicy;
14
+ };
15
+
16
+ export type MacosScutilVpnDriverConfig = {
17
+ type: "macos-scutil";
18
+ serviceName?: string;
19
+ };
20
+
21
+ export type LinuxNmcliVpnDriverConfig = {
22
+ type: "linux-nmcli";
23
+ connectionName?: string;
24
+ };
25
+
26
+ export type WindowsRasdialVpnDriverConfig = {
27
+ type: "windows-rasdial";
28
+ entryName?: string;
29
+ };
30
+
31
+ export type VpnDriverConfig =
32
+ | MacosScutilVpnDriverConfig
33
+ | LinuxNmcliVpnDriverConfig
34
+ | WindowsRasdialVpnDriverConfig;
35
+
36
+ export type VpnConfig = {
37
+ name: string;
38
+ /** Defaults to true. Auto maps name to the current OS driver deterministically. */
39
+ auto?: boolean;
40
+ defaultCleanup?: CleanupPolicy;
41
+ connectTimeoutMs?: number;
42
+ disconnectTimeoutMs?: number;
43
+ cooldownMs?: number;
44
+ leaseTtlMs?: number;
45
+ drivers?: {
46
+ darwin?: MacosScutilVpnDriverConfig;
47
+ linux?: LinuxNmcliVpnDriverConfig;
48
+ win32?: WindowsRasdialVpnDriverConfig;
49
+ };
50
+ /** Explicit current-platform driver, required when auto is false and no per-OS driver is available. */
51
+ driver?: VpnDriverConfig;
52
+ };
53
+
54
+ export type ProfilePrerequisites = {
55
+ prerequisites?: readonly VpnPrerequisite[];
56
+ /** Convenience input sugar; normalize to prerequisites before execution. */
57
+ vpn?: string;
58
+ };
59
+
8
60
  /** Kubernetes cluster profile configuration */
9
- export type K8sConfig = {
61
+ export type K8sConfig = ProfilePrerequisites & {
10
62
  clusterId: string;
11
63
  /** Named namespaces, e.g. { test: "my-app-test", prod: "my-app-prod" } */
12
64
  namespaces: Record<string, string>;
@@ -38,9 +90,10 @@ export type DatabaseConfig = {
38
90
  };
39
91
 
40
92
  /** Logs profile configuration */
41
- export type LogsConfig = {
93
+ export type LogsConfig = ProfilePrerequisites & {
42
94
  localDir: string;
43
95
  remotePath: string;
96
+ kubernetesProfile?: string;
44
97
  };
45
98
 
46
99
  /** Single observability environment connection details */
@@ -93,6 +146,8 @@ export type AgentToolsConfig = {
93
146
  $schema?: string;
94
147
  /** Named Azure DevOps profiles. e.g. { default: { organization: "...", defaultProject: "..." } } */
95
148
  azure?: Record<string, AzureConfig>;
149
+ /** Named VPN definitions referenced by profile prerequisites. */
150
+ vpns?: Record<string, VpnConfig>;
96
151
  /** Named Kubernetes cluster profiles. e.g. { default: {...}, staging: {...} } */
97
152
  kubernetes?: Record<string, K8sConfig>;
98
153
  /** Named database profiles. e.g. { default: {...}, analytics: {...} } */
@@ -82,7 +82,7 @@ const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
82
82
  const resolvedEnv = yield* resolveEnv(options.env, config);
83
83
 
84
84
  const k8sService = yield* K8sService;
85
- const result = yield* k8sService.runKubectl(command, options.dryRun).pipe(
85
+ const result = yield* k8sService.runKubectl(command, options.dryRun, profileName).pipe(
86
86
  Effect.catchTags({
87
87
  K8sContextError: (error) => {
88
88
  const errorResult: CommandResult = {
@@ -19,6 +19,7 @@ export class K8sService extends Context.Service<
19
19
  readonly runCommand: (
20
20
  cmd: string,
21
21
  env: Environment,
22
+ profile?: string,
22
23
  ) => Effect.Effect<
23
24
  string,
24
25
  K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
@@ -26,6 +27,7 @@ export class K8sService extends Context.Service<
26
27
  readonly runKubectl: (
27
28
  cmd: string,
28
29
  dryRun: boolean,
30
+ profile?: string,
29
31
  ) => Effect.Effect<
30
32
  CommandResult,
31
33
  K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
@@ -39,24 +41,27 @@ export class K8sService extends Context.Service<
39
41
  const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
40
42
 
41
43
  const config = yield* ConfigService;
42
- const k8sConfig = getToolConfig<K8sConfig>(config, "kubernetes");
43
44
 
44
- if (!k8sConfig) {
45
- const noConfigError = new K8sContextError({
46
- message:
47
- "No Kubernetes configuration found. Add a 'kubernetes' section to agent-tools.json5.",
48
- clusterId: "unknown",
49
- });
50
- return {
51
- runCommand: (_cmd: string, _env: Environment) => Effect.fail(noConfigError),
52
- runKubectl: (_cmd: string, _dryRun: boolean) => Effect.fail(noConfigError),
53
- };
54
- }
45
+ const getK8sConfig = (profile?: string) =>
46
+ getToolConfig<K8sConfig>(config, "kubernetes", profile);
47
+
48
+ const requireK8sConfig = (profile?: string) =>
49
+ Effect.gen(function* () {
50
+ const k8sConfig = getK8sConfig(profile);
51
+ if (!k8sConfig) {
52
+ return yield* new K8sContextError({
53
+ message: profile
54
+ ? `No Kubernetes configuration found for profile: ${profile}.`
55
+ : "No Kubernetes configuration found. Add a 'kubernetes' section to agent-tools.json5.",
56
+ clusterId: profile ?? "unknown",
57
+ });
58
+ }
55
59
 
56
- const KUBECTL_TIMEOUT_MS = k8sConfig.timeoutMs ?? 60000;
60
+ return k8sConfig;
61
+ });
57
62
 
58
- // Create Ref for context caching (replaces module-level let)
59
- const contextRef = yield* Ref.make<string | null>(null);
63
+ // Cache context by selected profile/cluster instead of a single default profile.
64
+ const contextRef = yield* Ref.make<Record<string, string>>({});
60
65
 
61
66
  // Helper that uses executor.spawn() to avoid ChildProcessSpawner requirement in return type
62
67
  const runShellCommand = (commandStr: string, timeoutMs: number) =>
@@ -97,22 +102,27 @@ export class K8sService extends Context.Service<
97
102
  ),
98
103
  );
99
104
 
100
- const resolveContext = Effect.fn("K8sService.resolveContext")(function* () {
101
- // Check cache first
105
+ const resolveContext = Effect.fn("K8sService.resolveContext")(function* (
106
+ profile: string | undefined,
107
+ k8sConfig: K8sConfig,
108
+ ) {
109
+ const timeoutMs = k8sConfig.timeoutMs ?? 60000;
110
+ const cacheKey = profile ?? `cluster:${k8sConfig.clusterId}`;
102
111
  const cached = yield* Ref.get(contextRef);
103
- if (cached !== null) {
104
- return cached;
112
+ const cachedContext = cached[cacheKey];
113
+ if (cachedContext !== undefined) {
114
+ return cachedContext;
105
115
  }
106
116
 
107
117
  const jqCommand = `kubectl config view -o json | jq -r '.contexts[] | select(.context.cluster == "${k8sConfig.clusterId}") | .name' | head -1`;
108
118
 
109
- const contextResultOption = yield* runShellCommand(jqCommand, KUBECTL_TIMEOUT_MS);
119
+ const contextResultOption = yield* runShellCommand(jqCommand, timeoutMs);
110
120
 
111
121
  if (Option.isNone(contextResultOption)) {
112
122
  return yield* new K8sTimeoutError({
113
- message: `Context resolution timed out after ${KUBECTL_TIMEOUT_MS}ms`,
123
+ message: `Context resolution timed out after ${timeoutMs}ms`,
114
124
  command: jqCommand,
115
- timeoutMs: KUBECTL_TIMEOUT_MS,
125
+ timeoutMs,
116
126
  });
117
127
  }
118
128
 
@@ -120,19 +130,22 @@ export class K8sService extends Context.Service<
120
130
 
121
131
  if (contextResult.exitCode === 0 && contextResult.stdout.trim()) {
122
132
  const resolvedContextValue = contextResult.stdout.trim();
123
- yield* Ref.set(contextRef, resolvedContextValue);
133
+ yield* Ref.update(contextRef, (contexts) => ({
134
+ ...contexts,
135
+ [cacheKey]: resolvedContextValue,
136
+ }));
124
137
  return resolvedContextValue;
125
138
  }
126
139
 
127
140
  const fallbackCommand = `kubectl config view -o json | jq -r '.contexts[] as $ctx | .clusters[] | select(.name == $ctx.context.cluster and (.cluster.server | contains("${k8sConfig.clusterId}"))) | $ctx.name' | head -1`;
128
141
 
129
- const fallbackResultOption = yield* runShellCommand(fallbackCommand, KUBECTL_TIMEOUT_MS);
142
+ const fallbackResultOption = yield* runShellCommand(fallbackCommand, timeoutMs);
130
143
 
131
144
  if (Option.isNone(fallbackResultOption)) {
132
145
  return yield* new K8sTimeoutError({
133
- message: `Context resolution timed out after ${KUBECTL_TIMEOUT_MS}ms`,
146
+ message: `Context resolution timed out after ${timeoutMs}ms`,
134
147
  command: fallbackCommand,
135
- timeoutMs: KUBECTL_TIMEOUT_MS,
148
+ timeoutMs,
136
149
  });
137
150
  }
138
151
 
@@ -140,7 +153,10 @@ export class K8sService extends Context.Service<
140
153
 
141
154
  if (fallbackResult.exitCode === 0 && fallbackResult.stdout.trim()) {
142
155
  const resolvedContextValue = fallbackResult.stdout.trim();
143
- yield* Ref.set(contextRef, resolvedContextValue);
156
+ yield* Ref.update(contextRef, (contexts) => ({
157
+ ...contexts,
158
+ [cacheKey]: resolvedContextValue,
159
+ }));
144
160
  return resolvedContextValue;
145
161
  }
146
162
 
@@ -150,17 +166,22 @@ export class K8sService extends Context.Service<
150
166
  });
151
167
  });
152
168
 
153
- const executeCommand = Effect.fn("K8sService.executeCommand")(function* (cmd: string) {
154
- const context = yield* resolveContext();
169
+ const executeCommand = Effect.fn("K8sService.executeCommand")(function* (
170
+ cmd: string,
171
+ profile?: string,
172
+ ) {
173
+ const k8sConfig = yield* requireK8sConfig(profile);
174
+ const timeoutMs = k8sConfig.timeoutMs ?? 60000;
175
+ const context = yield* resolveContext(profile, k8sConfig);
155
176
  const fullCommand = `kubectl --context ${context} ${cmd}`;
156
177
 
157
- const resultOption = yield* runShellCommand(fullCommand, KUBECTL_TIMEOUT_MS);
178
+ const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
158
179
 
159
180
  if (Option.isNone(resultOption)) {
160
181
  return yield* new K8sTimeoutError({
161
- message: `Command timed out after ${KUBECTL_TIMEOUT_MS}ms`,
182
+ message: `Command timed out after ${timeoutMs}ms`,
162
183
  command: fullCommand,
163
- timeoutMs: KUBECTL_TIMEOUT_MS,
184
+ timeoutMs,
164
185
  });
165
186
  }
166
187
 
@@ -177,6 +198,7 @@ export class K8sService extends Context.Service<
177
198
  const runCommand = Effect.fn("K8sService.runCommand")(function* (
178
199
  cmd: string,
179
200
  _env: Environment,
201
+ profile?: string,
180
202
  ) {
181
203
  // Security: block dangerous commands before execution
182
204
  const securityCheck = isKubectlCommandAllowed(cmd);
@@ -189,7 +211,7 @@ export class K8sService extends Context.Service<
189
211
  });
190
212
  }
191
213
 
192
- const result = yield* executeCommand(cmd);
214
+ const result = yield* executeCommand(cmd, profile);
193
215
  if (result.exitCode !== 0) {
194
216
  return yield* new K8sCommandError({
195
217
  message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
@@ -205,6 +227,7 @@ export class K8sService extends Context.Service<
205
227
  const runKubectl = Effect.fn("K8sService.runKubectl")(function* (
206
228
  cmd: string,
207
229
  dryRun: boolean,
230
+ profile?: string,
208
231
  ) {
209
232
  // Security: block dangerous commands before execution (even dry-run)
210
233
  const securityCheck = isKubectlCommandAllowed(cmd);
@@ -219,7 +242,8 @@ export class K8sService extends Context.Service<
219
242
 
220
243
  const startTime = Date.now();
221
244
  if (dryRun) {
222
- const context = yield* resolveContext();
245
+ const k8sConfig = yield* requireK8sConfig(profile);
246
+ const context = yield* resolveContext(profile, k8sConfig);
223
247
  const fullCommand = `kubectl --context ${context} ${cmd}`;
224
248
  return {
225
249
  success: true,
@@ -229,7 +253,7 @@ export class K8sService extends Context.Service<
229
253
  };
230
254
  }
231
255
 
232
- const result = yield* executeCommand(cmd);
256
+ const result = yield* executeCommand(cmd, profile);
233
257
 
234
258
  if (result.exitCode !== 0) {
235
259
  return yield* new K8sCommandError({
@@ -125,11 +125,13 @@ export class LogsService extends Context.Service<
125
125
  logsConfig: LogsConfig,
126
126
  ) {
127
127
  const remotePath = logsConfig.remotePath;
128
+ const kubernetesProfile = logsConfig.kubernetesProfile;
128
129
 
129
130
  const podResult = yield* k8s
130
131
  .runKubectl(
131
132
  `get pods --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'`,
132
133
  false,
134
+ kubernetesProfile,
133
135
  )
134
136
  .pipe(
135
137
  Effect.mapError(
@@ -143,15 +145,17 @@ export class LogsService extends Context.Service<
143
145
 
144
146
  const pod = readCommandOutput(podResult.output).replace(/'/g, "");
145
147
 
146
- const listResult = yield* k8s.runKubectl(`exec ${pod} -- ls -la ${remotePath}`, false).pipe(
147
- Effect.mapError(
148
- (error) =>
149
- new LogsReadError({
150
- message: error instanceof Error ? error.message : "Failed to list remote logs",
151
- source: `${pod}:${remotePath}`,
152
- }),
153
- ),
154
- );
148
+ const listResult = yield* k8s
149
+ .runKubectl(`exec ${pod} -- ls -la ${remotePath}`, false, kubernetesProfile)
150
+ .pipe(
151
+ Effect.mapError(
152
+ (error) =>
153
+ new LogsReadError({
154
+ message: error instanceof Error ? error.message : "Failed to list remote logs",
155
+ source: `${pod}:${remotePath}`,
156
+ }),
157
+ ),
158
+ );
155
159
 
156
160
  const files = parseLogFiles(readCommandOutput(listResult.output));
157
161
  if (files.length === 0) {
@@ -226,11 +230,13 @@ export class LogsService extends Context.Service<
226
230
  logsConfig: LogsConfig,
227
231
  ) {
228
232
  const remotePath = logsConfig.remotePath;
233
+ const kubernetesProfile = logsConfig.kubernetesProfile;
229
234
 
230
235
  const podResult = yield* k8s
231
236
  .runKubectl(
232
237
  `get pods --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'`,
233
238
  false,
239
+ kubernetesProfile,
234
240
  )
235
241
  .pipe(
236
242
  Effect.mapError(
@@ -252,7 +258,7 @@ export class LogsService extends Context.Service<
252
258
  }
253
259
 
254
260
  const execResult = yield* k8s
255
- .runKubectl(`exec ${pod} -- sh -c "${command}"`, false)
261
+ .runKubectl(`exec ${pod} -- sh -c "${command}"`, false, kubernetesProfile)
256
262
  .pipe(Effect.result);
257
263
 
258
264
  return yield* Result.match(execResult, {
@@ -0,0 +1,38 @@
1
+ import type { AgentToolsConfig, VpnPrerequisite, ProfilePrerequisites } from "#config/types";
2
+ import type { PrerequisiteResolution } from "#shared/prerequisites/types";
3
+
4
+ export function normalizeProfilePrerequisites(
5
+ profile: ProfilePrerequisites,
6
+ ): readonly VpnPrerequisite[] {
7
+ const prerequisites = [...(profile.prerequisites ?? [])];
8
+
9
+ if (
10
+ profile.vpn &&
11
+ !prerequisites.some(
12
+ (prerequisite) => prerequisite.type === "vpn" && prerequisite.key === profile.vpn,
13
+ )
14
+ ) {
15
+ prerequisites.push({ type: "vpn", key: profile.vpn });
16
+ }
17
+
18
+ return prerequisites;
19
+ }
20
+
21
+ export function resolveProfilePrerequisites(
22
+ config: AgentToolsConfig,
23
+ profile: ProfilePrerequisites,
24
+ ): PrerequisiteResolution {
25
+ const prerequisites = normalizeProfilePrerequisites(profile);
26
+
27
+ for (const prerequisite of prerequisites) {
28
+ if (prerequisite.type === "vpn" && !config.vpns?.[prerequisite.key]) {
29
+ return {
30
+ success: false,
31
+ error: `VPN prerequisite "${prerequisite.key}" is not defined.`,
32
+ hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
33
+ };
34
+ }
35
+ }
36
+
37
+ return { success: true, prerequisites };
38
+ }
@@ -0,0 +1,21 @@
1
+ import type {
2
+ LinuxNmcliVpnDriverConfig,
3
+ MacosScutilVpnDriverConfig,
4
+ VpnPrerequisite,
5
+ WindowsRasdialVpnDriverConfig,
6
+ } from "#config/types";
7
+
8
+ export type PrerequisiteResolution =
9
+ | { success: true; prerequisites: readonly VpnPrerequisite[] }
10
+ | { success: false; error: string; hint: string };
11
+
12
+ export type SupportedPlatform = "darwin" | "linux" | "win32";
13
+
14
+ export type ResolvedVpnDriver =
15
+ | (Required<MacosScutilVpnDriverConfig> & { platform: "darwin" })
16
+ | (Required<LinuxNmcliVpnDriverConfig> & { platform: "linux" })
17
+ | (Required<WindowsRasdialVpnDriverConfig> & { platform: "win32" });
18
+
19
+ export type VpnDriverResolution =
20
+ | { success: true; driver: ResolvedVpnDriver }
21
+ | { success: false; error: string; hint: string };
@@ -0,0 +1,119 @@
1
+ import type { VpnConfig, VpnDriverConfig } from "#config/types";
2
+ import type {
3
+ ResolvedVpnDriver,
4
+ SupportedPlatform,
5
+ VpnDriverResolution,
6
+ } from "#shared/prerequisites/types";
7
+
8
+ const isSupportedPlatform = (platform: typeof process.platform): platform is SupportedPlatform =>
9
+ platform === "darwin" || platform === "linux" || platform === "win32";
10
+
11
+ const resolveExplicitDriver = (
12
+ config: VpnConfig,
13
+ platform: SupportedPlatform,
14
+ driver: VpnDriverConfig,
15
+ ): VpnDriverResolution => {
16
+ if (driver.type === "macos-scutil") {
17
+ if (platform !== "darwin") {
18
+ return {
19
+ success: false,
20
+ error: `VPN driver "${driver.type}" is not supported on ${platform}.`,
21
+ hint: "Configure a driver for the current OS or enable auto detection.",
22
+ };
23
+ }
24
+
25
+ return {
26
+ success: true,
27
+ driver: { platform, type: driver.type, serviceName: driver.serviceName ?? config.name },
28
+ };
29
+ }
30
+
31
+ if (driver.type === "linux-nmcli") {
32
+ if (platform !== "linux") {
33
+ return {
34
+ success: false,
35
+ error: `VPN driver "${driver.type}" is not supported on ${platform}.`,
36
+ hint: "Configure a driver for the current OS or enable auto detection.",
37
+ };
38
+ }
39
+
40
+ return {
41
+ success: true,
42
+ driver: { platform, type: driver.type, connectionName: driver.connectionName ?? config.name },
43
+ };
44
+ }
45
+
46
+ if (driver.type === "windows-rasdial") {
47
+ if (platform !== "win32") {
48
+ return {
49
+ success: false,
50
+ error: `VPN driver "${driver.type}" is not supported on ${platform}.`,
51
+ hint: "Configure a driver for the current OS or enable auto detection.",
52
+ };
53
+ }
54
+
55
+ return {
56
+ success: true,
57
+ driver: { platform, type: driver.type, entryName: driver.entryName ?? config.name },
58
+ };
59
+ }
60
+
61
+ const exhaustive: never = driver;
62
+ return exhaustive;
63
+ };
64
+
65
+ export function resolveVpnDriverConfig(
66
+ config: VpnConfig,
67
+ platform: typeof process.platform = process.platform,
68
+ ): VpnDriverResolution {
69
+ if (!isSupportedPlatform(platform)) {
70
+ return {
71
+ success: false,
72
+ error: `VPN auto detection is not supported on ${platform}.`,
73
+ hint: "Configure an explicit supported VPN driver for this platform.",
74
+ };
75
+ }
76
+
77
+ const platformDriver = config.drivers?.[platform];
78
+ if (platformDriver) {
79
+ return resolveExplicitDriver(config, platform, platformDriver);
80
+ }
81
+
82
+ if (config.driver) {
83
+ return resolveExplicitDriver(config, platform, config.driver);
84
+ }
85
+
86
+ const auto = config.auto ?? true;
87
+ if (!auto) {
88
+ return {
89
+ success: false,
90
+ error: "VPN auto detection is disabled, but no driver is configured for this platform.",
91
+ hint: `Add vpns.<key>.drivers.${platform} or enable auto detection.`,
92
+ };
93
+ }
94
+
95
+ if (platform === "darwin") {
96
+ return { success: true, driver: { platform, type: "macos-scutil", serviceName: config.name } };
97
+ }
98
+
99
+ if (platform === "linux") {
100
+ return {
101
+ success: true,
102
+ driver: { platform, type: "linux-nmcli", connectionName: config.name },
103
+ };
104
+ }
105
+
106
+ return { success: true, driver: { platform, type: "windows-rasdial", entryName: config.name } };
107
+ }
108
+
109
+ export function missingVpnToolHint(driver: ResolvedVpnDriver): string {
110
+ if (driver.type === "macos-scutil") {
111
+ return "scutil was not found or is unavailable. Ensure macOS system tools are available and the VPN service name matches the Network service name.";
112
+ }
113
+
114
+ if (driver.type === "linux-nmcli") {
115
+ return "nmcli was not found. Install or enable NetworkManager CLI, or configure an explicit supported VPN driver.";
116
+ }
117
+
118
+ return "rasdial was not found or is unavailable. Ensure Windows RAS tooling is available and the VPN entry name matches the configured connection.";
119
+ }