@blogic-cz/agent-tools 0.18.1 → 1.1.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.
Files changed (75) hide show
  1. package/README.md +35 -6
  2. package/dist/az-tool/config.d.ts +60 -12
  3. package/dist/az-tool/config.d.ts.map +1 -1
  4. package/dist/az-tool/errors.d.ts +10 -1
  5. package/dist/az-tool/errors.d.ts.map +1 -1
  6. package/dist/az-tool/profile.d.ts +16 -0
  7. package/dist/az-tool/profile.d.ts.map +1 -0
  8. package/dist/az-tool/security.d.ts +20 -3
  9. package/dist/az-tool/security.d.ts.map +1 -1
  10. package/dist/az-tool/service.d.ts +9 -4
  11. package/dist/az-tool/service.d.ts.map +1 -1
  12. package/dist/az-tool/types.d.ts +10 -60
  13. package/dist/az-tool/types.d.ts.map +1 -1
  14. package/dist/{az-tool → azdo-tool}/build.d.ts +9 -9
  15. package/dist/azdo-tool/build.d.ts.map +1 -0
  16. package/dist/azdo-tool/config.d.ts +13 -0
  17. package/dist/azdo-tool/config.d.ts.map +1 -0
  18. package/dist/azdo-tool/errors.d.ts +43 -0
  19. package/dist/azdo-tool/errors.d.ts.map +1 -0
  20. package/dist/azdo-tool/extract-option-value.d.ts.map +1 -0
  21. package/dist/azdo-tool/index.d.ts +3 -0
  22. package/dist/azdo-tool/index.d.ts.map +1 -0
  23. package/dist/azdo-tool/security.d.ts +4 -0
  24. package/dist/azdo-tool/security.d.ts.map +1 -0
  25. package/dist/azdo-tool/service.d.ts +15 -0
  26. package/dist/azdo-tool/service.d.ts.map +1 -0
  27. package/dist/azdo-tool/transformers.d.ts.map +1 -0
  28. package/dist/azdo-tool/types.d.ts +64 -0
  29. package/dist/azdo-tool/types.d.ts.map +1 -0
  30. package/dist/config/index.d.ts +1 -1
  31. package/dist/config/index.d.ts.map +1 -1
  32. package/dist/config/loader.d.ts +1 -1
  33. package/dist/config/loader.d.ts.map +1 -1
  34. package/dist/config/types.d.ts +18 -1
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/credential-guard/index.d.ts.map +1 -1
  37. package/dist/gh-tool/gist.d.ts +102 -0
  38. package/dist/gh-tool/gist.d.ts.map +1 -0
  39. package/dist/gh-tool/text-input.d.ts +1 -0
  40. package/dist/gh-tool/text-input.d.ts.map +1 -1
  41. package/dist/shared/azure-credentials.d.ts +20 -0
  42. package/dist/shared/azure-credentials.d.ts.map +1 -0
  43. package/dist/shared/binary-preflight.d.ts +1 -1
  44. package/package.json +8 -2
  45. package/schemas/agent-tools.schema.json +42 -1
  46. package/src/az-tool/config.ts +135 -25
  47. package/src/az-tool/errors.ts +14 -1
  48. package/src/az-tool/index.ts +67 -134
  49. package/src/az-tool/profile.ts +48 -0
  50. package/src/az-tool/security.ts +248 -89
  51. package/src/az-tool/service.ts +137 -242
  52. package/src/az-tool/types.ts +11 -64
  53. package/src/{az-tool → azdo-tool}/build.ts +11 -11
  54. package/src/azdo-tool/config.ts +33 -0
  55. package/src/azdo-tool/errors.ts +41 -0
  56. package/src/azdo-tool/index.ts +222 -0
  57. package/src/azdo-tool/security.ts +157 -0
  58. package/src/azdo-tool/service.ts +322 -0
  59. package/src/azdo-tool/types.ts +67 -0
  60. package/src/config/index.ts +1 -0
  61. package/src/config/loader.ts +10 -1
  62. package/src/config/types.ts +19 -1
  63. package/src/credential-guard/index.ts +7 -2
  64. package/src/gh-tool/gist.ts +625 -0
  65. package/src/gh-tool/index.ts +21 -1
  66. package/src/gh-tool/text-input.ts +10 -2
  67. package/src/shared/azure-credentials.ts +50 -0
  68. package/src/shared/binary-preflight.ts +1 -1
  69. package/dist/az-tool/build.d.ts.map +0 -1
  70. package/dist/az-tool/extract-option-value.d.ts.map +0 -1
  71. package/dist/az-tool/transformers.d.ts.map +0 -1
  72. /package/dist/{az-tool → azdo-tool}/extract-option-value.d.ts +0 -0
  73. /package/dist/{az-tool → azdo-tool}/transformers.d.ts +0 -0
  74. /package/src/{az-tool → azdo-tool}/extract-option-value.ts +0 -0
  75. /package/src/{az-tool → azdo-tool}/transformers.ts +0 -0
