@porchestra/cli 1.0.0

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 (37) hide show
  1. package/README.md +625 -0
  2. package/bin/porchestra.js +2 -0
  3. package/package.json +51 -0
  4. package/src/agents/testPrompt/ast.json +71 -0
  5. package/src/agents/testPrompt/config.ts +18 -0
  6. package/src/agents/testPrompt/index.ts +64 -0
  7. package/src/agents/testPrompt/schemas.ts +45 -0
  8. package/src/agents/testPrompt/tools.ts +88 -0
  9. package/src/commands/agents.ts +173 -0
  10. package/src/commands/config.ts +97 -0
  11. package/src/commands/explore.ts +160 -0
  12. package/src/commands/login.ts +101 -0
  13. package/src/commands/logout.ts +52 -0
  14. package/src/commands/pull.ts +220 -0
  15. package/src/commands/status.ts +78 -0
  16. package/src/commands/whoami.ts +56 -0
  17. package/src/core/api/client.ts +133 -0
  18. package/src/core/auth/auth-service.ts +176 -0
  19. package/src/core/auth/token-manager.ts +47 -0
  20. package/src/core/config/config-manager.ts +107 -0
  21. package/src/core/config/config-schema.ts +56 -0
  22. package/src/core/config/project-tracker.ts +158 -0
  23. package/src/core/generators/code-generator.ts +329 -0
  24. package/src/core/generators/schema-generator.ts +59 -0
  25. package/src/index.ts +85 -0
  26. package/src/types/index.ts +214 -0
  27. package/src/utils/date.ts +23 -0
  28. package/src/utils/errors.ts +38 -0
  29. package/src/utils/logger.ts +11 -0
  30. package/src/utils/path-utils.ts +47 -0
  31. package/tests/unit/config-manager.test.ts +74 -0
  32. package/tests/unit/config-schema.test.ts +61 -0
  33. package/tests/unit/path-utils.test.ts +53 -0
  34. package/tests/unit/schema-generator.test.ts +82 -0
  35. package/tsconfig.json +30 -0
  36. package/tsup.config.ts +19 -0
  37. package/vitest.config.ts +20 -0
