@blogic-cz/agent-tools 1.0.0 → 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.
package/README.md CHANGED
@@ -282,6 +282,18 @@ Two optional scope guards sit on top. A profile keyed `prod`/`production`, or ca
282
282
 
283
283
  `azdo-tool` keeps its allowlist of read-only operations (`list`, `run`, `show`, `show-tags`) and rejects `create`/`delete`/`update`/`cancel`/`queue` anywhere in the command. `run` is accepted only in the `pipelines` group — `acr run` and `acr task run` execute arbitrary commands in Azure and are blocked. The `acr` and `account` groups remain reachable from `azdo-tool` for backwards compatibility; new work should use `az-tool` for them. Because those two groups address the Azure platform rather than Azure DevOps, the platform credential rules apply to them here as well — `acr credential show` is refused by both tools. The verb and segment lists behind that live in `src/shared/azure-credentials.ts` so the two tools cannot drift apart.
284
284
 
285
+ ### Gist commands
286
+
287
+ ```bash
288
+ bun gh-tool gist list
289
+ bun gh-tool gist view --id <gist-id>
290
+ bun gh-tool gist create --body "snippet" --filename example.txt
291
+ bun gh-tool gist edit --id <gist-id> --body "updated" --filename example.txt
292
+ bun gh-tool gist delete --id <gist-id> --confirm
293
+ ```
294
+
295
+ Gists are secret by default. `gist delete` prints a dry run unless `--confirm` is passed. `gist edit` requires a content or mutation flag and never opens an editor.
296
+
285
297
  ### gh-tool machine contracts
286
298
 
