cognitive-modules-cli 2.2.1 → 2.2.7

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 (101) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -29
  4. package/dist/cli.js +519 -23
  5. package/dist/commands/add.d.ts +33 -14
  6. package/dist/commands/add.js +383 -16
  7. package/dist/commands/compose.js +60 -23
  8. package/dist/commands/index.d.ts +4 -0
  9. package/dist/commands/index.js +4 -0
  10. package/dist/commands/init.js +23 -1
  11. package/dist/commands/migrate.d.ts +30 -0
  12. package/dist/commands/migrate.js +650 -0
  13. package/dist/commands/pipe.d.ts +1 -0
  14. package/dist/commands/pipe.js +31 -11
  15. package/dist/commands/remove.js +33 -2
  16. package/dist/commands/run.d.ts +2 -0
  17. package/dist/commands/run.js +61 -28
  18. package/dist/commands/search.d.ts +28 -0
  19. package/dist/commands/search.js +143 -0
  20. package/dist/commands/test.d.ts +65 -0
  21. package/dist/commands/test.js +454 -0
  22. package/dist/commands/update.d.ts +1 -0
  23. package/dist/commands/update.js +106 -14
  24. package/dist/commands/validate.d.ts +36 -0
  25. package/dist/commands/validate.js +97 -0
  26. package/dist/errors/index.d.ts +225 -0
  27. package/dist/errors/index.js +420 -0
  28. package/dist/mcp/server.js +84 -79
  29. package/dist/modules/composition.js +97 -32
  30. package/dist/modules/loader.js +4 -2
  31. package/dist/modules/runner.d.ts +72 -5
  32. package/dist/modules/runner.js +306 -59
  33. package/dist/modules/subagent.d.ts +6 -1
  34. package/dist/modules/subagent.js +18 -13
  35. package/dist/modules/validator.js +14 -6
  36. package/dist/providers/anthropic.d.ts +15 -0
  37. package/dist/providers/anthropic.js +147 -5
  38. package/dist/providers/base.d.ts +11 -0
  39. package/dist/providers/base.js +18 -0
  40. package/dist/providers/gemini.d.ts +15 -0
  41. package/dist/providers/gemini.js +122 -5
  42. package/dist/providers/ollama.d.ts +15 -0
  43. package/dist/providers/ollama.js +111 -3
  44. package/dist/providers/openai.d.ts +11 -0
  45. package/dist/providers/openai.js +133 -0
  46. package/dist/registry/client.d.ts +212 -0
  47. package/dist/registry/client.js +359 -0
  48. package/dist/registry/index.d.ts +4 -0
  49. package/dist/registry/index.js +4 -0
  50. package/dist/registry/tar.d.ts +8 -0
  51. package/dist/registry/tar.js +353 -0
  52. package/dist/server/http.js +301 -45
  53. package/dist/server/index.d.ts +2 -0
  54. package/dist/server/index.js +1 -0
  55. package/dist/server/sse.d.ts +13 -0
  56. package/dist/server/sse.js +22 -0
  57. package/dist/types.d.ts +32 -1
  58. package/dist/types.js +4 -1
  59. package/dist/version.d.ts +1 -0
  60. package/dist/version.js +4 -0
  61. package/package.json +31 -7
  62. package/dist/modules/composition.test.d.ts +0 -11
  63. package/dist/modules/composition.test.js +0 -450
  64. package/dist/modules/policy.test.d.ts +0 -10
  65. package/dist/modules/policy.test.js +0 -369
  66. package/src/cli.ts +0 -471
  67. package/src/commands/add.ts +0 -315
  68. package/src/commands/compose.ts +0 -185
  69. package/src/commands/index.ts +0 -13
  70. package/src/commands/init.ts +0 -94
  71. package/src/commands/list.ts +0 -33
  72. package/src/commands/pipe.ts +0 -76
  73. package/src/commands/remove.ts +0 -57
  74. package/src/commands/run.ts +0 -80
  75. package/src/commands/update.ts +0 -130
  76. package/src/commands/versions.ts +0 -79
  77. package/src/index.ts +0 -90
  78. package/src/mcp/index.ts +0 -5
  79. package/src/mcp/server.ts +0 -403
  80. package/src/modules/composition.test.ts +0 -558
  81. package/src/modules/composition.ts +0 -1674
  82. package/src/modules/index.ts +0 -9
  83. package/src/modules/loader.ts +0 -508
  84. package/src/modules/policy.test.ts +0 -455
  85. package/src/modules/runner.ts +0 -1983
  86. package/src/modules/subagent.ts +0 -277
  87. package/src/modules/validator.ts +0 -700
  88. package/src/providers/anthropic.ts +0 -89
  89. package/src/providers/base.ts +0 -29
  90. package/src/providers/deepseek.ts +0 -83
  91. package/src/providers/gemini.ts +0 -117
  92. package/src/providers/index.ts +0 -78
  93. package/src/providers/minimax.ts +0 -81
  94. package/src/providers/moonshot.ts +0 -82
  95. package/src/providers/ollama.ts +0 -83
  96. package/src/providers/openai.ts +0 -84
  97. package/src/providers/qwen.ts +0 -82
  98. package/src/server/http.ts +0 -316
  99. package/src/server/index.ts +0 -6
  100. package/src/types.ts +0 -599
  101. package/tsconfig.json +0 -17
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Add command - Install modules from GitHub
2
+ * Add command - Install modules from GitHub or Registry
3
3
  *
