@pi-stef/catalog 0.2.2

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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Extension registration for the catalog extension.
3
+ *
4
+ * Wires all catalog subcommands into pi's extension API as `/ct` commands
5
+ * and `ct_*` tools for LLM invocation.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "@sinclair/typebox";
10
+
11
+ import {
12
+ SUBCOMMAND_DEFS,
13
+ getAliasMap,
14
+ } from "./commands/definitions.js";
15
+ import { parseSubcommand } from "./commands/dispatch.js";
16
+ import { addCommand, type AddCtx } from "./commands/add.js";
17
+ import { initCommand, type InitContext } from "./commands/init.js";
18
+ import { removeCommand, type RemoveCtx } from "./commands/remove.js";
19
+ import {
20
+ toggleCommand,
21
+ enableCommand,
22
+ disableCommand,
23
+ type ToggleCtx,
24
+ } from "./commands/toggle.js";
25
+ import {
26
+ syncCommand,
27
+ pushCommand,
28
+ pullCommand,
29
+ type SyncCtx,
30
+ type PushPullCtx,
31
+ } from "./commands/sync.js";
32
+ import { loginCommand, type LoginCtx } from "./commands/login.js";
33
+ import { statusCommand, type StatusCtx } from "./commands/status.js";
34
+ import { diffCommand, type DiffCtx } from "./commands/diff.js";
35
+ import { verifyCommand, type VerifyCtx } from "./commands/verify.js";
36
+ import {
37
+ profilesCommand,
38
+ profileCommand,
39
+ type ProfilesCtx,
40
+ } from "./commands/profiles.js";
41
+ import type { CommandArgs, CommandCtx } from "./commands/types.js";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Alias map (derived from shared definitions)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const aliasMap = getAliasMap();
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Command handlers (delegate to implementation modules)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Handle a parsed subcommand invocation.
55
+ *
56
+ * Delegates to the appropriate command implementation module.
57
+ */
58
+ async function handleSubcommand(
59
+ subcommand: string,
60
+ args: string,
61
+ ctx: CommandCtx,
62
+ ): Promise<void> {
63
+ const canonical = aliasMap.get(subcommand);
64
+ if (!canonical) {
65
+ ctx.ui.notify(`Unknown subcommand: ${subcommand}`, "error");
66
+ return;
67
+ }
68
+
69
+ // Parse the raw argument string into structured { positional, flags }
70
+ const rawParts = (args ?? "").trim().split(/\s+/).filter(Boolean);
71
+ const parsed = parseSubcommand(rawParts);
72
+
73
+ switch (canonical) {
74
+ case "add":
75
+ await addCommand(parsed, ctx as AddCtx);
76
+ break;
77
+ case "init":
78
+ await initCommand(parsed, ctx as InitContext);
79
+ break;
80
+ case "remove":
81
+ await removeCommand(parsed, ctx as RemoveCtx);
82
+ break;
83
+ case "sync":
84
+ await syncCommand(parsed, ctx as SyncCtx);
85
+ break;
86
+ case "push":
87
+ await pushCommand(parsed, ctx as PushPullCtx);
88
+ break;
89
+ case "pull":
90
+ await pullCommand(parsed, ctx as PushPullCtx);
91
+ break;
92
+ case "toggle":
93
+ await toggleCommand(parsed, ctx as ToggleCtx);
94
+ break;
95
+ case "enable":
96
+ await enableCommand(parsed, ctx as ToggleCtx);
97
+ break;
98
+ case "disable":
99
+ await disableCommand(parsed, ctx as ToggleCtx);
100
+ break;
101
+ case "login":
102
+ await loginCommand(parsed, ctx as LoginCtx);
103
+ break;
104
+ case "status":
105
+ await statusCommand(parsed, ctx as StatusCtx);
106
+ break;
107
+ case "diff":
108
+ await diffCommand(parsed, ctx as DiffCtx);
109
+ break;
110
+ case "verify":
111
+ await verifyCommand(parsed, ctx as VerifyCtx);
112
+ break;
113
+ case "profiles":
114
+ await profilesCommand(parsed, ctx as ProfilesCtx);
115
+ break;
116
+ case "profile":
117
+ await profileCommand(parsed, ctx as ProfilesCtx);
118
+ break;
119
+ default:
120
+ ctx.ui.notify(`ct ${canonical}: not yet implemented`, "info");
121
+ }
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Public API
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Register all catalog commands and tools with the pi extension API.
130
+ *
131
+ * - `/ct` main command with subcommand routing and argument auto-completion
132
+ * - `/ct-sync`, `/ct-init`, … individual alias commands
133
+ * - `ct_sync`, `ct_add`, `ct_remove`, `ct_toggle`, `ct_status` LLM tools
134
+ */
135
+ export function registerCatalog(pi: ExtensionAPI): void {
136
+ // ----- /ct main command --------------------------------------------------
137
+ pi.registerCommand("ct", {
138
+ description: "Catalog management: sync, add, remove, toggle, and more",
139
+ getArgumentCompletions(prefix: string) {
140
+ const items = SUBCOMMAND_DEFS.map((s) => ({
141
+ value: s.name,
142
+ label: s.name,
143
+ description: s.description,
144
+ }));
145
+ const filtered = items.filter((i) => i.value.startsWith(prefix));
146
+ return filtered.length > 0 ? filtered : null;
147
+ },
148
+ async handler(args, ctx) {
149
+ const parts = (args ?? "").trim().split(/\s+/);
150
+ const sub = parts[0] || "";
151
+ const rest = parts.slice(1).join(" ");
152
+ await handleSubcommand(sub, rest, ctx);
153
+ },
154
+ });
155
+
156
+ // ----- Individual alias commands (e.g. /ct-sync, /ct-init) ---------------
157
+ for (const sub of SUBCOMMAND_DEFS) {
158
+ pi.registerCommand(`ct-${sub.name}`, {
159
+ description: sub.description,
160
+ async handler(args, ctx) {
161
+ await handleSubcommand(sub.name, args ?? "", ctx);
162
+ },
163
+ });
164
+ }
165
+
166
+ // ----- LLM tools ---------------------------------------------------------
167
+
168
+ pi.registerTool({
169
+ name: "ct_sync",
170
+ label: "Catalog Sync",
171
+ description:
172
+ "Synchronize the catalog with the remote gist. Pushes local changes and pulls remote changes, resolving conflicts.",
173
+ promptSnippet: "Sync catalog with remote",
174
+ promptGuidelines: [
175
+ "Use ct_sync when the user asks to sync their catalog or when catalog state may be stale.",
176
+ ],
177
+ parameters: Type.Object({
178
+ force: Type.Optional(Type.Boolean({ description: "Force sync even if no changes detected" })),
179
+ }),
180
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
181
+ try {
182
+ const args: CommandArgs = { positional: [], flags: params.force ? { force: true } : {} };
183
+ await syncCommand(args, ctx as unknown as SyncCtx);
184
+ return { content: [{ type: "text" as const, text: "Sync completed." }], details: undefined as unknown };
185
+ } catch (err) {
186
+ return { content: [{ type: "text" as const, text: `Sync failed: ${err instanceof Error ? err.message : String(err)}` }], details: undefined as unknown };
187
+ }
188
+ },
189
+ });
190
+
191
+ pi.registerTool({
192
+ name: "ct_add",
193
+ label: "Catalog Add",
194
+ description:
195
+ "Add a package to the catalog by name and source. Source must start with 'npm:' or 'git:'.",
196
+ promptSnippet: "Add a package to the catalog",
197
+ promptGuidelines: [
198
+ "Use ct_add when the user asks to add a new package or skill to their catalog.",
199
+ ],
200
+ parameters: Type.Object({
201
+ name: Type.String({ description: "Package name" }),
202
+ source: Type.String({ description: "Package source (npm:… or git:…)" }),
203
+ rating: Type.Optional(Type.String({ description: "Initial rating (core, useful, debatable)" })),
204
+ }),
205
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
206
+ try {
207
+ const args: CommandArgs = {
208
+ positional: [params.name, params.source],
209
+ flags: params.rating ? { rating: params.rating } : {},
210
+ };
211
+ await addCommand(args, ctx as unknown as AddCtx);
212
+ return { content: [{ type: "text" as const, text: `Added ${params.name}.` }], details: undefined as unknown };
213
+ } catch (err) {
214
+ return { content: [{ type: "text" as const, text: `Add failed: ${err instanceof Error ? err.message : String(err)}` }], details: undefined as unknown };
215
+ }
216
+ },
217
+ });
218
+
219
+ pi.registerTool({
220
+ name: "ct_remove",
221
+ label: "Catalog Remove",
222
+ description: "Remove a package from the catalog by name.",
223
+ promptSnippet: "Remove a package from the catalog",
224
+ promptGuidelines: [
225
+ "Use ct_remove when the user asks to remove or uninstall a package from their catalog.",
226
+ ],
227
+ parameters: Type.Object({
228
+ name: Type.String({ description: "Package name to remove" }),
229
+ }),
230
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
231
+ try {
232
+ const args: CommandArgs = { positional: [params.name], flags: {} };
233
+ await removeCommand(args, ctx as unknown as RemoveCtx);
234
+ return { content: [{ type: "text" as const, text: `Removed ${params.name}.` }], details: undefined as unknown };
235
+ } catch (err) {
236
+ return { content: [{ type: "text" as const, text: `Remove failed: ${err instanceof Error ? err.message : String(err)}` }], details: undefined as unknown };
237
+ }
238
+ },
239
+ });
240
+
241
+ pi.registerTool({
242
+ name: "ct_toggle",
243
+ label: "Catalog Toggle",
244
+ description:
245
+ "Toggle a package's rating through the cycle: core → useful → debatable → disabled → core.",
246
+ promptSnippet: "Toggle a package's catalog rating",
247
+ promptGuidelines: [
248
+ "Use ct_toggle when the user wants to cycle a package's rating.",
249
+ ],
250
+ parameters: Type.Object({
251
+ name: Type.String({ description: "Package name to toggle" }),
252
+ }),
253
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
254
+ try {
255
+ const args: CommandArgs = { positional: [params.name], flags: {} };
256
+ await toggleCommand(args, ctx as unknown as ToggleCtx);
257
+ return { content: [{ type: "text" as const, text: `Toggled ${params.name}.` }], details: undefined as unknown };
258
+ } catch (err) {
259
+ return { content: [{ type: "text" as const, text: `Toggle failed: ${err instanceof Error ? err.message : String(err)}` }], details: undefined as unknown };
260
+ }
261
+ },
262
+ });
263
+
264
+ pi.registerTool({
265
+ name: "ct_status",
266
+ label: "Catalog Status",
267
+ description: "Show the current catalog status including package counts and sync state.",
268
+ promptSnippet: "Show catalog status",
269
+ promptGuidelines: [
270
+ "Use ct_status when the user wants to check catalog health, package counts, or sync state.",
271
+ ],
272
+ parameters: Type.Object({
273
+ verbose: Type.Optional(Type.Boolean({ description: "Show detailed status" })),
274
+ }),
275
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
276
+ try {
277
+ const args: CommandArgs = { positional: [], flags: params.verbose ? { verbose: true } : {} };
278
+ await statusCommand(args, ctx as unknown as StatusCtx);
279
+ return { content: [{ type: "text" as const, text: "Status displayed." }], details: undefined as unknown };
280
+ } catch (err) {
281
+ return { content: [{ type: "text" as const, text: `Status failed: ${err instanceof Error ? err.message : String(err)}` }], details: undefined as unknown };
282
+ }
283
+ },
284
+ });
285
+ }
@@ -0,0 +1,109 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * Result of running a child-process command.
9
+ * Minimal version used internally by this module.
10
+ */
11
+ interface ShellResult {
12
+ stdout: string;
13
+ stderr: string;
14
+ exitCode: number;
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Internal helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Run a command via `execFile` and resolve with a ShellResult regardless of
23
+ * exit code. Rejects only on truly unexpected errors (e.g. bad arguments).
24
+ */
25
+ function runQuiet(
26
+ command: string,
27
+ args: string[],
28
+ timeout = 5_000,
29
+ ): Promise<ShellResult> {
30
+ return new Promise<ShellResult>((resolve) => {
31
+ execFile(
32
+ command,
33
+ args,
34
+ { timeout, maxBuffer: 1024 * 1024 },
35
+ (error, stdout, stderr) => {
36
+ const out = typeof stdout === "string" ? stdout : String(stdout ?? "");
37
+ const err = typeof stderr === "string" ? stderr : String(stderr ?? "");
38
+
39
+ if (error) {
40
+ const exitCode =
41
+ typeof (error as Error & { status?: number }).status === "number"
42
+ ? (error as Error & { status?: number }).status!
43
+ : 1;
44
+ resolve({ stdout: out, stderr: err, exitCode });
45
+ } else {
46
+ resolve({ stdout: out, stderr: err, exitCode: 0 });
47
+ }
48
+ },
49
+ );
50
+ });
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Public API
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Check whether the GitHub CLI (`gh`) is installed on the system.
59
+ *
60
+ * Returns `true` when `gh --version` exits with code 0, `false` otherwise.
61
+ */
62
+ export async function isGhInstalled(): Promise<boolean> {
63
+ try {
64
+ const result = await runQuiet("gh", ["--version"]);
65
+ return result.exitCode === 0;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Check whether the GitHub CLI (`gh`) is installed and the user is
73
+ * authenticated.
74
+ *
75
+ * Returns `true` when `gh auth status` exits with code 0, `false` otherwise
76
+ * (including when `gh` is not installed at all).
77
+ */
78
+ export async function checkAuth(): Promise<boolean> {
79
+ const result = await runQuiet("gh", ["auth", "status"]);
80
+ return result.exitCode === 0;
81
+ }
82
+
83
+ /**
84
+ * Obtain a GitHub personal-access token.
85
+ *
86
+ * Strategy:
87
+ * 1. Try `gh auth token` — if it succeeds, return the trimmed token.
88
+ * 2. Fall back to the `GITHUB_TOKEN` environment variable.
89
+ * 3. Return `undefined` when neither source yields a token.
90
+ */
91
+ export async function getToken(): Promise<string | undefined> {
92
+ // 1. Try gh CLI
93
+ const result = await runQuiet("gh", ["auth", "token"]);
94
+ if (result.exitCode === 0) {
95
+ const token = result.stdout.trim();
96
+ if (token.length > 0) {
97
+ return token;
98
+ }
99
+ }
100
+
101
+ // 2. Fall back to environment variable
102
+ const envToken = process.env.GITHUB_TOKEN;
103
+ if (envToken && envToken.length > 0) {
104
+ return envToken;
105
+ }
106
+
107
+ // 3. Nothing available
108
+ return undefined;
109
+ }
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { catalogDir } from "../config/paths.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Gist ID cache
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Path to the `.gist` file that caches the gist ID for a profile.
12
+ * Located inside `~/.pi/sf/catalog/.gist`.
13
+ */
14
+ export function gistCachePath(home?: string): string {
15
+ return path.join(catalogDir(home), ".gist");
16
+ }
17
+
18
+ /** Read the cached gist ID, or `undefined` if not cached. */
19
+ export function readCachedGistId(home?: string): string | undefined {
20
+ const cacheFile = gistCachePath(home);
21
+ try {
22
+ if (fs.existsSync(cacheFile)) {
23
+ const id = fs.readFileSync(cacheFile, "utf-8").trim();
24
+ return id.length > 0 ? id : undefined;
25
+ }
26
+ } catch {
27
+ // ignore read errors
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ /** Persist the gist ID to the cache file. */
33
+ export function writeCachedGistId(gistId: string, home?: string): void {
34
+ const cacheFile = gistCachePath(home);
35
+ const dir = path.dirname(cacheFile);
36
+ if (!fs.existsSync(dir)) {
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ }
39
+ fs.writeFileSync(cacheFile, gistId, "utf-8");
40
+ }
@@ -0,0 +1,253 @@
1
+ import { execFile } from "node:child_process";
2
+ import { Octokit } from "@octokit/rest";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** Map of filename → file content for gist operations. */
9
+ export interface GistFiles {
10
+ [filename: string]: string;
11
+ }
12
+
13
+ /** Result of a gist create or update operation. */
14
+ export interface GistResult {
15
+ id: string;
16
+ url?: string;
17
+ }
18
+
19
+ /** Minimal shape of a gist returned from list/find operations. */
20
+ export interface GistSummary {
21
+ id: string;
22
+ description?: string;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Internal helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Execute a command via `execFile` and return a promise.
31
+ */
32
+ function exec(
33
+ command: string,
34
+ args: string[],
35
+ options?: { stdin?: string },
36
+ ): Promise<{ stdout: string; stderr: string }> {
37
+ return new Promise((resolve, reject) => {
38
+ const child = execFile(
39
+ command,
40
+ args,
41
+ { maxBuffer: 1024 * 1024 },
42
+ (error, stdout, stderr) => {
43
+ if (error) {
44
+ reject(error);
45
+ return;
46
+ }
47
+ resolve({
48
+ stdout: typeof stdout === "string" ? stdout : String(stdout ?? ""),
49
+ stderr: typeof stderr === "string" ? stderr : String(stderr ?? ""),
50
+ });
51
+ },
52
+ );
53
+ if (options?.stdin !== undefined && child.stdin) {
54
+ child.stdin.write(options.stdin);
55
+ child.stdin.end();
56
+ }
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Lazy-initialized Octokit instance (only created when needed as fallback).
62
+ */
63
+ let _octokit: InstanceType<typeof Octokit> | null = null;
64
+ function getOctokit(): InstanceType<typeof Octokit> {
65
+ if (!_octokit) {
66
+ _octokit = new Octokit();
67
+ }
68
+ return _octokit;
69
+ }
70
+
71
+ /** Reset the cached Octokit (used by tests to ensure fresh mocks). */
72
+ export function _resetOctokit(): void {
73
+ _octokit = null;
74
+ }
75
+
76
+ /**
77
+ * Build the JSON body for the GitHub Gists API from a GistFiles map.
78
+ */
79
+ function gistApiBody(files: GistFiles): Record<string, { content: string }> {
80
+ const result: Record<string, { content: string }> = {};
81
+ for (const [name, content] of Object.entries(files)) {
82
+ result[name] = { content };
83
+ }
84
+ return result;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // createGist
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Create a new GitHub Gist with the given files and description.
93
+ *
94
+ * Tries `gh api` first; falls back to the Octokit REST API if the
95
+ * `gh` CLI is unavailable.
96
+ */
97
+ export async function createGist(
98
+ files: GistFiles,
99
+ description: string,
100
+ ): Promise<GistResult> {
101
+ // --- Try gh CLI first ---
102
+ try {
103
+ const body = JSON.stringify({
104
+ description,
105
+ public: false,
106
+ files: gistApiBody(files),
107
+ });
108
+
109
+ const { stdout } = await exec("gh", [
110
+ "api",
111
+ "--method", "POST",
112
+ "/gists",
113
+ "--input", "-",
114
+ ], { stdin: body });
115
+
116
+ const data = JSON.parse(stdout);
117
+ return { id: data.id, url: data.html_url };
118
+ } catch (ghError) {
119
+ // gh was found but the API call failed, or gh was not found (ENOENT).
120
+ // Fall through to octokit in either case.
121
+
122
+ // --- Fallback: Octokit REST API ---
123
+ const octokit = getOctokit();
124
+ const response = await octokit.gists.create({
125
+ description,
126
+ public: false,
127
+ files: gistApiBody(files),
128
+ });
129
+
130
+ return {
131
+ id: response.data.id!,
132
+ url: response.data.html_url ?? undefined,
133
+ };
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // readGist
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Fetch a GitHub Gist by ID and return its files.
143
+ *
144
+ * Tries `gh gist view --json` first; falls back to Octokit.
145
+ */
146
+ export async function readGist(gistId: string): Promise<{
147
+ id: string;
148
+ files: Record<string, { content: string }>;
149
+ }> {
150
+ // --- Try gh CLI first ---
151
+ try {
152
+ const { stdout } = await exec("gh", [
153
+ "gist", "view", gistId, "--json", "id,files",
154
+ ]);
155
+
156
+ const data = JSON.parse(stdout);
157
+ return {
158
+ id: data.id,
159
+ files: data.files,
160
+ };
161
+ } catch {
162
+ // --- Fallback: Octokit REST API ---
163
+ const octokit = getOctokit();
164
+ const response = await octokit.gists.get({ gist_id: gistId });
165
+
166
+ const files: Record<string, { content: string }> = {};
167
+ for (const [name, file] of Object.entries(response.data.files ?? {})) {
168
+ files[name] = { content: (file as { content?: string }).content ?? "" };
169
+ }
170
+
171
+ return { id: response.data.id!, files };
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // updateGist
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Update an existing GitHub Gist with new file contents.
181
+ *
182
+ * Tries `gh api` first; falls back to Octokit.
183
+ */
184
+ export async function updateGist(
185
+ gistId: string,
186
+ files: GistFiles,
187
+ ): Promise<GistResult> {
188
+ // --- Try gh CLI first ---
189
+ try {
190
+ const body = JSON.stringify({
191
+ files: gistApiBody(files),
192
+ });
193
+
194
+ const { stdout } = await exec("gh", [
195
+ "api",
196
+ "--method", "PATCH",
197
+ `/gists/${gistId}`,
198
+ "--input", "-",
199
+ ], { stdin: body });
200
+
201
+ const data = JSON.parse(stdout);
202
+ return { id: data.id, url: data.html_url };
203
+ } catch {
204
+ // --- Fallback: Octokit REST API ---
205
+ const octokit = getOctokit();
206
+ const response = await octokit.gists.update({
207
+ gist_id: gistId,
208
+ files: gistApiBody(files),
209
+ });
210
+
211
+ return {
212
+ id: response.data.id!,
213
+ url: response.data.html_url ?? undefined,
214
+ };
215
+ }
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // findGistByDescription
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Find an existing Gist by its description field.
224
+ *
225
+ * Tries `gh gist list --json` first; falls back to Octokit.
226
+ * Returns `undefined` if no matching gist is found.
227
+ */
228
+ export async function findGistByDescription(
229
+ description: string,
230
+ ): Promise<GistSummary | undefined> {
231
+ // --- Try gh CLI first ---
232
+ try {
233
+ const { stdout } = await exec("gh", [
234
+ "gist", "list", "--json", "id,description",
235
+ ]);
236
+
237
+ const gists: Array<{ id: string; description?: string }> = JSON.parse(stdout);
238
+ return gists.find((g) => g.description === description);
239
+ } catch {
240
+ // --- Fallback: Octokit REST API ---
241
+ try {
242
+ const octokit = getOctokit();
243
+ const response = await octokit.gists.list();
244
+
245
+ const raw = response.data as Array<{ id: string; description: string | null | undefined }>;
246
+ const found = raw.find((g) => g.description === description);
247
+ if (!found) return undefined;
248
+ return { id: found.id, description: found.description ?? undefined };
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ }
253
+ }