@blogic-cz/agent-tools 0.12.1 → 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.12.1",
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, {
@@ -1,7 +1,8 @@
1
- import { Console, Effect } from "effect";
1
+ import { Console, Effect, type Option } from "effect";
2
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
3
 
4
4
  import { formatOption, formatOutput } from "#shared";
5
+ import type { OutputFormat } from "#shared";
5
6
 
6
7
  import { ObservabilityToolError } from "./errors";
7
8
  import {
@@ -14,14 +15,31 @@ import {
14
15
  } from "./shared";
15
16
  import type {
16
17
  FlattenedSpan,
18
+ ObservabilityEnvConfig,
17
19
  OtlpAnyValue,
18
20
  OtlpAttribute,
21
+ ParsedId,
22
+ SearchWindow,
23
+ SpanResolution,
24
+ TempoSearchResponse,
19
25
  TempoTraceResponse,
20
26
  TraceSummary,
21
27
  } from "./types";
22
28
 
23
- function isHexTraceId(value: string): boolean {
24
- return /^[\da-f]{32}$/i.test(value);
29
+ const SPAN_SEARCH_WINDOWS: SearchWindow[] = [
30
+ { start: "now-1h", end: "now" },
31
+ { start: "now-24h", end: "now" },
32
+ ];
33
+
34
+ function parseId(value: string): ParsedId | undefined {
35
+ const trimmed = value.trim().toLowerCase();
36
+ if (/^[\da-f]{32}$/.test(trimmed)) {
37
+ return { rawId: value, normalizedId: trimmed, kind: "trace_id" };
38
+ }
39
+ if (/^[\da-f]{16}$/.test(trimmed)) {
40
+ return { rawId: value, normalizedId: trimmed, kind: "span_id" };
41
+ }
42
+ return undefined;
25
43
  }
26
44
 
27
45
  function getStringAttribute(
@@ -111,6 +129,16 @@ function computeDurationMs(start?: string, end?: string): number | undefined {
111
129
  return Number(endNano - startNano) / 1_000_000;
112
130
  }
113
131
 
132
+ function relativeToEpoch(value: string, nowEpoch: number): number {
133
+ const match = /^now-(\d+)([smhd])$/.exec(value.trim());
134
+ if (!match) return nowEpoch;
135
+
136
+ const amount = Number(match[1]);
137
+ const unit = match[2];
138
+ const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
139
+ return nowEpoch - amount * (multipliers[unit] ?? 1);
140
+ }
141
+
114
142
  function isErrorStatus(code?: string | number): boolean {
115
143
  if (code === undefined) return false;
116
144
  if (typeof code === "number") return code === 2;
@@ -205,72 +233,207 @@ function parseLabel(value: string | Record<string, string>): Record<string, stri
205
233
  }
206
234
  }
207
235
 
208
- const getCommand = Command.make(
209
- "get",
210
- {
211
- traceId: Argument.string("traceId"),
212
- format: formatOption,
213
- env: envOption,
214
- profile: profileOption,
215
- },
216
- ({ traceId, format, env, profile }) => {
217
- const startedAt = Date.now();
236
+ type ResolvedTrace = {
237
+ readonly resolution: SpanResolution;
238
+ readonly spans: FlattenedSpan[];
239
+ };
240
+
241
+ function searchTempoBySpanId(
242
+ config: ObservabilityEnvConfig,
243
+ spanId: string,
244
+ window: SearchWindow,
245
+ ): Effect.Effect<TempoSearchResponse, ObservabilityToolError> {
246
+ const now = Math.floor(Date.now() / 1000);
247
+ const startEpoch = relativeToEpoch(window.start, now);
248
+ const endEpoch = relativeToEpoch(window.end, now);
249
+ const traceql = encodeURIComponent(`{ span:id = "${spanId}" }`);
250
+ const searchUrl =
251
+ `/api/datasources/proxy/uid/${config.tempoUid}/api/search` +
252
+ `?q=${traceql}&start=${startEpoch}&end=${endEpoch}&limit=5`;
253
+
254
+ return observabilityFetch<TempoSearchResponse>(config, searchUrl);
255
+ }
218
256
 
219
- return Effect.gen(function* () {
220
- if (!isHexTraceId(traceId)) {
257
+ function fetchFullTrace(
258
+ config: ObservabilityEnvConfig,
259
+ traceId: string,
260
+ ): Effect.Effect<FlattenedSpan[], ObservabilityToolError> {
261
+ return Effect.gen(function* () {
262
+ const raw = yield* observabilityFetch<TempoTraceResponse>(
263
+ config,
264
+ `/api/datasources/proxy/uid/${config.tempoUid}/api/traces/${traceId}`,
265
+ );
266
+ return flattenTrace(raw);
267
+ });
268
+ }
269
+
270
+ function resolveTraceFromId(
271
+ config: ObservabilityEnvConfig,
272
+ parsed: ParsedId,
273
+ explicitWindows?: { start: string; end: string },
274
+ ): Effect.Effect<ResolvedTrace, ObservabilityToolError> {
275
+ return Effect.gen(function* () {
276
+ if (parsed.kind === "trace_id") {
277
+ const spans = yield* fetchFullTrace(config, parsed.normalizedId);
278
+ if (spans.length === 0) {
221
279
  return yield* new ObservabilityToolError({
222
- cause: new Error("trace get requires a 32-character hex trace ID"),
280
+ cause: new Error(`Trace ${parsed.normalizedId} returned zero spans`),
223
281
  });
224
282
  }
283
+ return {
284
+ resolution: {
285
+ via: "direct_trace_id" as const,
286
+ resolvedTraceId: parsed.normalizedId,
287
+ },
288
+ spans,
289
+ };
290
+ }
225
291
 
226
- const config = yield* resolveConfig(env, profile);
227
- const raw = yield* observabilityFetch<TempoTraceResponse>(
228
- config,
229
- `/api/datasources/proxy/uid/${config.tempoUid}/api/traces/${traceId.toLowerCase()}`,
230
- );
231
- const spans = flattenTrace(raw);
292
+ const windows = explicitWindows
293
+ ? [{ start: explicitWindows.start, end: explicitWindows.end }]
294
+ : SPAN_SEARCH_WINDOWS;
232
295
 
233
- if (spans.length === 0) {
296
+ const attemptedWindows: SearchWindow[] = [];
297
+ let usedWindow: SearchWindow | undefined;
298
+ let uniqueTraceIds: string[] = [];
299
+
300
+ for (const window of windows) {
301
+ attemptedWindows.push(window);
302
+ const searchResult = yield* searchTempoBySpanId(config, parsed.normalizedId, window);
303
+ const traces = searchResult.traces ?? [];
304
+
305
+ if (traces.length === 0) continue;
306
+
307
+ const candidateTraceIds = traces
308
+ .map((trace) => trace.traceID?.toLowerCase())
309
+ .filter((id): id is string => id !== undefined);
310
+
311
+ uniqueTraceIds = [...new Set(candidateTraceIds)];
312
+
313
+ if (uniqueTraceIds.length > 1) {
234
314
  return yield* new ObservabilityToolError({
235
- cause: new Error(`Trace ${traceId} returned zero spans`),
315
+ cause: {
316
+ message: `Ambiguous span ID ${parsed.normalizedId} — found in ${uniqueTraceIds.length} traces`,
317
+ code: "AMBIGUOUS_SPAN_ID",
318
+ retryable: true,
319
+ details: { candidateTraceIds: uniqueTraceIds },
320
+ },
236
321
  });
237
322
  }
238
323
 
239
- const result = {
240
- success: true,
241
- message: `Resolved trace ${traceId.toLowerCase()} with ${spans.length} span(s)`,
242
- data: {
243
- environment: env,
244
- grafanaUrl: config.url,
245
- tempoDatasourceUid: config.tempoUid,
246
- summary: summarizeTrace(traceId.toLowerCase(), spans),
247
- spans,
324
+ usedWindow = window;
325
+ break;
326
+ }
327
+
328
+ if (uniqueTraceIds.length === 0 || !usedWindow) {
329
+ const windowDesc = attemptedWindows
330
+ .map((window) => `${window.start} → ${window.end}`)
331
+ .join(", ");
332
+ return yield* new ObservabilityToolError({
333
+ cause: new Error(
334
+ `No trace found containing span ${parsed.normalizedId} (searched windows: ${windowDesc})`,
335
+ ),
336
+ });
337
+ }
338
+
339
+ const traceId = uniqueTraceIds[0];
340
+ const spans = yield* fetchFullTrace(config, traceId);
341
+
342
+ if (spans.length === 0) {
343
+ return yield* new ObservabilityToolError({
344
+ cause: new Error(`Trace ${traceId} returned zero spans`),
345
+ });
346
+ }
347
+
348
+ const focusSpan = spans.find((span) => span.spanId === parsed.normalizedId);
349
+
350
+ return {
351
+ resolution: {
352
+ via: "span_search" as const,
353
+ resolvedTraceId: traceId,
354
+ searchedSpanId: parsed.normalizedId,
355
+ focusSpan,
356
+ attemptedWindows,
357
+ usedWindow,
358
+ },
359
+ spans,
360
+ };
361
+ });
362
+ }
363
+
364
+ function handleTraceGet(
365
+ id: string,
366
+ format: OutputFormat,
367
+ env: string,
368
+ profile: Option.Option<string>,
369
+ ) {
370
+ const startedAt = Date.now();
371
+
372
+ return Effect.gen(function* () {
373
+ const parsed = parseId(id);
374
+ if (!parsed) {
375
+ return yield* new ObservabilityToolError({
376
+ cause: {
377
+ message: `Invalid ID format: expected 32-char trace ID or 16-char span ID, got ${id.length} characters`,
378
+ code: "INVALID_ID_FORMAT",
379
+ retryable: false,
248
380
  },
249
- executionTimeMs: Date.now() - startedAt,
250
- };
381
+ });
382
+ }
251
383
 
252
- yield* Console.log(formatOutput(result, format));
253
- }).pipe(
254
- Effect.catch((error) =>
255
- Effect.gen(function* () {
256
- const result = {
257
- success: false,
258
- message: "Failed to resolve trace from Tempo",
259
- error: formatObservabilityError(error),
260
- hint: "Check trace ID format and Grafana/Tempo connectivity",
261
- executionTimeMs: Date.now() - startedAt,
262
- };
263
- yield* Console.log(formatOutput(result, format));
264
- }),
265
- ),
266
- );
384
+ const config = yield* resolveConfig(env, profile);
385
+ const { resolution, spans } = yield* resolveTraceFromId(config, parsed);
386
+
387
+ const result = {
388
+ success: true,
389
+ message:
390
+ parsed.kind === "span_id"
391
+ ? `Found trace ${resolution.resolvedTraceId} via span ${parsed.normalizedId} with ${spans.length} span(s)`
392
+ : `Resolved trace ${resolution.resolvedTraceId} with ${spans.length} span(s)`,
393
+ data: {
394
+ environment: env,
395
+ grafanaUrl: config.url,
396
+ tempoDatasourceUid: config.tempoUid,
397
+ input: parsed,
398
+ resolution,
399
+ summary: summarizeTrace(resolution.resolvedTraceId, spans),
400
+ spans,
401
+ },
402
+ executionTimeMs: Date.now() - startedAt,
403
+ };
404
+
405
+ yield* Console.log(formatOutput(result, format));
406
+ }).pipe(
407
+ Effect.catch((error) =>
408
+ Effect.gen(function* () {
409
+ const result = {
410
+ success: false,
411
+ message: "Failed to resolve trace from Tempo",
412
+ error: formatObservabilityError(error),
413
+ hint: "Accepts 32-char trace ID or 16-char span ID. Check format and Grafana/Tempo connectivity",
414
+ executionTimeMs: Date.now() - startedAt,
415
+ };
416
+ yield* Console.log(formatOutput(result, format));
417
+ }),
418
+ ),
419
+ );
420
+ }
421
+
422
+ const getCommand = Command.make(
423
+ "get",
424
+ {
425
+ id: Argument.string("id"),
426
+ format: formatOption,
427
+ env: envOption,
428
+ profile: profileOption,
267
429
  },
268
- ).pipe(Command.withDescription("Resolve a trace by ID via Grafana/Tempo"));
430
+ ({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
431
+ ).pipe(Command.withDescription("Resolve a trace by trace ID or span ID via Grafana/Tempo"));
269
432
 
270
433
  const logsCommand = Command.make(
271
434
  "logs",
272
435
  {
273
- traceId: Argument.string("traceId"),
436
+ id: Argument.string("id"),
274
437
  format: formatOption,
275
438
  env: envOption,
276
439
  profile: profileOption,
@@ -287,19 +450,26 @@ const logsCommand = Command.make(
287
450
  Flag.withDefault("now"),
288
451
  ),
289
452
  },
290
- ({ traceId, format, env, profile, limit, start, end }) => {
453
+ ({ id, format, env, profile, limit, start, end }) => {
291
454
  const startedAt = Date.now();
292
455
 
293
456
  return Effect.gen(function* () {
294
- if (!isHexTraceId(traceId)) {
457
+ const parsed = parseId(id);
458
+ if (!parsed) {
295
459
  return yield* new ObservabilityToolError({
296
- cause: new Error("trace logs requires a 32-character hex trace ID"),
460
+ cause: {
461
+ message: `Invalid ID format: expected 32-char trace ID or 16-char span ID, got ${id.length} characters`,
462
+ code: "INVALID_ID_FORMAT",
463
+ retryable: false,
464
+ },
297
465
  });
298
466
  }
299
467
 
300
468
  const config = yield* resolveConfig(env, profile);
301
- const normalizedTraceId = traceId.toLowerCase();
302
- const logql = `{job=~".+"} |= "${normalizedTraceId}"`;
469
+ const { resolution } = yield* resolveTraceFromId(config, parsed, { start, end });
470
+ const resolvedTraceId = resolution.resolvedTraceId;
471
+
472
+ const logql = `{job=~".+"} |= "${resolvedTraceId}"`;
303
473
  const response = yield* observabilityDsQuery(config, config.lokiUid, "loki", logql, {
304
474
  from: start,
305
475
  to: end,
@@ -343,11 +513,16 @@ const logsCommand = Command.make(
343
513
 
344
514
  const result = {
345
515
  success: true,
346
- message: `Found ${logs.length} log line(s) mentioning trace ${normalizedTraceId}`,
516
+ message:
517
+ parsed.kind === "span_id"
518
+ ? `Found ${logs.length} log line(s) for trace ${resolvedTraceId} (resolved from span ${parsed.normalizedId})`
519
+ : `Found ${logs.length} log line(s) mentioning trace ${resolvedTraceId}`,
347
520
  data: {
348
521
  environment: env,
349
522
  grafanaUrl: config.url,
350
523
  lokiDatasourceUid: config.lokiUid,
524
+ input: parsed,
525
+ resolution,
351
526
  query: logql,
352
527
  logCount: logs.length,
353
528
  logs: logs.toSorted((left, right) => right.timestamp.localeCompare(left.timestamp)),
@@ -363,7 +538,7 @@ const logsCommand = Command.make(
363
538
  success: false,
364
539
  message: "Failed to execute trace log lookup",
365
540
  error: formatObservabilityError(error),
366
- hint: "Check trace ID format and Grafana/Loki connectivity",
541
+ hint: "Accepts 32-char trace ID or 16-char span ID. Check format and Grafana/Loki connectivity",
367
542
  executionTimeMs: Date.now() - startedAt,
368
543
  };
369
544
  yield* Console.log(formatOutput(result, format));
@@ -371,9 +546,20 @@ const logsCommand = Command.make(
371
546
  ),
372
547
  );
373
548
  },
374
- ).pipe(Command.withDescription("Find Loki logs mentioning a trace ID"));
549
+ ).pipe(Command.withDescription("Find Loki logs mentioning a trace (accepts trace ID or span ID)"));
550
+
551
+ const findCommand = Command.make(
552
+ "find",
553
+ {
554
+ id: Argument.string("id"),
555
+ format: formatOption,
556
+ env: envOption,
557
+ profile: profileOption,
558
+ },
559
+ ({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
560
+ ).pipe(Command.withDescription("Alias for 'trace get' — resolve a trace by trace ID or span ID"));
375
561
 
376
562
  export const traceCommand = Command.make("trace", {}).pipe(
377
563
  Command.withDescription("Tempo trace operations"),
378
- Command.withSubcommands([getCommand, logsCommand]),
564
+ Command.withSubcommands([getCommand, logsCommand, findCommand]),
379
565
  );
@@ -1,3 +1,5 @@
1
+ import { Schema } from "effect";
2
+
1
3
  export type ObservabilityEnvConfig = {
2
4
  url: string;
3
5
  token?: string;
@@ -89,6 +91,52 @@ export type TraceSummary = {
89
91
  readonly endedAtUnixNano?: string;
90
92
  };
91
93
 
94
+ export type TempoSearchResponse = {
95
+ readonly traces?: ReadonlyArray<{
96
+ readonly traceID?: string;
97
+ readonly rootServiceName?: string;
98
+ readonly rootTraceName?: string;
99
+ readonly startTimeUnixNano?: string;
100
+ readonly durationMs?: number;
101
+ readonly spanSets?: ReadonlyArray<{
102
+ readonly spans?: ReadonlyArray<{
103
+ readonly spanID?: string;
104
+ readonly startTimeUnixNano?: string;
105
+ readonly durationNanos?: string;
106
+ readonly attributes?: ReadonlyArray<OtlpAttribute>;
107
+ }>;
108
+ readonly matched?: number;
109
+ }>;
110
+ }>;
111
+ readonly metrics?: Record<string, unknown>;
112
+ };
113
+
114
+ export const IdKind = Schema.Literals(["trace_id", "span_id"]);
115
+ export type IdKind = typeof IdKind.Type;
116
+
117
+ export const ResolutionVia = Schema.Literals(["direct_trace_id", "span_search"]);
118
+ export type ResolutionVia = typeof ResolutionVia.Type;
119
+
120
+ export type ParsedId = {
121
+ readonly rawId: string;
122
+ readonly normalizedId: string;
123
+ readonly kind: IdKind;
124
+ };
125
+
126
+ export type SearchWindow = {
127
+ readonly start: string;
128
+ readonly end: string;
129
+ };
130
+
131
+ export type SpanResolution = {
132
+ readonly via: ResolutionVia;
133
+ readonly resolvedTraceId: string;
134
+ readonly searchedSpanId?: string;
135
+ readonly focusSpan?: FlattenedSpan;
136
+ readonly attemptedWindows?: ReadonlyArray<SearchWindow>;
137
+ readonly usedWindow?: SearchWindow;
138
+ };
139
+
92
140
  export type DsQueryOpts = {
93
141
  instant?: boolean;
94
142
  from?: string;
@@ -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
+ }