ultraclaude-agent 0.0.25 → 0.0.28
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.
- package/__tests__/cli-commands.test.ts +227 -0
- package/__tests__/config-registry.test.ts +130 -0
- package/__tests__/matching.test.ts +154 -0
- package/__tests__/registry.test.ts +171 -0
- package/__tests__/repl.test.ts +262 -102
- package/__tests__/update-notice.test.ts +358 -0
- package/dist/cli.d.ts +4 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +86 -429
- package/dist/cli.js.map +1 -1
- package/dist/commands/registry.d.ts +47 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +729 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +182 -33
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +10 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +32 -3
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/matching.d.ts +20 -0
- package/dist/matching.d.ts.map +1 -0
- package/dist/matching.js +77 -0
- package/dist/matching.js.map +1 -0
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +36 -477
- package/dist/repl.js.map +1 -1
- package/dist/scripts/postinstall.js +3 -0
- package/dist/scripts/postinstall.js.map +1 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +4 -1
- package/dist/service.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +18 -16
- package/dist/setup.js.map +1 -1
- package/dist/status.d.ts +5 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js.map +1 -1
- package/dist/sync.js +1 -1
- package/dist/sync.js.map +1 -1
- package/dist/usage-sync.d.ts.map +1 -1
- package/dist/usage-sync.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.d.ts +8 -8
- package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.js +8 -2
- package/node_modules/@ultra-claude/shared/dist/api/schemas/usage.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +91 -481
- package/src/commands/registry.ts +881 -0
- package/src/config.ts +206 -31
- package/src/daemon.ts +35 -4
- package/src/index.ts +1 -1
- package/src/matching.ts +96 -0
- package/src/repl.ts +40 -575
- package/src/scripts/postinstall.ts +3 -0
- package/src/service.ts +4 -1
- package/src/setup.ts +20 -21
- package/src/status.ts +6 -0
- package/src/sync.ts +1 -1
- package/src/usage-sync.ts +6 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the non-interactive CLI surface (src/cli.ts), driven through the shared registry.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: cli.ts runs `await runCli(process.argv)` at module top-level, so each case sets
|
|
5
|
+
* `process.argv`, calls `vi.resetModules()`, and `await import('../src/cli.js')` — which builds
|
|
6
|
+
* the commander program from the registry and parses the preset args. We then assert the
|
|
7
|
+
* config/console/exit-code effects of the account commands and `reset`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
|
|
12
|
+
// --- Mocks (hoisted; arrows capture the mock fns lazily at module-import time) ---
|
|
13
|
+
|
|
14
|
+
const mockLoadCredentials = vi.fn();
|
|
15
|
+
const mockLoadAllCredentials = vi.fn();
|
|
16
|
+
const mockLoadServerConfig = vi.fn();
|
|
17
|
+
const mockSaveServerConfig = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const mockGetServerUrl = vi.fn();
|
|
19
|
+
const mockGetProjectId = vi.fn();
|
|
20
|
+
const mockLoadRegistry = vi.fn();
|
|
21
|
+
const mockReadPid = vi.fn();
|
|
22
|
+
const mockIsDaemonRunning = vi.fn();
|
|
23
|
+
const mockIsPidAlive = vi.fn();
|
|
24
|
+
const mockCleanStalePidFile = vi.fn().mockResolvedValue(undefined);
|
|
25
|
+
const mockResolveServerPaths = vi.fn();
|
|
26
|
+
const mockClearServerConfig = vi.fn().mockResolvedValue(undefined);
|
|
27
|
+
|
|
28
|
+
vi.mock('../src/config.js', () => ({
|
|
29
|
+
loadCredentials: (...a: unknown[]) => mockLoadCredentials(...a),
|
|
30
|
+
loadAllCredentials: (...a: unknown[]) => mockLoadAllCredentials(...a),
|
|
31
|
+
loadServerConfig: (...a: unknown[]) => mockLoadServerConfig(...a),
|
|
32
|
+
saveServerConfig: (...a: unknown[]) => mockSaveServerConfig(...a),
|
|
33
|
+
getServerUrl: (...a: unknown[]) => mockGetServerUrl(...a),
|
|
34
|
+
getProjectId: (...a: unknown[]) => mockGetProjectId(...a),
|
|
35
|
+
loadRegistry: (...a: unknown[]) => mockLoadRegistry(...a),
|
|
36
|
+
readPid: (...a: unknown[]) => mockReadPid(...a),
|
|
37
|
+
isDaemonRunning: (...a: unknown[]) => mockIsDaemonRunning(...a),
|
|
38
|
+
isPidAlive: (...a: unknown[]) => mockIsPidAlive(...a),
|
|
39
|
+
cleanStalePidFile: (...a: unknown[]) => mockCleanStalePidFile(...a),
|
|
40
|
+
resolveServerPaths: (...a: unknown[]) => mockResolveServerPaths(...a),
|
|
41
|
+
clearServerConfig: (...a: unknown[]) => mockClearServerConfig(...a),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
46
|
+
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
|
47
|
+
return { ...actual, unlink: mockUnlink };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
vi.mock('../src/auth.js', () => ({ login: vi.fn() }));
|
|
51
|
+
|
|
52
|
+
const mockStopDaemon = vi.fn().mockResolvedValue(undefined);
|
|
53
|
+
const mockGetDaemonStatus = vi.fn();
|
|
54
|
+
const mockIsRunningInProcess = vi.fn().mockReturnValue(false);
|
|
55
|
+
const mockForkDaemon = vi.fn();
|
|
56
|
+
vi.mock('../src/daemon.js', () => ({
|
|
57
|
+
startDaemon: vi.fn(),
|
|
58
|
+
stopDaemon: (...a: unknown[]) => mockStopDaemon(...a),
|
|
59
|
+
getDaemonStatus: (...a: unknown[]) => mockGetDaemonStatus(...a),
|
|
60
|
+
isRunningInProcess: (...a: unknown[]) => mockIsRunningInProcess(...a),
|
|
61
|
+
forkDaemon: (...a: unknown[]) => mockForkDaemon(...a),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock('../src/service.js', () => ({
|
|
65
|
+
isServiceInstalled: vi.fn().mockResolvedValue(false),
|
|
66
|
+
installService: vi.fn().mockResolvedValue(undefined),
|
|
67
|
+
}));
|
|
68
|
+
vi.mock('../src/sync.js', () => ({
|
|
69
|
+
initialSync: vi.fn().mockResolvedValue(undefined),
|
|
70
|
+
createSnapshot: vi.fn().mockResolvedValue(true),
|
|
71
|
+
}));
|
|
72
|
+
vi.mock('../src/watcher.js', () => ({ getCurrentBranch: vi.fn().mockReturnValue('main') }));
|
|
73
|
+
vi.mock('../src/logger.js', () => ({
|
|
74
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: () => ({}) },
|
|
75
|
+
initMultistreamLogger: vi.fn(),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const SERVER_URL = 'http://localhost:3000';
|
|
79
|
+
const alice = {
|
|
80
|
+
apiKey: 'k-a',
|
|
81
|
+
userId: 'user-alice',
|
|
82
|
+
serverUrl: SERVER_URL,
|
|
83
|
+
email: 'alice@example.com',
|
|
84
|
+
};
|
|
85
|
+
const bob = { apiKey: 'k-b', userId: 'user-bob', serverUrl: SERVER_URL, email: 'bob@acme.io' };
|
|
86
|
+
|
|
87
|
+
function config(overrides: Record<string, unknown> = {}) {
|
|
88
|
+
return { defaultAccount: 'user-alice', projectAccounts: {}, ...overrides };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const originalArgv = process.argv;
|
|
92
|
+
let isTTYDescriptor: PropertyDescriptor | undefined;
|
|
93
|
+
|
|
94
|
+
/** Drive cli.ts with a preset argv. */
|
|
95
|
+
async function runCli(args: string[]): Promise<void> {
|
|
96
|
+
process.argv = ['node', 'cli.js', ...args];
|
|
97
|
+
vi.resetModules();
|
|
98
|
+
await import('../src/cli.js');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.clearAllMocks();
|
|
103
|
+
process.exitCode = 0;
|
|
104
|
+
isTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
|
105
|
+
|
|
106
|
+
mockGetServerUrl.mockReturnValue(SERVER_URL);
|
|
107
|
+
mockLoadCredentials.mockResolvedValue(alice);
|
|
108
|
+
mockLoadAllCredentials.mockResolvedValue(
|
|
109
|
+
new Map([
|
|
110
|
+
['user-alice', alice],
|
|
111
|
+
['user-bob', bob],
|
|
112
|
+
]),
|
|
113
|
+
);
|
|
114
|
+
mockLoadServerConfig.mockResolvedValue(config());
|
|
115
|
+
mockSaveServerConfig.mockResolvedValue(undefined);
|
|
116
|
+
mockLoadRegistry.mockResolvedValue({
|
|
117
|
+
projects: [{ path: '/home/user/projects/my-app', name: 'my-app' }],
|
|
118
|
+
});
|
|
119
|
+
mockGetProjectId.mockResolvedValue('proj-uuid');
|
|
120
|
+
mockResolveServerPaths.mockReturnValue({ accountsDir: '/tmp/accounts', logDir: '/tmp/logs' });
|
|
121
|
+
mockIsRunningInProcess.mockReturnValue(false);
|
|
122
|
+
mockReadPid.mockResolvedValue(null);
|
|
123
|
+
mockClearServerConfig.mockResolvedValue(undefined);
|
|
124
|
+
mockUnlink.mockResolvedValue(undefined);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
process.argv = originalArgv;
|
|
129
|
+
if (isTTYDescriptor) Object.defineProperty(process.stdin, 'isTTY', isTTYDescriptor);
|
|
130
|
+
process.exitCode = 0;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('cli — assign', () => {
|
|
134
|
+
it('writes the project→account mapping to config.json', async () => {
|
|
135
|
+
await runCli(['assign', 'my-app', 'bob']);
|
|
136
|
+
expect(mockSaveServerConfig).toHaveBeenCalledWith(
|
|
137
|
+
SERVER_URL,
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
projectAccounts: expect.objectContaining({ '/home/user/projects/my-app': 'user-bob' }),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('cli — default', () => {
|
|
146
|
+
it('sets the default account', async () => {
|
|
147
|
+
await runCli(['default', 'bob']);
|
|
148
|
+
expect(mockSaveServerConfig).toHaveBeenCalledWith(
|
|
149
|
+
SERVER_URL,
|
|
150
|
+
expect.objectContaining({ defaultAccount: 'user-bob' }),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('cli — auto-assign', () => {
|
|
156
|
+
it('on → sets autoAssignNewProjects true', async () => {
|
|
157
|
+
await runCli(['auto-assign', 'on']);
|
|
158
|
+
expect(mockSaveServerConfig).toHaveBeenCalledWith(
|
|
159
|
+
SERVER_URL,
|
|
160
|
+
expect.objectContaining({ autoAssignNewProjects: true }),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('off → sets autoAssignNewProjects false', async () => {
|
|
165
|
+
await runCli(['auto-assign', 'off']);
|
|
166
|
+
expect(mockSaveServerConfig).toHaveBeenCalledWith(
|
|
167
|
+
SERVER_URL,
|
|
168
|
+
expect.objectContaining({ autoAssignNewProjects: false }),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('cli — remove', () => {
|
|
174
|
+
it('deletes the account file and updates config', async () => {
|
|
175
|
+
await runCli(['remove', 'bob']);
|
|
176
|
+
expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('user-bob.json'));
|
|
177
|
+
expect(mockSaveServerConfig).toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('warns about orphaned projects', async () => {
|
|
181
|
+
mockLoadServerConfig.mockResolvedValue(
|
|
182
|
+
config({
|
|
183
|
+
projectAccounts: {
|
|
184
|
+
'/home/user/projects/work': 'user-bob',
|
|
185
|
+
'/home/user/projects/api': 'user-bob',
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
190
|
+
try {
|
|
191
|
+
await runCli(['remove', 'bob']);
|
|
192
|
+
const output = logSpy.mock.calls.flat().join('\n');
|
|
193
|
+
expect(output).toMatch(/warn/i);
|
|
194
|
+
expect(output).toContain('2');
|
|
195
|
+
} finally {
|
|
196
|
+
logSpy.mockRestore();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('cli — accounts / projects (read)', () => {
|
|
202
|
+
it('accounts lists logged-in accounts', async () => {
|
|
203
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
204
|
+
try {
|
|
205
|
+
await runCli(['accounts']);
|
|
206
|
+
const output = logSpy.mock.calls.flat().join('\n');
|
|
207
|
+
expect(output).toContain('alice@example.com');
|
|
208
|
+
expect(output).toContain('bob@acme.io');
|
|
209
|
+
} finally {
|
|
210
|
+
logSpy.mockRestore();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('cli — reset', () => {
|
|
216
|
+
it('--yes wipes configuration', async () => {
|
|
217
|
+
await runCli(['reset', '--yes']);
|
|
218
|
+
expect(mockClearServerConfig).toHaveBeenCalledWith(SERVER_URL);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('refuses (exit 1, no wipe) without --yes in a non-TTY', async () => {
|
|
222
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
223
|
+
await runCli(['reset']);
|
|
224
|
+
expect(process.exitCode).toBe(1);
|
|
225
|
+
expect(mockClearServerConfig).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
// These tests seed a real temp directory tree that mimics ~/.claude/projects/
|
|
7
|
+
// and exercise loadRegistry against it via a mocked os.homedir().
|
|
8
|
+
|
|
9
|
+
const mockHomedir = vi.fn<() => string>();
|
|
10
|
+
const mockPlatform = vi.fn<() => NodeJS.Platform>().mockReturnValue('linux');
|
|
11
|
+
|
|
12
|
+
vi.mock('node:os', async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import('node:os')>();
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
homedir: () => mockHomedir(),
|
|
17
|
+
platform: () => mockPlatform(),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('config — loadRegistry path decoding', () => {
|
|
22
|
+
let tmpHome: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
vi.resetModules();
|
|
26
|
+
tmpHome = await mkdtemp(join(tmpdir(), 'uc-registry-test-'));
|
|
27
|
+
mockHomedir.mockReturnValue(tmpHome);
|
|
28
|
+
mockPlatform.mockReturnValue('linux');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await rm(tmpHome, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function seedUltraProject(realPath: string, appContext?: string) {
|
|
36
|
+
await mkdir(join(realPath, '.claude', 'ultra'), { recursive: true });
|
|
37
|
+
await writeFile(join(realPath, '.claude', 'ultra', 'version.json'), '{}');
|
|
38
|
+
if (appContext) {
|
|
39
|
+
await writeFile(join(realPath, '.claude', 'ultra', 'app-context.md'), appContext);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function seedSessionFile(encodedEntry: string, fileName: string, cwd: string) {
|
|
44
|
+
const entryDir = join(tmpHome, '.claude', 'projects', encodedEntry);
|
|
45
|
+
await mkdir(entryDir, { recursive: true });
|
|
46
|
+
const line = JSON.stringify({ type: 'user', cwd }) + '\n';
|
|
47
|
+
await writeFile(join(entryDir, fileName), line);
|
|
48
|
+
return join(entryDir, fileName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- REPRODUCTION TEST ---
|
|
52
|
+
// Pre-patch: hyphen-permutation decoder cannot reinsert a space, so the
|
|
53
|
+
// entry is silently dropped and loadRegistry returns zero projects.
|
|
54
|
+
// Post-patch: session-file cwd lookup recovers the real path verbatim.
|
|
55
|
+
it('REPRODUCTION: resolves a path containing spaces via session cwd', async () => {
|
|
56
|
+
const realPath = join(tmpHome, 'Users', 'fake', 'WS LAB', 'proj');
|
|
57
|
+
await seedUltraProject(realPath);
|
|
58
|
+
await seedSessionFile('-Users-fake-WS-LAB-proj', 'session-abc.jsonl', realPath);
|
|
59
|
+
|
|
60
|
+
const { loadRegistry } = await import('../src/config.js');
|
|
61
|
+
const registry = await loadRegistry();
|
|
62
|
+
|
|
63
|
+
expect(registry.projects).toHaveLength(1);
|
|
64
|
+
expect(registry.projects[0]!.path).toBe(realPath);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('falls back to legacy permutation when no session files exist', async () => {
|
|
68
|
+
// The legacy decoder searches from absolute `/` and has no knowledge of our
|
|
69
|
+
// mocked homedir. So to exercise the fallback safely we make tmpHome itself
|
|
70
|
+
// the "real project" — mkdtemp produces a real absolute path that is
|
|
71
|
+
// guaranteed to exist and is safe to put marker files under.
|
|
72
|
+
await seedUltraProject(tmpHome);
|
|
73
|
+
const encoded = tmpHome.replace(/\//g, '-'); // e.g. -tmp-uc-registry-test-XXXXXX
|
|
74
|
+
// Empty encoded entry — no .jsonl files → forces legacy fallback path
|
|
75
|
+
await mkdir(join(tmpHome, '.claude', 'projects', encoded), { recursive: true });
|
|
76
|
+
|
|
77
|
+
const { loadRegistry } = await import('../src/config.js');
|
|
78
|
+
const registry = await loadRegistry();
|
|
79
|
+
|
|
80
|
+
expect(registry.projects).toHaveLength(1);
|
|
81
|
+
expect(registry.projects[0]!.path).toBe(tmpHome);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reads project name from app-context.md', async () => {
|
|
85
|
+
const realPath = join(tmpHome, 'Users', 'alice', 'My Project');
|
|
86
|
+
await seedUltraProject(
|
|
87
|
+
realPath,
|
|
88
|
+
'# Context\n\n**Name:** Alice\'s Thing\n\nSome description.\n',
|
|
89
|
+
);
|
|
90
|
+
await seedSessionFile('-Users-alice-My-Project', 'session-1.jsonl', realPath);
|
|
91
|
+
|
|
92
|
+
const { loadRegistry } = await import('../src/config.js');
|
|
93
|
+
const registry = await loadRegistry();
|
|
94
|
+
|
|
95
|
+
expect(registry.projects).toHaveLength(1);
|
|
96
|
+
expect(registry.projects[0]!.name).toBe("Alice's Thing");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('picks cwd from the newest session file when multiple exist', async () => {
|
|
100
|
+
const realPath = join(tmpHome, 'srv', 'app with spaces');
|
|
101
|
+
await seedUltraProject(realPath);
|
|
102
|
+
const encodedEntry = '-srv-app-with-spaces';
|
|
103
|
+
|
|
104
|
+
// Older session — points at a stale, non-existent path
|
|
105
|
+
const older = await seedSessionFile(encodedEntry, 'session-old.jsonl', '/tmp/does-not-exist');
|
|
106
|
+
// Force older mtime
|
|
107
|
+
const past = new Date(Date.now() - 60_000);
|
|
108
|
+
await utimes(older, past, past);
|
|
109
|
+
|
|
110
|
+
// Newer session — points at the real path
|
|
111
|
+
await seedSessionFile(encodedEntry, 'session-new.jsonl', realPath);
|
|
112
|
+
|
|
113
|
+
const { loadRegistry } = await import('../src/config.js');
|
|
114
|
+
const registry = await loadRegistry();
|
|
115
|
+
|
|
116
|
+
expect(registry.projects).toHaveLength(1);
|
|
117
|
+
expect(registry.projects[0]!.path).toBe(realPath);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('drops entries whose resolved cwd has no .claude/ultra/version.json', async () => {
|
|
121
|
+
const realPath = join(tmpHome, 'not', 'ultra', 'project');
|
|
122
|
+
await mkdir(realPath, { recursive: true }); // exists, but no marker
|
|
123
|
+
await seedSessionFile('-not-ultra-project', 'session-1.jsonl', realPath);
|
|
124
|
+
|
|
125
|
+
const { loadRegistry } = await import('../src/config.js');
|
|
126
|
+
const registry = await loadRegistry();
|
|
127
|
+
|
|
128
|
+
expect(registry.projects).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared fuzzy matchers (src/matching.ts).
|
|
3
|
+
* Pure module — no mocks needed. Both command surfaces consume these via the registry.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { matchProject, matchAccount, accountDisplay } from '../src/matching.js';
|
|
8
|
+
import type { AgentCredentials, ProjectRegistryEntry } from '@ultra-claude/shared';
|
|
9
|
+
|
|
10
|
+
function project(path: string): ProjectRegistryEntry {
|
|
11
|
+
return { path, name: path.split('/').pop()! };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function account(overrides: Partial<AgentCredentials> = {}): AgentCredentials {
|
|
15
|
+
return {
|
|
16
|
+
apiKey: 'key',
|
|
17
|
+
userId: 'user-x',
|
|
18
|
+
serverUrl: 'http://localhost:3000',
|
|
19
|
+
email: 'x@example.com',
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('matchProject', () => {
|
|
25
|
+
const projects = [
|
|
26
|
+
project('/home/user/projects/my-app'),
|
|
27
|
+
project('/home/user/projects/my-api'),
|
|
28
|
+
project('/home/user/projects/website'),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
it('matches an exact basename', () => {
|
|
32
|
+
const r = matchProject('website', projects);
|
|
33
|
+
expect(r.error).toBeNull();
|
|
34
|
+
expect(r.match?.path).toBe('/home/user/projects/website');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('matches a unique prefix', () => {
|
|
38
|
+
const r = matchProject('webs', projects);
|
|
39
|
+
expect(r.error).toBeNull();
|
|
40
|
+
expect(r.match?.path).toBe('/home/user/projects/website');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('matches a unique substring (includes)', () => {
|
|
44
|
+
const r = matchProject('bsit', projects);
|
|
45
|
+
expect(r.error).toBeNull();
|
|
46
|
+
expect(r.match?.path).toBe('/home/user/projects/website');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reports no match', () => {
|
|
50
|
+
const r = matchProject('nonexistent', projects);
|
|
51
|
+
expect(r.match).toBeNull();
|
|
52
|
+
expect(r.error).toMatch(/no project matching/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('reports ambiguity when multiple projects match', () => {
|
|
56
|
+
const r = matchProject('my-a', projects);
|
|
57
|
+
expect(r.match).toBeNull();
|
|
58
|
+
expect(r.error).toMatch(/ambiguous/i);
|
|
59
|
+
expect(r.error).toContain('my-app');
|
|
60
|
+
expect(r.error).toContain('my-api');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('prefers an exact basename over a broader prefix collision', () => {
|
|
64
|
+
const withExact = [project('/a/my'), project('/b/my-app')];
|
|
65
|
+
const r = matchProject('my', withExact);
|
|
66
|
+
expect(r.error).toBeNull();
|
|
67
|
+
expect(r.match?.path).toBe('/a/my');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is case-insensitive', () => {
|
|
71
|
+
const r = matchProject('WEBSITE', projects);
|
|
72
|
+
expect(r.match?.path).toBe('/home/user/projects/website');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('matchAccount', () => {
|
|
77
|
+
const alice = account({ userId: 'user-alice', email: 'alice@example.com' });
|
|
78
|
+
const bob = account({ userId: 'user-bob', email: 'bob@acme.io' });
|
|
79
|
+
const accounts = new Map([
|
|
80
|
+
['user-alice', alice],
|
|
81
|
+
['user-bob', bob],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
it('matches an exact email', () => {
|
|
85
|
+
const r = matchAccount('bob@acme.io', accounts);
|
|
86
|
+
expect(r.error).toBeNull();
|
|
87
|
+
expect(r.match?.userId).toBe('user-bob');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('matches an exact userId', () => {
|
|
91
|
+
const r = matchAccount('user-alice', accounts);
|
|
92
|
+
expect(r.error).toBeNull();
|
|
93
|
+
expect(r.match?.userId).toBe('user-alice');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('matches an email prefix (before @)', () => {
|
|
97
|
+
const r = matchAccount('bob', accounts);
|
|
98
|
+
expect(r.error).toBeNull();
|
|
99
|
+
expect(r.match?.userId).toBe('user-bob');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('matches an email startsWith', () => {
|
|
103
|
+
const r = matchAccount('ali', accounts);
|
|
104
|
+
expect(r.error).toBeNull();
|
|
105
|
+
expect(r.match?.userId).toBe('user-alice');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('is case-insensitive', () => {
|
|
109
|
+
const r = matchAccount('ALICE@EXAMPLE.COM', accounts);
|
|
110
|
+
expect(r.match?.userId).toBe('user-alice');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reports when there are no accounts', () => {
|
|
114
|
+
const r = matchAccount('anyone', new Map());
|
|
115
|
+
expect(r.match).toBeNull();
|
|
116
|
+
expect(r.error).toMatch(/no accounts found/i);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('reports no match when query matches nothing', () => {
|
|
120
|
+
const r = matchAccount('charlie', accounts);
|
|
121
|
+
expect(r.match).toBeNull();
|
|
122
|
+
expect(r.error).toMatch(/no account matching/i);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('reports ambiguity (richer logic preserved from the REPL)', () => {
|
|
126
|
+
const a1 = account({ userId: 'user-1', email: 'sam@one.com' });
|
|
127
|
+
const a2 = account({ userId: 'user-2', email: 'sam@two.com' });
|
|
128
|
+
const ambiguous = new Map([
|
|
129
|
+
['user-1', a1],
|
|
130
|
+
['user-2', a2],
|
|
131
|
+
]);
|
|
132
|
+
const r = matchAccount('sam', ambiguous);
|
|
133
|
+
expect(r.match).toBeNull();
|
|
134
|
+
expect(r.error).toMatch(/ambiguous/i);
|
|
135
|
+
expect(r.error).toContain('sam@one.com');
|
|
136
|
+
expect(r.error).toContain('sam@two.com');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('matches by userId when email is absent', () => {
|
|
140
|
+
const noEmail = account({ userId: 'user-keyonly', email: undefined });
|
|
141
|
+
const r = matchAccount('user-keyonly', new Map([['user-keyonly', noEmail]]));
|
|
142
|
+
expect(r.match?.userId).toBe('user-keyonly');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('accountDisplay', () => {
|
|
147
|
+
it('prefers email', () => {
|
|
148
|
+
expect(accountDisplay(account({ email: 'a@b.com', userId: 'u' }))).toBe('a@b.com');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('falls back to userId when email is absent', () => {
|
|
152
|
+
expect(accountDisplay(account({ email: undefined, userId: 'user-x' }))).toBe('user-x');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared command registry (src/commands/registry.ts).
|
|
3
|
+
* Verifies the single source of truth that both surfaces are generated from:
|
|
4
|
+
* - every command declares the required fields + a surface-agnostic run(ctx)
|
|
5
|
+
* - surfaces/interaction markers are honoured (quit/exit REPL-only; setup/logs/version CLI-only)
|
|
6
|
+
* - parity: account commands appear in BOTH surfaces (define once → both surfaces expose it)
|
|
7
|
+
* - generated help + signatures
|
|
8
|
+
*
|
|
9
|
+
* Heavy src deps are mocked so importing the registry is side-effect free; only metadata
|
|
10
|
+
* (and one trivial handler) are exercised here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/config.js', () => ({
|
|
16
|
+
loadAllCredentials: vi.fn(),
|
|
17
|
+
loadCredentials: vi.fn(),
|
|
18
|
+
loadServerConfig: vi.fn(),
|
|
19
|
+
saveServerConfig: vi.fn(),
|
|
20
|
+
getServerUrl: vi.fn(),
|
|
21
|
+
getProjectId: vi.fn(),
|
|
22
|
+
loadRegistry: vi.fn(),
|
|
23
|
+
readPid: vi.fn(),
|
|
24
|
+
isDaemonRunning: vi.fn(),
|
|
25
|
+
isPidAlive: vi.fn(),
|
|
26
|
+
cleanStalePidFile: vi.fn(),
|
|
27
|
+
resolveServerPaths: vi.fn(),
|
|
28
|
+
clearServerConfig: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('../src/auth.js', () => ({ login: vi.fn() }));
|
|
31
|
+
vi.mock('../src/daemon.js', () => ({
|
|
32
|
+
startDaemon: vi.fn(),
|
|
33
|
+
stopDaemon: vi.fn(),
|
|
34
|
+
getDaemonStatus: vi.fn(),
|
|
35
|
+
isRunningInProcess: vi.fn(),
|
|
36
|
+
forkDaemon: vi.fn(),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('../src/service.js', () => ({ isServiceInstalled: vi.fn(), installService: vi.fn() }));
|
|
39
|
+
vi.mock('../src/sync.js', () => ({ initialSync: vi.fn(), createSnapshot: vi.fn() }));
|
|
40
|
+
vi.mock('../src/watcher.js', () => ({ getCurrentBranch: vi.fn() }));
|
|
41
|
+
vi.mock('../src/logger.js', () => ({
|
|
42
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: () => ({}) },
|
|
43
|
+
initMultistreamLogger: vi.fn(),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { commands, commandSignature, formatReplHelp, type IO } from '../src/commands/registry.js';
|
|
47
|
+
|
|
48
|
+
const INTERACTIONS = new Set(['none', 'browser', 'wizard', 'confirm']);
|
|
49
|
+
const SURFACES = new Set(['cli', 'repl']);
|
|
50
|
+
|
|
51
|
+
function names(surface: 'cli' | 'repl'): string[] {
|
|
52
|
+
return commands.filter((d) => d.surfaces.includes(surface)).map((d) => d.name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('registry — command shape', () => {
|
|
56
|
+
it('every command declares the required fields and a run function', () => {
|
|
57
|
+
for (const def of commands) {
|
|
58
|
+
expect(typeof def.name).toBe('string');
|
|
59
|
+
expect(def.name.length).toBeGreaterThan(0);
|
|
60
|
+
expect(typeof def.summary).toBe('string');
|
|
61
|
+
expect(Array.isArray(def.args)).toBe(true);
|
|
62
|
+
expect(Array.isArray(def.surfaces)).toBe(true);
|
|
63
|
+
expect(def.surfaces.length).toBeGreaterThan(0);
|
|
64
|
+
for (const s of def.surfaces) expect(SURFACES.has(s)).toBe(true);
|
|
65
|
+
expect(INTERACTIONS.has(def.interaction)).toBe(true);
|
|
66
|
+
expect(typeof def.run).toBe('function');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('command names are unique', () => {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
for (const def of commands) {
|
|
73
|
+
expect(seen.has(def.name)).toBe(false);
|
|
74
|
+
seen.add(def.name);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('declared positionals are well-formed (variadic only last)', () => {
|
|
79
|
+
for (const def of commands) {
|
|
80
|
+
def.args.forEach((arg, idx) => {
|
|
81
|
+
expect(typeof arg.name).toBe('string');
|
|
82
|
+
expect(typeof arg.required).toBe('boolean');
|
|
83
|
+
if (arg.variadic) expect(idx).toBe(def.args.length - 1);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('registry — surfaces and interaction markers', () => {
|
|
90
|
+
it('quit/exit are REPL-only and absent from the CLI surface', () => {
|
|
91
|
+
expect(names('repl')).toEqual(expect.arrayContaining(['quit', 'exit']));
|
|
92
|
+
expect(names('cli')).not.toContain('quit');
|
|
93
|
+
expect(names('cli')).not.toContain('exit');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('help is REPL-only', () => {
|
|
97
|
+
expect(names('repl')).toContain('help');
|
|
98
|
+
expect(names('cli')).not.toContain('help');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('setup, logs, version are CLI-only', () => {
|
|
102
|
+
for (const name of ['setup', 'logs', 'version']) {
|
|
103
|
+
expect(names('cli')).toContain(name);
|
|
104
|
+
expect(names('repl')).not.toContain(name);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('login is browser, setup is wizard, reset is confirm, account commands are none', () => {
|
|
109
|
+
const byName = Object.fromEntries(commands.map((d) => [d.name, d]));
|
|
110
|
+
expect(byName.login!.interaction).toBe('browser');
|
|
111
|
+
expect(byName.setup!.interaction).toBe('wizard');
|
|
112
|
+
expect(byName.reset!.interaction).toBe('confirm');
|
|
113
|
+
for (const name of ['accounts', 'projects', 'assign', 'default', 'auto-assign', 'remove']) {
|
|
114
|
+
expect(byName[name]!.interaction).toBe('none');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reset declares a --yes option', () => {
|
|
119
|
+
const reset = commands.find((d) => d.name === 'reset')!;
|
|
120
|
+
expect((reset.options ?? []).some((o) => o.flags.includes('--yes'))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('registry — parity (define once → both surfaces)', () => {
|
|
125
|
+
it('account-routing commands appear in BOTH surfaces', () => {
|
|
126
|
+
const both = new Set(names('cli').filter((n) => names('repl').includes(n)));
|
|
127
|
+
for (const name of [
|
|
128
|
+
'accounts',
|
|
129
|
+
'projects',
|
|
130
|
+
'assign',
|
|
131
|
+
'default',
|
|
132
|
+
'auto-assign',
|
|
133
|
+
'remove',
|
|
134
|
+
'reset',
|
|
135
|
+
]) {
|
|
136
|
+
expect(both.has(name)).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('registry — generated signatures and help', () => {
|
|
142
|
+
it('renders positional signatures', () => {
|
|
143
|
+
const byName = Object.fromEntries(commands.map((d) => [d.name, d]));
|
|
144
|
+
expect(commandSignature(byName.assign!)).toBe('assign <project> <account>');
|
|
145
|
+
expect(commandSignature(byName['auto-assign']!)).toBe('auto-assign <on|off>');
|
|
146
|
+
expect(commandSignature(byName.snapshot!)).toBe('snapshot <project> [label...]');
|
|
147
|
+
expect(commandSignature(byName.accounts!)).toBe('accounts');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('formatReplHelp lists repl-surface commands only', () => {
|
|
151
|
+
const help = formatReplHelp();
|
|
152
|
+
expect(help).toContain('accounts');
|
|
153
|
+
expect(help).toContain('assign');
|
|
154
|
+
expect(help).toContain('reset');
|
|
155
|
+
expect(help).toContain('quit');
|
|
156
|
+
// CLI-only commands are not in the REPL help
|
|
157
|
+
expect(help).not.toContain('setup');
|
|
158
|
+
expect(help).not.toContain('logs');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('registry — handlers are surface-agnostic (use ctx.io)', () => {
|
|
163
|
+
it('version handler prints via io, not console', async () => {
|
|
164
|
+
const printed: string[] = [];
|
|
165
|
+
const io: IO = { print: (m) => printed.push(m), error: vi.fn(), confirm: vi.fn() };
|
|
166
|
+
const version = commands.find((d) => d.name === 'version')!;
|
|
167
|
+
await version.run({ serverUrl: 'http://x', positionals: [], flags: {}, io });
|
|
168
|
+
expect(printed).toHaveLength(1);
|
|
169
|
+
expect(printed[0]).toMatch(/^\d+\.\d+\.\d+/);
|
|
170
|
+
});
|
|
171
|
+
});
|