@amodalai/amodal 0.2.1 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/src/commands/connect-channel.d.ts +11 -0
  3. package/dist/src/commands/connect-channel.d.ts.map +1 -0
  4. package/dist/src/commands/connect-channel.js +169 -0
  5. package/dist/src/commands/connect-channel.js.map +1 -0
  6. package/dist/src/commands/connect.d.ts +3 -1
  7. package/dist/src/commands/connect.d.ts.map +1 -1
  8. package/dist/src/commands/connect.js +27 -16
  9. package/dist/src/commands/connect.js.map +1 -1
  10. package/dist/src/commands/groups/connect.d.ts +8 -0
  11. package/dist/src/commands/groups/connect.d.ts.map +1 -0
  12. package/dist/src/commands/groups/connect.js +17 -0
  13. package/dist/src/commands/groups/connect.js.map +1 -0
  14. package/dist/src/commands/groups/pkg.d.ts.map +1 -1
  15. package/dist/src/commands/groups/pkg.js +0 -12
  16. package/dist/src/commands/groups/pkg.js.map +1 -1
  17. package/dist/src/commands/index.d.ts +1 -1
  18. package/dist/src/commands/index.d.ts.map +1 -1
  19. package/dist/src/commands/index.js +3 -1
  20. package/dist/src/commands/index.js.map +1 -1
  21. package/dist/src/commands/init.d.ts.map +1 -1
  22. package/dist/src/commands/init.js +4 -1
  23. package/dist/src/commands/init.js.map +1 -1
  24. package/dist/src/commands/inspect.d.ts.map +1 -1
  25. package/dist/src/commands/inspect.js +32 -38
  26. package/dist/src/commands/inspect.js.map +1 -1
  27. package/dist/src/commands/install-pkg.d.ts +5 -5
  28. package/dist/src/commands/install-pkg.d.ts.map +1 -1
  29. package/dist/src/commands/install-pkg.js +35 -77
  30. package/dist/src/commands/install-pkg.js.map +1 -1
  31. package/dist/src/commands/uninstall.d.ts +2 -2
  32. package/dist/src/commands/uninstall.d.ts.map +1 -1
  33. package/dist/src/commands/uninstall.js +6 -32
  34. package/dist/src/commands/uninstall.js.map +1 -1
  35. package/dist/src/commands/validate.d.ts.map +1 -1
  36. package/dist/src/commands/validate.js +15 -21
  37. package/dist/src/commands/validate.js.map +1 -1
  38. package/dist/src/shared/tarball.js +1 -1
  39. package/dist/src/shared/tarball.js.map +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +5 -5
  42. package/src/commands/command-exports.test.ts +1 -1
  43. package/src/commands/connect-channel.ts +197 -0
  44. package/src/commands/connect.test.ts +28 -40
  45. package/src/commands/connect.ts +30 -24
  46. package/src/commands/groups/connect.ts +20 -0
  47. package/src/commands/groups/pkg.ts +0 -12
  48. package/src/commands/index.ts +5 -1
  49. package/src/commands/init.test.ts +2 -2
  50. package/src/commands/init.ts +5 -1
  51. package/src/commands/inspect.test.ts +1 -18
  52. package/src/commands/inspect.ts +31 -36
  53. package/src/commands/install-pkg.test.ts +39 -73
  54. package/src/commands/install-pkg.ts +38 -87
  55. package/src/commands/uninstall.test.ts +11 -51
  56. package/src/commands/uninstall.ts +7 -36
  57. package/src/commands/validate.test.ts +5 -26
  58. package/src/commands/validate.ts +15 -20
  59. package/src/e2e-commands.test.ts +0 -1
  60. package/src/e2e-plugins.test.ts +0 -1
  61. package/src/e2e.test.ts +0 -89
  62. package/src/shared/tarball.ts +1 -1
  63. package/dist/src/commands/diff.d.ts +0 -17
  64. package/dist/src/commands/diff.d.ts.map +0 -1
  65. package/dist/src/commands/diff.js +0 -118
  66. package/dist/src/commands/diff.js.map +0 -1
  67. package/dist/src/commands/list.d.ts +0 -18
  68. package/dist/src/commands/list.d.ts.map +0 -1
  69. package/dist/src/commands/list.js +0 -82
  70. package/dist/src/commands/list.js.map +0 -1
  71. package/dist/src/commands/publish.d.ts +0 -18
  72. package/dist/src/commands/publish.d.ts.map +0 -1
  73. package/dist/src/commands/publish.js +0 -121
  74. package/dist/src/commands/publish.js.map +0 -1
  75. package/dist/src/commands/search.d.ts +0 -19
  76. package/dist/src/commands/search.d.ts.map +0 -1
  77. package/dist/src/commands/search.js +0 -97
  78. package/dist/src/commands/search.js.map +0 -1
  79. package/dist/src/commands/update.d.ts +0 -19
  80. package/dist/src/commands/update.d.ts.map +0 -1
  81. package/dist/src/commands/update.js +0 -158
  82. package/dist/src/commands/update.js.map +0 -1
  83. package/src/commands/diff.test.ts +0 -165
  84. package/src/commands/diff.ts +0 -141
  85. package/src/commands/list.test.ts +0 -141
  86. package/src/commands/list.ts +0 -99
  87. package/src/commands/publish.test.ts +0 -169
  88. package/src/commands/publish.ts +0 -141
  89. package/src/commands/search.test.ts +0 -171
  90. package/src/commands/search.ts +0 -120
  91. package/src/commands/update.test.ts +0 -256
  92. package/src/commands/update.ts +0 -196
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amodalai/amodal",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Amodal CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,10 +30,10 @@
30
30
  "semver": "^7.6.0",
