@blogic-cz/agent-tools 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -4,6 +4,7 @@ import type { BuildJob, BuildLogs, BuildTimeline, JobSummary, PipelineRun } from
4
4
 
5
5
  import { AzParseError } from "./errors";
6
6
  import { AzService } from "./service";
7
+ import { transformBuildLogContent, transformTimeline } from "./transformers";
7
8
 
8
9
  /**
9
10
  * Get build timeline with all records (jobs, stages, tasks, etc.)
@@ -59,7 +60,10 @@ export const getBuildTimeline = Effect.fn("Build.getBuildTimeline")(function* (b
59
60
  ),
60
61
  );
61
62
 
62
- return parsed as BuildTimeline;
63
+ return {
64
+ ...parsed,
65
+ records: transformTimeline(parsed.records as BuildJob[]),
66
+ } as BuildTimeline;
63
67
  });
64
68
 
65
69
  /**
@@ -151,14 +155,14 @@ export const getBuildLogContent = Effect.fn("Build.getBuildLogContent")(function
151
155
  );
152
156
 
153
157
  if (typeof parsed === "string") {
154
- return parsed;
158
+ return transformBuildLogContent(parsed);
155
159
  }
156
160
 
157
161
  if (!Array.isArray(parsed.value)) {
158
162
  return "";
159
163
  }
160
164
 
161
- return parsed.value.join("\n");
165
+ return transformBuildLogContent(parsed.value.join("\n"));
162
166
  });
163
167
 
164
168
  /**
@@ -7,6 +7,7 @@ import type { AzureConfig } from "#config/types";
7
7
  import { DIRECT_AZ_COMMANDS, STANDALONE_AZ_COMMANDS } from "./config";
8
8
  import { AzSecurityError, AzCommandError, AzTimeoutError, AzParseError } from "./errors";
9
9
  import { isCommandAllowed, isInvokeAllowed } from "./security";
10
+ import { transformCmdOutput } from "./transformers";
10
11
  import { ConfigService, getToolConfig } from "#config";
11
12
 
12
13
  export class AzService extends ServiceMap.Service<
@@ -144,7 +145,14 @@ export class AzService extends ServiceMap.Service<
144
145
  });
145
146
  }
146
147
 
147
- return result.stdout;
148
+ const output = result.stdout.trim();
149
+ const transformed = transformCmdOutput(output);
150
+
151
+ if (typeof transformed === "string") {
152
+ return transformed;
153
+ }
154
+
155
+ return JSON.stringify(transformed);
148
156
  });
149
157
 
150
158
  const runInvoke = Effect.fn("AzService.runInvoke")(function* (params: InvokeParams) {
@@ -0,0 +1,127 @@
1
+ import { deduplicateLines, parseTextTable } from "#shared";
2
+
3
+ import type { BuildJob } from "./types";
4
+
5
+ type ParsedTable = {
6
+ headers: string[];
7
+ rows: Record<string, string>[];
8
+ };
9
+
10
+ const TIMESTAMP_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/;
11
+
12
+ const TIMESTAMP_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+$/;
13
+
14
+ const ERROR_MARKER_PATTERN = /(##\[error\]|error:|Error:|ERROR|FAILED)/;
15
+
16
+ function stripTimestampPrefix(line: string): string {
17
+ return line.replace(TIMESTAMP_PREFIX_PATTERN, "");
18
+ }
19
+
20
+ function isNoiseLine(line: string): boolean {
21
+ const trimmed = stripTimestampPrefix(line).trimStart();
22
+
23
+ if (trimmed.startsWith("##[section]")) {
24
+ return true;
25
+ }
26
+
27
+ if (trimmed.startsWith("##[debug]")) {
28
+ return true;
29
+ }
30
+
31
+ if (/^Downloading\b/i.test(trimmed)) {
32
+ return true;
33
+ }
34
+
35
+ return TIMESTAMP_ONLY_PATTERN.test(line);
36
+ }
37
+
38
+ function looksLikeTextTable(text: string): boolean {
39
+ const firstLine = text.split(/\r?\n/, 1)[0]?.trimEnd() ?? "";
40
+
41
+ if (firstLine.length === 0) {
42
+ return false;
43
+ }
44
+
45
+ return /^[A-Z][A-Z0-9_ -]*(\s{2,}[A-Z][A-Z0-9_ -]*)+$/.test(firstLine);
46
+ }
47
+
48
+ export function transformBuildLogContent(rawLog: string): string {
49
+ if (rawLog.length === 0) {
50
+ return "";
51
+ }
52
+
53
+ const hadTrailingNewline = rawLog.endsWith("\n");
54
+
55
+ const deduplicated = deduplicateLines(rawLog, {
56
+ normalizeTimestamps: true,
57
+ normalizeUUIDs: true,
58
+ });
59
+
60
+ const keptLines = deduplicated
61
+ .split(/\r?\n/)
62
+ .map((line) => line.trimEnd())
63
+ .filter((line) => line.length > 0)
64
+ .filter((line) => !isNoiseLine(line));
65
+
66
+ const errorLines: string[] = [];
67
+ const nonErrorLines: string[] = [];
68
+
69
+ for (const line of keptLines) {
70
+ if (ERROR_MARKER_PATTERN.test(line)) {
71
+ errorLines.push(line);
72
+ } else {
73
+ nonErrorLines.push(line);
74
+ }
75
+ }
76
+
77
+ const transformed = [...errorLines, ...nonErrorLines].join("\n");
78
+
79
+ if (hadTrailingNewline && transformed.length > 0) {
80
+ return `${transformed}\n`;
81
+ }
82
+
83
+ return transformed;
84
+ }
85
+
86
+ export function transformCmdOutput(
87
+ rawOutput: string,
88
+ ): string | Record<string, unknown> | unknown[] | ParsedTable {
89
+ if (rawOutput.length === 0) {
90
+ return "";
91
+ }
92
+
93
+ const trimmed = rawOutput.trim();
94
+
95
+ const parsedJson = (() => {
96
+ try {
97
+ return JSON.parse(trimmed) as unknown;
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ })();
102
+
103
+ if (typeof parsedJson === "object" && parsedJson !== null) {
104
+ return parsedJson as Record<string, unknown> | unknown[];
105
+ }
106
+
107
+ if (looksLikeTextTable(trimmed)) {
108
+ return parseTextTable(trimmed);
109
+ }
110
+
111
+ const lineCount = trimmed.split(/\r?\n/).length;
112
+ if (lineCount > 50) {
113
+ return deduplicateLines(trimmed);
114
+ }
115
+
116
+ return trimmed;
117
+ }
118
+
119
+ export function transformTimeline(records: BuildJob[]): BuildJob[] {
120
+ return records.filter((record) => {
121
+ if (record.type === "Stage" || record.type === "Job") {
122
+ return true;
123
+ }
124
+
125
+ return (record.errorCount ?? 0) > 0 || (record.warningCount ?? 0) > 0;
126
+ });
127
+ }
@@ -392,8 +392,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
392
392
  `\u{1F6AB} Access blocked: "${filePath}" is a sensitive file.\n\n` +
393
393
  `This file may contain credentials or secrets.\n` +
394
394
  `If you need this file's content, ask the user to provide relevant parts.\n\n` +
395
- `Think this should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the guard, and submit a PR.\n` +
396
- `→ Skill "agent-tools"`,
395
+ `Think this should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the guard, and submit a PR.`,
397
396
  );
398
397
  }
399
398
  }
@@ -409,8 +408,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
409
408
  `\u{1F6AB} Secret detected: Potential ${detected.name} found in content.\n\n` +
410
409
  `Matched: ${detected.match}\n\n` +
411
410
  `Never commit secrets to code. Use environment variables or secret managers.\n\n` +
412
- `Think this is a false positive? See https://github.com/blogic-cz/agent-tools — fork, fix the pattern, and submit a PR.\n` +
413
- `→ Skill "agent-tools"`,
411
+ `Think this is a false positive? See https://github.com/blogic-cz/agent-tools — fork, fix the pattern, and submit a PR.`,
414
412
  );
415
413
  }
416
414
  }
@@ -425,8 +423,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
425
423
  `\u{1F6AB} Command blocked: This command might expose secrets.\n\n` +
426
424
  `Command: ${command}\n\n` +
427
425
  `If you need environment info, ask the user directly.\n\n` +
428
- `Think this is wrong? See https://github.com/blogic-cz/agent-tools — fork, adjust the patterns, and submit a PR.\n` +
429
- `→ Skill "agent-tools"`,
426
+ `Think this is wrong? See https://github.com/blogic-cz/agent-tools — fork, adjust the patterns, and submit a PR.`,
430
427
  );
431
428
  }
432
429
 
@@ -436,20 +433,20 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
436
433
  `\u{26A0}\u{FE0F} Sleep-polling detected.\n\n` +
437
434
  `Instead of polling with sleep, use the built-in watch command:\n\n` +
438
435
  `Use instead: ${sleepSuggestion}\n\n` +
439
- `Watch commands block until completion — no polling needed.\n\n` +
440
- `→ Skill "agent-tools"`,
436
+ `Watch commands block until completion — no polling needed.`,
441
437
  );
442
438
  }
443
439
 
444
440
  const blockedTool = getBlockedCliTool(command);
445
441
  if (blockedTool) {
442
+ const skillName = blockedTool.wrapper.replace("agent-tools-", "") + "-tool";
446
443
  throw new Error(
447
444
  `\u{1F6AB} Direct ${blockedTool.name} usage blocked.\n\n` +
448
445
  `AI agents must use wrapper tools for security and audit.\n\n` +
449
- `Use instead: bun ${blockedTool.wrapper}\n\n` +
450
- `Example: bun ${blockedTool.wrapper} --help\n\n` +
446
+ `Use instead: bun ${skillName}\n\n` +
447
+ `Example: bun ${skillName} --help\n\n` +
451
448
  `Think this tool should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the whitelist, and submit a PR.\n` +
452
- `→ Skill "agent-tools"`,
449
+ `→ Skill "${skillName}"`,
453
450
  );
454
451
  }
455
452
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from "./errors";
15
15
  import { getColumns, getRelationships, getTableNames } from "./schema";
16
16
  import { detectSchemaError, isValidTableName, isMutationQuery } from "./security";
17
+ import { transformQueryResult } from "./transformers";
17
18
 
18
19
  const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1"]);
19
20
 
@@ -323,6 +324,7 @@ export class DbService extends ServiceMap.Service<
323
324
  sql: string,
324
325
  password: string,
325
326
  startTimeMs: number,
327
+ applyTransform = false,
326
328
  ) {
327
329
  const wrappedSql = `SELECT json_agg(t) FROM (${sql}) t;`;
328
330
  const command = buildPsqlCommand(config, wrappedSql, password, true);
@@ -376,7 +378,7 @@ export class DbService extends ServiceMap.Service<
376
378
  };
377
379
  }
378
380
 
379
- const data = yield* Effect.try({
381
+ const rawData = yield* Effect.try({
380
382
  try: () => JSON.parse(trimmedOutput) as Record<string, unknown>[],
381
383
  catch: () =>
382
384
  new DbParseError({
@@ -385,11 +387,21 @@ export class DbService extends ServiceMap.Service<
385
387
  }),
386
388
  });
387
389
 
390
+ const transformed = applyTransform
391
+ ? transformQueryResult(rawData)
392
+ : {
393
+ data: rawData,
394
+ showing: rawData.length,
395
+ truncated: false,
396
+ total: rawData.length,
397
+ };
398
+
388
399
  return {
389
400
  success: true,
390
- data,
391
- rowCount: data.length,
401
+ data: transformed.data,
402
+ rowCount: transformed.showing,
392
403
  executionTimeMs: Number(endTime) - startTimeMs,
404
+ ...(transformed.truncated ? { truncated: true, total: transformed.total } : {}),
393
405
  };
394
406
  });
395
407
 
@@ -558,7 +570,7 @@ export class DbService extends ServiceMap.Service<
558
570
 
559
571
  const queryEffect = mutation
560
572
  ? executeMutationQuery(config, sql, password, Number(startTimeMs))
561
- : executeSelectQuery(config, sql, password, Number(startTimeMs));
573
+ : executeSelectQuery(config, sql, password, Number(startTimeMs), true);
562
574
 
563
575
  return yield* runQueryWithOptionalTunnel(config, queryEffect);
564
576
  });
@@ -0,0 +1,38 @@
1
+ import { stripEmptyColumns, truncateRows } from "#shared";
2
+
3
+ export const DEFAULT_ROW_LIMIT = 50;
4
+ export const MAX_VALUE_LENGTH = 200;
5
+
6
+ export type TransformResult = {
7
+ data: Record<string, unknown>[];
8
+ truncated: boolean;
9
+ total: number;
10
+ showing: number;
11
+ };
12
+
13
+ function truncateValue(value: unknown): unknown {
14
+ if (typeof value !== "string") {
15
+ return value;
16
+ }
17
+
18
+ if (value.length <= MAX_VALUE_LENGTH) {
19
+ return value;
20
+ }
21
+
22
+ return `${value.slice(0, MAX_VALUE_LENGTH)}...`;
23
+ }
24
+
25
+ export function transformQueryResult(data: Record<string, unknown>[]): TransformResult {
26
+ const withoutEmptyColumns = stripEmptyColumns(data);
27
+ const truncatedValues = withoutEmptyColumns.map((record) =>
28
+ Object.fromEntries(Object.entries(record).map(([key, value]) => [key, truncateValue(value)])),
29
+ );
30
+ const { rows, truncated, total, showing } = truncateRows(truncatedValues, DEFAULT_ROW_LIMIT);
31
+
32
+ return {
33
+ data: rows,
34
+ truncated,
35
+ total,
36
+ showing,
37
+ };
38
+ }
@@ -25,6 +25,8 @@ export type QueryResult = {
25
25
  availableColumns?: string[];
26
26
  hint?: string;
27
27
  schemaFile?: string;
28
+ truncated?: boolean;
29
+ total?: number;
28
30
  };
29
31
 
30
32
  export type SchemaErrorInfo = {
@@ -11,6 +11,13 @@ import { K8sService, K8sServiceLayer } from "./service";
11
11
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment, getToolConfig } from "#config";
12
12
  import type { K8sConfig } from "#config";
13
13
  import { K8sContextError } from "./errors";
14
+ import {
15
+ transformDescribe,
16
+ transformGenericKubectl,
17
+ transformLogs,
18
+ transformPods,
19
+ transformTop,
20
+ } from "./transformers";
14
21
 
15
22
  /**
16
23
  * Resolve environment from explicit --env flag, config defaultEnvironment, or fail with hint.
@@ -54,7 +61,7 @@ type CommonK8sCommandOptions = {
54
61
  readonly profile: Option.Option<string>;
55
62
  };
56
63
 
57
- const runK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
64
+ const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
58
65
  Effect.gen(function* () {
59
66
  const config = yield* ConfigService;
60
67
  const profileName = Option.getOrUndefined(options.profile);
@@ -69,8 +76,7 @@ const runK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
69
76
  "echo '{ kubernetes: { default: { clusterId: \"my-cluster\" } } }' > agent-tools.json5",
70
77
  executionTimeMs: 0,
71
78
  };
72
- yield* Console.log(formatOutput(result, options.format));
73
- return;
79
+ return result;
74
80
  }
75
81
 
76
82
  const resolvedEnv = yield* resolveEnv(options.env, config);
@@ -126,7 +132,24 @@ const runK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
126
132
  }),
127
133
  );
128
134
 
129
- yield* Console.log(formatOutput({ ...result, environment: resolvedEnv }, options.format));
135
+ return { ...result, environment: resolvedEnv };
136
+ });
137
+
138
+ const runK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
139
+ Effect.gen(function* () {
140
+ const result = yield* executeK8sCommand(command, options);
141
+ yield* Console.log(formatOutput(result, options.format));
142
+ });
143
+
144
+ const logTransformedResult = (
145
+ result: CommandResult,
146
+ format: CommonK8sCommandOptions["format"],
147
+ transform: (output: string) => string | Record<string, unknown>,
148
+ ) =>
149
+ Effect.gen(function* () {
150
+ const transformedResult =
151
+ typeof result.output === "string" ? { ...result, output: transform(result.output) } : result;
152
+ yield* Console.log(formatOutput(transformedResult, format));
130
153
  });
131
154
 
132
155
  const commonFlags = {
@@ -178,7 +201,13 @@ const kubectlCommand = Command.make(
178
201
  Flag.withDescription('kubectl command (without "kubectl" prefix)'),
179
202
  ),
180
203
  },
181
- ({ cmd, dryRun, env, format, profile }) => runK8sCommand(cmd, { dryRun, env, format, profile }),
204
+ ({ cmd, dryRun, env, format, profile }) =>
205
+ Effect.gen(function* () {
206
+ const result = yield* executeK8sCommand(cmd, { dryRun, env, format, profile });
207
+ return yield* logTransformedResult(result, format, (output) =>
208
+ transformGenericKubectl(output, cmd),
209
+ );
210
+ }),
182
211
  ).pipe(
183
212
  Command.withDescription(
184
213
  `Kubernetes CLI Tool for Coding Agents
@@ -247,18 +276,18 @@ const podsCommand = Command.make(
247
276
  Flag.withDefault(false),
248
277
  ),
249
278
  },
250
- ({ dryRun, env, format, label, namespace, profile, wide }) =>
279
+ ({ dryRun, env, format, label, namespace, profile }) =>
251
280
  Effect.gen(function* () {
252
281
  const resolvedNamespace = yield* resolveStructuredNamespace(namespace, env, profile);
253
- const command = buildKubectlCommand("get pods", [
282
+ const command = buildKubectlCommand("get pods -o json", [
254
283
  Option.match(label, {
255
284
  onNone: () => "",
256
285
  onSome: (value) => `-l ${value}`,
257
286
  }),
258
287
  resolvedNamespace ? `-n ${resolvedNamespace}` : "",
259
- wide ? "-o wide" : "",
260
288
  ]);
261
- return yield* runK8sCommand(command, { dryRun, env, format, profile });
289
+ const result = yield* executeK8sCommand(command, { dryRun, env, format, profile });
290
+ return yield* logTransformedResult(result, format, transformPods);
262
291
  }),
263
292
  ).pipe(Command.withDescription("List pods (get pods) with optional namespace/label/wide output"));
264
293
 
@@ -297,7 +326,8 @@ const logsCommand = Command.make(
297
326
  }),
298
327
  follow ? "-f" : "",
299
328
  ]);
300
- return yield* runK8sCommand(command, { dryRun, env, format, profile });
329
+ const result = yield* executeK8sCommand(command, { dryRun, env, format, profile });
330
+ return yield* logTransformedResult(result, format, transformLogs);
301
331
  }),
302
332
  ).pipe(Command.withDescription("Fetch pod logs with tail/follow/container selectors"));
303
333
 
@@ -320,7 +350,8 @@ const describeCommand = Command.make(
320
350
  const command = buildKubectlCommand(`describe ${resource} ${name}`, [
321
351
  resolvedNamespace ? `-n ${resolvedNamespace}` : "",
322
352
  ]);
323
- return yield* runK8sCommand(command, { dryRun, env, format, profile });
353
+ const result = yield* executeK8sCommand(command, { dryRun, env, format, profile });
354
+ return yield* logTransformedResult(result, format, transformDescribe);
324
355
  }),
325
356
  ).pipe(Command.withDescription("Describe a Kubernetes resource by type and name"));
326
357
 
@@ -379,7 +410,8 @@ const topCommand = Command.make(
379
410
  onSome: (value) => `--sort-by=${value}`,
380
411
  }),
381
412
  ]);
382
- return yield* runK8sCommand(command, { dryRun, env, format, profile });
413
+ const result = yield* executeK8sCommand(command, { dryRun, env, format, profile });
414
+ return yield* logTransformedResult(result, format, transformTop);
383
415
  }),
384
416
  ).pipe(Command.withDescription("Show pod CPU/memory usage (kubectl top pod)"));
385
417