287
299
  `pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR. `pr request-review --reviewers alice,bob` emits sorted `submittedReviewers` (normalized input), `newlyRequested` and `alreadyPending` (the submitted logins split by whether a pending request already existed, so a fresh re-request is distinguishable from a no-op), and `requestedReviewers` (GitHub-confirmed result). `pr last-human-reviewer` derives `currentRequestedReviewers` from live `reviewRequests` only; timeline events are not replayed because GitHub clears a pending request on review submit without emitting `ReviewRequestRemovedEvent`.
@@ -0,0 +1,102 @@
1
+ import { Command } from "effect/unstable/cli";
2
+ import { Effect, Option } from "effect";
3
+ import { GitHubCommandError } from "./errors";
4
+ import { GitHubService } from "./service";
5
+ type GistApiFile = {
6
+ filename: string;
7
+ language: string | null;
8
+ type: string;
9
+ size: number;
10
+ truncated?: boolean;
11
+ content?: string;
12
+ };
13
+ type GistApi = {
14
+ id: string;
15
+ description: string | null;
16
+ public: boolean;
17
+ html_url: string;
18
+ created_at: string;
19
+ updated_at: string;
20
+ owner: {
21
+ login: string;
22
+ } | null;
23
+ files: Record<string, GistApiFile>;
24
+ };
25
+ type GistFile = {
26
+ filename: string;
27
+ language: string | null;
28
+ size: number;
29
+ truncated: boolean;
30
+ content?: string;
31
+ };
32
+ type GistDetail = {
33
+ id: string;
34
+ description: string | null;
35
+ public: boolean;
36
+ url: string;
37
+ createdAt: string;
38
+ updatedAt: string;
39
+ owner: string | null;
40
+ files: GistFile[];
41
+ };
42
+ type GistCreateResult = {
43
+ created: true;
44
+ id: string;
45
+ url: string;
46
+ public: boolean;
47
+ files: string[];
48
+ };
49
+ export declare const toGistDetail: (gist: GistApi, opts: {
50
+ filename: string | null;
51
+ withContent: boolean;
52
+ }) => GistDetail;
53
+ export declare const validateBodyFilename: (body: string | null, filename: string | null, command: string) => GitHubCommandError | null;
54
+ export declare const validateEditInput: (opts: {
55
+ body: string | null;
56
+ filename: string | null;
57
+ description: string | null;
58
+ add: string | null;
59
+ remove: string | null;
60
+ }) => GitHubCommandError | null;
61
+ export declare const createGist: (opts: {
62
+ paths: string[];
63
+ description: string | null;
64
+ public: boolean;
65
+ }) => Effect.Effect<GistCreateResult, GitHubCommandError | import("./errors").GitHubNotFoundError | import("./errors").GitHubAuthError, GitHubService>;
66
+ export declare const gistListCommand: Command.Command<"list", {
67
+ readonly format: "json" | "toon";
68
+ readonly limit: number;
69
+ readonly visibility: Option.Option<"public" | "secret">;
70
+ }, {}, GitHubCommandError | import("./errors").GitHubNotFoundError | import("./errors").GitHubAuthError, GitHubService>;
71
+ export declare const gistViewCommand: Command.Command<"view", {
72
+ readonly filename: Option.Option<string>;
73
+ readonly format: "json" | "toon";
74
+ readonly id: string;
75
+ readonly metadataOnly: boolean;
76
+ }, {}, GitHubCommandError | import("./errors").GitHubNotFoundError | import("./errors").GitHubAuthError, GitHubService>;
77
+ export declare const gistCreateCommand: Command.Command<"create", {
78
+ readonly body: Option.Option<string>;
79
+ readonly bodyFile: Option.Option<string>;
80
+ readonly desc: Option.Option<string>;
81
+ readonly filename: Option.Option<string>;
82
+ readonly files: Option.Option<string>;
83
+ readonly format: "json" | "toon";
84
+ readonly public: boolean;
85
+ }, {}, GitHubCommandError | import("./errors").GitHubNotFoundError | import("./errors").GitHubAuthError, GitHubService>;
86
+ export declare const gistEditCommand: Command.Command<"edit", {
87
+ readonly add: Option.Option<string>;
88
+ readonly body: Option.Option<string>;
89
+ readonly bodyFile: Option.Option<string>;
90
+ readonly desc: Option.Option<string>;
91
+ readonly filename: Option.Option<string>;
92
+ readonly format: "json" | "toon";
93
+ readonly id: string;
94
+ readonly remove: Option.Option<string>;
95
+ }, {}, GitHubCommandError | import("./errors").GitHubNotFoundError | import("./errors").GitHubAuthError, GitHubService>;
96
+ export declare const gistDeleteCommand: Command.Command<"delete", {
97
+ readonly confirm: boolean;
98
+ readonly format: "json" | "toon";
99
+ readonly id: string;
100
+ }, {}, GitHubCommandError | import("./errors").GitHubNotFoundError | import("./errors").GitHubAuthError, GitHubService>;
101
+ export {};
102
+ //# sourceMappingURL=gist.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gist.d.ts","sourceRoot":"","sources":["../../src/gh-tool/gist.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { GitHubCommandError } from "#gh/errors";
3
3
  declare const MissingMode: Schema.Literals<readonly ["error", "null", "default"]>;
4
+ export declare const isSensitivePath: (filePath: string) => boolean;
4
5
  type ResolveTextInputOptions = {
5
6
  command: string;
6
7
  value: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/gh-tool/text-input.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAExC,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIhD,QAAA,MAAM,WAAW,wDAAgD,CAAC;AAoBlE,KAAK,uBAAuB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,WAAW,CAAC,CAAC;IACpD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAsFF,eAAO,MAAM,wBAAwB,GACnC,SAAS,IAAI,CAAC,uBAAuB,EAAE,aAAa,GAAG,cAAc,CAAC,KACrE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAIuD,CAAC;AAEnG,eAAO,MAAM,wBAAwB,GACnC,SAAS,IAAI,CAAC,uBAAuB,EAAE,aAAa,GAAG,cAAc,CAAC,KACrE,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,IAAI,EAAE,kBAAkB,CAI9C,CAAC;AAEL,eAAO,MAAM,uBAAuB,GAClC,SAAS,IAAI,CAAC,uBAAuB,EAAE,aAAa,GAAG,cAAc,CAAC,GAAG;IACvE,YAAY,EAAE,MAAM,CAAC;CACtB,KACA,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAKsD,CAAC"}
1
+ {"version":3,"file":"text-input.d.ts","sourceRoot":"","sources":["../../src/gh-tool/text-input.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAExC,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAShD,QAAA,MAAM,WAAW,wDAAgD,CAAC;AAIlE,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,YACmB,CAAC;AAkBpE,KAAK,uBAAuB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,WAAW,CAAC,CAAC;IACpD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAsFF,eAAO,MAAM,wBAAwB,GACnC,SAAS,IAAI,CAAC,uBAAuB,EAAE,aAAa,GAAG,cAAc,CAAC,KACrE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAIuD,CAAC;AAEnG,eAAO,MAAM,wBAAwB,GACnC,SAAS,IAAI,CAAC,uBAAuB,EAAE,aAAa,GAAG,cAAc,CAAC,KACrE,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,IAAI,EAAE,kBAAkB,CAI9C,CAAC;AAEL,eAAO,MAAM,uBAAuB,GAClC,SAAS,IAAI,CAAC,uBAAuB,EAAE,aAAa,GAAG,cAAc,CAAC,GAAG;IACvE,YAAY,EAAE,MAAM,CAAC;CACtB,KACA,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAKsD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure platform, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -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