@dot-ai/core 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdir, writeFile, rm, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ listPackages,
7
+ resolvePackages,
8
+ ensurePackagesInstalled,
9
+ } from '../package-manager.js';
10
+
11
+ // Use temp directory for test fixtures
12
+ const testDir = join(tmpdir(), 'dot-ai-pkg-test-' + Date.now());
13
+
14
+ beforeEach(async () => {
15
+ await mkdir(join(testDir, '.ai', 'packages'), { recursive: true });
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await rm(testDir, { recursive: true, force: true });
20
+ });
21
+
22
+ // ── Helper: create a fake installed package ─────────────────────────────────
23
+ async function createFakePackage(
24
+ name: string,
25
+ version: string,
26
+ dotAi?: { extensions?: string[] },
27
+ ) {
28
+ const installDir = join(testDir, '.ai', 'packages');
29
+ const pkgDir = join(installDir, 'node_modules', name);
30
+ await mkdir(pkgDir, { recursive: true });
31
+
32
+ // Write package.json for installed package
33
+ const pkgJson: Record<string, unknown> = { name, version };
34
+ if (dotAi) pkgJson['dot-ai'] = dotAi;
35
+ await writeFile(join(pkgDir, 'package.json'), JSON.stringify(pkgJson));
36
+
37
+ // Create extension files if declared
38
+ if (dotAi?.extensions) {
39
+ for (const ext of dotAi.extensions) {
40
+ const extPath = join(pkgDir, ext);
41
+ await mkdir(join(extPath, '..'), { recursive: true });
42
+ await writeFile(extPath, 'export default function() {}');
43
+ }
44
+ }
45
+
46
+ // Update the packages/package.json dependencies
47
+ const depsPkgPath = join(installDir, 'package.json');
48
+ let deps: Record<string, string> = {};
49
+ try {
50
+ const raw = await readFile(depsPkgPath, 'utf-8');
51
+ const existing = JSON.parse(raw);
52
+ deps = existing.dependencies ?? {};
53
+ } catch { /* no file yet */ }
54
+
55
+ deps[name] = version;
56
+ await writeFile(depsPkgPath, JSON.stringify({ dependencies: deps }));
57
+ }
58
+
59
+ describe('listPackages', () => {
60
+ it('returns empty array when no packages installed', async () => {
61
+ const result = await listPackages(testDir);
62
+ expect(result).toEqual([]);
63
+ });
64
+
65
+ it('lists installed packages with dot-ai field', async () => {
66
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0', {
67
+ extensions: ['./dist/extension.js'],
68
+ });
69
+
70
+ const result = await listPackages(testDir);
71
+ expect(result).toHaveLength(1);
72
+ expect(result[0].name).toBe('@dot-ai/ext-memory');
73
+ expect(result[0].version).toBe('1.0.0');
74
+ expect(result[0].dotAi?.extensions).toEqual(['./dist/extension.js']);
75
+ });
76
+
77
+ it('lists packages without dot-ai field', async () => {
78
+ await createFakePackage('some-lib', '2.0.0');
79
+
80
+ const result = await listPackages(testDir);
81
+ expect(result).toHaveLength(1);
82
+ expect(result[0].name).toBe('some-lib');
83
+ expect(result[0].dotAi).toBeUndefined();
84
+ });
85
+
86
+ it('lists multiple packages', async () => {
87
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0', {
88
+ extensions: ['./dist/extension.js'],
89
+ });
90
+ await createFakePackage('@dot-ai/ext-identity', '1.0.0', {
91
+ extensions: ['./dist/index.js'],
92
+ });
93
+
94
+ const result = await listPackages(testDir);
95
+ expect(result).toHaveLength(2);
96
+ });
97
+ });
98
+
99
+ describe('resolvePackages', () => {
100
+ it('returns empty when no packages', async () => {
101
+ const result = await resolvePackages(testDir);
102
+ expect(result.extensions).toEqual([]);
103
+ expect(result.skills).toEqual([]);
104
+ expect(result.providers).toEqual([]);
105
+ });
106
+
107
+ it('resolves extension paths from packages with dot-ai field', async () => {
108
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0', {
109
+ extensions: ['./dist/extension.js'],
110
+ });
111
+
112
+ const result = await resolvePackages(testDir);
113
+ expect(result.extensions).toHaveLength(1);
114
+ expect(result.extensions[0]).toContain('ext-memory');
115
+ expect(result.extensions[0]).toContain('dist/extension.js');
116
+ });
117
+
118
+ it('skips packages without dot-ai field', async () => {
119
+ await createFakePackage('some-lib', '2.0.0');
120
+
121
+ const result = await resolvePackages(testDir);
122
+ expect(result.extensions).toHaveLength(0);
123
+ });
124
+ });
125
+
126
+ describe('ensurePackagesInstalled', () => {
127
+ it('skips already installed packages', async () => {
128
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0', {
129
+ extensions: ['./dist/extension.js'],
130
+ });
131
+
132
+ const result = await ensurePackagesInstalled(
133
+ testDir,
134
+ ['@dot-ai/ext-memory'],
135
+ );
136
+
137
+ expect(result.skipped).toContain('@dot-ai/ext-memory');
138
+ expect(result.installed).toHaveLength(0);
139
+ expect(result.errors).toHaveLength(0);
140
+ });
141
+
142
+ it('skips packages when onMissing returns skip', async () => {
143
+ const result = await ensurePackagesInstalled(
144
+ testDir,
145
+ ['@dot-ai/ext-not-there'],
146
+ async () => 'skip',
147
+ );
148
+
149
+ expect(result.skipped).toContain('@dot-ai/ext-not-there');
150
+ expect(result.installed).toHaveLength(0);
151
+ });
152
+
153
+ it('errors when onMissing returns error', async () => {
154
+ const result = await ensurePackagesInstalled(
155
+ testDir,
156
+ ['@dot-ai/ext-not-there'],
157
+ async () => 'error',
158
+ );
159
+
160
+ expect(result.errors).toHaveLength(1);
161
+ expect(result.errors[0].source).toBe('@dot-ai/ext-not-there');
162
+ expect(result.errors[0].error).toContain('Missing package');
163
+ });
164
+
165
+ it('reports errors for failed installs', async () => {
166
+ // Use an impossible package name that npm will fail to find
167
+ const result = await ensurePackagesInstalled(
168
+ testDir,
169
+ ['@dot-ai-fake-nonexistent-pkg-xyz/no-exist'],
170
+ );
171
+
172
+ expect(result.errors).toHaveLength(1);
173
+ expect(result.errors[0].source).toBe('@dot-ai-fake-nonexistent-pkg-xyz/no-exist');
174
+ });
175
+
176
+ it('handles npm: prefix in source', async () => {
177
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0', {
178
+ extensions: ['./dist/extension.js'],
179
+ });
180
+
181
+ const result = await ensurePackagesInstalled(
182
+ testDir,
183
+ ['npm:@dot-ai/ext-memory@1.0.0'],
184
+ );
185
+
186
+ // npm:@dot-ai/ext-memory@1.0.0 → name = @dot-ai/ext-memory → already installed
187
+ expect(result.skipped).toContain('npm:@dot-ai/ext-memory@1.0.0');
188
+ });
189
+
190
+ it('handles multiple packages', async () => {
191
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0', {
192
+ extensions: ['./dist/extension.js'],
193
+ });
194
+
195
+ const result = await ensurePackagesInstalled(
196
+ testDir,
197
+ ['@dot-ai/ext-memory', '@dot-ai/ext-not-there'],
198
+ async (source) => source.includes('not-there') ? 'skip' : 'install',
199
+ );
200
+
201
+ expect(result.skipped).toContain('@dot-ai/ext-memory');
202
+ expect(result.skipped).toContain('@dot-ai/ext-not-there');
203
+ });
204
+ });
205
+
206
+ describe('parsePackageSource (via ensurePackagesInstalled)', () => {
207
+ // We test parsePackageSource indirectly through ensurePackagesInstalled
208
+
209
+ it('handles scoped package with version', async () => {
210
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0');
211
+
212
+ const result = await ensurePackagesInstalled(testDir, ['@dot-ai/ext-memory@1.0.0']);
213
+ // Should find it as already installed (strips version to get name)
214
+ expect(result.skipped).toContain('@dot-ai/ext-memory@1.0.0');
215
+ });
216
+
217
+ it('handles scoped package without version', async () => {
218
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0');
219
+
220
+ const result = await ensurePackagesInstalled(testDir, ['@dot-ai/ext-memory']);
221
+ expect(result.skipped).toContain('@dot-ai/ext-memory');
222
+ });
223
+
224
+ it('handles unscoped package with version', async () => {
225
+ await createFakePackage('my-ext', '2.0.0');
226
+
227
+ const result = await ensurePackagesInstalled(testDir, ['my-ext@2.0.0']);
228
+ expect(result.skipped).toContain('my-ext@2.0.0');
229
+ });
230
+
231
+ it('handles npm: prefix with scoped package', async () => {
232
+ await createFakePackage('@dot-ai/ext-memory', '1.0.0');
233
+
234
+ const result = await ensurePackagesInstalled(testDir, ['npm:@dot-ai/ext-memory']);
235
+ expect(result.skipped).toContain('npm:@dot-ai/ext-memory');
236
+ });
237
+ });
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
3
4
  import type { DotAiConfig, ExtensionsConfig } from './types.js';
