@blogic-cz/agent-tools 0.14.45 → 0.14.47

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
@@ -312,7 +312,19 @@ All settings are optional — audit works out of the box with sensible defaults.
312
312
 
313
313
  ## Configuration
314
314
 
315
- Config is loaded from `agent-tools.json5` (or `agent-tools.json`) by walking up from the current working directory. Missing config = zero-config mode (works for `gh-tool`; others require config).
315
+ Config is loaded by walking up from the current working directory to the nearest regular config file:
316
+
317
+ 1. `agent-tools.json`
318
+ 2. `agent-tools.json5`
319
+
320
+ That nearest regular config is the base. Local override files are then merged from that directory down to the current working directory:
321
+
322
+ 1. `agent-tools.local.json`
323
+ 2. `agent-tools.local.json5`
324
+
325
+ Later files override earlier files. Objects are merged deeply; arrays and primitive values are replaced. Missing config = zero-config mode (works for `gh-tool`; others require config).
326
+
327
+ Use `agent-tools.local.json5` for machine-specific ports, paths, and worktree overrides. Keep local files gitignored.
316
328
 
317
329
  ### Global Settings
318
330
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.45",
3
+ "version": "0.14.47",
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",
@@ -194,8 +194,12 @@ const AgentToolsConfigSchema = Schema.Struct({
194
194
  github: Schema.optionalKey(Schema.Record(Schema.String, GitHubRepoConfigSchema)),
195
195
  });
196
196
 
197
+ function isRecord(value: unknown): value is Record<string, unknown> {
198
+ return typeof value === "object" && value !== null && !Array.isArray(value);
199
+ }
200
+
197
201
  function stripUnknownTopLevelKeys(parsed: unknown): unknown {
198
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
202
+ if (!isRecord(parsed)) {
199
203
  return parsed;
200
204
  }
201
205
 
@@ -223,40 +227,101 @@ export function decodeConfig(
223
227
  }
224
228
  }
225
229
 
