@fredlackey/devutils 0.0.19 → 0.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 (122) hide show
  1. package/README.md +223 -32
  2. package/package.json +7 -5
  3. package/src/api/loader.js +229 -0
  4. package/src/api/registry.json +62 -0
  5. package/src/cli.js +305 -0
  6. package/src/commands/ai/index.js +16 -0
  7. package/src/commands/ai/launch.js +112 -0
  8. package/src/commands/ai/list.js +54 -0
  9. package/src/commands/ai/resume.js +70 -0
  10. package/src/commands/ai/sessions.js +121 -0
  11. package/src/commands/ai/set.js +131 -0
  12. package/src/commands/ai/show.js +74 -0
  13. package/src/commands/ai/tools.js +46 -0
  14. package/src/commands/alias/add.js +93 -0
  15. package/src/commands/alias/helpers.js +107 -0
  16. package/src/commands/alias/index.js +14 -0
  17. package/src/commands/alias/list.js +55 -0
  18. package/src/commands/alias/remove.js +62 -0
  19. package/src/commands/alias/sync.js +109 -0
  20. package/src/commands/api/disable.js +73 -0
  21. package/src/commands/api/enable.js +148 -0
  22. package/src/commands/api/index.js +15 -0
  23. package/src/commands/api/list.js +66 -0
  24. package/src/commands/api/update.js +87 -0
  25. package/src/commands/auth/index.js +15 -0
  26. package/src/commands/auth/list.js +49 -0
  27. package/src/commands/auth/login.js +384 -0
  28. package/src/commands/auth/logout.js +111 -0
  29. package/src/commands/auth/refresh.js +184 -0
  30. package/src/commands/auth/services.js +169 -0
  31. package/src/commands/auth/status.js +104 -0
  32. package/src/commands/config/export.js +224 -0
  33. package/src/commands/config/get.js +52 -0
  34. package/src/commands/config/import.js +308 -0
  35. package/src/commands/config/index.js +17 -0
  36. package/src/commands/config/init.js +143 -0
  37. package/src/commands/config/reset.js +57 -0
  38. package/src/commands/config/set.js +93 -0
  39. package/src/commands/config/show.js +35 -0
  40. package/src/commands/help.js +338 -0
  41. package/src/commands/identity/add.js +133 -0
  42. package/src/commands/identity/index.js +17 -0
  43. package/src/commands/identity/link.js +76 -0
  44. package/src/commands/identity/list.js +48 -0
  45. package/src/commands/identity/remove.js +72 -0
  46. package/src/commands/identity/show.js +65 -0
  47. package/src/commands/identity/sync.js +172 -0
  48. package/src/commands/identity/unlink.js +57 -0
  49. package/src/commands/ignore/add.js +165 -0
  50. package/src/commands/ignore/index.js +14 -0
  51. package/src/commands/ignore/list.js +89 -0
  52. package/src/commands/ignore/markers.js +43 -0
  53. package/src/commands/ignore/remove.js +164 -0
  54. package/src/commands/ignore/show.js +169 -0
  55. package/src/commands/machine/detect.js +122 -0
  56. package/src/commands/machine/index.js +14 -0
  57. package/src/commands/machine/list.js +74 -0
  58. package/src/commands/machine/set.js +106 -0
  59. package/src/commands/machine/show.js +35 -0
  60. package/src/commands/schema.js +152 -0
  61. package/src/commands/search/collections.js +134 -0
  62. package/src/commands/search/get.js +71 -0
  63. package/src/commands/search/index-cmd.js +54 -0
  64. package/src/commands/search/index.js +21 -0
  65. package/src/commands/search/keyword.js +60 -0
  66. package/src/commands/search/qmd.js +70 -0
  67. package/src/commands/search/query.js +64 -0
  68. package/src/commands/search/semantic.js +62 -0
  69. package/src/commands/search/status.js +46 -0
  70. package/src/commands/status.js +276 -0
  71. package/src/commands/tools/check.js +79 -0
  72. package/src/commands/tools/index.js +14 -0
  73. package/src/commands/tools/install.js +110 -0
  74. package/src/commands/tools/list.js +91 -0
  75. package/src/commands/tools/search.js +60 -0
  76. package/src/commands/update.js +113 -0
  77. package/src/commands/util/add.js +151 -0
  78. package/src/commands/util/index.js +15 -0
  79. package/src/commands/util/list.js +97 -0
  80. package/src/commands/util/remove.js +76 -0
  81. package/src/commands/util/run.js +79 -0
  82. package/src/commands/util/show.js +67 -0
  83. package/src/commands/version.js +33 -0
  84. package/src/installers/_template.js +104 -0
  85. package/src/installers/git.js +150 -0
  86. package/src/installers/homebrew.js +190 -0
  87. package/src/installers/node.js +223 -0
  88. package/src/installers/registry.json +29 -0
  89. package/src/lib/config.js +125 -0
  90. package/src/lib/detect.js +74 -0
  91. package/src/lib/errors.js +114 -0
  92. package/src/lib/github.js +315 -0
  93. package/src/lib/installer.js +225 -0
  94. package/src/lib/output.js +239 -0
  95. package/src/lib/platform.js +112 -0
  96. package/src/lib/platforms/amazon-linux.js +41 -0
  97. package/src/lib/platforms/gitbash.js +46 -0
  98. package/src/lib/platforms/macos.js +45 -0
  99. package/src/lib/platforms/raspbian.js +41 -0
  100. package/src/lib/platforms/ubuntu.js +39 -0
  101. package/src/lib/platforms/windows.js +45 -0
  102. package/src/lib/prompt.js +161 -0
  103. package/src/lib/schema.js +211 -0
  104. package/src/lib/shell.js +75 -0
  105. package/src/patterns/gitignore/claude-code.txt +25 -0
  106. package/src/patterns/gitignore/docker.txt +15 -0
  107. package/src/patterns/gitignore/go.txt +24 -0
  108. package/src/patterns/gitignore/java.txt +38 -0
  109. package/src/patterns/gitignore/jetbrains.txt +26 -0
  110. package/src/patterns/gitignore/linux.txt +18 -0
  111. package/src/patterns/gitignore/macos.txt +27 -0
  112. package/src/patterns/gitignore/node.txt +51 -0
  113. package/src/patterns/gitignore/python.txt +55 -0
  114. package/src/patterns/gitignore/rust.txt +14 -0
  115. package/src/patterns/gitignore/terraform.txt +30 -0
  116. package/src/patterns/gitignore/vscode.txt +15 -0
  117. package/src/patterns/gitignore/windows.txt +25 -0
  118. package/src/utils/clone/index.js +165 -0
  119. package/src/utils/git-push/index.js +230 -0
  120. package/src/utils/git-status/index.js +116 -0
  121. package/src/utils/git-status/unix.sh +75 -0
  122. package/src/utils/registry.json +41 -0
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ /**
8
+ * The path to the DevUtils configuration directory.
9
+ * All user data, preferences, and plugin data live here.
10
+ * @type {string}
11
+ */
12
+ const CONFIG_DIR = path.join(os.homedir(), '.devutils');
13
+
14
+ /**
15
+ * Creates the ~/.devutils/ directory if it doesn't exist.
16
+ * Idempotent: calling it multiple times has the same effect as calling it once.
17
+ *
18
+ * @returns {string} The config directory path.
19
+ */
20
+ function ensureDir() {
21
+ if (!fs.existsSync(CONFIG_DIR)) {
22
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
23
+ }
24
+ return CONFIG_DIR;
25
+ }
26
+
27
+ /**
28
+ * Returns the full path to a file inside ~/.devutils/.
29
+ * Does not check if the file exists.
30
+ *
31
+ * @param {string} filename - The filename (e.g. 'config.json').
32
+ * @returns {string} The full file path.
33
+ */
34
+ function getPath(filename) {
35
+ return path.join(CONFIG_DIR, filename);
36
+ }
37
+
38
+ /**
39
+ * Checks if a file exists in ~/.devutils/.
40
+ *
41
+ * @param {string} filename - The filename to check.
42
+ * @returns {boolean}
43
+ */
44
+ function exists(filename) {
45
+ return fs.existsSync(getPath(filename));
46
+ }
47
+
48
+ /**
49
+ * Reads a JSON file from ~/.devutils/ and returns the parsed object.
50
+ * Returns null if the file doesn't exist or can't be parsed.
51
+ *
52
+ * @param {string} filename - The filename to read.
53
+ * @returns {object|null} The parsed JSON object, or null.
54
+ */
55
+ function read(filename) {
56
+ const filePath = getPath(filename);
57
+ if (!fs.existsSync(filePath)) {
58
+ return null;
59
+ }
60
+ try {
61
+ const content = fs.readFileSync(filePath, 'utf8');
62
+ return JSON.parse(content);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Writes a JavaScript object to a JSON file in ~/.devutils/.
70
+ * Creates the directory if it doesn't exist. Uses 2-space indentation
71
+ * and a trailing newline for human-readability.
72
+ *
73
+ * @param {string} filename - The filename to write.
74
+ * @param {object} data - The data to write.
75
+ * @returns {string} The full file path that was written.
76
+ */
77
+ function write(filename, data) {
78
+ ensureDir();
79
+ const filePath = getPath(filename);
80
+ const content = JSON.stringify(data, null, 2) + '\n';
81
+ fs.writeFileSync(filePath, content, 'utf8');
82
+ return filePath;
83
+ }
84
+
85
+ /**
86
+ * Deletes a file from ~/.devutils/.
87
+ * Idempotent: does not throw if the file doesn't exist.
88
+ *
89
+ * @param {string} filename - The filename to delete.
90
+ * @returns {string} The full file path (whether it existed or not).
91
+ */
92
+ function remove(filename) {
93
+ const filePath = getPath(filename);
94
+ if (fs.existsSync(filePath)) {
95
+ fs.unlinkSync(filePath);
96
+ }
97
+ return filePath;
98
+ }
99
+
100
+ /**
101
+ * Lists all files (not directories) in ~/.devutils/.
102
+ * Returns an empty array if the directory doesn't exist.
103
+ *
104
+ * @returns {string[]} Array of filenames.
105
+ */
106
+ function list() {
107
+ if (!fs.existsSync(CONFIG_DIR)) {
108
+ return [];
109
+ }
110
+ return fs.readdirSync(CONFIG_DIR).filter(entry => {
111
+ const fullPath = path.join(CONFIG_DIR, entry);
112
+ return fs.statSync(fullPath).isFile();
113
+ });
114
+ }
115
+
116
+ module.exports = {
117
+ CONFIG_DIR,
118
+ ensureDir,
119
+ getPath,
120
+ exists,
121
+ read,
122
+ write,
123
+ remove,
124
+ list,
125
+ };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Environment variables set by AI coding assistants.
5
+ * Each entry is [varName, expectedValue].
6
+ * expectedValue can be null to match any truthy value.
7
+ */
8
+ const AI_ENV_VARS = [
9
+ ['CLAUDECODE', '1'],
10
+ ['GEMINI_CLI', '1'],
11
+ ];
12
+
13
+ /**
14
+ * Environment variables set by CI/CD systems.
15
+ * Each entry is [varName, expectedValue].
16
+ * expectedValue can be null to match any truthy value.
17
+ */
18
+ const CI_ENV_VARS = [
19
+ ['CI', 'true'],
20
+ ['BUILD_NUMBER', null],
21
+ ['TF_BUILD', 'True'],
22
+ ];
23
+
24
+ /**
25
+ * Checks if any environment variable in the list is set and matches.
26
+ * When expectedValue is null, any truthy value counts as a match.
27
+ *
28
+ * @param {Array<[string, string|null]>} varList - Array of [varName, expectedValue] pairs.
29
+ * @returns {boolean}
30
+ */
31
+ function matchesEnv(varList) {
32
+ for (const [varName, expectedValue] of varList) {
33
+ const actual = process.env[varName];
34
+ if (actual === undefined) continue;
35
+ if (expectedValue === null) return true;
36
+ if (actual === expectedValue) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Detects the output mode based on the calling environment.
43
+ * Checks three layers in priority order: AI tools, CI/CD, then TTY.
44
+ *
45
+ * - AI tools (Claude Code, Gemini CLI) get JSON format.
46
+ * - CI/CD systems (GitHub Actions, Jenkins, Azure) get JSON format.
47
+ * - Humans at a terminal get table format.
48
+ * - Piped or redirected output gets JSON format.
49
+ *
50
+ * @returns {{ format: string, caller: string }}
51
+ * - format: 'json' or 'table'
52
+ * - caller: 'ai', 'ci', 'tty', or 'pipe'
53
+ */
54
+ function detectOutputMode() {
55
+ // Layer 1: AI tool environment
56
+ if (matchesEnv(AI_ENV_VARS)) {
57
+ return { format: 'json', caller: 'ai' };
58
+ }
59
+
60
+ // Layer 2: CI/CD environment
61
+ if (matchesEnv(CI_ENV_VARS)) {
62
+ return { format: 'json', caller: 'ci' };
63
+ }
64
+
65
+ // Layer 3: TTY detection
66
+ if (process.stdout.isTTY) {
67
+ return { format: 'table', caller: 'tty' };
68
+ }
69
+
70
+ // Fallback: piped or redirected output
71
+ return { format: 'json', caller: 'pipe' };
72
+ }
73
+
74
+ module.exports = { detectOutputMode };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Creates a structured error object in the DevUtils standard format.
5
+ * Does not write anything or exit the process.
6
+ *
7
+ * @param {number} code - Numeric error code (400=bad input, 401=unauthorized, 404=not found, 500=internal).
8
+ * @param {string} message - Human-readable description of what went wrong.
9
+ * @param {string} [service='devutils'] - The service that generated the error.
10
+ * @returns {{ error: { code: number, message: string, service: string } }}
11
+ */
12
+ function createError(code, message, service) {
13
+ return {
14
+ error: {
15
+ code: code,
16
+ message: message,
17
+ service: service || 'devutils',
18
+ },
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Creates a structured error, writes it to stderr, and exits with code 1.
24
+ * Uses detect.js to determine the output format and output.js to render.
25
+ *
26
+ * Uses lazy require() to avoid circular dependency issues.
27
+ *
28
+ * @param {number} code - Numeric error code.
29
+ * @param {string} message - Human-readable error message.
30
+ * @param {string} [service] - The service that generated the error.
31
+ */
32
+ function throwError(code, message, service) {
33
+ const err = createError(code, message, service);
34
+ const output = require('./output');
35
+ const detect = require('./detect');
36
+
37
+ const { format } = detect.detectOutputMode();
38
+ const formatted = output.renderError(err, format);
39
+ process.stderr.write(formatted + '\n');
40
+ process.exit(1);
41
+ }
42
+
43
+ /**
44
+ * Alias for throwError. Some command stories use context.errors.exit().
45
+ */
46
+ const exit = throwError;
47
+
48
+ /**
49
+ * Checks if an object matches the DevUtils error shape.
50
+ * Uses duck-typing: if it has error.code (number) and error.message (string), it's valid.
51
+ *
52
+ * @param {*} obj - The object to check.
53
+ * @returns {boolean}
54
+ */
55
+ function isDevUtilsError(obj) {
56
+ if (obj === null || obj === undefined) return false;
57
+ if (typeof obj !== 'object') return false;
58
+ if (!obj.error) return false;
59
+ if (typeof obj.error !== 'object') return false;
60
+ if (typeof obj.error.code !== 'number') return false;
61
+ if (typeof obj.error.message !== 'string') return false;
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Creates a 404 Not Found error.
67
+ * @param {string} message - What was not found.
68
+ * @param {string} [service] - The service that generated the error.
69
+ * @returns {{ error: { code: number, message: string, service: string } }}
70
+ */
71
+ function notFound(message, service) {
72
+ return createError(404, message, service);
73
+ }
74
+
75
+ /**
76
+ * Creates a 400 Bad Input error.
77
+ * @param {string} message - What was wrong with the input.
78
+ * @param {string} [service] - The service that generated the error.
79
+ * @returns {{ error: { code: number, message: string, service: string } }}
80
+ */
81
+ function badInput(message, service) {
82
+ return createError(400, message, service);
83
+ }
84
+
85
+ /**
86
+ * Creates a 500 Internal Error.
87
+ * @param {string} message - What went wrong internally.
88
+ * @param {string} [service] - The service that generated the error.
89
+ * @returns {{ error: { code: number, message: string, service: string } }}
90
+ */
91
+ function internal(message, service) {
92
+ return createError(500, message, service);
93
+ }
94
+
95
+ /**
96
+ * Creates a 401 Unauthorized error.
97
+ * @param {string} message - Why access was denied.
98
+ * @param {string} [service] - The service that generated the error.
99
+ * @returns {{ error: { code: number, message: string, service: string } }}
100
+ */
101
+ function unauthorized(message, service) {
102
+ return createError(401, message, service);
103
+ }
104
+
105
+ module.exports = {
106
+ createError,
107
+ throwError,
108
+ exit,
109
+ isDevUtilsError,
110
+ notFound,
111
+ badInput,
112
+ internal,
113
+ unauthorized,
114
+ };
@@ -0,0 +1,315 @@
1
+ 'use strict';
2
+
3
+ const shell = require('./shell');
4
+
5
+ /**
6
+ * Check if the GitHub CLI (gh) is installed on the system.
7
+ * @returns {boolean} True if gh is available on PATH
8
+ */
9
+ function isInstalled() {
10
+ return shell.commandExists('gh');
11
+ }
12
+
13
+ /**
14
+ * Throw a clear error if gh is not installed.
15
+ * Call this at the top of any function that needs gh.
16
+ */
17
+ function requireGh() {
18
+ if (!isInstalled()) {
19
+ throw new Error(
20
+ 'GitHub CLI (gh) is not installed.\n' +
21
+ 'Install it from: https://cli.github.com/\n' +
22
+ 'Or with Homebrew: brew install gh'
23
+ );
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Check if the user is authenticated with GitHub via gh.
29
+ * @returns {Promise<boolean>} True if authenticated
30
+ */
31
+ async function isAuthenticated() {
32
+ requireGh();
33
+ const result = await shell.exec('gh auth status');
34
+ return result.exitCode === 0;
35
+ }
36
+
37
+ /**
38
+ * Get the authenticated username.
39
+ * @returns {Promise<string|null>} The GitHub username, or null if not authenticated
40
+ */
41
+ async function getUsername() {
42
+ requireGh();
43
+ const result = await shell.exec('gh api user --jq .login');
44
+ if (result.exitCode !== 0) return null;
45
+ return result.stdout.trim() || null;
46
+ }
47
+
48
+ /**
49
+ * Create a new GitHub repository.
50
+ * @param {string} name - Repository name
51
+ * @param {boolean} isPrivate - Whether the repo should be private
52
+ * @returns {Promise<{ success: boolean, url: string|null, error: string|null }>}
53
+ */
54
+ async function createRepo(name, isPrivate = true) {
55
+ requireGh();
56
+ const visibility = isPrivate ? '--private' : '--public';
57
+ const result = await shell.exec(`gh repo create "${name}" ${visibility} --yes`);
58
+
59
+ if (result.exitCode !== 0) {
60
+ return { success: false, url: null, error: result.stderr };
61
+ }
62
+
63
+ // gh repo create outputs the repo URL
64
+ return { success: true, url: result.stdout.trim(), error: null };
65
+ }
66
+
67
+ /**
68
+ * Clone a GitHub repository to a local directory.
69
+ * @param {string} url - Repository URL or owner/name
70
+ * @param {string} dest - Local destination directory
71
+ * @returns {Promise<{ success: boolean, error: string|null }>}
72
+ */
73
+ async function cloneRepo(url, dest) {
74
+ requireGh();
75
+ const result = await shell.exec(`gh repo clone "${url}" "${dest}"`);
76
+
77
+ if (result.exitCode !== 0) {
78
+ return { success: false, error: result.stderr };
79
+ }
80
+
81
+ return { success: true, error: null };
82
+ }
83
+
84
+ /**
85
+ * Push local changes to the remote repository.
86
+ * Runs git add, commit, and push inside the given directory.
87
+ * If there are no changes, returns success without committing.
88
+ * @param {string} dest - Local repository directory
89
+ * @param {string} [message] - Commit message (defaults to 'DevUtils config update')
90
+ * @returns {Promise<{ success: boolean, error: string|null }>}
91
+ */
92
+ async function pushRepo(dest, message = 'DevUtils config update') {
93
+ const escapedMessage = message.replace(/"/g, '\\"');
94
+
95
+ // Stage all changes
96
+ let result = await shell.exec('git add -A', { cwd: dest });
97
+ if (result.exitCode !== 0) {
98
+ return { success: false, error: 'git add failed: ' + result.stderr };
99
+ }
100
+
101
+ // Check if there is anything to commit
102
+ const status = await shell.exec('git status --porcelain', { cwd: dest });
103
+ if (!status.stdout.trim()) {
104
+ // Nothing to commit -- still a success, just nothing changed
105
+ return { success: true, error: null };
106
+ }
107
+
108
+ // Commit
109
+ result = await shell.exec(`git commit -m "${escapedMessage}"`, { cwd: dest });
110
+ if (result.exitCode !== 0) {
111
+ return { success: false, error: 'git commit failed: ' + result.stderr };
112
+ }
113
+
114
+ // Push
115
+ result = await shell.exec('git push', { cwd: dest });
116
+ if (result.exitCode !== 0) {
117
+ return { success: false, error: 'git push failed: ' + result.stderr };
118
+ }
119
+
120
+ return { success: true, error: null };
121
+ }
122
+
123
+ /**
124
+ * Pull the latest changes from the remote repository.
125
+ * @param {string} dest - Local repository directory
126
+ * @returns {Promise<{ success: boolean, error: string|null }>}
127
+ */
128
+ async function pullRepo(dest) {
129
+ const result = await shell.exec('git pull', { cwd: dest });
130
+
131
+ if (result.exitCode !== 0) {
132
+ return { success: false, error: result.stderr };
133
+ }
134
+
135
+ return { success: true, error: null };
136
+ }
137
+
138
+ /**
139
+ * List the authenticated user's repositories.
140
+ * @param {number} [limit] - Maximum number of repos to return (default: 100)
141
+ * @returns {Promise<Array<{ name: string, url: string, private: boolean }>>}
142
+ */
143
+ async function listRepos(limit = 100) {
144
+ requireGh();
145
+ const result = await shell.exec(
146
+ `gh repo list --json name,url,isPrivate --limit ${limit}`
147
+ );
148
+
149
+ if (result.exitCode !== 0) return [];
150
+
151
+ try {
152
+ const repos = JSON.parse(result.stdout);
153
+ return repos.map(r => ({
154
+ name: r.name,
155
+ url: r.url,
156
+ private: r.isPrivate
157
+ }));
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Create a new GitHub gist.
165
+ * Writes temp files to disk because gh gist create expects file paths.
166
+ * Temp files are cleaned up even if the command fails.
167
+ * @param {Object<string, string>} files - Object mapping filename to content
168
+ * @param {string} [description] - Gist description
169
+ * @param {boolean} [secret] - Whether the gist should be secret (default: true)
170
+ * @returns {Promise<{ success: boolean, id: string|null, url: string|null, error: string|null }>}
171
+ */
172
+ async function createGist(files, description = '', secret = true) {
173
+ requireGh();
174
+
175
+ const fs = require('fs');
176
+ const path = require('path');
177
+ const nodeOs = require('os');
178
+
179
+ const tempDir = fs.mkdtempSync(path.join(nodeOs.tmpdir(), 'devutils-gist-'));
180
+ const tempFiles = [];
181
+
182
+ try {
183
+ for (const [filename, content] of Object.entries(files)) {
184
+ const filePath = path.join(tempDir, filename);
185
+ fs.writeFileSync(filePath, content);
186
+ tempFiles.push(filePath);
187
+ }
188
+
189
+ const secretFlag = secret ? '' : '--public';
190
+ const descFlag = description ? `--desc "${description.replace(/"/g, '\\"')}"` : '';
191
+ const fileArgs = tempFiles.map(f => `"${f}"`).join(' ');
192
+
193
+ const result = await shell.exec(
194
+ `gh gist create ${fileArgs} ${secretFlag} ${descFlag}`
195
+ );
196
+
197
+ if (result.exitCode !== 0) {
198
+ return { success: false, id: null, url: null, error: result.stderr };
199
+ }
200
+
201
+ // gh gist create outputs the gist URL
202
+ const url = result.stdout.trim();
203
+ // Extract the gist ID from the URL (last path segment)
204
+ const id = url.split('/').pop();
205
+
206
+ return { success: true, id, url, error: null };
207
+ } finally {
208
+ // Clean up temp files
209
+ fs.rmSync(tempDir, { recursive: true, force: true });
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Get the contents of a gist by ID.
215
+ * Uses gh api with a jq filter to extract file contents as structured JSON.
216
+ * @param {string} id - The gist ID
217
+ * @returns {Promise<{ success: boolean, files: Object<string, string>|null, error: string|null }>}
218
+ */
219
+ async function getGist(id) {
220
+ requireGh();
221
+ const result = await shell.exec(
222
+ `gh api gists/${id} --jq '.files | to_entries | map({key: .key, value: .value.content}) | from_entries'`
223
+ );
224
+
225
+ if (result.exitCode !== 0) {
226
+ return { success: false, files: null, error: result.stderr };
227
+ }
228
+
229
+ try {
230
+ const files = JSON.parse(result.stdout);
231
+ return { success: true, files, error: null };
232
+ } catch {
233
+ return { success: false, files: null, error: 'Failed to parse gist content' };
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Update an existing gist with new file contents.
239
+ * Updates each file sequentially using gh gist edit --add.
240
+ * @param {string} id - The gist ID
241
+ * @param {Object<string, string>} files - Object mapping filename to new content
242
+ * @returns {Promise<{ success: boolean, error: string|null }>}
243
+ */
244
+ async function updateGist(id, files) {
245
+ requireGh();
246
+
247
+ const fs = require('fs');
248
+ const path = require('path');
249
+ const nodeOs = require('os');
250
+
251
+ const tempDir = fs.mkdtempSync(path.join(nodeOs.tmpdir(), 'devutils-gist-'));
252
+
253
+ try {
254
+ // Write each file to disk and update via gh gist edit
255
+ for (const [filename, content] of Object.entries(files)) {
256
+ const filePath = path.join(tempDir, filename);
257
+ fs.writeFileSync(filePath, content);
258
+
259
+ const result = await shell.exec(
260
+ `gh gist edit ${id} --add "${filePath}"`
261
+ );
262
+
263
+ if (result.exitCode !== 0) {
264
+ return { success: false, error: `Failed to update ${filename}: ${result.stderr}` };
265
+ }
266
+ }
267
+
268
+ return { success: true, error: null };
269
+ } finally {
270
+ fs.rmSync(tempDir, { recursive: true, force: true });
271
+ }
272
+ }
273
+
274
+ /**
275
+ * List the authenticated user's gists.
276
+ * Parses tab-separated output from gh gist list.
277
+ * @param {number} [limit] - Maximum number of gists to return (default: 30)
278
+ * @returns {Promise<Array<{ id: string, description: string, public: boolean, files: string[] }>>}
279
+ */
280
+ async function listGists(limit = 30) {
281
+ requireGh();
282
+ const result = await shell.exec(
283
+ `gh gist list --limit ${limit}`
284
+ );
285
+
286
+ if (result.exitCode !== 0) return [];
287
+
288
+ // gh gist list outputs a tab-separated table
289
+ // Columns: ID, Description, Files, Visibility, Updated
290
+ const lines = result.stdout.split('\n').filter(Boolean);
291
+ return lines.map(line => {
292
+ const parts = line.split('\t');
293
+ return {
294
+ id: parts[0] || '',
295
+ description: parts[1] || '',
296
+ public: (parts[3] || '').trim() === 'public',
297
+ files: (parts[2] || '').split(',').map(f => f.trim()).filter(Boolean)
298
+ };
299
+ });
300
+ }
301
+
302
+ module.exports = {
303
+ isInstalled,
304
+ isAuthenticated,
305
+ getUsername,
306
+ createRepo,
307
+ cloneRepo,
308
+ pushRepo,
309
+ pullRepo,
310
+ listRepos,
311
+ createGist,
312
+ getGist,
313
+ updateGist,
314
+ listGists
315
+ };