4
5
 
5
6
  /**
@@ -61,14 +62,52 @@ function settingsJsonToConfig(json: Record<string, unknown>): DotAiConfig {
61
62
  return config;
62
63
  }
63
64
 
65
+ /**
66
+ * Merge two configs: global (base) + project (override).
67
+ * Arrays (paths, packages) are concatenated and deduplicated.
68
+ * Scalar values from project take precedence.
69
+ */
70
+ function mergeConfigs(global: DotAiConfig, project: DotAiConfig): DotAiConfig {
71
+ const merged: DotAiConfig = { ...global, ...project };
72
+
73
+ // Merge extensions arrays (global + project, deduplicated)
74
+ if (global.extensions || project.extensions) {
75
+ const globalExt = global.extensions ?? {};
76
+ const projectExt = project.extensions ?? {};
77
+ merged.extensions = {
78
+ paths: dedup([...(globalExt.paths ?? []), ...(projectExt.paths ?? [])]),
79
+ packages: dedup([...(globalExt.packages ?? []), ...(projectExt.packages ?? [])]),
80
+ };
81
+ // Clean up empty arrays
82
+ if (merged.extensions.paths!.length === 0) delete merged.extensions.paths;
83
+ if (merged.extensions.packages!.length === 0) delete merged.extensions.packages;
84
+ if (!merged.extensions.paths && !merged.extensions.packages) delete merged.extensions;
85
+ }
86
+
87
+ // Project debug/workspace override global
88
+ if (project.debug) merged.debug = project.debug;
89
+ if (project.workspace) merged.workspace = project.workspace;
90
+
91
+ return merged;
92
+ }
93
+
94
+ function dedup(arr: string[]): string[] {
95
+ return [...new Set(arr)];
96
+ }
97
+
64
98
  /**
65
99
  * Load config from workspace root.
66
- * Tries settings.json first, falls back to empty config.
100
+ *
101
+ * Loads from two sources and merges them:
102
+ * 1. Global: ~/.ai/settings.json (user-level defaults)
103
+ * 2. Project: {workspaceRoot}/.ai/settings.json (project-level overrides)
104
+ *
105
+ * Arrays (packages, extensions) are merged. Scalar values from project win.
67
106
  */