@@ -0,0 +1,625 @@
1
+ import { Command, Flag } from "effect/unstable/cli";
2
+ import { Effect, Option } from "effect";
3
+ import { join } from "node:path";
4
+
5
+ import { formatOption, logFormatted } from "#shared";
6
+ import { isSensitivePath, resolveOptionalTextInput } from "#gh/text-input";
7
+ import { GitHubCommandError } from "./errors";
8
+ import { GitHubService } from "./service";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ // `gh gist list/view` have no --json, so reads go through `gh api gists` (REST shape).
15
+ type GistApiFile = {
16
+ filename: string;
17
+ language: string | null;
18
+ type: string;
19
+ size: number;
20
+ truncated?: boolean;
21
+ content?: string;
22
+ };
23
+
24
+ type GistApi = {
25
+ id: string;
26
+ description: string | null;
27
+ public: boolean;
28
+ html_url: string;
29
+ created_at: string;
30
+ updated_at: string;
31
+ owner: { login: string } | null;
32
+ files: Record<string, GistApiFile>;
33
+ };
34
+
35
+ type GistListItem = {
36
+ id: string;
37
+ description: string | null;
38
+ public: boolean;
39
+ url: string;
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ files: string[];
43
+ };
44
+
45
+ type GistFile = {
46
+ filename: string;
47
+ language: string | null;
48
+ size: number;
49
+ truncated: boolean;
50
+ content?: string;
51
+ };
52
+
53
+ type GistDetail = {
54
+ id: string;
55
+ description: string | null;
56
+ public: boolean;
57
+ url: string;
58
+ createdAt: string;
59
+ updatedAt: string;
60
+ owner: string | null;
61
+ files: GistFile[];
62
+ };
63
+
64
+ type GistCreateResult = {
65
+ created: true;
66
+ id: string;
67
+ url: string;
68
+ public: boolean;
69
+ files: string[];
70
+ };
71
+
72
+ type GistEditResult = {
73
+ edited: true;
74
+ id: string;
75
+ changes: string[];
76
+ };
77
+
78
+ type GistDeleteResult = {
79
+ deleted: boolean;
80
+ id: string;
81
+ dryRun?: true;
82
+ message?: string;
83
+ };
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ const inputError = (message: string, command: string) =>
90
+ new GitHubCommandError({
91
+ message,
92
+ command,
93
+ exitCode: 1,
94
+ stderr: message,
95
+ });
96
+
97
+ const parsePaths = (value: string, command: string) => {
98
+ const paths = value.split(",").map((path) => path.trim());
99
+
100
+ if (paths.length === 0 || paths.some((path) => path.length === 0)) {
101
+ return Effect.fail(
102
+ inputError(`--files must be comma-separated paths without empty segments: ${value}`, command),
103
+ );
104
+ }
105
+
106
+ const sensitivePath = paths.find((path) => validateFilePath(path, command) !== null);
107
+ if (sensitivePath !== undefined) {
108
+ const validation = validateFilePath(sensitivePath, command);
109
+ if (validation !== null) return Effect.fail(validation);
110
+ }
111
+
112
+ return Effect.succeed(paths);
113
+ };
114
+
115
+ const validateFilePath = (path: string, command: string) =>
116
+ isSensitivePath(path) ? inputError(`Refusing to read sensitive file: ${path}`, command) : null;
117
+
118
+ // `gh gist create/edit` read content from files only; inline --body is staged in a temp file
119
+ // whose basename becomes the gist filename.
120
+ const stageBody = Effect.fn("gist.stageBody")(function* (opts: {
121
+ body: string;
122
+ filename: string;
123
+ command: string;
124
+ }) {
125
+ const directory = yield* Effect.acquireRelease(
126
+ Effect.tryPromise({
127
+ try: async () => {
128
+ const proc = Bun.spawn(
129
+ ["mktemp", "-d", join(process.env.TMPDIR ?? "/tmp", "gh-tool-gist-XXXXXX")],
130
+ { stdout: "pipe", stderr: "pipe" },
131
+ );
132
+ const [exitCode, stdout, stderr] = await Promise.all([
133
+ proc.exited,
134
+ new Response(proc.stdout).text(),
135
+ new Response(proc.stderr).text(),
136
+ ]);
137
+
138
+ if (exitCode !== 0) {
139
+ throw new Error(stderr.trim() || `mktemp exited with code ${exitCode}`);
140
+ }
141
+
142
+ return stdout.trim();
143
+ },
144
+ catch: (error) =>
145
+ inputError(
146
+ `Failed to stage gist content: ${error instanceof Error ? error.message : String(error)}`,
147
+ opts.command,
148
+ ),
149
+ }),
150
+ (tempDirectory) =>
151
+ Effect.tryPromise({
152
+ try: async () => {
153
+ const proc = Bun.spawn(["rm", "-rf", tempDirectory], {
154
+ stdout: "ignore",
155
+ stderr: "ignore",
156
+ });
157
+ await proc.exited;
158
+ },
159
+ catch: () => undefined,
160
+ }).pipe(Effect.ignore),
161
+ );
162
+ const path = join(directory, opts.filename);
163
+
164
+ yield* Effect.tryPromise({
165
+ try: () => Bun.write(path, opts.body),
166
+ catch: (error) =>
167
+ inputError(
168
+ `Failed to stage gist content: ${error instanceof Error ? error.message : String(error)}`,
169
+ opts.command,
170
+ ),
171
+ });
172
+
173
+ return path;
174
+ });
175
+
176
+ const toListItem = (gist: GistApi): GistListItem => ({
177
+ id: gist.id,
178
+ description: gist.description,
179
+ public: gist.public,
180
+ url: gist.html_url,
181
+ createdAt: gist.created_at,
182
+ updatedAt: gist.updated_at,
183
+ files: Object.keys(gist.files ?? {}),
184
+ });
185
+
186
+ export const toGistDetail = (
187
+ gist: GistApi,
188
+ opts: { filename: string | null; withContent: boolean },
189
+ ): GistDetail => {
190
+ const files = Object.values(gist.files ?? {})
191
+ .filter((file) => opts.filename === null || file.filename === opts.filename)
192
+ .map((file) => {
193
+ const result: GistFile = {
194
+ filename: file.filename,
195
+ language: file.language,
196
+ size: file.size,
197
+ truncated: file.truncated ?? false,
198
+ };
199
+
200
+ if (opts.withContent) {
201
+ result.content = file.content ?? "";
202
+ }
203
+
204
+ return result;
205
+ });
206
+
207
+ return {
208
+ id: gist.id,
209
+ description: gist.description,
210
+ public: gist.public,
211
+ url: gist.html_url,
212
+ createdAt: gist.created_at,
213
+ updatedAt: gist.updated_at,
214
+ owner: gist.owner?.login ?? null,
215
+ files,
216
+ };
217
+ };
218
+
219
+ export const validateBodyFilename = (
220
+ body: string | null,
221
+ filename: string | null,
222
+ command: string,
223
+ ): GitHubCommandError | null => {
224
+ if (body === null) return null;
225
+ if (filename === null) {
226
+ return inputError("--filename is required with --body/--body-file", command);
227
+ }
228
+ if (filename.length === 0 || filename === "." || filename === ".." || /[\\/]/.test(filename)) {
229
+ return inputError("--filename must be a file name without path separators", command);
230
+ }
231
+ return null;
232
+ };
233
+
234
+ export const validateEditInput = (opts: {
235
+ body: string | null;
236
+ filename: string | null;
237
+ description: string | null;
238
+ add: string | null;
239
+ remove: string | null;
240
+ }): GitHubCommandError | null => {
241
+ const command = "gh-tool gist edit";
242
+
243
+ if (
244
+ opts.body === null &&
245
+ opts.description === null &&
246
+ opts.add === null &&
247
+ opts.remove === null
248
+ ) {
249
+ return inputError(
250
+ "Provide at least one of --body/--body-file (with --filename), --desc, --add, or --remove",
251
+ command,
252
+ );
253
+ }
254
+
255
+ return validateBodyFilename(opts.body, opts.filename, command);
256
+ };
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Internal handlers
260
+ // ---------------------------------------------------------------------------
261
+
262
+ const listGists = Effect.fn("gist.listGists")(function* (opts: {
263
+ limit: number;
264
+ visibility: string | null;
265
+ }) {
266
+ const gh = yield* GitHubService;
267
+
268
+ const gists = yield* gh.runGhJson<GistApi[]>(["api", `gists?per_page=${opts.limit}`]);
269
+
270
+ return gists
271
+ .filter(
272
+ (gist) =>
273
+ opts.visibility === null || (opts.visibility === "public" ? gist.public : !gist.public),
274
+ )
275
+ .map(toListItem);
276
+ });
277
+
278
+ const viewGist = Effect.fn("gist.viewGist")(function* (opts: {
279
+ id: string;
280
+ filename: string | null;
281
+ withContent: boolean;
282
+ }) {
283
+ const gh = yield* GitHubService;
284
+
285
+ const gist = yield* gh.runGhJson<GistApi>(["api", `gists/${opts.id}`]);
286
+
287
+ return toGistDetail(gist, { filename: opts.filename, withContent: opts.withContent });
288
+ });
289
+
290
+ export const createGist = Effect.fn("gist.createGist")(function* (opts: {
291
+ paths: string[];
292
+ description: string | null;
293
+ public: boolean;
294
+ }) {
295
+ const gh = yield* GitHubService;
296
+
297
+ const args = ["gist", "create", ...opts.paths];
298
+
299
+ if (opts.description !== null) {
300
+ args.push("--desc", opts.description);
301
+ }
302
+
303
+ if (opts.public) {
304
+ args.push("--public");
305
+ }
306
+
307
+ const result = yield* gh.runGh(args);
308
+ const url =
309
+ result.stdout
310
+ .trim()
311
+ .split("\n")
312
+ .map((line) => line.trim())
313
+ .findLast((line) => line.startsWith("https://")) ?? "";
314
+
315
+ if (url === "") {
316
+ return yield* Effect.fail(
317
+ inputError("gh gist create did not return a gist URL", "gh-tool gist create"),
318
+ );
319
+ }
320
+
321
+ const created: GistCreateResult = {
322
+ created: true,
323
+ id: url.split("/").at(-1) ?? "",
324
+ url,
325
+ public: opts.public,
326
+ files: opts.paths.map((path) => path.split("/").at(-1) ?? path),
327
+ };
328
+
329
+ return created;
330
+ });
331
+
332
+ const editGist = Effect.fn("gist.editGist")(function* (opts: {
333
+ id: string;
334
+ description: string | null;
335
+ add: string | null;
336
+ remove: string | null;
337
+ filename: string | null;
338
+ sourcePath: string | null;
339
+ }) {
340
+ const gh = yield* GitHubService;
341
+
342
+ const args = ["gist", "edit", opts.id];
343
+ const changes: string[] = [];
344
+
345
+ if (opts.sourcePath !== null) {
346
+ args.push(opts.sourcePath);
347
+ changes.push(`content:${opts.filename ?? opts.sourcePath.split("/").at(-1)}`);
348
+ }
349
+
350
+ if (opts.description !== null) {
351
+ args.push("--desc", opts.description);
352
+ changes.push("description");
353
+ }
354
+
355
+ if (opts.add !== null) {
356
+ args.push("--add", opts.add);
357
+ changes.push(`add:${opts.add.split("/").at(-1)}`);
358
+ }
359
+
360
+ if (opts.remove !== null) {
361
+ args.push("--remove", opts.remove);
362
+ changes.push(`remove:${opts.remove}`);
363
+ }
364
+
365
+ if (opts.filename !== null) {
366
+ args.push("--filename", opts.filename);
367
+ }
368
+
369
+ yield* gh.runGh(args);
370
+
371
+ const edited: GistEditResult = { edited: true, id: opts.id, changes };
372
+
373
+ return edited;
374
+ });
375
+
376
+ const deleteGist = Effect.fn("gist.deleteGist")(function* (opts: { id: string; confirm: boolean }) {
377
+ const gh = yield* GitHubService;
378
+
379
+ if (!opts.confirm) {
380
+ const dryRun: GistDeleteResult = {
381
+ deleted: false,
382
+ id: opts.id,
383
+ dryRun: true,
384
+ message: `Would delete gist ${opts.id}. Re-run with --confirm to execute.`,
385
+ };
386
+
387
+ return dryRun;
388
+ }
389
+
390
+ yield* gh.runGh(["gist", "delete", opts.id, "--yes"]);
391
+
392
+ const deleted: GistDeleteResult = { deleted: true, id: opts.id };
393
+
394
+ return deleted;
395
+ });
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Commands
399
+ // ---------------------------------------------------------------------------
400
+
401
+ export const gistListCommand = Command.make(
402
+ "list",
403
+ {
404
+ format: formatOption,
405
+ limit: Flag.integer("limit").pipe(
406
+ Flag.withDescription("Maximum number of gists to return"),
407
+ Flag.withDefault(10),
408
+ ),
409
+ visibility: Flag.choice("visibility", ["public", "secret"]).pipe(
410
+ Flag.withDescription("Filter by visibility: public or secret"),
411
+ Flag.optional,
412
+ ),
413
+ },
414
+ ({ format, limit, visibility }) =>
415
+ Effect.gen(function* () {
416
+ const requested = Option.getOrNull(visibility);
417
+
418
+ const gists = yield* listGists({ limit, visibility: requested });
419
+
420
+ yield* logFormatted(gists, format);
421
+ }),
422
+ ).pipe(Command.withDescription("List your gists (id, description, visibility, file names)"));
423
+
424
+ export const gistViewCommand = Command.make(
425
+ "view",
426
+ {
427
+ filename: Flag.string("filename").pipe(
428
+ Flag.withDescription("Return only this file from the gist"),
429
+ Flag.optional,
430
+ ),
431
+ format: formatOption,
432
+ id: Flag.string("id").pipe(Flag.withDescription("Gist id or URL")),
433
+ metadataOnly: Flag.boolean("metadata-only").pipe(
434
+ Flag.withDescription("Omit file contents"),
435
+ Flag.withDefault(false),
436
+ ),
437
+ },
438
+ ({ filename, format, id, metadataOnly }) =>
439
+ Effect.gen(function* () {
440
+ const gist = yield* viewGist({
441
+ id: id.split("/").at(-1) ?? id,
442
+ filename: Option.getOrNull(filename),
443
+ withContent: !metadataOnly,
444
+ });
445
+
446
+ yield* logFormatted(gist, format);
447
+ }),
448
+ ).pipe(Command.withDescription("View a gist with file contents"));
449
+
450
+ export const gistCreateCommand = Command.make(
451
+ "create",
452
+ {
453
+ body: Flag.string("body").pipe(
454
+ Flag.withDescription("Inline gist content (requires --filename)"),
455
+ Flag.optional,
456
+ ),
457
+ bodyFile: Flag.string("body-file").pipe(
458
+ Flag.withDescription(
459
+ "Read gist content from a file path or '-' for stdin (requires --filename)",
460
+ ),
461
+ Flag.optional,
462
+ ),
463
+ desc: Flag.string("desc").pipe(Flag.withDescription("Gist description"), Flag.optional),
464
+ filename: Flag.string("filename").pipe(
465
+ Flag.withDescription("File name used for --body/--body-file content"),
466
+ Flag.optional,
467
+ ),
468
+ files: Flag.string("files").pipe(
469
+ Flag.withDescription("Comma-separated paths of existing files to upload"),
470
+ Flag.optional,
471
+ ),
472
+ format: formatOption,
473
+ public: Flag.boolean("public").pipe(
474
+ Flag.withDescription("Publish as a public gist (gists are secret by default)"),
475
+ Flag.withDefault(false),
476
+ ),
477
+ },
478
+ ({ body, bodyFile, desc, filename, files, format, public: isPublic }) =>
479
+ Effect.scoped(
480
+ Effect.gen(function* () {
481
+ const command = "gh-tool gist create";
482
+ const filesValue = Option.getOrNull(files);
483
+ const resolvedBody = yield* resolveOptionalTextInput({
484
+ command,
485
+ value: Option.getOrNull(body),
486
+ fileValue: Option.getOrNull(bodyFile),
487
+ valueFlag: "--body",
488
+ fileFlag: "--body-file",
489
+ label: "body",
490
+ });
491
+
492
+ if (filesValue === null && resolvedBody === null) {
493
+ return yield* Effect.fail(
494
+ inputError("Provide --files, or --body/--body-file with --filename", command),
495
+ );
496
+ }
497
+
498
+ const paths: string[] = [];
499
+
500
+ if (filesValue !== null) {
501
+ paths.push(...(yield* parsePaths(filesValue, command)));
502
+ }
503
+
504
+ if (resolvedBody !== null) {
505
+ const name = Option.getOrNull(filename);
506
+ const filenameValidation = validateBodyFilename(resolvedBody, name, command);
507
+
508
+ if (filenameValidation !== null) {
509
+ return yield* Effect.fail(filenameValidation);
510
+ }
511
+
512
+ paths.push(yield* stageBody({ body: resolvedBody, filename: name ?? "", command }));
513
+ }
514
+
515
+ const created = yield* createGist({
516
+ paths,
517
+ description: Option.getOrNull(desc),
518
+ public: isPublic,
519
+ });
520
+
521
+ yield* logFormatted(created, format);
522
+ }),
523
+ ),
524
+ ).pipe(Command.withDescription("Create a gist from files or inline content (secret by default)"));
525
+
526
+ export const gistEditCommand = Command.make(
527
+ "edit",
528
+ {
529
+ add: Flag.string("add").pipe(
530
+ Flag.withDescription("Path of a new file to add to the gist"),
531
+ Flag.optional,
532
+ ),
533
+ body: Flag.string("body").pipe(
534
+ Flag.withDescription("Replacement content for --filename"),
535
+ Flag.optional,
536
+ ),
537
+ bodyFile: Flag.string("body-file").pipe(
538
+ Flag.withDescription("Read replacement content from a file path or '-' for stdin"),
539
+ Flag.optional,
540
+ ),
541
+ desc: Flag.string("desc").pipe(Flag.withDescription("New gist description"), Flag.optional),
542
+ filename: Flag.string("filename").pipe(
543
+ Flag.withDescription("Gist file to replace with --body/--body-file"),
544
+ Flag.optional,
545
+ ),
546
+ format: formatOption,
547
+ id: Flag.string("id").pipe(Flag.withDescription("Gist id or URL")),
548
+ remove: Flag.string("remove").pipe(
549
+ Flag.withDescription("File name to remove from the gist"),
550
+ Flag.optional,
551
+ ),
552
+ },
553
+ ({ add, body, bodyFile, desc, filename, format, id, remove }) =>
554
+ Effect.scoped(
555
+ Effect.gen(function* () {
556
+ const command = "gh-tool gist edit";
557
+ const resolvedBody = yield* resolveOptionalTextInput({
558
+ command,
559
+ value: Option.getOrNull(body),
560
+ fileValue: Option.getOrNull(bodyFile),
561
+ valueFlag: "--body",
562
+ fileFlag: "--body-file",
563
+ label: "body",
564
+ });
565
+
566
+ const description = Option.getOrNull(desc);
567
+ const addPath = Option.getOrNull(add);
568
+ const removeName = Option.getOrNull(remove);
569
+ const name = Option.getOrNull(filename);
570
+
571
+ const validation = validateEditInput({
572
+ body: resolvedBody,
573
+ filename: name,
574
+ description,
575
+ add: addPath,
576
+ remove: removeName,
577
+ });
578
+
579
+ if (validation !== null) {
580
+ return yield* Effect.fail(validation);
581
+ }
582
+
583
+ if (addPath !== null) {
584
+ const pathValidation = validateFilePath(addPath, command);
585
+ if (pathValidation !== null) {
586
+ return yield* Effect.fail(pathValidation);
587
+ }
588
+ }
589
+
590
+ const sourcePath =
591
+ resolvedBody === null || name === null
592
+ ? null
593
+ : yield* stageBody({ body: resolvedBody, filename: name, command });
594
+
595
+ const edited = yield* editGist({
596
+ id: id.split("/").at(-1) ?? id,
597
+ description,
598
+ add: addPath,
599
+ remove: removeName,
600
+ filename: name,
601
+ sourcePath,
602
+ });
603
+
604
+ yield* logFormatted(edited, format);
605
+ }),
606
+ ),
607
+ ).pipe(Command.withDescription("Edit a gist (never opens an editor — content flags required)"));
608
+
609
+ export const gistDeleteCommand = Command.make(
610
+ "delete",
611
+ {
612
+ confirm: Flag.boolean("confirm").pipe(
613
+ Flag.withDescription("Actually delete the gist (without this flag, only shows dry-run)"),
614
+ Flag.withDefault(false),
615
+ ),
616
+ format: formatOption,
617
+ id: Flag.string("id").pipe(Flag.withDescription("Gist id or URL")),
618
+ },
619
+ ({ confirm, format, id }) =>
620
+ Effect.gen(function* () {
621
+ const result = yield* deleteGist({ id: id.split("/").at(-1) ?? id, confirm });
622
+
623
+ yield* logFormatted(result, format);
624
+ }),
625
+ ).pipe(Command.withDescription("Delete a gist (dry-run by default, use --confirm to execute)"));
@@ -51,6 +51,13 @@ import {
51
51
  prWatchCommand,
52
52
  } from "./pr/index";