226
- async function findConfigFile(startDirectory: string = process.cwd()): Promise<string | undefined> {
230
+ const BASE_CONFIG_FILES = ["agent-tools.json", "agent-tools.json5"] as const;
231
+ const LOCAL_CONFIG_FILES = ["agent-tools.local.json", "agent-tools.local.json5"] as const;
232
+
233
+ async function existingFile(filePath: string): Promise<string | undefined> {
234
+ return (await Bun.file(filePath).exists()) ? filePath : undefined;
235
+ }
236
+
237
+ async function findBaseConfigDirectory(
238
+ startDirectory: string = process.cwd(),
239
+ ): Promise<string | undefined> {
227
240
  let currentDirectory = startDirectory;
228
241
 
229
242
  while (true) {
230
- const json5Path = `${currentDirectory}/agent-tools.json5`;
231
- // eslint-disable-next-line eslint/no-await-in-loop -- sequential directory walk, each iteration may short-circuit
232
- if (await Bun.file(json5Path).exists()) {
233
- return json5Path;
243
+ for (const fileName of BASE_CONFIG_FILES) {
244
+ // eslint-disable-next-line eslint/no-await-in-loop -- sequential directory walk, each iteration may short-circuit
245
+ if (await Bun.file(`${currentDirectory}/${fileName}`).exists()) {
246
+ return currentDirectory;
247
+ }
234
248
  }
235
249
 
236
- const jsonPath = `${currentDirectory}/agent-tools.json`;
237
- // eslint-disable-next-line eslint/no-await-in-loop -- sequential directory walk, each iteration may short-circuit
238
- if (await Bun.file(jsonPath).exists()) {
239
- return jsonPath;
250
+ const parentDirectory = dirname(currentDirectory);
251
+ if (parentDirectory === currentDirectory) {
252
+ return undefined;
253
+ }
254
+ currentDirectory = parentDirectory;
255
+ }
256
+ }
257
+
258
+ async function findConfigFiles(startDirectory: string = process.cwd()): Promise<readonly string[]> {
259
+ const baseDirectory = await findBaseConfigDirectory(startDirectory);
260
+ if (!baseDirectory) {
261
+ return [];
262
+ }
263
+
264
+ const directories: string[] = [];
265
+ let currentDirectory = startDirectory;
266
+ while (true) {
267
+ directories.push(currentDirectory);
268
+ if (currentDirectory === baseDirectory) {
269
+ break;
240
270
  }
241
271
 
242
272
  const parentDirectory = dirname(currentDirectory);
243
273
  if (parentDirectory === currentDirectory) {
244
- return undefined;
274
+ return [];
245
275
  }
246
276
  currentDirectory = parentDirectory;
247
277
  }
278
+ directories.reverse();
279
+
280
+ const configFiles: string[] = [];
281
+ for (const directory of directories) {
282
+ const fileNames =
283
+ directory === baseDirectory
284
+ ? [...BASE_CONFIG_FILES, ...LOCAL_CONFIG_FILES]
285
+ : LOCAL_CONFIG_FILES;
286
+
287
+ for (const fileName of fileNames) {
288
+ // eslint-disable-next-line eslint/no-await-in-loop -- config precedence is directory/file order
289
+ const filePath = await existingFile(`${directory}/${fileName}`);
290
+ if (filePath) {
291
+ configFiles.push(filePath);
292
+ }
293
+ }
294
+ }
295
+
296
+ return configFiles;
297
+ }
298
+
299
+ function mergeConfigValue(left: unknown, right: unknown): unknown {
300
+ if (!isRecord(left) || !isRecord(right)) {
301
+ return right;
302
+ }
303
+
304
+ const merged: Record<string, unknown> = { ...left };
305
+ for (const [key, value] of Object.entries(right)) {
306
+ merged[key] = key in merged ? mergeConfigValue(merged[key], value) : value;
307
+ }
308
+ return merged;
248
309
  }
249
310
 
250
311
  export async function loadConfig(): Promise<AgentToolsConfig | undefined> {
251
- const configPath = await findConfigFile();
252
- if (!configPath) {
312
+ const configPaths = await findConfigFiles();
313
+ if (configPaths.length === 0) {
253
314
  return undefined;
254
315
  }
255
316
 
256
- const fileContent = await Bun.file(configPath).text();
257
- const parsed = Bun.JSON5.parse(fileContent);
317
+ let parsed: unknown = {};
318
+ for (const configPath of configPaths) {
319
+ // eslint-disable-next-line eslint/no-await-in-loop -- config precedence is file order
320
+ const fileContent = await Bun.file(configPath).text();
321
+ parsed = mergeConfigValue(parsed, Bun.JSON5.parse(fileContent));
322
+ }
258
323
 
259
- return decodeConfig(parsed, configPath);
324
+ return decodeConfig(parsed, configPaths.join(", "));
260
325
  }
261
326
 
262
327
  export class ConfigService extends Context.Service<ConfigService, AgentToolsConfig | undefined>()(
@@ -64,16 +64,21 @@ const sqlCommand = Command.make(
64
64
  ),
65
65
  ),
66
66
  sql: Flag.string("sql").pipe(Flag.withDescription("SQL query to execute")),
67
+ limit: Flag.optional(Flag.integer("limit")).pipe(
68
+ Flag.withDescription(
69
+ "Max rows to return (default 50). Use 0 for no cap. Prefer a SQL LIMIT for large tables.",
70
+ ),
71
+ ),
67
72
  format: formatOption,
68
73
  profile: Flag.optional(Flag.string("profile")).pipe(
69
74
  Flag.withDescription("Database profile name from agent-tools.json5 (if multiple configured)"),
70
75
  ),
71
76
  },
72
- ({ env, sql, format }) =>
77
+ ({ env, sql, limit, format }) =>
73
78
  Effect.gen(function* () {
74
79
  const resolvedEnv = yield* resolveEnv(env);
75
80
  const db = yield* DbService;
76
- const result = yield* db.executeQuery(resolvedEnv, sql);
81
+ const result = yield* db.executeQuery(resolvedEnv, sql, Option.getOrUndefined(limit));
77
82
  yield* Console.log(formatOutput(result, format));
78
83
  }),
79
84
  ).pipe(Command.withDescription("Execute a SQL query"));
@@ -59,7 +59,11 @@ export { buildApiProbeArgs } from "#shared/k8s-probe";
59
59
  export class DbService extends Context.Service<
60
60
  DbService,
61
61
  {
62
- readonly executeQuery: (env: string, sql: string) => Effect.Effect<QueryResult, DbError>;
62
+ readonly executeQuery: (
63
+ env: string,
64
+ sql: string,
65
+ limit?: number,
66
+ ) => Effect.Effect<QueryResult, DbError>;
63
67
  readonly executeSchemaQuery: (
64
68
  env: string,
65
69
  mode: SchemaMode,
@@ -83,7 +87,8 @@ export class DbService extends Context.Service<
83
87
  environment: env,
84
88
  });
85
89
  return {
86
- executeQuery: (env: string, _sql: string) => Effect.fail(noConfigError(env)),
90
+ executeQuery: (env: string, _sql: string, _limit?: number) =>
91
+ Effect.fail(noConfigError(env)),
87
92
  executeSchemaQuery: (env: string, _mode: SchemaMode, _table?: string) =>
88
93
  Effect.fail(noConfigError(env)),
89
94
  };
@@ -468,6 +473,7 @@ export class DbService extends Context.Service<
468
473
  password: string,
469
474
  startTimeMs: number,
470
475
  applyTransform = false,
476
+ limit?: number,
471
477
  ) {
472
478
  const selectSql = sql.trim().replace(/;\s*$/, "");
473
479
  const wrappedSql = `SELECT json_agg(t) FROM (${selectSql}) t;`;
@@ -532,7 +538,7 @@ export class DbService extends Context.Service<
532
538
  });