68
107
  export async function loadConfig(workspaceRoot: string): Promise<DotAiConfig> {
69
- const settingsConfig = await loadSettingsJson(workspaceRoot);
70
- if (settingsConfig) return settingsConfig;
71
- return {};
108
+ const globalConfig = await loadSettingsJson(homedir()) ?? {};
109
+ const projectConfig = await loadSettingsJson(workspaceRoot) ?? {};
110
+ return mergeConfigs(globalConfig, projectConfig);
72
111
  }
73
112
 
74
113
  /**
@@ -5,77 +5,198 @@ import type { ExtensionAPI } from './extension-api.js';
5
5
  import type { LoadedExtension, ToolDefinition, CommandDefinition } from './extension-types.js';
6
6
  import type { ExtensionsConfig, Skill, Identity } from './types.js';
7
7
 
8
+ function isExtensionFile(name: string): boolean {
9
+ return name.endsWith('.ts') || name.endsWith('.js');
10
+ }
11
+
12
+ /**
13
+ * Resolve extension entry points from a directory.
14
+ *
15
+ * Checks for:
16
+ * 1. package.json with "dot-ai.extensions" field -> returns declared paths
17
+ * 2. index.ts or index.js -> returns the index file
18
+ *
19
+ * Returns resolved paths or null if no entry points found.
20
+ */
21
+ async function resolveExtensionEntries(dir: string): Promise<string[] | null> {
22
+ // Check for package.json with "dot-ai" field first
23
+ try {
24
+ const pkgPath = join(dir, 'package.json');
25
+ const pkgRaw = await readFile(pkgPath, 'utf-8');
26
+ const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
27
+ const dotAi = pkg['dot-ai'] as { extensions?: string[] } | undefined;
28
+ if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
29
+ const entries: string[] = [];
30
+ for (const ext of dotAi.extensions) {
31
+ const resolvedPath = resolve(dir, ext);
32
+ try {
33
+ await stat(resolvedPath);
34
+ entries.push(resolvedPath);
35
+ } catch { /* entry doesn't exist */ }
36
+ }
37
+ if (entries.length > 0) return entries;
38
+ }
39
+ } catch { /* no package.json or invalid */ }
40
+
41
+ // Check for index.ts or index.js
42
+ for (const indexName of ['index.ts', 'index.js']) {
43
+ const indexPath = join(dir, indexName);
44
+ try {
45
+ await stat(indexPath);
46
+ return [indexPath];
47
+ } catch { /* not found */ }
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Scan a directory for extensions.
55
+ *
56
+ * Discovery rules:
57
+ * 1. Direct files: `dir/*.ts` or `*.js` -> load
58
+ * 2. Subdirectory with package.json: `dir/sub/package.json` with "dot-ai" field -> load what it declares
59
+ * 3. Subdirectory with index: `dir/sub/index.ts` or `index.js` -> load
60
+ *
61
+ * No recursion beyond one level. Complex packages must use package.json manifest.
62
+ */
63
+ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
64
+ const discovered: string[] = [];
65
+
66
+ try {
67
+ const entries = await readdir(dir, { withFileTypes: true });
68
+
69
+ for (const entry of entries) {
70
+ const entryPath = join(dir, entry.name);
71
+
72
+ // 1. Direct files: *.ts or *.js
73
+ if (entry.isFile() && isExtensionFile(entry.name)) {
74
+ discovered.push(entryPath);
75
+ continue;
76
+ }
77
+
78
+ // 2 & 3. Subdirectories: package.json or index file
79
+ if (entry.isDirectory()) {
80
+ const resolved = await resolveExtensionEntries(entryPath);
81
+ if (resolved) {
82
+ discovered.push(...resolved);
83
+ }
84
+ }
85
+ }
86
+ } catch { /* directory doesn't exist — skip */ }
87
+
88
+ return discovered;
89
+ }
90
+
91
+ /**
92
+ * Resolve extensions from installed packages in .ai/packages/.
93
+ *
94
+ * Reads the package.json created by `npm --prefix .ai/packages/` and resolves
95
+ * extension entry points from each installed package's "dot-ai.extensions" field.
96
+ */
97
+ async function resolveInstalledPackages(workspaceRoot: string): Promise<string[]> {
98
+ const packagesDir = join(workspaceRoot, '.ai', 'packages');
99
+ const pkgJsonPath = join(packagesDir, 'package.json');
100
+
101
+ try {
102
+ const raw = await readFile(pkgJsonPath, 'utf-8');
103
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
104
+ const deps = pkg.dependencies as Record<string, string> | undefined;
105
+ if (!deps) return [];
106
+
107
+ const paths: string[] = [];
108
+ for (const name of Object.keys(deps)) {
109
+ const pkgDir = join(packagesDir, 'node_modules', name);
110
+ const entries = await resolveExtensionEntries(pkgDir);
111
+ if (entries) {
112
+ paths.push(...entries);
113
+ }
114
+ }
115
+ return paths;
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
8
121
  /**
9
- * Discover extension file paths from configured locations.
122
+ * Discover extension file paths from all sources.
123
+ *
124
+ * Sources (in order):
125
+ * 1. Auto-discovery: .ai/extensions/ (project-local)
126
+ * 2. Auto-discovery: ~/.ai/extensions/ (global)
127
+ * 3. Installed packages: .ai/packages/node_modules/ (installed via dot-ai install)
128
+ * 4. Configured paths: settings.json "extensions" array (explicit paths/dirs)
129
+ * 5. Configured packages: settings.json "packages" array (npm package names resolved from workspace node_modules)
10
130
  */
11
131
  export async function discoverExtensions(
12
132
  workspaceRoot: string,
13
133
  config?: ExtensionsConfig,
14
134
  ): Promise<string[]> {
15
- const paths = new Set<string>();
135
+ const seen = new Set<string>();
136
+ const allPaths: string[] = [];
137
+
138
+ const addPaths = (paths: string[]) => {
139
+ for (const p of paths) {
140
+ const resolved = resolve(p);
141
+ if (!seen.has(resolved)) {
142
+ seen.add(resolved);
143
+ allPaths.push(resolved);
144
+ }
145
+ }
146
+ };
16
147
 
17
- // Default discovery paths
18
- const searchDirs = [
19
- join(workspaceRoot, '.ai', 'extensions'),
20
- join(homedir(), '.ai', 'extensions'),
21
- ...(config?.paths ?? []).map(p => resolve(workspaceRoot, p)),
22
- ];
148
+ // 1. Project-local extensions: .ai/extensions/
149
+ addPaths(await discoverExtensionsInDir(join(workspaceRoot, '.ai', 'extensions')));
23
150
 
24
- for (const dir of searchDirs) {
25
- try {
26
- const entries = await readdir(dir, { withFileTypes: true });
27
- for (const entry of entries) {
28
- const fullPath = join(dir, entry.name);
29
- if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) {
30
- paths.add(fullPath);
31
- } else if (entry.isDirectory()) {
32
- // Check for index.ts or index.js
33
- for (const indexName of ['index.ts', 'index.js']) {
34
- const indexPath = join(fullPath, indexName);
35
- try {
36
- await stat(indexPath);
37
- paths.add(indexPath);
38
- break;
39
- } catch { /* not found */ }
151
+ // 2. Global extensions: ~/.ai/extensions/
152
+ addPaths(await discoverExtensionsInDir(join(homedir(), '.ai', 'extensions')));
153
+
154
+ // 3. Installed packages: .ai/packages/node_modules/
155
+ addPaths(await resolveInstalledPackages(workspaceRoot));
156
+
157
+ // 4. Explicitly configured paths from settings.json "extensions" array
158
+ if (config?.paths) {
159
+ for (const p of config.paths) {
160
+ const resolved = resolve(workspaceRoot, p);
161
+ try {
162
+ const s = await stat(resolved);
163
+ if (s.isDirectory()) {
164
+ // Check for package.json or index file first
165
+ const entries = await resolveExtensionEntries(resolved);
166
+ if (entries) {
167
+ addPaths(entries);
168
+ } else {
169
+ // Discover individual files in directory
170
+ addPaths(await discoverExtensionsInDir(resolved));
40
171
  }
41
- // Check for package.json with dot-ai field
42
- try {
43
- const pkgPath = join(fullPath, 'package.json');
44
- const pkgRaw = await readFile(pkgPath, 'utf-8');
45
- const pkg = JSON.parse(pkgRaw) as Record<string, unknown>;
46
- const dotAi = pkg['dot-ai'] as { extensions?: string[] } | undefined;
47
- if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
48
- for (const ext of dotAi.extensions) {
49
- paths.add(resolve(fullPath, ext));
50
- }
51
- }
52
- } catch { /* no package.json or no dot-ai field */ }
172
+ } else if (s.isFile()) {
173
+ addPaths([resolved]);
53
174
  }
175
+ } catch {
176
+ // Path doesn't exist — add anyway (will fail at load time with clear error)
177
+ addPaths([resolved]);
54
178
  }
55
- } catch { /* directory doesn't exist — skip */ }
179
+ }
56
180
  }
57
181
 
58
- // Also resolve npm packages from config
182
+ // 5. Configured npm packages from settings.json "packages" array
183
+ // These are resolved from the workspace's own node_modules
59
184
  if (config?.packages) {
60
185
  for (const pkg of config.packages) {
61
186
  try {
62
187
  const { createRequire } = await import('node:module');
63
188
  const require = createRequire(join(workspaceRoot, 'package.json'));
64
189
  const pkgJsonPath = require.resolve(`${pkg}/package.json`);
65
- const pkgRaw = await readFile(pkgJsonPath, 'utf-8');
66
- const pkgJson = JSON.parse(pkgRaw) as Record<string, unknown>;
67
- const dotAi = pkgJson['dot-ai'] as { extensions?: string[] } | undefined;
68
- if (dotAi?.extensions && Array.isArray(dotAi.extensions)) {
69
- const pkgDir = join(pkgJsonPath, '..');
70
- for (const ext of dotAi.extensions) {
71
- paths.add(resolve(pkgDir, ext));
72
- }
190
+ const pkgDir = join(pkgJsonPath, '..');
191
+ const entries = await resolveExtensionEntries(pkgDir);
192
+ if (entries) {
193
+ addPaths(entries);
73
194
  }
74
195
  } catch { /* package not found */ }
75
196
  }
76
197
  }
77
198
 
78
- return Array.from(paths);
199
+ return allPaths;
79
200
  }
80
201
 
81
202
  /**
package/src/index.ts CHANGED
@@ -85,8 +85,8 @@ export { NoopLogger, JsonFileLogger, StderrLogger } from './logger.js';
85
85
  export { loadConfig, migrateConfig } from './config.js';
86
86
 
87
87
  // ── Package Manager ──
88
- export { install, remove, listPackages, resolvePackages } from './package-manager.js';
89
- export type { PackageInfo } from './package-manager.js';
88
+ export { install, remove, listPackages, resolvePackages, ensurePackagesInstalled } from './package-manager.js';
89
+ export type { PackageInfo, MissingPackageAction } from './package-manager.js';
90
90
 
91
91
  // ── Boot Cache ──
92
92
  export { computeChecksum, loadBootCache, writeBootCache, clearBootCache } from './boot-cache.js';