@@ -0,0 +1,133 @@
1
+ import got from 'got';
2
+ import { ConfigManager } from '../config/config-manager.js';
3
+ import { NetworkError, AuthenticationError } from '../../utils/errors.js';
4
+ import {
5
+ ProjectsBriefResponse,
6
+ AgentsListResponse,
7
+ AgentToolsResponse,
8
+ UserInfo,
9
+ CliConfigResponse,
10
+ ApiResponse,
11
+ } from '../../types/index.js';
12
+
13
+ const MAX_RETRIES = 3;
14
+ const RETRY_DELAY = 1000;
15
+
16
+ interface RequestOptions {
17
+ method: 'GET' | 'POST' | 'DELETE';
18
+ headers?: Record<string, string>;
19
+ }
20
+
21
+ export class ApiClient {
22
+ private configManager: ConfigManager;
23
+
24
+ constructor(configManager: ConfigManager) {
25
+ this.configManager = configManager;
26
+ }
27
+
28
+ private async getBaseUrl(): Promise<string> {
29
+ const config = await this.configManager.get();
30
+ return config.api?.baseUrl || 'https://api.porchestra.io/v1';
31
+ }
32
+
33
+ private async getToken(): Promise<string | undefined> {
34
+ const config = await this.configManager.get();
35
+ return config.auth?.token;
36
+ }
37
+
38
+ private async requestWithRetry<T>(
39
+ url: string,
40
+ options: RequestOptions,
41
+ attempt = 1
42
+ ): Promise<T> {
43
+ try {
44
+ const token = await this.getToken();
45
+ const headers: Record<string, string> = {
46
+ 'Content-Type': 'application/json',
47
+ ...(token && { Authorization: `Bearer ${token}` }),
48
+ ...(options.headers || {}),
49
+ };
50
+
51
+ const response = await got(url, {
52
+ method: options.method,
53
+ headers,
54
+ retry: { limit: 0 },
55
+ }).json<ApiResponse<T>>();
56
+
57
+ return response.data;
58
+ } catch (error: any) {
59
+ if (error.response?.statusCode === 401) {
60
+ throw new AuthenticationError('Authentication failed. Please login again.');
61
+ }
62
+
63
+ const isRetryable =
64
+ error.code === 'ECONNRESET' ||
65
+ error.code === 'ETIMEDOUT' ||
66
+ error.code === 'ENOTFOUND' ||
67
+ (error.response?.statusCode && error.response.statusCode >= 500);
68
+
69
+ if (isRetryable && attempt < MAX_RETRIES) {
70
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * attempt));
71
+ return this.requestWithRetry(url, options, attempt + 1);
72
+ }
73
+
74
+ throw new NetworkError(
75
+ `API request failed: ${error.message}`,
76
+ error.response?.statusCode,
77
+ isRetryable
78
+ );
79
+ }
80
+ }
81
+
82
+ async getProjectsBrief(): Promise<ProjectsBriefResponse> {
83
+ const baseUrl = await this.getBaseUrl();
84
+ return this.requestWithRetry<ProjectsBriefResponse>(
85
+ `${baseUrl}/projects/brief`,
86
+ { method: 'GET' }
87
+ );
88
+ }
89
+
90
+ async getProjectAgents(
91
+ projectId: string,
92
+ environment?: 'PRODUCTION' | 'STAGING' | 'DEVELOPMENT'
93
+ ): Promise<AgentsListResponse> {
94
+ const baseUrl = await this.getBaseUrl();
95
+ const query = environment ? `?environment=${environment}` : '';
96
+ return this.requestWithRetry<AgentsListResponse>(
97
+ `${baseUrl}/projects/${projectId}/agents${query}`,
98
+ { method: 'GET' }
99
+ );
100
+ }
101
+
102
+ async getAgentTools(
103
+ projectId: string,
104
+ agentId: string,
105
+ environment?: 'PRODUCTION' | 'STAGING' | 'DEVELOPMENT'
106
+ ): Promise<AgentToolsResponse> {
107
+ const baseUrl = await this.getBaseUrl();
108
+ const query = environment ? `?environment=${environment}` : '';
109
+ return this.requestWithRetry<AgentToolsResponse>(
110
+ `${baseUrl}/projects/${projectId}/agents/${agentId}/tools${query}`,
111
+ { method: 'GET' }
112
+ );
113
+ }
114
+
115
+ async getCurrentUser(token: string): Promise<UserInfo> {
116
+ const baseUrl = await this.getBaseUrl();
117
+ return this.requestWithRetry<UserInfo>(
118
+ `${baseUrl}/auth/me`,
119
+ {
120
+ method: 'GET',
121
+ headers: { Authorization: `Bearer ${token}` },
122
+ }
123
+ );
124
+ }
125
+
126
+ async getCliConfig(): Promise<CliConfigResponse> {
127
+ const baseUrl = await this.getBaseUrl();
128
+ return this.requestWithRetry<CliConfigResponse>(
129
+ `${baseUrl}/cli/config`,
130
+ { method: 'GET' }
131
+ );
132
+ }
133
+ }
@@ -0,0 +1,176 @@
1
+ import { ConfigManager } from '../config/config-manager.js';
2
+ import { AuthenticationError, NetworkError } from '../../utils/errors.js';
3
+ import {
4
+ ApiResponse,
5
+ CliTokenResponse,
6
+ CliTokenRefreshResponse,
7
+ CliTokenRevokeResponse,
8
+ UserInfo,
9
+ } from '../../types/index.js';
10
+ import got from 'got';
11
+ import os from 'os';
12
+
13
+ interface LoginCredentials {
14
+ email: string;
15
+ password: string;
16
+ apiUrl?: string;
17
+ deviceName?: string;
18
+ skipTlsVerify?: boolean;
19
+ }
20
+
21
+ export class AuthService {
22
+ private configManager: ConfigManager;
23
+
24
+ constructor(configManager: ConfigManager) {
25
+ this.configManager = configManager;
26
+ }
27
+
28
+ async login(credentials: LoginCredentials): Promise<CliTokenResponse> {
29
+ const config = await this.configManager.get();
30
+ const baseUrl = credentials.apiUrl || config.api?.baseUrl || 'https://api.porchestra.io/v1';
31
+
32
+ const deviceInfo = {
33
+ os: os.platform(),
34
+ version: os.release(),
35
+ cliVersion: '1.0.0',
36
+ };
37
+
38
+ const deviceName = credentials.deviceName || `${os.hostname()} - ${deviceInfo.os}`;
39
+
40
+ try {
41
+ const response = await got.post(`${baseUrl}/cli/login`, {
42
+ json: {
43
+ email: credentials.email,
44
+ password: credentials.password,
45
+ deviceName,
46
+ deviceInfo,
47
+ },
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ },
51
+ https: {
52
+ rejectUnauthorized: !credentials.skipTlsVerify,
53
+ },
54
+ }).json<ApiResponse<CliTokenResponse>>();
55
+
56
+ return response.data;
57
+ } catch (error: any) {
58
+ if (error.response?.statusCode) {
59
+ if (error.response.statusCode === 401) {
60
+ throw new AuthenticationError('Invalid email or password');
61
+ }
62
+ throw new AuthenticationError(`Login failed: ${error.message}`);
63
+ }
64
+ if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT') {
65
+ throw new NetworkError(`Network error: ${error.message}`);
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ async refreshToken(tokenId: string, currentToken: string): Promise<CliTokenRefreshResponse> {
72
+ const config = await this.configManager.get();
73
+ const baseUrl = config.api?.baseUrl || 'https://api.porchestra.io/v1';
74
+
75
+ try {
76
+ const response = await got.post(`${baseUrl}/cli/token/refresh`, {
77
+ json: {
78
+ tokenId,
79
+ currentToken,
80
+ },
81
+ headers: {
82
+ 'Authorization': `Bearer ${currentToken}`,
83
+ 'Content-Type': 'application/json',
84
+ },
85
+ }).json<ApiResponse<CliTokenRefreshResponse>>();
86
+
87
+ return response.data;
88
+ } catch (error: any) {
89
+ if (error.response?.statusCode === 401) {
90
+ throw new AuthenticationError('Token refresh failed. Please login again.');
91
+ }
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ async revokeToken(token: string, revokeAll = false): Promise<CliTokenRevokeResponse> {
97
+ const config = await this.configManager.get();
98
+ const baseUrl = config.api?.baseUrl || 'https://api.porchestra.io/v1';
99
+
100
+ try {
101
+ const response = await got.delete(`${baseUrl}/cli/token`, {
102
+ json: {
103
+ revokeAll,
104
+ },
105
+ headers: {
106
+ 'Authorization': `Bearer ${token}`,
107
+ 'Content-Type': 'application/json',
108
+ },
109
+ }).json<ApiResponse<CliTokenRevokeResponse>>();
110
+
111
+ return response.data;
112
+ } catch (error: any) {
113
+ if (error.response?.statusCode === 401) {
114
+ throw new AuthenticationError('Token already revoked or invalid');
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ async getCurrentUser(token: string): Promise<UserInfo> {
121
+ const config = await this.configManager.get();
122
+ const baseUrl = config.api?.baseUrl || 'https://api.porchestra.io/v1';
123
+
124
+ try {
125
+ const response = await got.get(`${baseUrl}/auth/me`, {
126
+ headers: {
127
+ 'Authorization': `Bearer ${token}`,
128
+ },
129
+ }).json<ApiResponse<UserInfo>>();
130
+
131
+ return response.data;
132
+ } catch (error: any) {
133
+ if (error.response?.statusCode === 401) {
134
+ throw new AuthenticationError('Token is invalid or expired');
135
+ }
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ async checkAndRefreshTokenIfNeeded(): Promise<boolean> {
141
+ const config = await this.configManager.get();
142
+
143
+ if (!config.auth?.token || !config.auth?.tokenId || !config.auth?.expiresAt) {
144
+ return false;
145
+ }
146
+
147
+ const expiresAt = new Date(config.auth.expiresAt);
148
+ const now = new Date();
149
+ const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
150
+
151
+ // If token expires within 7 days, refresh it
152
+ if (expiresAt < sevenDaysFromNow) {
153
+ try {
154
+ const refreshed = await this.refreshToken(
155
+ config.auth.tokenId,
156
+ config.auth.token
157
+ );
158
+
159
+ await this.configManager.update((cfg) => ({
160
+ ...cfg,
161
+ auth: {
162
+ ...cfg.auth,
163
+ token: refreshed.token,
164
+ expiresAt: refreshed.expiresAt,
165
+ },
166
+ }));
167
+
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ return true;
175
+ }
176
+ }
@@ -0,0 +1,47 @@
1
+ import { ConfigManager } from '../config/config-manager.js';
2
+
3
+ export class TokenManager {
4
+ constructor(private configManager: ConfigManager) {}
5
+
6
+ async getToken(): Promise<string | undefined> {
7
+ const config = await this.configManager.get();
8
+ return config.auth?.token;
9
+ }
10
+
11
+ async getTokenId(): Promise<string | undefined> {
12
+ const config = await this.configManager.get();
13
+ return config.auth?.tokenId;
14
+ }
15
+
16
+ async getExpiresAt(): Promise<string | undefined> {
17
+ const config = await this.configManager.get();
18
+ return config.auth?.expiresAt;
19
+ }
20
+
21
+ async isAuthenticated(): Promise<boolean> {
22
+ const token = await this.getToken();
23
+ if (!token) return false;
24
+
25
+ const expiresAt = await this.getExpiresAt();
26
+ if (!expiresAt) return false;
27
+
28
+ const expiryDate = new Date(expiresAt);
29
+ return expiryDate > new Date();
30
+ }
31
+
32
+ async getDaysUntilExpiry(): Promise<number | null> {
33
+ const expiresAt = await this.getExpiresAt();
34
+ if (!expiresAt) return null;
35
+
36
+ const expiryDate = new Date(expiresAt);
37
+ const now = new Date();
38
+ const diffMs = expiryDate.getTime() - now.getTime();
39
+ return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
40
+ }
41
+
42
+ async isExpiringSoon(days = 7): Promise<boolean> {
43
+ const daysUntilExpiry = await this.getDaysUntilExpiry();
44
+ if (daysUntilExpiry === null) return false;
45
+ return daysUntilExpiry <= days;
46
+ }
47
+ }
@@ -0,0 +1,107 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { z } from 'zod';
5
+ import { CliConfig, CliConfigSchema } from './config-schema.js';
6
+
7
+ const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.porchestra');
8
+ const CONFIG_FILE = 'config.json';
9
+
10
+ export class ConfigManager {
11
+ private configPath: string;
12
+ private config: CliConfig | null = null;
13
+
14
+ constructor() {
15
+ const configDir = process.env.PORCHESTRA_CONFIG_DIR || DEFAULT_CONFIG_DIR;
16
+ this.configPath = path.join(configDir, CONFIG_FILE);
17
+ }
18
+
19
+ async load(): Promise<CliConfig> {
20
+ try {
21
+ const data = await fs.readFile(this.configPath, 'utf-8');
22
+ const parsed = JSON.parse(data);
23
+
24
+ // STRICT VALIDATION - throws on error
25
+ const validated = CliConfigSchema.parse(parsed);
26
+ this.config = validated;
27
+ return validated;
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
30
+ // Return default config
31
+ const defaultConfig = { version: '1.0.0' } as const;
32
+ this.config = CliConfigSchema.parse(defaultConfig);
33
+ return this.config;
34
+ }
35
+
36
+ // THROW on validation errors
37
+ if (error instanceof z.ZodError) {
38
+ console.error('❌ Config validation failed:');
39
+ error.errors.forEach((err) => {
40
+ console.error(` - ${err.path.join('.')}: ${err.message}`);
41
+ });
42
+ throw new Error(`Invalid config file at ${this.configPath}. Please run 'porchestra config reset' to fix.`);
43
+ }
44
+
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ async save(config: CliConfig): Promise<void> {
50
+ try {
51
+ // STRICT VALIDATION before save
52
+ const validated = CliConfigSchema.parse(config);
53
+
54
+ // Ensure directory exists
55
+ const configDir = path.dirname(this.configPath);
56
+ await fs.mkdir(configDir, { recursive: true });
57
+
58
+ // Atomic write (write to temp, then rename)
59
+ const tempPath = `${this.configPath}.tmp`;
60
+ await fs.writeFile(
61
+ tempPath,
62
+ JSON.stringify(validated, null, 2),
63
+ 'utf-8'
64
+ );
65
+ await fs.rename(tempPath, this.configPath);
66
+
67
+ this.config = validated;
68
+ } catch (error) {
69
+ if (error instanceof z.ZodError) {
70
+ console.error('❌ Config validation failed on save:');
71
+ error.errors.forEach((err) => {
72
+ console.error(` - ${err.path.join('.')}: ${err.message}`);
73
+ });
74
+ throw new Error('Failed to save config: validation error');
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ async get(): Promise<CliConfig> {
81
+ if (!this.config) {
82
+ return this.load();
83
+ }
84
+ return this.config;
85
+ }
86
+
87
+ async update(updater: (config: CliConfig) => CliConfig): Promise<void> {
88
+ const config = await this.get();
89
+ const updated = updater({ ...config }); // Create new object
90
+ await this.save(updated);
91
+ }
92
+
93
+ async clear(): Promise<void> {
94
+ try {
95
+ await fs.unlink(this.configPath);
96
+ } catch (error) {
97
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
98
+ throw error;
99
+ }
100
+ }
101
+ this.config = null;
102
+ }
103
+
104
+ getConfigPath(): string {
105
+ return this.configPath;
106
+ }
107
+ }
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+
3
+ // Simplified datetime validation - just accepts any string
4
+ const DateTimeStringSchema = z.string().optional();
5
+
6
+ export const TrackedAgentSchema = z.object({
7
+ agentId: z.string().uuid(),
8
+ agentName: z.string(),
9
+ agentSlug: z.string(),
10
+ folderPath: z.string(),
11
+ selectedAt: DateTimeStringSchema,
12
+ lastPulledAt: DateTimeStringSchema.optional(),
13
+ lastPulledVersion: z.string().optional(),
14
+ });
15
+
16
+ export const TrackedProjectSchema = z.object({
17
+ projectId: z.string().uuid(),
18
+ projectName: z.string(),
19
+ projectSlug: z.string(),
20
+ selectedAt: DateTimeStringSchema,
21
+ agents: z.array(TrackedAgentSchema),
22
+ lastPulledAt: DateTimeStringSchema.optional(),
23
+ });
24
+
25
+ export const CliConfigSchema = z.object({
26
+ auth: z.object({
27
+ token: z.string().optional(),
28
+ tokenId: z.string().uuid().optional(),
29
+ expiresAt: DateTimeStringSchema.optional(),
30
+ deviceName: z.string().optional(),
31
+ }).optional(),
32
+
33
+ api: z.object({
34
+ baseUrl: z.string().url().default('https://api.porchestra.io/v1'),
35
+ skipTlsVerify: z.boolean().default(false),
36
+ }).default({}),
37
+
38
+ trackedProjects: z.array(TrackedProjectSchema).default([]),
39
+
40
+ output: z.object({
41
+ baseDir: z.string().default('./src/agents'),
42
+ createIndexFiles: z.boolean().default(true),
43
+ }).default({}),
44
+
45
+ cli: z.object({
46
+ lastLoginAt: DateTimeStringSchema.optional(),
47
+ lastVersionCheck: DateTimeStringSchema.optional(),
48
+ latestKnownVersion: z.string().optional(),
49
+ }).optional(),
50
+
51
+ version: z.literal('1.0.0'),
52
+ });
53
+
54
+ export type TrackedAgent = z.infer<typeof TrackedAgentSchema>;
55
+ export type TrackedProject = z.infer<typeof TrackedProjectSchema>;
56
+ export type CliConfig = z.infer<typeof CliConfigSchema>;
@@ -0,0 +1,158 @@
1
+ import { ConfigManager } from './config-manager.js';
2
+ import { TrackedProject, TrackedAgent } from './config-schema.js';
3
+
4
+ export interface ProjectBrief {
5
+ id: string;
6
+ name: string;
7
+ slug: string;
8
+ }
9
+
10
+ export interface AgentBrief {
11
+ id: string;
12
+ name: string;
13
+ slug: string;
14
+ folderPath: string;
15
+ }
16
+
17
+ export class ProjectTracker {
18
+ constructor(private configManager: ConfigManager) {}
19
+
20
+ async getTrackedProjects(): Promise<TrackedProject[]> {
21
+ const config = await this.configManager.get();
22
+ return config.trackedProjects || [];
23
+ }
24
+
25
+ async getTrackedAgents(projectId: string): Promise<TrackedAgent[]> {
26
+ const config = await this.configManager.get();
27
+ const project = config.trackedProjects?.find(p => p.projectId === projectId);
28
+ return project?.agents || [];
29
+ }
30
+
31
+ async listTrackedAgents(): Promise<Array<{ project: TrackedProject; agent: TrackedAgent }>> {
32
+ const projects = await this.getTrackedProjects();
33
+ return projects.flatMap(project =>
34
+ project.agents.map(agent => ({ project, agent }))
35
+ );
36
+ }
37
+
38
+ async trackProject(project: ProjectBrief, agents: AgentBrief[]): Promise<void> {
39
+ await this.configManager.update((config) => {
40
+ const existingIndex = config.trackedProjects.findIndex(
41
+ p => p.projectId === project.id
42
+ );
43
+
44
+ const trackedAgents: TrackedAgent[] = agents.map(agent => ({
45
+ agentId: agent.id,
46
+ agentName: agent.name,
47
+ agentSlug: agent.slug,
48
+ folderPath: agent.folderPath,
49
+ selectedAt: new Date().toISOString(),
50
+ }));
51
+
52
+ const trackedProject: TrackedProject = {
53
+ projectId: project.id,
54
+ projectName: project.name,
55
+ projectSlug: project.slug,
56
+ selectedAt: new Date().toISOString(),
57
+ agents: trackedAgents,
58
+ };
59
+
60
+ const newProjects = [...config.trackedProjects];
61
+ if (existingIndex >= 0) {
62
+ newProjects[existingIndex] = trackedProject;
63
+ } else {
64
+ newProjects.push(trackedProject);
65
+ }
66
+
67
+ return { ...config, trackedProjects: newProjects };
68
+ });
69
+ }
70
+
71
+ async untrackProject(projectId: string): Promise<void> {
72
+ await this.configManager.update((config) => ({
73
+ ...config,
74
+ trackedProjects: config.trackedProjects.filter(
75
+ p => p.projectId !== projectId
76
+ ),
77
+ }));
78
+ }
79
+
80
+ async untrackAgent(projectId: string, agentId: string): Promise<boolean> {
81
+ let removed = false;
82
+
83
+ await this.configManager.update((config) => {
84
+ const projectIndex = config.trackedProjects.findIndex(
85
+ p => p.projectId === projectId
86
+ );
87
+
88
+ if (projectIndex === -1) return config;
89
+
90
+ const project = config.trackedProjects[projectIndex];
91
+ const remainingAgents = project.agents.filter(
92
+ agent => agent.agentId !== agentId
93
+ );
94
+
95
+ if (remainingAgents.length === project.agents.length) {
96
+ return config;
97
+ }
98
+
99
+ removed = true;
100
+
101
+ const newProjects = [...config.trackedProjects];
102
+
103
+ if (remainingAgents.length === 0) {
104
+ newProjects.splice(projectIndex, 1);
105
+ } else {
106
+ newProjects[projectIndex] = { ...project, agents: remainingAgents };
107
+ }
108
+
109
+ return { ...config, trackedProjects: newProjects };
110
+ });
111
+
112
+ return removed;
113
+ }
114
+
115
+ async updateLastPulled(
116
+ projectId: string,
117
+ agentId: string,
118
+ version: string
119
+ ): Promise<void> {
120
+ await this.configManager.update((config) => {
121
+ const projectIndex = config.trackedProjects.findIndex(
122
+ p => p.projectId === projectId
123
+ );
124
+ if (projectIndex === -1) return config;
125
+
126
+ const project = config.trackedProjects[projectIndex];
127
+ const agentIndex = project.agents.findIndex(
128
+ a => a.agentId === agentId
129
+ );
130
+ if (agentIndex === -1) return config;
131
+
132
+ const now = new Date().toISOString();
133
+ const newAgents = [...project.agents];
134
+ newAgents[agentIndex] = {
135
+ ...newAgents[agentIndex],
136
+ lastPulledAt: now,
137
+ lastPulledVersion: version,
138
+ };
139
+
140
+ const newProjects = [...config.trackedProjects];
141
+ newProjects[projectIndex] = {
142
+ ...project,
143
+ agents: newAgents,
144
+ lastPulledAt: now,
145
+ };
146
+
147
+ return { ...config, trackedProjects: newProjects };
148
+ });
149
+ }
150
+
151
+ async getSummary(): Promise<{ projectCount: number; agentCount: number }> {
152
+ const projects = await this.getTrackedProjects();
153
+ return {
154
+ projectCount: projects.length,
155
+ agentCount: projects.reduce((sum, p) => sum + p.agents.length, 0),
156
+ };
157
+ }
158
+ }