@crystalplatforms/adm 0.2.3

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/bin/adm +26 -0
  4. package/bin/adm-tui +13 -0
  5. package/package.json +62 -0
  6. package/scripts/completion/adm.bash +81 -0
  7. package/scripts/completion/adm.zsh +89 -0
  8. package/scripts/install.js +8 -0
  9. package/src/ai/knowledge.js +61 -0
  10. package/src/commands/connect.js +76 -0
  11. package/src/commands/dotfiles.js +72 -0
  12. package/src/commands/issue.js +80 -0
  13. package/src/commands/pr.js +55 -0
  14. package/src/commands/theme.js +103 -0
  15. package/src/commands/uninstall.js +72 -0
  16. package/src/commands/upgrade.js +114 -0
  17. package/src/config/index.js +32 -0
  18. package/src/data/extensions.json +358 -0
  19. package/src/installer/git-config.js +34 -0
  20. package/src/installer/node-installer.js +50 -0
  21. package/src/installer/package-manager-installer.js +31 -0
  22. package/src/installer/ssh-setup.js +31 -0
  23. package/src/installer/system-packages.js +49 -0
  24. package/src/installers/index.js +66 -0
  25. package/src/integrations/ai-backend.js +60 -0
  26. package/src/integrations/ai-providers/registry.js +261 -0
  27. package/src/integrations/github.js +107 -0
  28. package/src/integrations/gitlab.js +121 -0
  29. package/src/plugins/context.js +64 -0
  30. package/src/plugins/loader.js +50 -0
  31. package/src/plugins/runner.js +25 -0
  32. package/src/setup/catalog.js +13 -0
  33. package/src/setup/installer.js +192 -0
  34. package/src/setup/wizard.js +220 -0
  35. package/src/shell/detector.js +10 -0
  36. package/src/tui/app-state.js +374 -0
  37. package/src/tui/app.js +442 -0
  38. package/src/tui/autocomplete.js +57 -0
  39. package/src/tui/commands/registry.js +823 -0
  40. package/src/tui/components/SetupScreen.js +292 -0
  41. package/src/ui/animations.js +24 -0
  42. package/src/ui/spinner.js +34 -0
  43. package/src/ui/theme/cyberpunk.js +14 -0
  44. package/src/ui/theme/dark.js +14 -0
  45. package/src/ui/theme/forest.js +14 -0
  46. package/src/ui/theme/light.js +14 -0
  47. package/src/ui/theme/monokai.js +14 -0
  48. package/src/ui/theme/nord.js +14 -0
  49. package/src/ui/theme.js +49 -0
  50. package/src/utils/command-history.js +43 -0
  51. package/src/utils/dotfiles-sync.js +89 -0
  52. package/src/utils/error-handler.js +69 -0
  53. package/src/utils/file-conflict-handler.js +30 -0
  54. package/src/utils/fuzzy-search.js +20 -0
  55. package/src/utils/github-error-handler.js +26 -0
  56. package/src/utils/keychain.js +97 -0
  57. package/src/utils/open-url.js +15 -0
  58. package/src/utils/terminal-detection.js +26 -0
  59. package/src/utils/token-encryption.js +96 -0