4
- * cog add ziel-io/cognitive-modules -m code-simplifier
5
- * cog add https://github.com/org/repo --module name --tag v1.0.0
4
+ * From Registry:
5
+ * cog add code-reviewer
6
+ * cog add code-reviewer@2.0.0
7
+ *
8
+ * From GitHub:
9
+ * cog add ziel-io/cognitive-modules -m code-simplifier
10
+ * cog add https://github.com/org/repo --module name --tag v1.0.0
6
11
  */
7
12
  import type { CommandContext, CommandResult } from '../types.js';
8
13
  export interface AddOptions {
@@ -10,25 +15,39 @@ export interface AddOptions {
10
15
  name?: string;
11
16
  branch?: string;
12
17
  tag?: string;
18
+ registry?: string;
19
+ }
20
+ export interface InstallInfo {
21
+ source: string;
22
+ githubUrl: string;
23
+ modulePath?: string;
24
+ tag?: string;
25
+ branch?: string;
26
+ version?: string;
27
+ installedAt: string;
28
+ installedTime: string;
29
+ /** Registry module name if installed from registry */
30
+ registryModule?: string;
31
+ /** Registry URL if installed from a custom registry */
32
+ registryUrl?: string;
13
33
  }
14
34
  interface InstallManifest {
15
- [moduleName: string]: {
16
- source: string;
17
- githubUrl: string;
18
- modulePath?: string;
19
- tag?: string;
20
- branch?: string;
21
- version?: string;
22
- installedAt: string;
23
- installedTime: string;
24
- };
35
+ [moduleName: string]: InstallInfo;
25
36
  }
26
37
  /**
27
38
  * Get installation info for a module
28
39
  */
29
40
  export declare function getInstallInfo(moduleName: string): Promise<InstallManifest[string] | null>;
41
+ /**
42
+ * Add a module from the registry
43
+ */
44
+ export declare function addFromRegistry(moduleSpec: string, ctx: CommandContext, options?: AddOptions): Promise<CommandResult>;
30
45
  /**
31
46
  * Add a module from GitHub
32
47
  */
33
- export declare function add(url: string, ctx: CommandContext, options?: AddOptions): Promise<CommandResult>;
48
+ export declare function addFromGitHub(url: string, ctx: CommandContext, options?: AddOptions): Promise<CommandResult>;
49
+ /**
50
+ * Add a module from GitHub or Registry (auto-detect source)
51
+ */
52
+ export declare function add(source: string, ctx: CommandContext, options?: AddOptions): Promise<CommandResult>;
34
53
  export {};
@@ -1,15 +1,23 @@
1
1
  /**
2
- * Add command - Install modules from GitHub
2
+ * Add command - Install modules from GitHub or Registry
3
3
  *
4
- * cog add ziel-io/cognitive-modules -m code-simplifier
5
- * cog add https://github.com/org/repo --module name --tag v1.0.0
4
+ * From Registry:
5
+ * cog add code-reviewer
6
+ * cog add code-reviewer@2.0.0
7
+ *
8
+ * From GitHub:
9
+ * cog add ziel-io/cognitive-modules -m code-simplifier
10
+ * cog add https://github.com/org/repo --module name --tag v1.0.0
6
11
  */
7
- import { createWriteStream, existsSync, mkdirSync, rmSync, readdirSync, statSync, copyFileSync } from 'node:fs';
12
+ import { createWriteStream, existsSync, mkdirSync, rmSync, readdirSync, statSync, copyFileSync, lstatSync } from 'node:fs';
8
13
  import { writeFile, readFile, mkdir } from 'node:fs/promises';
9
- import { pipeline } from 'node:stream/promises';
14
+ import { pipeline, finished } from 'node:stream/promises';
10
15
  import { Readable } from 'node:stream';
11
- import { join, basename } from 'node:path';
16
+ import { join, basename, dirname, resolve, sep, isAbsolute } from 'node:path';
12
17
  import { homedir, tmpdir } from 'node:os';
18
+ import { createHash } from 'node:crypto';
19
+ import { RegistryClient } from '../registry/client.js';
20
+ import { extractTarGzFile } from '../registry/tar.js';
13
21
  // Module storage paths
14
22
  const USER_MODULES_DIR = join(homedir(), '.cognitive', 'modules');
15
23
  const INSTALLED_MANIFEST = join(homedir(), '.cognitive', 'installed.json');
@@ -33,6 +41,33 @@ function parseGitHubUrl(url) {
33
41
  fullUrl: `https://github.com/${org}/${repo}`,
34
42
  };
35
43
  }
