@blogic-cz/agent-tools 0.14.11 → 0.14.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.11",
3
+ "version": "0.14.13",
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",
@@ -123,6 +123,10 @@
123
123
  "type": "object",
124
124
  "additionalProperties": false,
125
125
  "properties": {
126
+ "kubeconfig": {
127
+ "description": "Optional kubeconfig path. Supports ${ENV_VAR} templates.",
128
+ "type": "string"
129
+ },
126
130
  "clusterId": {
127
131
  "description": "Cluster identifier.",
128
132
  "type": "string"
@@ -202,6 +206,10 @@
202
206
  "type": "object",
203
207
  "additionalProperties": false,
204
208
  "properties": {
209
+ "kubeconfig": {
210
+ "description": "Optional kubeconfig path. Supports ${ENV_VAR} templates.",
211
+ "type": "string"
212
+ },
205
213
  "context": {
206
214
  "description": "Kubectl context name.",
207
215
  "type": "string"
@@ -75,6 +75,7 @@ const AzureConfigSchema = Schema.Struct({
75
75
  });
76
76
 
77
77
  const K8sConfigSchema = Schema.Struct({
78
+ kubeconfig: Schema.optionalKey(Schema.String),
78
79
  clusterId: Schema.String,
79
80
  namespaces: Schema.Record(Schema.String, Schema.String),
80
81
  timeoutMs: Schema.optionalKey(Schema.Number),
@@ -95,6 +96,7 @@ const DatabaseConfigSchema = Schema.Struct({
95
96
  environments: Schema.Record(Schema.String, DbEnvConfigSchema),
96
97
  kubectl: Schema.optionalKey(
97
98
  Schema.Struct({
99
+ kubeconfig: Schema.optionalKey(Schema.String),
98
100
  context: Schema.String,
99
101
  namespace: Schema.String,
100
102
  service: Schema.optionalKey(Schema.String),
@@ -63,6 +63,8 @@ export type ProfilePrerequisites = {
63
63
 
64
64
  /** Kubernetes cluster profile configuration */
65
65
  export type K8sConfig = ProfilePrerequisites & {
66
+ /** Optional kubeconfig path. Supports ${ENV_VAR} templates. */
67
+ kubeconfig?: string;
66
68
  clusterId: string;
67
69
  /** Named namespaces, e.g. { test: "my-app-test", prod: "my-app-prod" } */
68
70
  namespaces: Record<string, string>;
@@ -86,6 +88,8 @@ export type DatabaseConfig = ProfilePrerequisites & {
86
88
  /** Named database environments, e.g. { local: {...}, test: {...}, prod: {...} } */
87
89
  environments: Record<string, DbEnvConfig>;
88
90
  kubectl?: {
91
+ /** Optional kubeconfig path. Supports ${ENV_VAR} templates. */
92
+ kubeconfig?: string;
89
93
  context: string;
90
94
  namespace: string;
91
95
  service?: string;
@@ -5,6 +5,7 @@ import type { DbConfig, QueryResult, SchemaMode } from "./types";
5
5
 
6
6
  import { ConfigService } from "#config";
7
7
  import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
8
+ import { resolveEnvTemplate } from "#shared/env-template";
8
9
  import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
9
10
  import { DbConfigService, DbConfigServiceLayer, TUNNEL_CHECK_INTERVAL_MS } from "./config-service";
10
11
  import {
@@ -75,6 +76,7 @@ export class DbService extends Context.Service<
75
76
  };
76
77
  }
77
78
 
79
+ const kubectlKubeconfig = dbConfig.kubectl?.kubeconfig;
78
80
  const kubectlContext = dbConfig.kubectl?.context;
79
81
  const kubectlNamespace = dbConfig.kubectl?.namespace;
80
82
  const kubectlService = dbConfig.kubectl?.service ?? "postgresql";
@@ -262,6 +264,24 @@ export class DbService extends Context.Service<
262
264
  }
263
265
  });
264
266
 
267
+ const resolveKubeconfig = Effect.fn("DbService.resolveKubeconfig")(function* (
268
+ port: number,
269
+ ) {
270
+ if (!kubectlKubeconfig) {
271
+ return undefined;
272
+ }
273
+
274
+ return yield* resolveEnvTemplate(kubectlKubeconfig).pipe(
275
+ Effect.mapError(
276
+ ({ envVar }) =>
277
+ new DbTunnelError({
278
+ message: `Environment variable ${envVar} (required for kubeconfig) is not set.`,
279
+ port,
280
+ }),
281
+ ),
282
+ );
283
+ });
284
+
265
285
  const startTunnelProcess = (config: DbConfig) =>
266
286
  Effect.gen(function* () {
267
287
  if (!kubectlContext || !kubectlNamespace) {
@@ -274,10 +294,14 @@ export class DbService extends Context.Service<
274
294
  );
275
295
  }
276
296
 
297
+ const kubeconfig = yield* resolveKubeconfig(config.port);
298
+ const kubeconfigArgs = kubeconfig ? ["--kubeconfig", kubeconfig] : [];
299
+
277
300
  const proc = yield* executor.spawn(
278
301
  ChildProcess.make(
279
302
  "kubectl",
280
303
  [
304
+ ...kubeconfigArgs,
281
305
  "port-forward",
282
306
  "--context",
283
307
  kubectlContext,
@@ -11,7 +11,8 @@ import {
11
11
  } from "./errors";
12
12
  import { ConfigService, getToolConfig } from "#config";
13
13
  import type { K8sConfig } from "#config";
14
- import { collectProcessOutput } from "#shared/exec";
14
+ import { collectProcessOutput, quoteShellArg } from "#shared/exec";
15
+ import { resolveEnvTemplate } from "#shared/env-template";
15
16
  import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
16
17
  import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
17
18
  import { isKubectlCommandAllowed } from "./security";
@@ -63,6 +64,28 @@ export class K8sService extends Context.Service<
63
64
  return k8sConfig;
64
65
  });
65
66
 
67
+ const resolveKubeconfig = Effect.fn("K8sService.resolveKubeconfig")(function* (
68
+ k8sConfig: K8sConfig,
69
+ ) {
70
+ const kubeconfig = k8sConfig.kubeconfig;
71
+ if (!kubeconfig) {
72
+ return undefined;
73
+ }
74
+
75
+ return yield* resolveEnvTemplate(kubeconfig).pipe(
76
+ Effect.mapError(
77
+ ({ envVar }) =>
78
+ new K8sContextError({
79
+ message: `Environment variable ${envVar} (required for kubeconfig) is not set.`,
80
+ clusterId: k8sConfig.clusterId,
81
+ }),
82
+ ),
83
+ );
84
+ });
85
+
86
+ const withKubeconfig = (command: string, kubeconfig: string | undefined) =>
87
+ kubeconfig ? `KUBECONFIG=${quoteShellArg(kubeconfig)} ${command}` : command;
88
+
66
89
  // Cache context by selected profile/cluster instead of a single default profile.
67
90
  const contextRef = yield* Ref.make<Record<string, string>>({});
68
91
 
@@ -128,14 +151,18 @@ export class K8sService extends Context.Service<
128
151
  k8sConfig: K8sConfig,
129
152
  ) {
130
153
  const timeoutMs = k8sConfig.timeoutMs ?? 60000;
131
- const cacheKey = profile ?? `cluster:${k8sConfig.clusterId}`;
154
+ const kubeconfig = yield* resolveKubeconfig(k8sConfig);
155
+ const cacheKey = profile ?? `cluster:${k8sConfig.clusterId}:${kubeconfig ?? "default"}`;
132
156
  const cached = yield* Ref.get(contextRef);
133
157
  const cachedContext = cached[cacheKey];
134
158
  if (cachedContext !== undefined) {
135
- return cachedContext;
159
+ return { context: cachedContext, kubeconfig };
136
160
  }
137
161
 
138
- const jqCommand = `kubectl config view -o json | jq -r '.contexts[] | select(.context.cluster == "${k8sConfig.clusterId}") | .name' | head -1`;
162
+ const jqCommand = withKubeconfig(
163
+ `kubectl config view -o json | jq -r '.contexts[] | select(.context.cluster == "${k8sConfig.clusterId}") | .name' | head -1`,
164
+ kubeconfig,
165
+ );
139
166
 
140
167
  const contextResultOption = yield* runShellCommand(jqCommand, timeoutMs);
141
168
 
@@ -155,10 +182,13 @@ export class K8sService extends Context.Service<
155
182
  ...contexts,
156
183
  [cacheKey]: resolvedContextValue,
157
184
  }));
158
- return resolvedContextValue;
185
+ return { context: resolvedContextValue, kubeconfig };
159
186
  }
160
187
 
161
- 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`;
188
+ const fallbackCommand = withKubeconfig(
189
+ `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`,
190
+ kubeconfig,
191
+ );
162
192
 
163
193
  const fallbackResultOption = yield* runShellCommand(fallbackCommand, timeoutMs);
164
194
 
@@ -178,7 +208,7 @@ export class K8sService extends Context.Service<
178
208
  ...contexts,
179
209
  [cacheKey]: resolvedContextValue,
180
210
  }));
181
- return resolvedContextValue;
211
+ return { context: resolvedContextValue, kubeconfig };
182
212
  }
183
213
 
184
214
  return yield* new K8sContextError({
@@ -198,8 +228,8 @@ export class K8sService extends Context.Service<
198
228
  k8sConfig,
199
229
  runPrerequisiteCommand,
200
230
  Effect.gen(function* () {
201
- const context = yield* resolveContext(profile, k8sConfig);
202
- const fullCommand = `kubectl --context ${context} ${cmd}`;
231
+ const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
232
+ const fullCommand = withKubeconfig(`kubectl --context ${context} ${cmd}`, kubeconfig);
203
233
 
204
234
  const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
205
235
 
@@ -282,8 +312,8 @@ export class K8sService extends Context.Service<
282
312
  const startTime = Date.now();
283
313
  if (dryRun) {
284
314
  const k8sConfig = yield* requireK8sConfig(profile);
285
- const context = yield* resolveContext(profile, k8sConfig);
286
- const fullCommand = `kubectl --context ${context} ${cmd}`;
315
+ const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
316
+ const fullCommand = withKubeconfig(`kubectl --context ${context} ${cmd}`, kubeconfig);
287
317
  return {
288
318
  success: true,
289
319
  command: fullCommand,
@@ -10,6 +10,7 @@ import { AuditServiceLayer, withAudit } from "#shared/audit";
10
10
  import { VERSION } from "#shared";
11
11
 
12
12
  import { metricsCommand } from "./metrics";
13
+ import { logsCommand } from "./logs";
13
14
  import { traceCommand } from "./trace";
14
15
 
15
16
  const renderCauseToStderr = (cause: Cause.Cause<unknown>) => Console.error(cause.toString());
@@ -18,7 +19,7 @@ const mainCommand = Command.make("observability-tool", {}).pipe(
18
19
  Command.withDescription(
19
20
  "LGTM observability queries — Tempo traces, Loki logs, Prometheus metrics",
20
21
  ),
21
- Command.withSubcommands([traceCommand, metricsCommand]),
22
+ Command.withSubcommands([traceCommand, metricsCommand, logsCommand]),
22
23
  );
23
24
 
24
25
  const cli = Command.run(mainCommand, { version: VERSION });
@@ -0,0 +1,181 @@
1
+ import { Console, Effect } from "effect";
2
+ import { Argument, Command, Flag } from "effect/unstable/cli";
3
+
4
+ import { formatOption, formatOutput } from "#shared";
5
+
6
+ import { ObservabilityToolError } from "./errors";
7
+ import {
8
+ envOption,
9
+ formatObservabilityError,
10
+ observabilityDsQuery,
11
+ profileOption,
12
+ resolveConfig,
13
+ } from "./shared";
14
+
15
+ import type { LogLine, StructuredLogLine } from "./types";
16
+
17
+ function isStringRecord(value: unknown): value is Record<string, string> {
18
+ return (
19
+ typeof value === "object" &&
20
+ value !== null &&
21
+ !Array.isArray(value) &&
22
+ Object.values(value).every((item) => typeof item === "string")
23
+ );
24
+ }
25
+
26
+ function parseLabel(value: string | Record<string, string>): Record<string, string> {
27
+ if (isStringRecord(value)) {
28
+ return value;
29
+ }
30
+
31
+ try {
32
+ const parsed = JSON.parse(value) as unknown;
33
+ return isStringRecord(parsed) ? parsed : {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function parseStructuredLogLine(line: string): StructuredLogLine {
40
+ try {
41
+ const parsed = JSON.parse(line) as unknown;
42
+ if (typeof parsed !== "object" || parsed === null) {
43
+ return {};
44
+ }
45
+
46
+ const record = parsed as Record<string, unknown>;
47
+ return {
48
+ body: typeof record.body === "string" ? record.body : undefined,
49
+ severity: typeof record.severity === "string" ? record.severity : undefined,
50
+ attributes:
51
+ typeof record.attributes === "object" && record.attributes !== null
52
+ ? (record.attributes as Record<string, unknown>)
53
+ : undefined,
54
+ };
55
+ } catch {
56
+ return {};
57
+ }
58
+ }
59
+
60
+ export function extractLogsFromDsQuery(response: {
61
+ results: {
62
+ A: {
63
+ frames?: Array<{
64
+ schema: { fields: Array<{ name: string; type?: string; labels?: Record<string, string> }> };
65
+ data: { values: unknown[][] };
66
+ }>;
67
+ };
68
+ };
69
+ }): LogLine[] {
70
+ const logs: LogLine[] = [];
71
+
72
+ for (const frame of response.results.A.frames ?? []) {
73
+ const fields = frame.schema.fields;
74
+ const values = frame.data.values;
75
+ const timeIndex = fields.findIndex(
76
+ (field) => field.name === "timestamp" || field.name === "Time" || field.type === "time",
77
+ );
78
+ const lineIndex = fields.findIndex(
79
+ (field) => field.name === "body" || field.name === "Line" || field.name === "line",
80
+ );
81
+ const labelsIndex = fields.findIndex((field) => field.name === "labels");
82
+
83
+ const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
84
+ const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
85
+ const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
86
+ string | Record<string, string>
87
+ >;
88
+ const streamLabels = lineIndex >= 0 ? fields[lineIndex]?.labels : undefined;
89
+
90
+ for (const [index, line] of lines.entries()) {
91
+ const structured = parseStructuredLogLine(line);
92
+ logs.push({
93
+ timestamp: String(timestamps[index] ?? ""),
94
+ line,
95
+ ...structured,
96
+ labels: labelValues[index] ? parseLabel(labelValues[index]) : (streamLabels ?? {}),
97
+ });
98
+ }
99
+ }
100
+
101
+ return logs;
102
+ }
103
+
104
+ const queryCommand = Command.make(
105
+ "query",
106
+ {
107
+ logql: Argument.string("logql"),
108
+ format: formatOption,
109
+ env: envOption,
110
+ profile: profileOption,
111
+ start: Flag.string("start").pipe(
112
+ Flag.withDescription("Start time (default: now-1h)"),
113
+ Flag.withDefault("now-1h"),
114
+ ),
115
+ end: Flag.string("end").pipe(
116
+ Flag.withDescription("End time (default: now)"),
117
+ Flag.withDefault("now"),
118
+ ),
119
+ limit: Flag.integer("limit").pipe(
120
+ Flag.withDescription("Max log lines (default: 100)"),
121
+ Flag.withDefault(100),
122
+ ),
123
+ },
124
+ ({ logql, format, env, profile, start, end, limit }) => {
125
+ const startedAt = Date.now();
126
+
127
+ return Effect.gen(function* () {
128
+ const config = yield* resolveConfig(env, profile);
129
+ const response = yield* observabilityDsQuery(config, config.lokiUid, "loki", logql, {
130
+ from: start,
131
+ to: end,
132
+ maxLines: limit,
133
+ });
134
+
135
+ if (response.results.A.error) {
136
+ return yield* new ObservabilityToolError({ cause: new Error(response.results.A.error) });
137
+ }
138
+
139
+ const logs = extractLogsFromDsQuery(response).toSorted((left, right) =>
140
+ right.timestamp.localeCompare(left.timestamp),
141
+ );
142
+
143
+ const result = {
144
+ success: true,
145
+ message: `Found ${logs.length} Loki log line(s)`,
146
+ data: {
147
+ environment: env,
148
+ grafanaUrl: config.url,
149
+ lokiDatasourceUid: config.lokiUid,
150
+ query: logql,
151
+ start,
152
+ end,
153
+ limit,
154
+ logCount: logs.length,
155
+ logs,
156
+ },
157
+ executionTimeMs: Date.now() - startedAt,
158
+ };
159
+
160
+ yield* Console.log(formatOutput(result, format));
161
+ }).pipe(
162
+ Effect.catchTag("ObservabilityToolError", (error) =>
163
+ Effect.gen(function* () {
164
+ const result = {
165
+ success: false,
166
+ message: "Failed to execute LogQL query",
167
+ error: formatObservabilityError(error),
168
+ hint: "Check LogQL syntax and Grafana/Loki connectivity",
169
+ executionTimeMs: Date.now() - startedAt,
170
+ };
171
+ yield* Console.log(formatOutput(result, format));
172
+ }),
173
+ ),
174
+ );
175
+ },
176
+ ).pipe(Command.withDescription("Execute LogQL range query via Grafana/Loki"));
177
+
178
+ export const logsCommand = Command.make("logs", {}).pipe(
179
+ Command.withDescription("Loki log operations via Grafana"),
180
+ Command.withSubcommands([queryCommand]),
181
+ );
@@ -13,6 +13,7 @@ import {
13
13
  profileOption,
14
14
  resolveConfig,
15
15
  } from "./shared";
16
+ import { extractLogsFromDsQuery } from "./logs";
16
17
  import type {
17
18
  FlattenedSpan,
18
19
  ObservabilityEnvConfig,
@@ -221,18 +222,6 @@ function summarizeTrace(traceId: string, spans: readonly FlattenedSpan[]): Trace
221
222
  };
222
223
  }
223
224
 
224
- function parseLabel(value: string | Record<string, string>): Record<string, string> {
225
- if (typeof value === "object" && value !== null) {
226
- return value;
227
- }
228
-
229
- try {
230
- return JSON.parse(value) as Record<string, string>;
231
- } catch {
232
- return {};
233
- }
234
- }
235
-
236
225
  type ResolvedTrace = {
237
226
  readonly resolution: SpanResolution;
238
227
  readonly spans: FlattenedSpan[];
@@ -482,34 +471,7 @@ const logsCommand = Command.make(
482
471
  });
483
472
  }
484
473
 
485
- const logs: Array<{ timestamp: string; line: string; labels: Record<string, string> }> = [];
486
- for (const frame of response.results.A.frames ?? []) {
487
- const fields = frame.schema.fields;
488
- const values = frame.data.values;
489
- const timeIndex = fields.findIndex(
490
- (field) => field.name === "timestamp" || field.type === "time",
491
- );
492
- const lineIndex = fields.findIndex(
493
- (field) => field.name === "body" || field.name === "Line" || field.name === "line",
494
- );
495
- const labelsIndex = fields.findIndex(
496
- (field) => field.name === "labels" || field.name === "labelTypes",
497
- );
498
-
499
- const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
500
- const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
501
- const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
502
- string | Record<string, string>
503
- >;
504
-
505
- for (const [index, line] of lines.entries()) {
506
- logs.push({
507
- timestamp: String(timestamps[index] ?? ""),
508
- line,
509
- labels: labelValues[index] ? parseLabel(labelValues[index]) : {},
510
- });
511
- }
512
- }
474
+ const logs = extractLogsFromDsQuery(response);
513
475
 
514
476
  const result = {
515
477
  success: true,
@@ -8,6 +8,21 @@ export type ObservabilityEnvConfig = {
8
8
  tempoUid: string;
9
9
  };
10
10
 
11
+ export type LogLine = {
12
+ readonly timestamp: string;
13
+ readonly line: string;
14
+ readonly body?: string;
15
+ readonly severity?: string;
16
+ readonly attributes?: Record<string, unknown>;
17
+ readonly labels: Record<string, string>;
18
+ };
19
+
20
+ export type StructuredLogLine = {
21
+ readonly body?: string;
22
+ readonly severity?: string;
23
+ readonly attributes?: Record<string, unknown>;
24
+ };
25
+
11
26
  export type GrafanaDatasource = {
12
27
  uid: string;
13
28
  name: string;
@@ -0,0 +1,38 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ const envTemplateRegex = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
4
+
5
+ export class EnvTemplateError extends Schema.TaggedErrorClass<EnvTemplateError>()(
6
+ "@agent-tools/EnvTemplateError",
7
+ { envVar: Schema.String },
8
+ ) {}
9
+
10
+ export const resolveEnvTemplate = Effect.fn("resolveEnvTemplate")(function* (value: string) {
11
+ let resolved = "";
12
+ let lastIndex = 0;
13
+ const env = globalThis.Bun?.env ?? process.env;
14
+
15
+ for (const match of value.matchAll(envTemplateRegex)) {
16
+ const fullMatch = match[0];
17
+ const envVar = match[1];
18
+ const index = match.index;
19
+ if (index === undefined) {
20
+ continue;
21
+ }
22
+
23
+ resolved += value.slice(lastIndex, index);
24
+ const fromEnv = env[envVar];
25
+ if (fromEnv === undefined) {
26
+ return yield* new EnvTemplateError({ envVar });
27
+ }
28
+
29
+ resolved += fromEnv;
30
+ lastIndex = index + fullMatch.length;
31
+ }
32
+
33
+ if (lastIndex === 0) {
34
+ return value;
35
+ }
36
+
37
+ return resolved + value.slice(lastIndex);
38
+ });
@@ -67,3 +67,5 @@ export const execEffect = (
67
67
  ),
68
68
  ),
69
69
  );
70
+
71
+ export const quoteShellArg = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;