53
53
  import { branchRenameCommand } from "./branch";
54
+ import {
55
+ gistCreateCommand,
56
+ gistDeleteCommand,
57
+ gistEditCommand,
58
+ gistListCommand,
59
+ gistViewCommand,
60
+ } from "./gist";
54
61
  import {
55
62
  releaseCreateCommand,
56
63
  releaseDeleteCommand,
@@ -168,6 +175,17 @@ const releaseCommand = Command.make("release", {}).pipe(
168
175
  ]),
169
176
  );
170
177
 
178
+ const gistCommand = Command.make("gist", {}).pipe(
179
+ Command.withDescription("Gist operations (list, view, create, edit, delete)"),
180
+ Command.withSubcommands([
181
+ gistListCommand,
182
+ gistViewCommand,
183
+ gistCreateCommand,
184
+ gistEditCommand,
185
+ gistDeleteCommand,
186
+ ]),
187
+ );
188
+
171
189
  const commandsCommand = makeSchemaCommand(() => mainCommand);
172
190
 
173
191
  const mainCommand = Command.make("gh-tool", {}).pipe(
@@ -200,7 +218,8 @@ WORKFLOW FOR AI AGENTS:
200
218
  20. Use 'release status' to inspect latest release + repository context
201
219
  21. Use 'release create --tag vX.Y.Z --generate-notes' to publish a release
202
220
  22. Use 'release edit/view/list/delete' to maintain existing releases
203
- 23. Use 'branch rename --old-name X --new-name Y --confirm' to rename a branch`,
221
+ 23. Use 'branch rename --old-name X --new-name Y --confirm' to rename a branch
222
+ 24. Use 'gist list/view' to read gists and 'gist create --files a.ts --desc "..."' to share snippets`,
204
223
  ),
