ultraclaude-agent 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/__tests__/config-windows.test.ts +93 -0
  2. package/__tests__/daemon.test.ts +166 -0
  3. package/__tests__/service-windows.test.ts +123 -0
  4. package/__tests__/sync-bugs.test.ts +246 -0
  5. package/__tests__/sync.test.ts +169 -0
  6. package/__tests__/usage-sync.test.ts +291 -0
  7. package/__tests__/version-check.test.ts +128 -0
  8. package/dist/auth.d.ts +10 -0
  9. package/dist/auth.d.ts.map +1 -0
  10. package/dist/auth.js +105 -0
  11. package/dist/auth.js.map +1 -0
  12. package/dist/cli.d.ts +3 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +196 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/config.d.ts +26 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/config.js +181 -0
  19. package/dist/config.js.map +1 -0
  20. package/dist/daemon.d.ts +27 -0
  21. package/dist/daemon.d.ts.map +1 -0
  22. package/dist/daemon.js +214 -0
  23. package/dist/daemon.js.map +1 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +7 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/logger.d.ts +3 -0
  29. package/dist/logger.d.ts.map +1 -0
  30. package/dist/logger.js +37 -0
  31. package/dist/logger.js.map +1 -0
  32. package/dist/service.d.ts +4 -0
  33. package/dist/service.d.ts.map +1 -0
  34. package/dist/service.js +223 -0
  35. package/dist/service.js.map +1 -0
  36. package/dist/sync.d.ts +25 -0
  37. package/dist/sync.d.ts.map +1 -0
  38. package/dist/sync.js +344 -0
  39. package/dist/sync.js.map +1 -0
  40. package/dist/usage-sync.d.ts +7 -0
  41. package/dist/usage-sync.d.ts.map +1 -0
  42. package/dist/usage-sync.js +208 -0
  43. package/dist/usage-sync.js.map +1 -0
  44. package/dist/watcher.d.ts +16 -0
  45. package/dist/watcher.d.ts.map +1 -0
  46. package/dist/watcher.js +90 -0
  47. package/dist/watcher.js.map +1 -0
  48. package/package.json +31 -0
  49. package/run.sh +28 -0
  50. package/src/auth.ts +127 -0
  51. package/src/cli.ts +235 -0
  52. package/src/config.ts +207 -0
  53. package/src/daemon.ts +264 -0
  54. package/src/index.ts +7 -0
  55. package/src/logger.ts +42 -0
  56. package/src/service.ts +237 -0
  57. package/src/sync.ts +473 -0
  58. package/src/usage-sync.ts +275 -0
  59. package/src/watcher.ts +106 -0
  60. package/tsconfig.build.json +6 -0
  61. package/tsconfig.json +8 -0
  62. package/tsconfig.tsbuildinfo +1 -0