44
+ function assertSafeModuleName(name) {
45
+ const trimmed = name.trim();
46
+ if (!trimmed) {
47
+ throw new Error('Invalid module name: empty');
48
+ }
49
+ if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
50
+ throw new Error(`Invalid module name: ${name}`);
51
+ }
52
+ if (isAbsolute(trimmed)) {
53
+ throw new Error(`Invalid module name (absolute path not allowed): ${name}`);
54
+ }
55
+ return trimmed;
56
+ }
57
+ function resolveModuleTarget(moduleName) {
58
+ const safeName = assertSafeModuleName(moduleName);
59
+ const targetPath = resolve(USER_MODULES_DIR, safeName);
60
+ const root = resolve(USER_MODULES_DIR) + sep;
61
+ if (!targetPath.startsWith(root)) {
62
+ throw new Error(`Invalid module name (path traversal): ${moduleName}`);
63
+ }
64
+ return targetPath;
65
+ }
66
+ function isPathWithinRoot(rootDir, targetPath) {
67
+ const root = resolve(rootDir);
68
+ const target = resolve(targetPath);
69
+ return target === root || target.startsWith(root + sep);
70
+ }
36
71
  /**
37
72
  * Download and extract ZIP from GitHub
38
73
  */
@@ -56,6 +91,9 @@ async function downloadAndExtract(org, repo, ref, isTag) {
56
91
  await pipeline(Readable.fromWeb(response.body), fileStream);
57
92
  // Extract using built-in unzip (available on most systems)
58
93
  const { execSync } = await import('node:child_process');
94
+ // Validate ZIP entries to avoid path traversal (Zip Slip)
95
+ const listing = execSync(`unzip -Z1 "${zipPath}"`, { stdio: 'pipe' }).toString('utf-8');
96
+ assertSafeZipEntries(listing);
59
97
  execSync(`unzip -q "${zipPath}" -d "${tempDir}"`, { stdio: 'pipe' });
60
98
  // Find extracted directory
61
99
  const entries = readdirSync(tempDir).filter(e => e !== 'repo.zip' && statSync(join(tempDir, e)).isDirectory());
@@ -64,6 +102,22 @@ async function downloadAndExtract(org, repo, ref, isTag) {
64
102
  }
65
103
  return join(tempDir, entries[0]);
66
104
  }
105
+ /**
106
+ * Validate ZIP listing to prevent path traversal.
107
+ */
108
+ function assertSafeZipEntries(listing) {
109
+ const entries = listing.split(/\r?\n/).map(e => e.trim()).filter(Boolean);
110
+ for (const entry of entries) {
111
+ const normalized = entry.replace(/\\/g, '/');
112
+ if (normalized.startsWith('/') || /^[a-zA-Z]:\//.test(normalized)) {
113
+ throw new Error(`Unsafe ZIP entry (absolute path): ${entry}`);
114
+ }
115
+ const parts = normalized.split('/');
116
+ if (parts.includes('..')) {
117
+ throw new Error(`Unsafe ZIP entry (path traversal): ${entry}`);
118
+ }
119
+ }
120
+ }
67
121
  /**
68
122
  * Check if a directory is a valid module
69
123
  */