533
539
 
534
540
  const transformed = applyTransform
535
- ? transformQueryResult(rawData)
541
+ ? transformQueryResult(rawData, limit)
536
542
  : {
537
543
  data: rawData,
538
544
  showing: rawData.length,
@@ -720,6 +726,7 @@ export class DbService extends Context.Service<
720
726
  const executeQuery = Effect.fn("DbService.executeQuery")(function* (
721
727
  env: string,
722
728
  sql: string,
729
+ limit?: number,
723
730
  ) {
724
731
  const config = yield* getConfigForEnv(env);
725
732
  const startTimeMs = yield* Clock.currentTimeMillis;
@@ -753,7 +760,7 @@ export class DbService extends Context.Service<
753
760
 
754
761
  const queryEffect = mutation
755
762
  ? executeMutationQuery(resolvedConfig, sql, password, Number(startTimeMs))
756
- : executeSelectQuery(resolvedConfig, sql, password, Number(startTimeMs), true);
763
+ : executeSelectQuery(resolvedConfig, sql, password, Number(startTimeMs), true, limit);
757
764
 
758
765
  return yield* runWithVpnPrerequisites(
759
766
  resolvedConfig.port,
@@ -22,12 +22,17 @@ function truncateValue(value: unknown): unknown {
22
22
  return `${value.slice(0, MAX_VALUE_LENGTH)}...`;
23
23
  }
24
24
 
25
- export function transformQueryResult(data: Record<string, unknown>[]): TransformResult {
25
+ export function transformQueryResult(
26
+ data: Record<string, unknown>[],
27
+ // limit 0 (or negative) means "no row cap".
28
+ limit: number = DEFAULT_ROW_LIMIT,
29
+ ): TransformResult {
26
30
  const withoutEmptyColumns = stripEmptyColumns(data);
27
31
  const truncatedValues = withoutEmptyColumns.map((record) =>
28
32
  Object.fromEntries(Object.entries(record).map(([key, value]) => [key, truncateValue(value)])),
29
33
  );
30
- const { rows, truncated, total, showing } = truncateRows(truncatedValues, DEFAULT_ROW_LIMIT);
34
+ const effectiveLimit = limit > 0 ? limit : Number.POSITIVE_INFINITY;
35
+ const { rows, truncated, total, showing } = truncateRows(truncatedValues, effectiveLimit);
31
36
 
32
37
  return {
33
38
  data: rows,