package/src/config.ts ADDED
@@ -0,0 +1,207 @@
1
+ // Configuration, paths, and credential management for the sync agent daemon
2
+
3
+ import { homedir, platform } from 'node:os';
4
+ import { join, basename } from 'node:path';
5
+ import { readFile, writeFile, mkdir, unlink, access, readdir } from 'node:fs/promises';
6
+ import type { AgentCredentials, ProjectRegistry } from '@ultra-claude/shared';
7
+
8
+ // --- Paths ---
9
+
10
+ function resolveConfigDir(): string {
11
+ if (platform() === 'win32') {
12
+ // Windows: use %APPDATA%\ultraclaude-agent\
13
+ const appData = process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
14
+ return join(appData, 'ultraclaude-agent');
15
+ }
16
+ return join(homedir(), '.claude', 'ultra', 'dashboard');
17
+ }
18
+
19
+ const CONFIG_DIR = resolveConfigDir();
20
+ const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
21
+ const PID_FILE = join(CONFIG_DIR, 'daemon.pid');
22
+ const LOG_DIR = join(CONFIG_DIR, 'logs');
23
+ // Registry is auto-discovered from ~/.claude/projects/ — no file needed
24
+ const PROJECT_ID_FILE = '.claude/ultra/project-id';
25
+
26
+ export const paths = {
27
+ configDir: CONFIG_DIR,
28
+ credentials: CREDENTIALS_FILE,
29
+ pid: PID_FILE,
30
+ logDir: LOG_DIR,
31
+ claudeProjects: join(homedir(), '.claude', 'projects'),
32
+ projectIdFile: PROJECT_ID_FILE,
33
+ } as const;
34
+
35
+ // --- Server URL ---
36
+
37
+ export function getServerUrl(credentials?: AgentCredentials | null): string {
38
+ return (
39
+ process.env.ULTRACLAUDE_DASHBOARD_URL ??
40
+ credentials?.serverUrl ??
41
+ 'https://dashboard.ultra-claude.dev'
42
+ );
43
+ }
44
+
45
+ // --- Credentials ---
46
+
47
+ export async function loadCredentials(): Promise<AgentCredentials | null> {
48
+ try {
49
+ const data = await readFile(CREDENTIALS_FILE, 'utf8');
50
+ return JSON.parse(data) as AgentCredentials;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function saveCredentials(creds: AgentCredentials): Promise<void> {
57
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
58
+ await writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
59
+ }
60
+
61
+ // --- PID file ---
62
+
63
+ export async function writePid(): Promise<void> {
64
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
65
+ await writeFile(PID_FILE, String(process.pid));
66
+ }
67
+
68
+ export async function readPid(): Promise<number | null> {
69
+ try {
70
+ const pid = parseInt(await readFile(PID_FILE, 'utf8'), 10);
71
+ return Number.isNaN(pid) ? null : pid;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ export async function removePid(): Promise<void> {
78
+ try {
79
+ await unlink(PID_FILE);
80
+ } catch {
81
+ // Ignore if already gone
82
+ }
83
+ }
84
+
85
+ export function isDaemonRunning(pid: number): boolean {
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ // --- Registry (auto-discovery) ---
95
+
96
+ /**
97
+ * Decode a Claude Code project directory name back to a real filesystem path.
98
+ * Claude encodes paths by replacing / with - (e.g., /home/user/my-project -> -home-user-my-project).
99
+ * Since hyphens also appear in real directory names, we try all possible splits
100
+ * and return the first path that actually exists on disk.
101
+ */
102
+ async function decodeProjectPath(encoded: string): Promise<string | null> {
103
+ // Strip leading dash: "-home-user-project" -> "home-user-project"
104
+ const segments = encoded.slice(1).split('-');
105
+
106
+ // Recursively try combining segments with / or - at each position
107
+ async function tryResolve(idx: number, current: string): Promise<string | null> {
108
+ if (idx === segments.length) {
109
+ // Check if this path has a .claude/ultra/version.json
110
+ try {
111
+ await access(join('/', current, '.claude', 'ultra', 'version.json'));
112
+ return '/' + current;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ const seg = segments[idx]!;
119
+
120
+ // Try as new path segment (/)
121
+ const withSlash = current ? `${current}/${seg}` : seg;
122
+ const slashResult = await tryResolve(idx + 1, withSlash);
123
+ if (slashResult) return slashResult;
124
+
125
+ // Try as continuation of current segment (-)
126
+ if (current) {
127
+ const lastSlash = current.lastIndexOf('/');
128
+ const prefix = lastSlash >= 0 ? current.slice(0, lastSlash + 1) : '';
129
+ const lastSeg = lastSlash >= 0 ? current.slice(lastSlash + 1) : current;
130
+ const withDash = `${prefix}${lastSeg}-${seg}`;
131
+ return tryResolve(idx + 1, withDash);
132
+ }
133
+
134
+ return null;
135
+ }
136
+
137
+ return tryResolve(0, '');
138
+ }
139
+
140
+ /**
141
+ * Auto-discover Ultra Claude projects by scanning ~/.claude/projects/.
142
+ * A project is an Ultra Claude project if it has .claude/ultra/version.json.
143
+ */
144
+ export async function loadRegistry(): Promise<ProjectRegistry> {
145
+ const claudeProjectsDir = join(homedir(), '.claude', 'projects');
146
+ const projects: ProjectRegistry['projects'] = [];
147
+
148
+ try {
149
+ const entries = await readdir(claudeProjectsDir, { withFileTypes: true });
150
+
151
+ for (const entry of entries) {
152
+ if (!entry.isDirectory()) continue;
153
+
154
+ const realPath = await decodeProjectPath(entry.name);
155
+ if (realPath) {
156
+ // Read project name from app-context.md or fall back to directory name
157
+ let name = basename(realPath);
158
+ try {
159
+ const ctx = await readFile(join(realPath, '.claude', 'ultra', 'app-context.md'), 'utf8');
160
+ const nameMatch = ctx.match(/\*\*Name:\*\*\s*(.+)/);
161
+ if (nameMatch) name = nameMatch[1]!.trim();
162
+ } catch {
163
+ // Use directory name
164
+ }
165
+
166
+ projects.push({ path: realPath, name });
167
+ }
168
+ }
169
+ } catch {
170
+ // ~/.claude/projects/ doesn't exist
171
+ }
172
+
173
+ return { projects };
174
+ }
175
+
176
+ // --- Project ID ---
177
+
178
+ export async function getProjectId(projectPath: string): Promise<string | null> {
179
+ try {
180
+ return (await readFile(join(projectPath, PROJECT_ID_FILE), 'utf8')).trim();
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ export async function writeProjectId(projectPath: string, id: string): Promise<void> {
187
+ const dir = join(projectPath, '.claude', 'ultra');
188
+ await mkdir(dir, { recursive: true });
189
+ await writeFile(join(projectPath, PROJECT_ID_FILE), id, 'utf8');
190
+ }
191
+
192
+ // --- Log dir ---
193
+
194
+ export async function ensureLogDir(): Promise<void> {
195
+ await mkdir(LOG_DIR, { recursive: true });
196
+ }
197
+
198
+ // --- File existence check ---
199
+
200
+ export async function fileExists(path: string): Promise<boolean> {
201
+ try {
202
+ await access(path);
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,264 @@
1
+ // Global daemon — manages project registry, per-project watchers, lifecycle
2
+
3
+ import chokidar from 'chokidar';
4
+ import { basename } from 'node:path';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { join, dirname } from 'node:path';
8
+ import {
9
+ paths,
10
+ loadCredentials,
11
+ loadRegistry,
12
+ getProjectId,
13
+ writeProjectId,
14
+ writePid,
15
+ removePid,
16
+ } from './config.js';
17
+ import { startProjectWatcher, type ProjectWatcher } from './watcher.js';
18
+ import { createProjectOnServer, initialSync, stopSync } from './sync.js';
19
+ import { startUsageWatcher, type UsageWatcher } from './usage-sync.js';
20
+ import { logger } from './logger.js';
21
+ import { ok, err, type Result } from '@ultra-claude/shared';
22
+ import type { ProjectRegistryEntry } from '@ultra-claude/shared';
23
+
24
+ /**
25
+ * Compare two semver-style version strings (e.g. "1.2.3").
26
+ * Returns negative if a < b, 0 if equal, positive if a > b.
27
+ */
28
+ function compareVersions(a: string, b: string): number {
29
+ const partsA = a.split('.').map(Number);
30
+ const partsB = b.split('.').map(Number);
31
+ const len = Math.max(partsA.length, partsB.length);
32
+ for (let i = 0; i < len; i++) {
33
+ const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
34
+ if (diff !== 0) return diff;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ /**
40
+ * Check npm registry for a newer version. Non-blocking — network errors are silently ignored.
41
+ */
42
+ export async function checkForUpdate(): Promise<void> {
43
+ const log = logger.child({ op: 'versionCheck' });
44
+ try {
45
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
46
+ const pkgJson = JSON.parse(await readFile(pkgPath, 'utf8')) as { version: string };
47
+ const currentVersion = pkgJson.version;
48
+
49
+ const controller = new AbortController();
50
+ const timeout = setTimeout(() => controller.abort(), 2000);
51
+
52
+ const res = await fetch('https://registry.npmjs.org/ultraclaude-agent/latest', {
53
+ signal: controller.signal,
54
+ });
55
+ clearTimeout(timeout);
56
+
57
+ if (!res.ok) return;
58
+
59
+ const data = (await res.json()) as { version: string };
60
+ const latestVersion = data.version;
61
+
62
+ if (compareVersions(currentVersion, latestVersion) < 0) {
63
+ log.info(
64
+ { current: currentVersion, latest: latestVersion },
65
+ `Update available: ${currentVersion} → ${latestVersion}. Run: npm update -g ultraclaude-agent`,
66
+ );
67
+ }
68
+ } catch {
69
+ // Network error, timeout, or parse error — silently ignore
70
+ }
71
+ }
72
+
73
+ const activeWatchers = new Map<string, ProjectWatcher>();
74
+ let registryWatcher: ReturnType<typeof chokidar.watch> | null = null;
75
+ let usageWatcher: UsageWatcher | null = null;
76
+ let running = false;
77
+
78
+ type StartDaemonError = 'NOT_LOGGED_IN' | 'ALREADY_RUNNING';
79
+
80
+ /**
81
+ * Start the global daemon. Watches the project registry and manages per-project watchers.
82
+ */
83
+ export async function startDaemon(): Promise<Result<void, StartDaemonError>> {
84
+ const log = logger.child({ op: 'daemon' });
85
+
86
+ if (running) {
87
+ log.warn('Daemon already running in this process');
88
+ return err('ALREADY_RUNNING', 'Daemon already running in this process');
89
+ }
90
+
91
+ // Verify credentials
92
+ const creds = await loadCredentials();
93
+ if (!creds) {
94
+ return err('NOT_LOGGED_IN', 'Not logged in — run `ultraclaude-dashboard-agent login` first');
95
+ }
96
+
97
+ running = true;
98
+ await writePid();
99
+
100
+ // Register process-level handlers per error-handling.md §Daemon Process Lifecycle
101
+ const gracefulShutdown = async (exitCode: number) => {
102
+ await stopDaemon();
103
+ process.exit(exitCode);
104
+ };
105
+
106
+ process.on('SIGTERM', () => {
107
+ log.info({ signal: 'SIGTERM' }, 'Shutting down');
108
+ gracefulShutdown(0);
109
+ });
110
+ process.on('SIGINT', () => {
111
+ log.info({ signal: 'SIGINT' }, 'Shutting down');
112
+ gracefulShutdown(0);
113
+ });
114
+ process.on('uncaughtException', (error: Error) => {
115
+ logger.fatal({ err: error }, 'Uncaught exception — shutting down');
116
+ gracefulShutdown(1);
117
+ });
118
+ process.on('unhandledRejection', (reason: unknown) => {
119
+ logger.error({ err: reason }, 'Unhandled rejection in background task');
120
+ // Do NOT exit — log and continue. The offending promise should be fixed.
121
+ });
122
+
123
+ log.info('Daemon starting');
124
+
125
+ // Fire-and-forget version check — non-blocking, errors silently ignored
126
+ checkForUpdate().catch(() => {});
127
+
128
+ // Start global usage watcher (watches ~/.claude/ultra/ for usage-status.json and accounts/)
129
+ usageWatcher = startUsageWatcher();
130
+
131
+ // Load initial registry and start watchers
132
+ const registry = await loadRegistry();
133
+ log.info({ projectCount: registry.projects.length }, 'Registry loaded');
134
+
135
+ for (const entry of registry.projects) {
136
+ await ensureProject(entry);
137
+ }
138
+
139
+ // Watch ~/.claude/projects/ for new projects being opened
140
+ registryWatcher = chokidar.watch(paths.claudeProjects, {
141
+ persistent: true,
142
+ ignoreInitial: true,
143
+ depth: 0,
144
+ });
145
+
146
+ registryWatcher.on('addDir', async () => {
147
+ log.info('New Claude project directory detected — reconciling');
148
+ await reconcileWatchers();
149
+ });
150
+
151
+ log.info('Daemon running');
152
+ return ok(undefined);
153
+ }
154
+
155
+ /**
156
+ * Stop the daemon and clean up all watchers.
157
+ */
158
+ export async function stopDaemon(): Promise<void> {
159
+ const log = logger.child({ op: 'daemon' });
160
+ running = false;
161
+
162
+ // Close usage watcher
163
+ if (usageWatcher) {
164
+ await usageWatcher.close();
165
+ usageWatcher = null;
166
+ }
167
+
168
+ // Close registry watcher
169
+ if (registryWatcher) {
170
+ await registryWatcher.close();
171
+ registryWatcher = null;
172
+ }
173
+
174
+ // Close all project watchers
175
+ const closePromises = Array.from(activeWatchers.values()).map((w) => w.close());
176
+ await Promise.all(closePromises);
177
+ activeWatchers.clear();
178
+
179
+ stopSync();
180
+ await removePid();
181
+
182
+ log.info('Daemon stopped');
183
+ }
184
+
185
+ /**
186
+ * Ensure a project has a server-side ID and start watching it.
187
+ */
188
+ async function ensureProject(entry: ProjectRegistryEntry): Promise<void> {
189
+ const log = logger.child({ project: entry.name, path: entry.path });
190
+
191
+ // Skip if already watching
192
+ if (activeWatchers.has(entry.path)) return;
193
+
194
+ // Check/create project ID
195
+ let projectId = await getProjectId(entry.path);
196
+
197
+ if (!projectId) {
198
+ log.info('No project-id found — auto-creating on server');
199
+ const slug = entry.name
200
+ .toLowerCase()
201
+ .replace(/[^a-z0-9]+/g, '-')
202
+ .replace(/^-+|-+$/g, '');
203
+ const result = await createProjectOnServer(entry.name, slug);
204
+
205
+ if (!result) {
206
+ log.error('Failed to auto-create project — will retry on next registry change');
207
+ return;
208
+ }
209
+
210
+ projectId = result.id;
211
+ await writeProjectId(entry.path, projectId);
212
+ log.info({ projectId }, 'Project created and ID written');
213
+ }
214
+
215
+ // Start watcher
216
+ const watcher = startProjectWatcher({ projectId, projectPath: entry.path });
217
+ activeWatchers.set(entry.path, watcher);
218
+
219
+ // Initial sync in background (wrapped in catch per error-handling standard)
220
+ initialSync(projectId, entry.path).catch((syncErr: unknown) => {
221
+ log.error({ err: syncErr }, 'Initial sync failed');
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Reconcile active watchers with the current registry.
227
+ * Add watchers for new projects, remove watchers for removed projects.
228
+ */
229
+ async function reconcileWatchers(): Promise<void> {
230
+ const log = logger.child({ op: 'reconcile' });
231
+ const registry = await loadRegistry();
232
+ const registryPaths = new Set(registry.projects.map((p) => p.path));
233
+
234
+ // Stop watchers for removed projects
235
+ for (const [path, watcher] of activeWatchers) {
236
+ if (!registryPaths.has(path)) {
237
+ log.info({ project: basename(path) }, 'Project removed from registry — stopping watcher');
238
+ await watcher.close();
239
+ activeWatchers.delete(path);
240
+ }
241
+ }
242
+
243
+ // Start watchers for new projects
244
+ for (const entry of registry.projects) {
245
+ if (!activeWatchers.has(entry.path)) {
246
+ log.info({ project: entry.name }, 'New project detected — starting watcher');
247
+ await ensureProject(entry);
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Get status of all active project watchers.
254
+ */
255
+ export function getDaemonStatus(): { running: boolean; usageWatcher: boolean; projects: { path: string; projectId: string }[] } {
256
+ return {
257
+ running,
258
+ usageWatcher: usageWatcher !== null,
259
+ projects: Array.from(activeWatchers.values()).map((w) => ({
260
+ path: w.projectPath,
261
+ projectId: w.projectId,
262
+ })),
263
+ };
264
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // @ultra-claude/agent — CLI/daemon entry point
2
+ // Run via: ultraclaude-dashboard-agent <command>
3
+ // See cli.ts for all commands
4
+
5
+ export { startDaemon, stopDaemon, getDaemonStatus } from './daemon.js';
6
+ export { login } from './auth.js';
7
+ export { installService, uninstallService, isServiceInstalled } from './service.js';
package/src/logger.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Pino logger for the sync agent daemon
2
+
3
+ import pino from 'pino';
4
+
5
+ const REDACT_CONFIG = {
6
+ paths: [
7
+ 'apiKey',
8
+ '*.apiKey',
9
+ 'credentials',
10
+ '*.credentials',
11
+ 'token',
12
+ '*.token',
13
+ 'password',
14
+ '*.password',
15
+ 'secret',
16
+ '*.secret',
17
+ 'req.headers.authorization',
18
+ 'req.headers.cookie',
19
+ ],
20
+ censor: '[REDACTED]',
21
+ };
22
+
23
+ function createLogger() {
24
+ const isForeground = process.argv.includes('--foreground') || process.env.NODE_ENV !== 'production';
25
+
26
+ if (isForeground) {
27
+ // Interactive / foreground mode: pretty-print to stderr
28
+ return pino({
29
+ level: process.env.LOG_LEVEL ?? 'info',
30
+ redact: REDACT_CONFIG,
31
+ transport: { target: 'pino-pretty', options: { colorize: true } },
32
+ });
33
+ }
34
+
35
+ // Background daemon mode: JSON to stdout (captured by systemd/launchd)
36
+ return pino({
37
+ level: process.env.LOG_LEVEL ?? 'info',
38
+ redact: REDACT_CONFIG,
39
+ });
40
+ }
41
+
42
+ export const logger = createLogger();