31
31
  "yargs": "^17.7.2",
32
32
  "zod": "^4.3.6",
33
- "@amodalai/core": "0.2.1",
34
- "@amodalai/types": "0.2.1",
35
- "@amodalai/runtime": "0.2.1",
36
- "@amodalai/runtime-app": "0.2.1"
33
+ "@amodalai/types": "0.2.2",
34
+ "@amodalai/core": "0.2.2",
35
+ "@amodalai/runtime": "0.2.2",
36
+ "@amodalai/runtime-app": "0.2.2"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^20.11.24",
@@ -47,6 +47,6 @@ describe('command-exports', () => {
47
47
  });
48
48
 
49
49
  it('amodalCommands has expected count', () => {
50
- expect(amodalCommands).toHaveLength(11);
50
+ expect(amodalCommands).toHaveLength(12);
51
51
  });
52
52
  });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Amodal Labs, Inc.
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+
7
+ /**
8
+ * `amodal connect channel <name>` — install a channel package and run
9
+ * its interactive setup flow (prompt for credentials, set webhooks, etc.).
10
+ */
11
+
12
+
13
+ import {findRepoRoot} from '../shared/repo-discovery.js';
14
+ import type {CommandModule} from 'yargs';
15
+ import {readFileSync, writeFileSync, existsSync} from 'node:fs';
16
+ import path from 'node:path';
17
+ import {pathToFileURL} from 'node:url';
18
+ import prompts from 'prompts';
19
+ import {ensurePackageJson, pmAdd, toNpmName} from '@amodalai/core';
20
+ import type {ChannelPlugin, ChannelSetupContext} from '@amodalai/types';
21
+
22
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- yargs CommandModule default generic
23
+ export const connectChannelCommand: CommandModule<{}, {name: string; 'webhook-url'?: string}> = {
24
+ command: 'channel <name>',
25
+ describe: 'Connect a messaging channel (install + setup)',
26
+ builder: (yargs) =>
27
+ yargs
28
+ .positional('name', {type: 'string', demandOption: true, describe: 'Channel package name or short name'})
29
+ .option('webhook-url', {type: 'string', describe: 'Public webhook URL for this channel'}),
30
+ handler: async (argv) => {
31
+ const code = await runConnectChannel({name: argv.name, webhookUrl: argv['webhook-url']});
32
+ process.exit(code);
33
+ },
34
+ };
35
+
36
+ interface ConnectChannelOptions {
37
+ name: string;
38
+ cwd?: string;
39
+ webhookUrl?: string;
40
+ }
41
+
42
+ async function runConnectChannel(options: ConnectChannelOptions): Promise<number> {
43
+ let repoPath: string;
44
+ try {
45
+ repoPath = findRepoRoot(options.cwd);
46
+ } catch (err) {
47
+ const msg = err instanceof Error ? err.message : String(err);
48
+ process.stderr.write(`[connect channel] ${msg}\n`);
49
+ return 1;
50
+ }
51
+
52
+ const npmName = toNpmName(options.name);
53
+
54
+ // Step 1: Install if not present
55
+ const packageDir = path.join(repoPath, 'node_modules', ...npmName.split('/'));
56
+ let alreadyInstalled = false;
57
+ try {
58
+ alreadyInstalled = existsSync(path.join(packageDir, 'package.json'));
59
+ } catch {
60
+ // Not installed
61
+ }
62
+
63
+ if (!alreadyInstalled) {
64
+ process.stderr.write(`[connect channel] Installing ${npmName}...\n`);
65
+ try {
66
+ ensurePackageJson(repoPath, 'amodal-project');
67
+ await pmAdd(repoPath, npmName);
68
+ } catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ process.stderr.write(`[connect channel] Install failed: ${msg}\n`);
71
+ return 1;
72
+ }
73
+ } else {
74
+ process.stderr.write(`[connect channel] ${npmName} already installed.\n`);
75
+ }
76
+
77
+ // Step 2: Load the plugin
78
+ const plugin = await loadChannelPlugin(packageDir, npmName);
79
+ if (!plugin) return 1;
80
+
81
+ // Step 3: Add to packages array in amodal.json if not present
82
+ const configPath = path.join(repoPath, 'amodal.json');
83
+ if (!existsSync(configPath)) {
84
+ process.stderr.write('[connect channel] No amodal.json found. Run `amodal init` first.\n');
85
+ return 1;
86
+ }
87
+
88
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- parsing external JSON
89
+ const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
90
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- config.packages is string[]
91
+ const packages = (config['packages'] ?? []) as string[];
92
+ if (!packages.includes(npmName)) {
93
+ packages.push(npmName);
94
+ config['packages'] = packages;
95
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
96
+ process.stderr.write(`[connect channel] Added ${npmName} to amodal.json packages.\n`);
97
+ }
98
+
99
+ // Step 4: Run plugin setup if available
100
+ if (!plugin.setup) {
101
+ process.stderr.write(`[connect channel] ${plugin.channelType} connected. No interactive setup available.\n`);
102
+ return 0;
103
+ }
104
+
105
+ const context = buildSetupContext(repoPath, configPath, config, options.webhookUrl);
106
+ try {
107
+ await plugin.setup(context);
108
+ } catch (err) {
109
+ const msg = err instanceof Error ? err.message : String(err);
110
+ process.stderr.write(`[connect channel] Setup failed: ${msg}\n`);
111
+ return 1;
112
+ }
113
+
114
+ process.stderr.write(`\n✅ Channel "${plugin.channelType}" connected and configured.\n`);
115
+ return 0;
116
+ }
117
+
118
+ async function loadChannelPlugin(
119
+ packageDir: string,
120
+ npmName: string,
121
+ ): Promise<ChannelPlugin | null> {
122
+ const pkgJsonPath = path.join(packageDir, 'package.json');
123
+ if (!existsSync(pkgJsonPath)) {
124
+ process.stderr.write(`[connect channel] Package "${npmName}" not found in node_modules.\n`);
125
+ return null;
126
+ }
127
+
128
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- parsing external JSON
129
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as Record<string, unknown>;
130
+ const mainField = String(pkgJson['main'] ?? 'dist/index.js');
131
+ const entryPath = path.resolve(packageDir, mainField);
132
+
133
+ if (!entryPath.startsWith(path.resolve(packageDir))) {
134
+ process.stderr.write(`[connect channel] Package "${npmName}" has invalid main field.\n`);
135
+ return null;
136
+ }
137
+
138
+ try {
139
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- dynamic import
140
+ const mod = await import(pathToFileURL(entryPath).href) as {default?: unknown};
141
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validating shape below
142
+ const plugin = mod.default as ChannelPlugin | undefined;
143
+
144
+ if (!plugin || typeof plugin.channelType !== 'string' || typeof plugin.createAdapter !== 'function') {
145
+ process.stderr.write(`[connect channel] Package "${npmName}" does not export a valid ChannelPlugin.\n`);
146
+ return null;
147
+ }
148
+ return plugin;
149
+ } catch (err) {
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ process.stderr.write(`[connect channel] Failed to load plugin: ${msg}\n`);
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function buildSetupContext(
157
+ repoPath: string,
158
+ configPath: string,
159
+ config: Record<string, unknown>,
160
+ webhookUrl?: string,
161
+ ): ChannelSetupContext {
162
+ return {
163
+ repoPath,
164
+ config,
165
+ webhookUrl,
166
+ writeEnv: async (key: string, value: string) => {
167
+ const envPath = path.join(repoPath, '.env');
168
+ const existing = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
169
+ const lines = existing.split('\n');
170
+ const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
171
+ if (idx >= 0) {
172
+ lines[idx] = `${key}=${value}`;
173
+ } else {
174
+ lines.push(`${key}=${value}`);
175
+ }
176
+ writeFileSync(envPath, lines.join('\n'));
177
+ },
178
+ updateConfig: async (patch: Record<string, unknown>) => {
179
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- parsing project config
180
+ const current = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
181
+ Object.assign(current, patch);
182
+ writeFileSync(configPath, JSON.stringify(current, null, 2) + '\n');
183
+ },
184
+ prompt: async (message: string, options?: {secret?: boolean; default?: string}) => {
185
+ const response = await prompts({
186
+ type: options?.secret ? 'password' : 'text',
187
+ name: 'value',
188
+ message,
189
+ initial: options?.default,
190
+ });
191
+ if (response.value === undefined) {
192
+ throw new Error('Setup cancelled by user');
193
+ }
194
+ return String(response.value);
195
+ },
196
+ };
197
+ }
@@ -1,24 +1,18 @@
1
1
  /**
2
2
  * @license
3
- * Copyright 2025 Amodal Labs, Inc.
3
+ * Copyright 2026 Amodal Labs, Inc.
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
 
7
7
  import {describe, it, expect, vi, beforeEach} from 'vitest';
8
8
 
9
9
  const mockFindRepoRoot = vi.fn(() => '/test/repo');
10
- const mockEnsureNpmContext = vi.fn();
11
- const mockNpmInstall = vi.fn();
12
- const mockAddLockEntry = vi.fn();
13
- const mockGetLockEntry = vi.fn();
14
- const mockGetNpmContextPaths = vi.fn();
10
+ const mockEnsurePackageJson = vi.fn();
11
+ const mockPmAdd = vi.fn();
15
12
  const mockReadPackageManifest = vi.fn();
16
13
  const mockToNpmName = vi.fn((name: string) => `@amodalai/connection-${name}`);
17
14
  const mockFindMissingEnvVars = vi.fn();
18
15
  const mockUpsertEnvEntries = vi.fn();
19
- const mockAddConfigDep = vi.fn();
20
- const mockDiscoverInstalledPackages = vi.fn();
21
- const mockBuildLockFile = vi.fn();
22
16
 
23
17
  const mockPromptForCredentials = vi.fn();
24
18
  const mockTestConnection = vi.fn();
@@ -26,23 +20,19 @@ const mockRunOAuth2Flow = vi.fn();
26
20
 
27
21
  const mockPrompts = vi.fn();
28
22
 
23
+ const mockStatSync = vi.fn();
24
+
29
25
  vi.mock('../shared/repo-discovery.js', () => ({
30
26
  findRepoRoot: mockFindRepoRoot,
31
27
  }));
32
28
 
33
29
  vi.mock('@amodalai/core', () => ({
34
- ensureNpmContext: mockEnsureNpmContext,
35
- npmInstall: mockNpmInstall,
36
- addLockEntry: mockAddLockEntry,
37
- getLockEntry: mockGetLockEntry,
38
- getNpmContextPaths: mockGetNpmContextPaths,
30
+ ensurePackageJson: mockEnsurePackageJson,
31
+ pmAdd: mockPmAdd,
39
32
  readPackageManifest: mockReadPackageManifest,
40
33
  toNpmName: mockToNpmName,
41
34
  findMissingEnvVars: mockFindMissingEnvVars,
42
35
  upsertEnvEntries: mockUpsertEnvEntries,
43
- addConfigDep: mockAddConfigDep,
44
- discoverInstalledPackages: mockDiscoverInstalledPackages,
45
- buildLockFile: mockBuildLockFile,
46
36
  }));
47
37
 
48
38
  vi.mock('../auth/index.js', () => ({
@@ -55,13 +45,13 @@ vi.mock('prompts', () => ({
55
45
  default: mockPrompts,
56
46
  }));
57
47
 
58
- const mockPaths = {
59
- root: '/test/repo/.amodal/packages',
60
- npmDir: '/test/repo/.amodal/packages/.npm',
61
- npmrc: '/test/repo/.amodal/packages/.npm/.npmrc',
62
- packageJson: '/test/repo/.amodal/packages/.npm/package.json',
63
- nodeModules: '/test/repo/.amodal/packages/.npm/node_modules',
64
- };
48
+ vi.mock('node:fs', async (importOriginal) => {
49
+ const actual = await importOriginal<typeof import('node:fs')>();
50
+ return {
51
+ ...actual,
52
+ statSync: mockStatSync,
53
+ };
54
+ });
65
55
 
66
56
  const bearerManifest = {
67
57
  name: 'stripe',
@@ -105,16 +95,13 @@ describe('runConnect', () => {
105
95
  beforeEach(() => {
106
96
  vi.clearAllMocks();
107
97
  mockFindRepoRoot.mockReturnValue('/test/repo');
108
- mockEnsureNpmContext.mockResolvedValue(mockPaths);
109
- mockGetLockEntry.mockResolvedValue(null); // fresh install by default
110
- mockNpmInstall.mockResolvedValue({version: '1.0.0', integrity: 'sha512-abc'});
111
- mockAddLockEntry.mockResolvedValue({lockVersion: 2, packages: {}});
112
- mockGetNpmContextPaths.mockReturnValue(mockPaths);
98
+ // By default, package is NOT installed (statSync throws)
99
+ mockStatSync.mockImplementation(() => {
100
+ throw new Error('ENOENT');
101
+ });
102
+ mockPmAdd.mockResolvedValue(undefined);
113
103
  mockFindMissingEnvVars.mockResolvedValue([]);
114
104
  mockUpsertEnvEntries.mockResolvedValue(undefined);
115
- mockAddConfigDep.mockResolvedValue(undefined);
116
- mockDiscoverInstalledPackages.mockResolvedValue([]);
117
- mockBuildLockFile.mockResolvedValue(undefined);
118
105
  mockPromptForCredentials.mockResolvedValue({credentials: {}, summary: 'Set 1 credential'});
119
106
  mockTestConnection.mockResolvedValue({connectionName: 'test', results: [], allPassed: true});
120
107
  stderrOutput = '';
@@ -135,7 +122,7 @@ describe('runConnect', () => {
135
122
  const {runConnect} = await import('./connect.js');
136
123
  const result = await runConnect({name: 'stripe'});
137
124
  expect(result).toBe(0);
138
- expect(mockNpmInstall).toHaveBeenCalled();
125
+ expect(mockPmAdd).toHaveBeenCalled();
139
126
  expect(mockPromptForCredentials).toHaveBeenCalled();
140
127
  expect(mockTestConnection).toHaveBeenCalled();
141
128
  expect(stderrOutput).toContain('Connected: stripe');
@@ -147,7 +134,7 @@ describe('runConnect', () => {
147
134
  const {runConnect} = await import('./connect.js');
148
135
  const result = await runConnect({name: 'datadog'});
149
136
  expect(result).toBe(0);
150
- expect(mockNpmInstall).toHaveBeenCalled();
137
+ expect(mockPmAdd).toHaveBeenCalled();
151
138
  expect(mockPromptForCredentials).toHaveBeenCalled();
152
139
  });
153
140
 
@@ -182,18 +169,19 @@ describe('runConnect', () => {
182
169
  });
183
170
 
184
171
  it('should skip install on reconnect', async () => {
185
- mockGetLockEntry.mockResolvedValue({version: '1.0.0', integrity: 'sha512-abc'});
172
+ // Package already installed in node_modules
173
+ mockStatSync.mockReturnValue({isDirectory: () => true});
186
174
  mockReadPackageManifest.mockResolvedValue(bearerManifest);
187
175
 
188
176
  const {runConnect} = await import('./connect.js');
189
177
  const result = await runConnect({name: 'stripe'});
190
178
  expect(result).toBe(0);
191
- expect(mockNpmInstall).not.toHaveBeenCalled();
179
+ expect(mockPmAdd).not.toHaveBeenCalled();
192
180
  expect(stderrOutput).toContain('already installed');
193
181
  });
194
182
 
195
183
  it('should skip credential prompt on reconnect when vars present', async () => {
196
- mockGetLockEntry.mockResolvedValue({version: '1.0.0', integrity: 'sha512-abc'});
184
+ mockStatSync.mockReturnValue({isDirectory: () => true});
197
185
  mockReadPackageManifest.mockResolvedValue(bearerManifest);
198
186
  mockFindMissingEnvVars.mockResolvedValue([]);
199
187
 
@@ -204,7 +192,7 @@ describe('runConnect', () => {
204
192
  });
205
193
 
206
194
  it('should re-prompt on reconnect with force', async () => {
207
- mockGetLockEntry.mockResolvedValue({version: '1.0.0', integrity: 'sha512-abc'});
195
+ mockStatSync.mockReturnValue({isDirectory: () => true});
208
196
  mockReadPackageManifest.mockResolvedValue(bearerManifest);
209
197
 
210
198
  const {runConnect} = await import('./connect.js');
@@ -239,7 +227,7 @@ describe('runConnect', () => {
239
227
  });
240
228
 
241
229
  it('should return 1 when npm install fails', async () => {
242
- mockNpmInstall.mockRejectedValue(new Error('Registry down'));
230
+ mockPmAdd.mockRejectedValue(new Error('Registry down'));
243
231
 
244
232
  const {runConnect} = await import('./connect.js');
245
233
  const result = await runConnect({name: 'stripe'});
@@ -291,7 +279,7 @@ describe('runConnect', () => {
291
279
  });
292
280
 
293
281
  it('should prompt for missing vars on reconnect without force', async () => {
294
- mockGetLockEntry.mockResolvedValue({version: '1.0.0', integrity: 'sha512-abc'});
282
+ mockStatSync.mockReturnValue({isDirectory: () => true});
295
283
  mockReadPackageManifest.mockResolvedValue(bearerManifest);
296
284
  mockFindMissingEnvVars.mockResolvedValue(['STRIPE_API_KEY']);
297
285
 
@@ -1,23 +1,18 @@
1
1
  /**
2
2
  * @license
3
- * Copyright 2025 Amodal Labs, Inc.
3
+ * Copyright 2026 Amodal Labs, Inc.
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
 
7
+ import {statSync} from 'node:fs';
7
8
  import {join} from 'node:path';
8
9
 
9
10
  import type {CommandModule} from 'yargs';
10
11
  import prompts from 'prompts';
11
12
  import {
12
- addConfigDep,
13
- addLockEntry,
14
- buildLockFile,
15
- discoverInstalledPackages,
16
- ensureNpmContext,
13
+ ensurePackageJson,
17
14
  findMissingEnvVars,
18
- getLockEntry,
19
- getNpmContextPaths,
20
- npmInstall,
15
+ pmAdd,
21
16
  readPackageManifest,
22
17
  toNpmName,
23
18
  upsertEnvEntries,
@@ -47,24 +42,23 @@ export async function runConnect(options: ConnectOptions): Promise<number> {
47
42
  return 1;
48
43
  }
49
44
 
50
- const paths = await ensureNpmContext(repoPath);
51
45
  const npmName = toNpmName(options.name);
52
- const existing = await getLockEntry(repoPath, npmName);
53
- const isReconnect = existing !== null;
46
+ const packageDir = join(repoPath, 'node_modules', npmName);
47
+
48
+ // Check if already installed by looking in node_modules
49
+ let isReconnect = false;
50
+ try {
51
+ isReconnect = statSync(packageDir).isDirectory();
52
+ } catch {
53
+ // Not installed
54
+ }
54
55
 
55
56
  // Step 1: Install if fresh
56
57
  if (!isReconnect) {
57
58
  process.stderr.write(`[connect] Installing ${npmName}...\n`);
58
59
  try {
59
- const result = await npmInstall(paths, npmName);
60
- await addLockEntry(repoPath, npmName, {
61
- version: result.version,
62
- integrity: result.integrity,
63
- });
64
- await addConfigDep(repoPath, npmName, result.version);
65
- // Rebuild lock file from what's actually installed
66
- const discovered = await discoverInstalledPackages(paths);
67
- await buildLockFile(repoPath, discovered);
60
+ ensurePackageJson(repoPath, 'amodal-project');
61
+ await pmAdd(repoPath, npmName);
68
62
  } catch (err) {
69
63
  const msg = err instanceof Error ? err.message : String(err);
70
64
  process.stderr.write(`[connect] Install failed: ${msg}\n`);
@@ -75,9 +69,6 @@ export async function runConnect(options: ConnectOptions): Promise<number> {
75
69
  }
76
70
 
77
71
  // Step 2: Read manifest
78
- const contextPaths = getNpmContextPaths(repoPath);
79
- const packageDir = join(contextPaths.nodeModules, npmName);
80
-
81
72
  let manifest;
82
73
  try {
83
74
  manifest = await readPackageManifest(packageDir);
@@ -230,3 +221,18 @@ export const connectCommand: CommandModule = {
230
221
  process.exit(code);
231
222
  },
232
223
  };
224
+
225
+ /** Variant of connectCommand registered as `amodal connect connection <name>`. */
226
+ export const connectConnectionCommand: CommandModule = {
227
+ command: 'connection <name>',
228
+ describe: 'Connect a connection package (install + auth + test)',
229
+ builder: (yargs) =>
230
+ yargs
231
+ .positional('name', {type: 'string', demandOption: true, describe: 'Connection name'})
232
+ .option('force', {type: 'boolean', default: false, describe: 'Force re-authentication'}),
233
+ handler: async (argv) => {
234
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
235
+ const code = await runConnect({name: argv['name'] as string, force: argv['force'] as boolean});
236
+ process.exit(code);
237
+ },
238
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Amodal Labs, Inc.
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+
7
+ import type {CommandModule} from 'yargs';
8
+ import {connectConnectionCommand} from '../connect.js';
9
+ import {connectChannelCommand} from '../connect-channel.js';
10
+
11
+ export const connectGroupCommand: CommandModule = {
12
+ command: 'connect <command>',
13
+ describe: 'Connect a package (connection or channel)',
14
+ builder: (yargs) =>
15
+ yargs
16
+ .command(connectConnectionCommand)
17
+ .command(connectChannelCommand)
18
+ .demandCommand(1, 'Specify: connection or channel'),
19
+ handler: () => {},
20
+ };
@@ -5,14 +5,8 @@
5
5
  */
6
6
 
7
7
  import type {CommandModule} from 'yargs';
8
- import {connectCommand} from '../connect.js';
9
8
  import {installPkgCommand} from '../install-pkg.js';
10
9
  import {uninstallCommand} from '../uninstall.js';
11
- import {listCommand} from '../list.js';
12
- import {updateCommand} from '../update.js';
13
- import {diffCommand} from '../diff.js';
14
- import {searchCommand} from '../search.js';
15
- import {publishCommand} from '../publish.js';
16
10
  import {linkCommand} from '../link.js';
17
11
  import {syncCommand} from '../sync.js';
18
12
 
@@ -21,14 +15,8 @@ export const pkgCommand: CommandModule = {
21
15
  describe: 'Manage packages',
22
16
  builder: (yargs) =>
23
17
  yargs
24
- .command(connectCommand)
25
18
  .command(installPkgCommand)
26
19
  .command(uninstallCommand)
27
- .command(listCommand)
28
- .command(updateCommand)
29
- .command(diffCommand)
30
- .command(searchCommand)
31
- .command(publishCommand)
32
20
  .command(linkCommand)
33
21
  .command(syncCommand)
34
22
  .demandCommand(1, 'Specify a subcommand'),
@@ -4,6 +4,8 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
 
7
+
8
+
7
9
  import {initCommand} from './init.js';
8
10
  import {devCommand} from './dev.js';
9
11
  import {inspectCommand} from './inspect.js';
@@ -15,12 +17,13 @@ import {pkgCommand} from './groups/pkg.js';
15
17
  import {deployGroupCommand} from './groups/deploy.js';
16
18
  import {opsCommand} from './groups/ops.js';
17
19
  import {authCommand} from './groups/auth.js';
20
+ import {connectGroupCommand} from './groups/connect.js';
18
21
 
19
22
  /**
20
23
  * All amodal subcommands registered on the root yargs instance.
21
24
  *
22
25
  * Top-level: daily-driver commands (init, dev, chat, validate, inspect, eval, test)
23
- * Groups: pkg, deploy, ops, auth
26
+ * Groups: pkg, deploy, ops, auth, connect
24
27
  */
25
28
  export const amodalCommands = [
26
29
  // Top-level
@@ -36,4 +39,5 @@ export const amodalCommands = [
36
39
  deployGroupCommand,
37
40
  opsCommand,
38
41
  authCommand,
42
+ connectGroupCommand,
39
43
  ];
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @license
3
- * Copyright 2025 Amodal Labs, Inc.
3
+ * Copyright 2026 Amodal Labs, Inc.
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
 
@@ -36,7 +36,7 @@ describe('runInit', () => {
36
36
  expect(existsSync(join(tempDir, 'evals'))).toBe(true);
37
37
  expect(existsSync(join(tempDir, '.gitignore'))).toBe(true);
38
38
  const gitignore = readFileSync(join(tempDir, '.gitignore'), 'utf-8');
39
- expect(gitignore).toContain('amodal_packages/');
39
+ expect(gitignore).toContain('node_modules/');
40
40
  expect(gitignore).toContain('.env');
41
41
  });
42
42
 
@@ -7,6 +7,7 @@
7
7
  import {existsSync, mkdirSync, writeFileSync} from 'node:fs';
8
8
  import {join} from 'node:path';
9
9
  import type {CommandModule} from 'yargs';
10
+ import {ensurePackageJson} from '@amodalai/core';
10
11
  import {generateConfigTemplate} from '../templates/config-template.js';
11
12
 
12
13
  export interface InitOptions {
@@ -46,10 +47,13 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
46
47
  // Write amodal.json at repo root
47
48
  writeFileSync(configPath, generateConfigTemplate({name, provider}));
48
49
 
50
+ // Ensure package.json exists
51
+ ensurePackageJson(cwd, name);
52
+
49
53
  // Write .gitignore if it doesn't exist
50
54
  const gitignorePath = join(cwd, '.gitignore');
51
55
  if (!existsSync(gitignorePath)) {
52
- writeFileSync(gitignorePath, '.amodal/\namodal_packages/\n.env\n.env.*\n');
56
+ writeFileSync(gitignorePath, '.amodal/\nnode_modules/\n.env\n.env.*\n');
53
57
  }
54
58
 
55
59
  process.stderr.write(`[init] Created project "${name}" (${provider})\n`);
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * @license
3
- * Copyright 2025 Amodal Labs, Inc.
3
+ * Copyright 2026 Amodal Labs, Inc.
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
 
7
7
  import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
8
8
 
9
9
  const mockFindRepoRoot = vi.fn(() => '/test/repo');
10
- const mockReadLockFile = vi.fn();
11
10
  const mockResolveAllPackages = vi.fn();
12
11
 
13
12
  vi.mock('../shared/repo-discovery.js', () => ({
@@ -15,7 +14,6 @@ vi.mock('../shared/repo-discovery.js', () => ({
15
14
  }));
16
15
 
17
16
  vi.mock('@amodalai/core', () => ({
18
- readLockFile: mockReadLockFile,
19
17
  resolveAllPackages: mockResolveAllPackages,
20
18
  loadRepo: vi.fn().mockResolvedValue({
21
19
  config: {models: {main: {provider: 'anthropic', model: 'test'}}},
@@ -130,7 +128,6 @@ describe('runInspect', () => {
130
128
 
131
129
  // Resolved package tests
132
130
  it('should show resolved packages when resolved flag set', async () => {
133
- mockReadLockFile.mockResolvedValue({lockVersion: 2, packages: {}});
134
131
  mockResolveAllPackages.mockResolvedValue({
135
132
  connections: new Map([['salesforce', {surface: [{}, {}, {}], spec: {auth: {type: 'bearer'}}}]]),
136
133
  skills: [{name: 'triage', body: 'Triage methodology content'}],
@@ -149,7 +146,6 @@ describe('runInspect', () => {
149
146
  });
150
147
 
151
148
  it('should filter by scope', async () => {
152
- mockReadLockFile.mockResolvedValue({lockVersion: 2, packages: {}});
153
149
  mockResolveAllPackages.mockResolvedValue({
154
150
  connections: new Map([
155
151
  ['salesforce', {surface: [{}, {}], spec: {auth: {type: 'bearer'}}}],
@@ -169,18 +165,7 @@ describe('runInspect', () => {
169
165
  expect(output).not.toContain('stripe');
170
166
  });
171
167
 
172
- it('should show no lock file message when resolved without lock', async () => {
173
- mockReadLockFile.mockResolvedValue(null);
174
-
175
- const {runInspect} = await import('./inspect.js');
176
- await runInspect({resolved: true});
177
-
178
- const output = stdoutOutput.join('');
179
- expect(output).toContain('No lock file found');
180
- });
181
-
182
168
  it('should show resolution warnings', async () => {
183
- mockReadLockFile.mockResolvedValue({lockVersion: 2, packages: {}});
184
169
  mockResolveAllPackages.mockResolvedValue({
185
170
  connections: new Map(),
186
171
  skills: [],
@@ -198,7 +183,6 @@ describe('runInspect', () => {
198
183
  });
199
184
 
200
185
  it('should show connection details in resolved view', async () => {
201
- mockReadLockFile.mockResolvedValue({lockVersion: 2, packages: {}});
202
186
  mockResolveAllPackages.mockResolvedValue({
203
187
  connections: new Map([['crm', {surface: [{}, {}], spec: {auth: {type: 'oauth2'}}}]]),
204
188
  skills: [],
@@ -216,7 +200,6 @@ describe('runInspect', () => {
216
200
  });
217
201
 
218
202
  it('should show skill body length in resolved view', async () => {
219
- mockReadLockFile.mockResolvedValue({lockVersion: 2, packages: {}});
220
203
  mockResolveAllPackages.mockResolvedValue({
221
204
  connections: new Map(),
222
205
  skills: [{name: 'investigate', body: 'A'.repeat(500)}],