@@ -0,0 +1,60 @@
1
+ const FLASH_MODELS = ['glm-4.7-flash', 'glm-4-flash', 'glm-z1-flash'];
2
+ const DEFAULT_MAX_TOKENS = 1024;
3
+ const API_BASE = 'https://open.bigmodel.cn/api/paas/v4';
4
+
5
+ function createClient({ apiKey } = {}) {
6
+ const key = apiKey || process.env.GLM_API_KEY;
7
+ if (!key) return null;
8
+ return { apiKey: key, model: FLASH_MODELS[0] };
9
+ }
10
+
11
+ async function query(prompt, options = {}) {
12
+ const client = options.client || createClient({ apiKey: options.apiKey });
13
+ if (!client) throw new Error('AI not configured. Set GLM_API_KEY or run /setup.');
14
+
15
+ const models = options.model ? [options.model] : FLASH_MODELS;
16
+ let lastError;
17
+
18
+ for (const model of models) {
19
+ const body = {
20
+ model,
21
+ max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS,
22
+ messages: options.messages || [{ role: 'user', content: prompt }],
23
+ };
24
+
25
+ try {
26
+ const res = await fetch(`${API_BASE}/chat/completions`, {
27
+ method: 'POST',
28
+ headers: {
29
+ 'Content-Type': 'application/json',
30
+ 'Authorization': `Bearer ${client.apiKey}`,
31
+ },
32
+ body: JSON.stringify(body),
33
+ });
34
+
35
+ if (!res.ok) {
36
+ const text = await res.text().catch(() => '');
37
+ if (res.status === 429 && models.indexOf(model) < models.length - 1) {
38
+ lastError = text;
39
+ continue;
40
+ }
41
+ throw new Error(`API returned ${res.status}: ${text}`);
42
+ }
43
+
44
+ const response = await res.json();
45
+ const choice = response.choices && response.choices[0];
46
+ if (!choice || !choice.message) throw new Error('AI returned empty response.');
47
+ return choice.message.content;
48
+ } catch (err) {
49
+ const msg = err.message || '';
50
+ if (msg.includes('401') || msg.includes('auth')) {
51
+ throw new Error(`AI authentication failed. Check your API key. (${msg})`);
52
+ }
53
+ lastError = msg;
54
+ }
55
+ }
56
+
57
+ throw new Error(`AI request failed (all models tried): ${lastError}`);
58
+ }
59
+
60
+ module.exports = { createClient, query };
@@ -0,0 +1,261 @@
1
+ const PROVIDERS = [
2
+ { id: 'glm', name: 'GLM', requiresAuth: true },
3
+ { id: 'openai', name: 'OpenAI', requiresAuth: true },
4
+ { id: 'anthropic', name: 'Anthropic Claude', requiresAuth: true },
5
+ { id: 'ollama', name: 'Ollama (local)', requiresAuth: false },
6
+ { id: 'groq', name: 'Groq', requiresAuth: true },
7
+ { id: 'deepseek', name: 'DeepSeek', requiresAuth: true },
8
+ { id: 'mistral', name: 'Mistral AI', requiresAuth: true },
9
+ { id: 'perplexity', name: 'Perplexity', requiresAuth: true },
10
+ { id: 'openrouter', name: 'OpenRouter', requiresAuth: true },
11
+ { id: 'together', name: 'Together AI', requiresAuth: true },
12
+ { id: 'fireworks', name: 'Fireworks AI', requiresAuth: true },
13
+ { id: 'xai', name: 'Grok (xAI)', requiresAuth: true },
14
+ { id: 'gemini', name: 'Google Gemini', requiresAuth: true },
15
+ { id: 'cohere', name: 'Cohere', requiresAuth: true },
16
+ { id: 'qwen', name: 'Qwen', requiresAuth: true },
17
+ ];
18
+
19
+ const GLM_API_BASE = 'https://open.bigmodel.cn/api/paas/v4';
20
+ const GLM_MODELS = ['glm-4.7-flash', 'glm-4-flash', 'glm-z1-flash'];
21
+
22
+ function listProviders() {
23
+ return PROVIDERS;
24
+ }
25
+
26
+ function getProvider(id) {
27
+ return PROVIDERS.find(p => p.id === id) || null;
28
+ }
29
+
30
+ async function queryGlmFree(prompt, options = {}) {
31
+ const apiKey = options.apiKey || process.env.GLM_API_KEY;
32
+ if (!apiKey) throw new Error('GLM requires GLM_API_KEY. Set it with /model glm.');
33
+
34
+ const messages = options.messages || [{ role: 'user', content: prompt }];
35
+ const body = {
36
+ model: options.model || GLM_MODELS[0],
37
+ max_tokens: options.maxTokens || 1024,
38
+ messages,
39
+ };
40
+
41
+ const res = await fetch(`${GLM_API_BASE}/chat/completions`, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'Authorization': `Bearer ${apiKey}`,
46
+ },
47
+ body: JSON.stringify(body),
48
+ });
49
+
50
+ if (!res.ok) {
51
+ const text = await res.text().catch(() => '');
52
+ throw new Error(`GLM API returned ${res.status}: ${text}`);
53
+ }
54
+
55
+ const response = await res.json();
56
+ const choice = response.choices && response.choices[0];
57
+ if (!choice || !choice.message) throw new Error('GLM returned empty response.');
58
+ return choice.message.content;
59
+ }
60
+
61
+ function openaiCompatible(baseUrl, defaultModel, providerName) {
62
+ return async function queryOpenaiCompat(prompt, options = {}) {
63
+ const apiKey = options.apiKey;
64
+ if (!apiKey) throw new Error(`${providerName} requires an API key. Set it with /model ${providerName.toLowerCase()}.`);
65
+
66
+ const messages = options.messages || [{ role: 'user', content: prompt }];
67
+ const body = {
68
+ model: options.model || defaultModel,
69
+ max_tokens: options.maxTokens || 1024,
70
+ messages,
71
+ };
72
+
73
+ const res = await fetch(`${baseUrl}/chat/completions`, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ 'Authorization': `Bearer ${apiKey}`,
78
+ },
79
+ body: JSON.stringify(body),
80
+ });
81
+
82
+ if (!res.ok) {
83
+ const text = await res.text().catch(() => '');
84
+ throw new Error(`${providerName} API returned ${res.status}: ${text}`);
85
+ }
86
+
87
+ const response = await res.json();
88
+ const choice = response.choices && response.choices[0];
89
+ if (!choice || !choice.message) throw new Error(`${providerName} returned empty response.`);
90
+ return choice.message.content;
91
+ };
92
+ }
93
+
94
+ const queryOpenai = openaiCompatible('https://api.openai.com/v1', 'gpt-4o-mini', 'OpenAI');
95
+ const queryGroq = openaiCompatible('https://api.groq.com/openai/v1', 'llama-3.3-70b-versatile', 'Groq');
96
+ const queryDeepseek = openaiCompatible('https://api.deepseek.com/v1', 'deepseek-chat', 'DeepSeek');
97
+ const queryMistral = openaiCompatible('https://api.mistral.ai/v1', 'mistral-large-latest', 'Mistral');
98
+ const queryPerplexity = openaiCompatible('https://api.perplexity.ai', 'sonar', 'Perplexity');
99
+ const queryOpenrouter = openaiCompatible('https://openrouter.ai/api/v1', 'auto', 'OpenRouter');
100
+ const queryTogether = openaiCompatible('https://api.together.xyz/v1', 'meta-llama/Llama-3-70b', 'Together');
101
+ const queryFireworks = openaiCompatible('https://api.fireworks.ai/inference/v1', 'llama-v3-70b', 'Fireworks');
102
+ const queryXai = openaiCompatible('https://api.x.ai/v1', 'grok-3', 'Grok');
103
+ const queryQwen = openaiCompatible('https://dashscope.aliyuncs.com/compatible-mode/v1', 'qwen-turbo', 'Qwen');
104
+
105
+ async function queryGemini(prompt, options = {}) {
106
+ const apiKey = options.apiKey;
107
+ if (!apiKey) throw new Error('Gemini requires an API key. Set it with /model gemini.');
108
+
109
+ const model = options.model || 'gemini-2.0-flash';
110
+ const body = {
111
+ contents: [{ parts: [{ text: prompt }] }],
112
+ };
113
+
114
+ const res = await fetch(
115
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
116
+ {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify(body),
120
+ },
121
+ );
122
+
123
+ if (!res.ok) {
124
+ const text = await res.text().catch(() => '');
125
+ throw new Error(`Gemini API returned ${res.status}: ${text}`);
126
+ }
127
+
128
+ const response = await res.json();
129
+ const candidate = response.candidates && response.candidates[0];
130
+ if (!candidate || !candidate.content || !candidate.content.parts || !candidate.content.parts[0]) {
131
+ throw new Error('Gemini returned empty response.');
132
+ }
133
+ return candidate.content.parts[0].text;
134
+ }
135
+
136
+ async function queryCohere(prompt, options = {}) {
137
+ const apiKey = options.apiKey;
138
+ if (!apiKey) throw new Error('Cohere requires an API key. Set it with /model cohere.');
139
+
140
+ const messages = options.messages || [{ role: 'user', content: prompt }];
141
+ const body = {
142
+ model: options.model || 'command-r-plus',
143
+ messages,
144
+ };
145
+
146
+ const res = await fetch('https://api.cohere.com/v2/chat', {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'application/json',
150
+ 'Authorization': `Bearer ${apiKey}`,
151
+ },
152
+ body: JSON.stringify(body),
153
+ });
154
+
155
+ if (!res.ok) {
156
+ const text = await res.text().catch(() => '');
157
+ throw new Error(`Cohere API returned ${res.status}: ${text}`);
158
+ }
159
+
160
+ const response = await res.json();
161
+ const content = response.message && response.message.content && response.message.content[0];
162
+ if (!content || content.type !== 'text') throw new Error('Cohere returned empty response.');
163
+ return content.text;
164
+ }
165
+
166
+ async function queryAnthropic(prompt, options = {}) {
167
+ const apiKey = options.apiKey;
168
+ if (!apiKey) throw new Error('Anthropic requires an API key. Set it with /model anthropic.');
169
+
170
+ const messages = options.messages || [{ role: 'user', content: prompt }];
171
+ const body = {
172
+ model: options.model || 'claude-sonnet-4-6-20250514',
173
+ max_tokens: options.maxTokens || 1024,
174
+ messages,
175
+ };
176
+
177
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
178
+ method: 'POST',
179
+ headers: {
180
+ 'Content-Type': 'application/json',
181
+ 'x-api-key': apiKey,
182
+ 'anthropic-version': '2023-06-01',
183
+ },
184
+ body: JSON.stringify(body),
185
+ });
186
+
187
+ if (!res.ok) {
188
+ const text = await res.text().catch(() => '');
189
+ throw new Error(`Anthropic API returned ${res.status}: ${text}`);
190
+ }
191
+
192
+ const response = await res.json();
193
+ const block = response.content && response.content[0];
194
+ if (!block || block.type !== 'text') throw new Error('Anthropic returned empty response.');
195
+ return block.text;
196
+ }
197
+
198
+ async function queryOllama(prompt, options = {}) {
199
+ const baseUrl = options.baseUrl || 'http://localhost:11434';
200
+ const messages = options.messages || [{ role: 'user', content: prompt }];
201
+ const body = {
202
+ model: options.model || 'llama3',
203
+ messages,
204
+ stream: false,
205
+ };
206
+
207
+ const res = await fetch(`${baseUrl}/api/chat`, {
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify(body),
211
+ });
212
+
213
+ if (!res.ok) {
214
+ const text = await res.text().catch(() => '');
215
+ throw new Error(`Ollama API returned ${res.status}: ${text}`);
216
+ }
217
+
218
+ const response = await res.json();
219
+ if (!response.message || !response.message.content) {
220
+ throw new Error('Ollama returned empty response.');
221
+ }
222
+ return response.message.content;
223
+ }
224
+
225
+ const QUERY_MAP = {
226
+ 'glm': queryGlmFree,
227
+ 'openai': queryOpenai,
228
+ 'anthropic': queryAnthropic,
229
+ 'ollama': queryOllama,
230
+ 'groq': queryGroq,
231
+ 'deepseek': queryDeepseek,
232
+ 'mistral': queryMistral,
233
+ 'perplexity': queryPerplexity,
234
+ 'openrouter': queryOpenrouter,
235
+ 'together': queryTogether,
236
+ 'fireworks': queryFireworks,
237
+ 'xai': queryXai,
238
+ 'gemini': queryGemini,
239
+ 'cohere': queryCohere,
240
+ 'qwen': queryQwen,
241
+ };
242
+
243
+ async function queryWithProvider(providerId, prompt, options = {}) {
244
+ const provider = getProvider(providerId);
245
+ if (!provider) throw new Error(`Unknown provider: ${providerId}`);
246
+
247
+ const queryFn = QUERY_MAP[providerId];
248
+ if (!queryFn) throw new Error(`Provider ${providerId} not yet implemented`);
249
+
250
+ try {
251
+ return await queryFn(prompt, options);
252
+ } catch (err) {
253
+ if (options.fallback && options.fallback !== providerId) {
254
+ const fallbackFn = QUERY_MAP[options.fallback];
255
+ if (fallbackFn) return await fallbackFn(prompt, options);
256
+ }
257
+ throw err;
258
+ }
259
+ }
260
+
261
+ module.exports = { listProviders, getProvider, queryWithProvider };
@@ -0,0 +1,107 @@
1
+ const { Octokit } = require('@octokit/rest');
2
+ const { storeToken, retrieveToken, removeToken } = require('../utils/keychain');
3
+ const { handleGitHubError } = require('../utils/github-error-handler');
4
+
5
+ function createClient(token) {
6
+ return new Octokit({ auth: token });
7
+ }
8
+
9
+ async function validateToken(token) {
10
+ const octokit = createClient(token);
11
+ try {
12
+ const { data } = await octokit.users.getAuthenticated();
13
+ return { valid: true, user: data };
14
+ } catch (err) {
15
+ throw handleGitHubError(err);
16
+ }
17
+ }
18
+
19
+ async function connect(token) {
20
+ const result = await validateToken(token);
21
+ await storeToken('github', token);
22
+ return result;
23
+ }
24
+
25
+ async function disconnect() {
26
+ return removeToken('github');
27
+ }
28
+
29
+ async function getClient() {
30
+ const token = await retrieveToken('github');
31
+ if (!token) throw new Error('GitHub not connected. Run /connect first.');
32
+ return createClient(token);
33
+ }
34
+
35
+ async function listPRs(options = {}) {
36
+ const octokit = await getClient();
37
+ try {
38
+ const { data: user } = await octokit.users.getAuthenticated();
39
+ const { data } = await octokit.search.issuesAndPullRequests({
40
+ q: `author:${user.login} is:pr is:open ${options.repo ? `repo:${options.repo}` : ''}`,
41
+ sort: 'updated',
42
+ order: 'desc',
43
+ per_page: options.limit || 20,
44
+ });
45
+ return data.items.map(pr => ({
46
+ number: pr.number,
47
+ title: pr.title,
48
+ repo: pr.repository_url.split('/repos/')[1],
49
+ state: pr.state,
50
+ url: pr.html_url,
51
+ updatedAt: pr.updated_at,
52
+ }));
53
+ } catch (err) {
54
+ throw handleGitHubError(err);
55
+ }
56
+ }
57
+
58
+ async function createDraftPR(title, options = {}) {
59
+ const octokit = await getClient();
60
+ try {
61
+ const { execSync } = require('child_process');
62
+ const branch = options.branch || execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
63
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
64
+ const repoMatch = remoteUrl.match(/[:/]([^/]+\/[^/.]+)/);
65
+ if (!repoMatch) throw new Error('Could not detect repo from git remote.');
66
+ const repo = repoMatch[1];
67
+
68
+ const base = options.base || 'main';
69
+ const { data } = await octokit.pulls.create({
70
+ owner: repo.split('/')[0],
71
+ repo: repo.split('/')[1],
72
+ title,
73
+ head: branch,
74
+ base,
75
+ draft: true,
76
+ });
77
+ return { number: data.number, url: data.html_url, draft: data.draft };
78
+ } catch (err) {
79
+ throw handleGitHubError(err);
80
+ }
81
+ }
82
+
83
+ async function commentOnPR(prNumber, message, options = {}) {
84
+ const octokit = await getClient();
85
+ try {
86
+ const repo = options.repo || await detectRepo();
87
+ const [owner, name] = repo.split('/');
88
+ const { data } = await octokit.issues.createComment({
89
+ owner, repo: name, issue_number: prNumber, body: message,
90
+ });
91
+ return { id: data.id, url: data.html_url };
92
+ } catch (err) {
93
+ throw handleGitHubError(err);
94
+ }
95
+ }
96
+
97
+ async function detectRepo() {
98
+ const { execSync } = require('child_process');
99
+ const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
100
+ const match = remoteUrl.match(/[:/]([^/]+\/[^/.]+)/);
101
+ if (!match) throw new Error('Could not detect repo from git remote.');
102
+ return match[1];
103
+ }
104
+
105
+ module.exports = {
106
+ connect, disconnect, getClient, listPRs, createDraftPR, commentOnPR, detectRepo,
107
+ };
@@ -0,0 +1,121 @@
1
+ const { storeToken, retrieveToken, removeToken } = require('../utils/keychain');
2
+
3
+ /**
4
+ * GitLab API wrapper — mirrors the pattern from github.js
5
+ * Uses @gitbeaker/node as API client
6
+ */
7
+
8
+ let Gitlab;
9
+ try {
10
+ Gitlab = require('@gitbeaker/node').Gitlab;
11
+ } catch {
12
+ // @gitbeaker/node unavailable — API methods will throw
13
+ }
14
+
15
+ function createClient(token) {
16
+ if (!Gitlab) throw new Error('Package @gitbeaker/node is not installed. Run: npm install @gitbeaker/node');
17
+ return new Gitlab({ token });
18
+ }
19
+
20
+ async function validateToken(token) {
21
+ const client = createClient(token);
22
+ try {
23
+ const user = await client.Users.current();
24
+ return { valid: true, user };
25
+ } catch (err) {
26
+ const message = err.response?.data?.message || err.message;
27
+ throw new Error(`GitLab token validation failed: ${message}`);
28
+ }
29
+ }
30
+
31
+ async function connect(token) {
32
+ const result = await validateToken(token);
33
+ await storeToken('gitlab', token);
34
+ return result;
35
+ }
36
+
37
+ async function disconnect() {
38
+ return removeToken('gitlab');
39
+ }
40
+
41
+ async function getClient() {
42
+ const token = await retrieveToken('gitlab');
43
+ if (!token) throw new Error('GitLab not connected. Run /connect gitlab first.');
44
+ return createClient(token);
45
+ }
46
+
47
+ async function listMRs(options = {}) {
48
+ const client = await getClient();
49
+ try {
50
+ const mrs = await client.MergeRequests.all({
51
+ state: 'opened',
52
+ scope: 'created_by_me',
53
+ perPage: options.limit || 20,
54
+ });
55
+ return mrs.map(mr => ({
56
+ iid: mr.iid,
57
+ title: mr.title,
58
+ projectId: mr.project_id,
59
+ state: mr.state,
60
+ url: mr.web_url,
61
+ updatedAt: mr.updated_at,
62
+ }));
63
+ } catch (err) {
64
+ throw new Error(`Failed to fetch MRs: ${err.message}`);
65
+ }
66
+ }
67
+
68
+ async function createDraftMR(title, options = {}) {
69
+ const client = await getClient();
70
+ try {
71
+ const mr = await client.MergeRequests.create(
72
+ options.projectId,
73
+ options.sourceBranch || 'main',
74
+ options.targetBranch || 'main',
75
+ title,
76
+ { draft: true },
77
+ );
78
+ return { iid: mr.iid, url: mr.web_url, draft: true };
79
+ } catch (err) {
80
+ throw new Error(`Failed to create MR: ${err.message}`);
81
+ }
82
+ }
83
+
84
+ async function commentOnMR(mrIid, message, options = {}) {
85
+ const client = await getClient();
86
+ try {
87
+ const note = await client.MergeRequestNotes.create(
88
+ options.projectId,
89
+ mrIid,
90
+ message,
91
+ );
92
+ return { id: note.id, body: note.body };
93
+ } catch (err) {
94
+ throw new Error(`Failed to comment on MR: ${err.message}`);
95
+ }
96
+ }
97
+
98
+ async function listIssues(options = {}) {
99
+ const client = await getClient();
100
+ try {
101
+ const issues = await client.Issues.all({
102
+ state: 'opened',
103
+ scope: 'created_by_me',
104
+ perPage: options.limit || 20,
105
+ });
106
+ return issues.map(issue => ({
107
+ iid: issue.iid,
108
+ title: issue.title,
109
+ projectId: issue.project_id,
110
+ state: issue.state,
111
+ url: issue.web_url,
112
+ updatedAt: issue.updated_at,
113
+ }));
114
+ } catch (err) {
115
+ throw new Error(`Failed to fetch issues: ${err.message}`);
116
+ }
117
+ }
118
+
119
+ module.exports = {
120
+ connect, disconnect, getClient, listMRs, createDraftMR, commentOnMR, listIssues,
121
+ };
@@ -0,0 +1,64 @@
1
+ const chalk = require('chalk');
2
+ const { readConfig } = require('../config');
3
+ const github = require('../integrations/github');
4
+
5
+ /**
6
+ * Safe module loading — returns null when module is unavailable
7
+ */
8
+ function safeRequire(modulePath) {
9
+ try {
10
+ return require(modulePath);
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Creates an API wrapper for a given integration (github/gitlab)
18
+ * Returns null when platform is not connected
19
+ */
20
+ async function buildPlatformApi(integration, methods) {
21
+ if (!integration) return null;
22
+ try {
23
+ await integration.getClient();
24
+ } catch {
25
+ return null;
26
+ }
27
+
28
+ const api = {};
29
+ for (const method of methods) {
30
+ if (typeof integration[method] === 'function') {
31
+ api[method] = (...args) => integration[method](...args);
32
+ }
33
+ }
34
+ return api;
35
+ }
36
+
37
+ /**
38
+ * Creates a context object passed to plugins
39
+ * { config, logger, github, gitlab }
40
+ */
41
+ async function createPluginContext() {
42
+ const config = await readConfig();
43
+
44
+ const logger = {
45
+ info: (msg) => console.log(chalk.blue(` ℹ ${msg}`)),
46
+ warn: (msg) => console.log(chalk.yellow(` ⚠ ${msg}`)),
47
+ error: (msg) => console.log(chalk.red(` ✖ ${msg}`)),
48
+ success: (msg) => console.log(chalk.green(` ✔ ${msg}`)),
49
+ };
50
+
51
+ const gitlab = safeRequire('../integrations/gitlab');
52
+
53
+ const githubApi = await buildPlatformApi(github, ['listPRs', 'createDraftPR', 'commentOnPR']);
54
+ const gitlabApi = await buildPlatformApi(gitlab, ['listMRs', 'createDraftMR', 'commentOnMR']);
55
+
56
+ return {
57
+ config,
58
+ logger,
59
+ github: githubApi,
60
+ gitlab: gitlabApi,
61
+ };
62
+ }
63
+
64
+ module.exports = { createPluginContext };
@@ -0,0 +1,50 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+
5
+ /**
6
+ * Plugin Loader — discovers and loads .js files from ~/.adm/plugins/
7
+ *
8
+ * Each plugin is a CommonJS module exporting:
9
+ * { name: string, description: string, execute(args, context): Promise<string> }
10
+ *
11
+ * File name minus .js = command name (e.g. audit-deps.js → command audit-deps)
12
+ */
13
+ function loadPlugins() {
14
+ const configDir = process.env.ADM_CONFIG_DIR || path.join(require('os').homedir(), '.adm');
15
+ const pluginsDir = path.join(configDir, 'plugins');
16
+ const plugins = new Map();
17
+
18
+ if (!fs.existsSync(pluginsDir)) {
19
+ return plugins;
20
+ }
21
+
22
+ const entries = fs.readdirSync(pluginsDir);
23
+ for (const entry of entries) {
24
+ if (!entry.endsWith('.js')) continue;
25
+
26
+ const filePath = path.join(pluginsDir, entry);
27
+ const commandName = path.basename(entry, '.js');
28
+
29
+ try {
30
+ const plugin = require(filePath);
31
+
32
+ if (!plugin || typeof plugin.execute !== 'function') {
33
+ console.error(chalk.yellow(` Plugin "${entry}" skipped — missing execute function.`));
34
+ continue;
35
+ }
36
+
37
+ plugins.set(commandName, {
38
+ name: plugin.name || commandName,
39
+ description: plugin.description || '',
40
+ execute: plugin.execute,
41
+ });
42
+ } catch (err) {
43
+ console.error(chalk.yellow(` Plugin "${entry}" skipped — load error: ${err.message}`));
44
+ }
45
+ }
46
+
47
+ return plugins;
48
+ }
49
+
50
+ module.exports = { loadPlugins };
@@ -0,0 +1,25 @@
1
+ const chalk = require('chalk');
2
+ const { loadPlugins } = require('./loader');
3
+ const { createPluginContext } = require('./context');
4
+
5
+ /**
6
+ * Tries to run a plugin by name.
7
+ * Returns string (plugin output) or null when plugin doesn't exist.
8
+ * Execution errors are handled gracefully — returns error message.
9
+ */
10
+ async function tryRunPlugin(commandName, args) {
11
+ const plugins = loadPlugins();
12
+ const plugin = plugins.get(commandName);
13
+
14
+ if (!plugin) return null;
15
+
16
+ try {
17
+ const context = await createPluginContext();
18
+ const output = await plugin.execute(args || '', context);
19
+ return output;
20
+ } catch (err) {
21
+ return chalk.red(` Plugin error "${commandName}": ${err.message}`);
22
+ }
23
+ }
24
+
25
+ module.exports = { tryRunPlugin };