205
224
  Command.withSubcommands([
206
225
  prCommand,
@@ -209,6 +228,7 @@ WORKFLOW FOR AI AGENTS:
209
228
  branchCommand,
210
229
  workflowCommand,
211
230
  releaseCommand,
231
+ gistCommand,
212
232
  commandsCommand,
213
233
  ]),
214
234
  );
@@ -3,13 +3,21 @@ import { Effect, Schema } from "effect";
3
3
  import { GitHubCommandError } from "#gh/errors";
4
4
 
5
5
  const STDIN_SENTINEL = "-";
6
- const SENSITIVE_PATH_PATTERNS = [/\.env(\..+)?$/, /\.envrc$/, /\.(pem|key|p12|pfx|cer|crt)$/i];
6
+ const SENSITIVE_PATH_PATTERNS = [
7
+ /\.env(\..+)?$/,
8
+ /\.envrc$/,
9
+ /\.(pem|key|p12|pfx|cer|crt)$/i,
10
+ /(?:^|[\\/])(credentials?|passwd|shadow)$/i,
11
+ ];
7
12
  const MissingMode = Schema.Literals(["error", "null", "default"]);
8
13
 
9
14
  const readTextFromStdin = () => Bun.stdin.text();
10
15
 
16
+ export const isSensitivePath = (filePath: string) =>
17
+ SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
18
+
11
19
  const readTextFile = (filePath: string) => {
12
- if (SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(filePath))) {
20
+ if (isSensitivePath(filePath)) {
13
21
  return Promise.reject(new Error(`Refusing to read sensitive file: ${filePath}`));
14
22
  }
15
23