@@ -76,11 +130,15 @@ function isValidModule(path) {
76
130
  * Find module within repository
77
131
  */
78
132
  function findModuleInRepo(repoRoot, modulePath) {
79
- const possiblePaths = [
80
- join(repoRoot, modulePath),
81
- join(repoRoot, 'cognitive', 'modules', modulePath),
82
- join(repoRoot, 'modules', modulePath),
133
+ const candidatePaths = [
134
+ resolve(repoRoot, modulePath),
135
+ resolve(repoRoot, 'cognitive', 'modules', modulePath),
136
+ resolve(repoRoot, 'modules', modulePath),
83
137
  ];
138
+ const possiblePaths = candidatePaths.filter((p) => isPathWithinRoot(repoRoot, p));
139
+ if (possiblePaths.length === 0) {
140
+ throw new Error(`Invalid module path (outside repository root): ${modulePath}`);
141
+ }
84
142
  for (const p of possiblePaths) {
85
143
  if (existsSync(p) && isValidModule(p)) {
86
144
  return p;
@@ -97,7 +155,11 @@ function copyDir(src, dest) {
97
155
  for (const entry of readdirSync(src)) {
98
156
  const srcPath = join(src, entry);
99
157
  const destPath = join(dest, entry);
100
- if (statSync(srcPath).isDirectory()) {
158
+ const st = lstatSync(srcPath);
159
+ if (st.isSymbolicLink()) {
160
+ throw new Error(`Refusing to install module containing symlink: ${srcPath}`);
161
+ }
162
+ if (st.isDirectory()) {
101
163
  copyDir(srcPath, destPath);
102
164
  }
103
165
  else {
@@ -105,6 +167,74 @@ function copyDir(src, dest) {
105
167
  }
106
168
  }
107
169
  }
170
+ async function downloadTarballWithSha256(url, outPath, maxBytes) {
171
+ const controller = new AbortController();
172
+ const timeout = setTimeout(() => controller.abort(), 10_000);
173
+ const hash = createHash('sha256');
174
+ let received = 0;
175
+ const fileStream = createWriteStream(outPath);
176
+ try {
177
+ const response = await fetch(url, {
178
+ headers: { 'User-Agent': 'cognitive-runtime/2.2' },
179
+ signal: controller.signal,
180
+ });
181
+ if (!response.ok) {
182
+ throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
183
+ }
184
+ const contentLengthHeader = response.headers?.get?.('content-length');
185
+ if (contentLengthHeader) {
186
+ const contentLength = Number(contentLengthHeader);
187
+ if (!Number.isNaN(contentLength) && contentLength > maxBytes) {
188
+ throw new Error(`Tarball too large: ${contentLength} bytes (max ${maxBytes})`);
189
+ }
190
+ }
191
+ if (!response.body) {
192
+ throw new Error('Tarball response has no body');
193
+ }
194
+ const reader = response.body.getReader?.();
195
+ if (!reader) {
196
+ // Fallback: stream pipeline without incremental hash.
197
+ await pipeline(Readable.fromWeb(response.body), fileStream);
198
+ const content = await readFile(outPath);
199
+ return createHash('sha256').update(content).digest('hex');
200
+ }
201
+ while (true) {
202
+ const { done, value } = await reader.read();
203
+ if (done)
204
+ break;
205
+ if (value) {
206
+ received += value.byteLength;
207
+ if (received > maxBytes) {
208
+ controller.abort();
209
+ throw new Error(`Tarball too large: ${received} bytes (max ${maxBytes})`);
210
+ }
211
+ const chunk = Buffer.from(value);
212
+ hash.update(chunk);
213
+ if (!fileStream.write(chunk)) {
214
+ await new Promise((resolve) => fileStream.once('drain', () => resolve()));
215
+ }
216
+ }
217
+ }
218
+ fileStream.end();
219
+ await finished(fileStream);
220
+ return hash.digest('hex');
221
+ }
222
+ catch (error) {
223
+ try {
224
+ fileStream.destroy();
225
+ }
226
+ catch {
227
+ // ignore
228
+ }
229
+ if (error instanceof Error && error.name === 'AbortError') {
230
+ throw new Error('Tarball download timed out after 10000ms');
231
+ }
232
+ throw error;
233
+ }
234
+ finally {
235
+ clearTimeout(timeout);
236
+ }
237
+ }
108
238
  /**
109
239
  * Get module version from module.yaml or MODULE.md
110
240
  */
@@ -157,10 +287,219 @@ export async function getInstallInfo(moduleName) {
157
287
  const manifest = JSON.parse(content);
158
288
  return manifest[moduleName] || null;
159
289
  }
290
+ /**
291
+ * Check if input looks like a GitHub URL or org/repo format
292
+ *
293
+ * GitHub formats:
294
+ * - https://github.com/org/repo
295
+ * - http://github.com/org/repo
296
+ * - github:org/repo
297
+ * - org/repo (shorthand, must be exactly two parts)
298
+ *
299
+ * NOT GitHub (registry module names):
300
+ * - code-reviewer
301
+ * - code-reviewer@1.0.0
302
+ * - my-module (no slash)
303
+ */
304
+ function isGitHubSource(input) {
305
+ // URL formats
306
+ if (input.startsWith('http://') || input.startsWith('https://')) {
307
+ return true;
308
+ }
309
+ // github: prefix format
310
+ if (input.startsWith('github:')) {
311
+ return true;
312
+ }
313
+ // Contains github.com
314
+ if (input.includes('github.com')) {
315
+ return true;
316
+ }
317
+ // Shorthand format: org/repo (exactly two parts, no @ version suffix)
318
+ // Must match pattern like: owner/repo or owner/repo but NOT module@version
319
+ const shorthandMatch = input.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
320
+ if (shorthandMatch) {
321
+ // Additional check: if it contains @, it's likely a scoped npm package or versioned module
322
+ // But GitHub shorthand shouldn't have @ in the middle
323
+ return !input.includes('@');
324
+ }
325
+ return false;
326
+ }
327
+ /**
328
+ * Parse module name with optional version: "module-name@1.0.0"
329
+ */
330
+ function parseModuleSpec(spec) {
331
+ const match = spec.match(/^([^@]+)(?:@(.+))?$/);
332
+ if (!match) {
333
+ return { name: spec };
334
+ }
335
+ return { name: match[1], version: match[2] };
336
+ }
337
+ /**
338
+ * Add a module from the registry
339
+ */
340
+ export async function addFromRegistry(moduleSpec, ctx, options = {}) {
341
+ const { name: moduleName, version: requestedVersion } = parseModuleSpec(moduleSpec);
342
+ const { name: customName, registry: registryUrl } = options;
343
+ let tempDir;
344
+ try {
345
+ const client = new RegistryClient(registryUrl);
346
+ const moduleInfo = await client.getModule(moduleName);
347
+ if (!moduleInfo) {
348
+ return {
349
+ success: false,
350
+ error: `Module not found in registry: ${moduleName}\nUse 'cog search' to find available modules.`,
351
+ };
352
+ }
353
+ // Check if deprecated
354
+ if (moduleInfo.deprecated) {
355
+ console.error(`Warning: Module '${moduleName}' is deprecated.`);
356
+ }
357
+ // Get download info
358
+ const downloadInfo = await client.getDownloadUrl(moduleName);
359
+ if (downloadInfo.isGitHub && downloadInfo.githubInfo) {
360
+ const { org, repo, path, ref } = downloadInfo.githubInfo;
361
+ // Use addFromGitHub() directly (not add() to avoid recursion)
362
+ const result = await addFromGitHub(`${org}/${repo}`, ctx, {
363
+ module: path,
364
+ name: customName || moduleName,
365
+ tag: requestedVersion || ref,
366
+ branch: ref || 'main',
367
+ });
368
+ // If successful, update the install manifest to track registry info
369
+ if (result.success && result.data) {
370
+ const data = result.data;
371
+ const installName = data.name;
372
+ // Update manifest with registry info
373
+ const existingInfo = await getInstallInfo(installName);
374
+ if (existingInfo) {
375
+ await recordInstall(installName, {
376
+ ...existingInfo,
377
+ registryModule: moduleName,
378
+ registryUrl: registryUrl,
379
+ });
380
+ }
381
+ else {
382
+ // Fallback: create minimal install info if not found (shouldn't happen but safety first)
383
+ await recordInstall(installName, {
384
+ source: 'registry',
385
+ githubUrl: `${org}/${repo}`,
386
+ modulePath: path,
387
+ version: data.version,
388
+ installedAt: data.location || '',
389
+ installedTime: new Date().toISOString(),
390
+ registryModule: moduleName,
391
+ registryUrl: registryUrl,
392
+ });
393
+ }
394
+ // Update result to indicate registry source
395
+ result.data = {
396
+ ...result.data,
397
+ source: 'registry',
398
+ registryModule: moduleName,
399
+ };
400
+ }
401
+ return result;
402
+ }
403
+ // Tarball sources: require checksum, verify, and extract safely.
404
+ if (!downloadInfo.url.startsWith('http')) {
405
+ return { success: false, error: `Unsupported registry download URL: ${downloadInfo.url}` };
406
+ }
407
+ if (!downloadInfo.checksum) {
408
+ return {
409
+ success: false,
410
+ error: `Registry tarball missing checksum (required for safe install): ${moduleName}`,
411
+ };
412
+ }
413
+ tempDir = join(tmpdir(), `cog-reg-${Date.now()}`);
414
+ mkdirSync(tempDir, { recursive: true });
415
+ const tarPath = join(tempDir, 'module.tar.gz');
416
+ const MAX_TARBALL_BYTES = 20 * 1024 * 1024; // 20MB
417
+ const actualSha256 = await downloadTarballWithSha256(downloadInfo.url, tarPath, MAX_TARBALL_BYTES);
418
+ const expected = downloadInfo.checksum;
419
+ const checksumMatch = expected.match(/^sha256:([a-f0-9]{64})$/);
420
+ if (!checksumMatch) {
421
+ throw new Error(`Unsupported checksum format (expected sha256:<64hex>): ${expected}`);
422
+ }
423
+ const expectedHash = checksumMatch[1];
424
+ if (actualSha256 !== expectedHash) {
425
+ throw new Error(`Checksum mismatch for ${moduleName}: expected ${expectedHash}, got ${actualSha256}`);
426
+ }
427
+ const extractedRoot = join(tempDir, 'pkg');
428
+ mkdirSync(extractedRoot, { recursive: true });
429
+ await extractTarGzFile(tarPath, extractedRoot, {
430
+ maxFiles: 5_000,
431
+ maxTotalBytes: 50 * 1024 * 1024,
432
+ maxSingleFileBytes: 20 * 1024 * 1024,
433
+ maxTarBytes: 100 * 1024 * 1024,
434
+ });
435
+ // Find module directory inside extractedRoot.
436
+ const rootNames = readdirSync(extractedRoot).filter((e) => e !== '__MACOSX' && e !== '.DS_Store');
437
+ const rootPaths = rootNames.map((e) => join(extractedRoot, e)).filter((p) => existsSync(p));
438
+ const rootDirs = rootPaths.filter((p) => statSync(p).isDirectory());
439
+ const rootFiles = rootPaths.filter((p) => !statSync(p).isDirectory());
440
+ if (rootDirs.length === 0) {
441
+ throw new Error('Tarball extraction produced no root directory');
442
+ }
443
+ if (rootDirs.length !== 1 || rootFiles.length > 0) {
444
+ throw new Error(`Tarball must contain exactly one module root directory and no other top-level entries. ` +
445
+ `dirs=${rootDirs.map((p) => basename(p)).join(',') || '(none)'} files=${rootFiles.map((p) => basename(p)).join(',') || '(none)'}`);
446
+ }
447
+ // Strict mode: require root dir itself to be a valid module.
448
+ const sourcePath = rootDirs[0];
449
+ if (!isValidModule(sourcePath)) {
450
+ throw new Error('Root directory in tarball is not a valid module');
451
+ }
452
+ const installName = (customName || moduleName);
453
+ const safeInstallName = assertSafeModuleName(installName);
454
+ const targetPath = resolveModuleTarget(safeInstallName);
455
+ if (existsSync(targetPath)) {
456
+ rmSync(targetPath, { recursive: true, force: true });
457
+ }
458
+ await mkdir(USER_MODULES_DIR, { recursive: true });
459
+ copyDir(sourcePath, targetPath);
460
+ const version = await getModuleVersion(sourcePath);
461
+ await recordInstall(safeInstallName, {
462
+ source: downloadInfo.url,
463
+ githubUrl: downloadInfo.url,
464
+ version,
465
+ installedAt: targetPath,
466
+ installedTime: new Date().toISOString(),
467
+ registryModule: moduleName,
468
+ registryUrl,
469
+ });
470
+ return {
471
+ success: true,
472
+ data: {
473
+ message: `Added: ${safeInstallName}${version ? ` v${version}` : ''} (registry tarball)`,
474
+ name: safeInstallName,
475
+ version,
476
+ location: targetPath,
477
+ source: 'registry',
478
+ registryModule: moduleName,
479
+ },
480
+ };
481
+ }
482
+ catch (error) {
483
+ return {
484
+ success: false,
485
+ error: error instanceof Error ? error.message : String(error),
486
+ };
487
+ }
488
+ finally {
489
+ if (tempDir) {
490
+ try {
491
+ rmSync(tempDir, { recursive: true, force: true });
492
+ }
493
+ catch {
494
+ // ignore
495
+ }
496
+ }
497
+ }
498
+ }
160
499
  /**
161
500
  * Add a module from GitHub
162
501
  */
163
- export async function add(url, ctx, options = {}) {
502
+ export async function addFromGitHub(url, ctx, options = {}) {
164
503
  const { org, repo, fullUrl } = parseGitHubUrl(url);
165
504
  const { module: modulePath, name, branch = 'main', tag } = options;
166
505
  // Determine ref (tag takes priority)
@@ -184,11 +523,11 @@ export async function add(url, ctx, options = {}) {
184
523
  sourcePath = repoRoot;
185
524
  }
186
525
  // Determine module name
187
- moduleName = name || basename(sourcePath);
526
+ moduleName = name ? assertSafeModuleName(name) : basename(sourcePath);
188
527
  // Get version
189
528
  const version = await getModuleVersion(sourcePath);
190
529
  // Install to user modules dir
191
- const targetPath = join(USER_MODULES_DIR, moduleName);
530
+ const targetPath = resolveModuleTarget(moduleName);
192
531
  // Remove existing
193
532
  if (existsSync(targetPath)) {
194
533
  rmSync(targetPath, { recursive: true, force: true });
@@ -208,8 +547,10 @@ export async function add(url, ctx, options = {}) {
208
547
  installedTime: new Date().toISOString(),
209
548
  });
210
549
  // Cleanup temp directory
211
- const tempDir = repoRoot.split('/').slice(0, -1).join('/');
212
- rmSync(tempDir, { recursive: true, force: true });
550
+ const tempDir = dirname(repoRoot);
551
+ if (tempDir && tempDir !== '/' && tempDir !== '.' && tempDir !== USER_MODULES_DIR) {
552
+ rmSync(tempDir, { recursive: true, force: true });
553
+ }
213
554
  return {
214
555
  success: true,
215
556
  data: {
@@ -217,13 +558,39 @@ export async function add(url, ctx, options = {}) {
217
558
  name: moduleName,
218
559
  version,
219
560
  location: targetPath,
561
+ source: 'github',
220
562
  },
221
563
  };
222
564
  }
223
565
  catch (error) {
566
+ // Cleanup temp directory on error
567
+ if (repoRoot) {
568
+ try {
569
+ const tempDir = dirname(repoRoot);
570
+ if (tempDir && tempDir !== '/' && tempDir !== '.' && tempDir !== USER_MODULES_DIR) {
571
+ rmSync(tempDir, { recursive: true, force: true });
572
+ }
573
+ }
574
+ catch {
575
+ // Ignore cleanup errors
576
+ }
577
+ }
224
578
  return {
225
579
  success: false,
226
580
  error: error instanceof Error ? error.message : String(error),
227
581
  };
228
582
  }
229
583
  }
584
+ /**
585
+ * Add a module from GitHub or Registry (auto-detect source)
586
+ */
587
+ export async function add(source, ctx, options = {}) {
588
+ // Determine source type
589
+ if (isGitHubSource(source)) {
590
+ return addFromGitHub(source, ctx, options);
591
+ }
592
+ else {
593
+ // Treat as registry module name
594
+ return addFromRegistry(source, ctx, options);
595
+ }
596
+ }
@@ -8,14 +8,30 @@
8
8
  * - Iterative: A → (check) → A → ... → Done
9
9
  */
10
10
  import { findModule, getDefaultSearchPaths, executeComposition } from '../modules/index.js';
11
+ import { ErrorCodes, attachContext, makeErrorEnvelope } from '../errors/index.js';
12
+ function looksLikeCode(str) {
13
+ const codeIndicators = [
14
+ /^(def|function|class|const|let|var|import|export|public|private)\s/,
15
+ /[{};()]/,
16
+ /=>/,
17
+ /\.(py|js|ts|go|rs|java|cpp|c|rb)$/,
18
+ ];
19
+ return codeIndicators.some(re => re.test(str));
20
+ }
11
21
  export async function compose(moduleName, ctx, options = {}) {
12
22
  const searchPaths = getDefaultSearchPaths(ctx.cwd);
13
23
  // Find module
14
24
  const module = await findModule(moduleName, searchPaths);
15
25
  if (!module) {
26
+ const errorEnvelope = attachContext(makeErrorEnvelope({
27
+ code: ErrorCodes.MODULE_NOT_FOUND,
28
+ message: `Module not found: ${moduleName}\nSearch paths: ${searchPaths.join(', ')}`,
29
+ suggestion: "Use 'cog list' to see installed modules or 'cog search' to find modules in registry",
30
+ }), { module: moduleName, provider: ctx.provider.name });
16
31
  return {
17
32
  success: false,
18
- error: `Module not found: ${moduleName}\nSearch paths: ${searchPaths.join(', ')}`,
33
+ error: errorEnvelope.error.message,
34
+ data: errorEnvelope,
19
35
  };
20
36
  }
21
37
  try {
@@ -26,16 +42,26 @@ export async function compose(moduleName, ctx, options = {}) {
26
42
  inputData = JSON.parse(options.input);
27
43
  }
28
44
  catch {
45
+ const errorEnvelope = attachContext(makeErrorEnvelope({
46
+ code: ErrorCodes.INVALID_INPUT,
47
+ message: `Invalid JSON input: ${options.input}`,
48
+ suggestion: 'Ensure input is valid JSON format',
49
+ }), { module: moduleName, provider: ctx.provider.name });
29
50
  return {
30
51
  success: false,
31
- error: `Invalid JSON input: ${options.input}`,
52
+ error: errorEnvelope.error.message,
53
+ data: errorEnvelope,
32
54
  };
33
55
  }
34
56
  }
35
57
  // Handle --args as text input
36
58
  if (options.args) {
37
- inputData.query = options.args;
38
- inputData.code = options.args;
59
+ if (looksLikeCode(options.args)) {
60
+ inputData.code = options.args;
61
+ }
62
+ else {
63
+ inputData.query = options.args;
64
+ }
39
65
  }
40
66
  // Execute composition
41
67
  const result = await executeComposition(moduleName, inputData, ctx.provider, {
@@ -56,11 +82,27 @@ export async function compose(moduleName, ctx, options = {}) {
56
82
  }
57
83
  console.error(`--- Total: ${result.totalTimeMs}ms ---`);
58
84
  }
85
+ if (!result.ok) {
86
+ const partialData = options.trace
87
+ ? { moduleResults: result.moduleResults, trace: result.trace }
88
+ : { moduleResults: result.moduleResults };
89
+ const errorEnvelope = attachContext(makeErrorEnvelope({
90
+ code: result.error?.code ?? ErrorCodes.INTERNAL_ERROR,
91
+ message: result.error?.message ?? 'Composition failed',
92
+ details: result.error?.module ? { module: result.error.module } : undefined,
93
+ partial_data: partialData,
94
+ }), { module: moduleName, provider: ctx.provider.name });
95
+ return {
96
+ success: false,
97
+ error: errorEnvelope.error.message,
98
+ data: errorEnvelope,
99
+ };
100
+ }
59
101
  // Return result
60
102
  if (options.trace) {
61
103
  // Include full result with trace
62
104
  return {
63
- success: result.ok,
105
+ success: true,
64
106
  data: {
65
107
  ok: result.ok,
66
108
  result: result.result,
@@ -73,33 +115,28 @@ export async function compose(moduleName, ctx, options = {}) {
73
115
  }
74
116
  else if (options.pretty) {
75
117
  return {
76
- success: result.ok,
118
+ success: true,
77
119
  data: result.result,
78
120
  };
79
121
  }
80
122
  else {
81
- // For non-pretty mode, return data (success) or error (failure)
82
- if (result.ok && result.result) {
83
- return {
84
- success: true,
85
- data: result.result.data,
86
- };
87
- }
88
- else {
89
- return {
90
- success: false,
91
- error: result.error
92
- ? `${result.error.code}: ${result.error.message}`
93
- : 'Composition failed',
94
- data: result.moduleResults,
95
- };
96
- }
123
+ // Keep compose output consistent with run/pipe: always return full v2.2 envelope
124
+ return {
125
+ success: true,
126
+ data: result.result,
127
+ };
97
128
  }
98
129
  }
99
130
  catch (e) {
131
+ const errorEnvelope = attachContext(makeErrorEnvelope({
132
+ code: ErrorCodes.INTERNAL_ERROR,
133
+ message: e instanceof Error ? e.message : String(e),
134
+ recoverable: false,
135
+ }), { module: moduleName, provider: ctx.provider.name });
100
136
  return {
101
137
  success: false,
102
- error: e instanceof Error ? e.message : String(e),
138
+ error: errorEnvelope.error.message,
139
+ data: errorEnvelope,
103
140
  };
104
141
  }
105
142
  }