@agentuity/cli 1.0.6 → 1.0.7

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.
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { createSubcommand } from '../../../types';
3
3
  import * as tui from '../../../tui';
4
- import { projectEnvUpdate, orgEnvUpdate } from '@agentuity/server';
4
+ import { projectEnvUpdate, orgEnvUpdate, projectGet, orgEnvGet } from '@agentuity/server';
5
5
  import {
6
6
  findExistingEnvFile,
7
7
  readEnvFile,
@@ -11,14 +11,19 @@ import {
11
11
  } from '../../../env-util';
12
12
  import { getCommand } from '../../../command-prefix';
13
13
  import { resolveOrgId, isOrgScope } from './org-util';
14
+ import { computeEnvDiff, displayEnvDiff } from './env-diff';
14
15
 
15
16
  const EnvPushResponseSchema = z.object({
16
17
  success: z.boolean().describe('Whether push succeeded'),
17
18
  pushed: z.number().describe('Number of items pushed'),
18
19
  envCount: z.number().describe('Number of env vars pushed'),
19
20
  secretCount: z.number().describe('Number of secrets pushed'),
21
+ newCount: z.number().describe('Number of new variables added'),
22
+ changedCount: z.number().describe('Number of existing variables overwritten'),
23
+ unchangedCount: z.number().describe('Number of unchanged variables'),
20
24
  source: z.string().describe('Source file path'),
21
25
  scope: z.enum(['project', 'org']).describe('The scope where variables were pushed'),
26
+ force: z.boolean().describe('Whether force mode was used'),
22
27
  });
23
28
 
24
29
  export const pushSubcommand = createSubcommand({
@@ -39,6 +44,7 @@ export const pushSubcommand = createSubcommand({
39
44
  .union([z.boolean(), z.string()])
40
45
  .optional()
41
46
  .describe('push to organization level (use --org for default org)'),
47
+ force: z.boolean().default(false).describe('overwrite remote values without confirmation'),
42
48
  }),
43
49
  response: EnvPushResponseSchema,
44
50
  },
@@ -59,6 +65,8 @@ export const pushSubcommand = createSubcommand({
59
65
  // Filter out reserved AGENTUITY_ prefixed keys
60
66
  const filteredEnv = filterAgentuitySdkKeys(localEnv);
61
67
 
68
+ const forceMode = opts?.force ?? false;
69
+
62
70
  if (Object.keys(filteredEnv).length === 0) {
63
71
  tui.warning('No variables to push');
64
72
  return {
@@ -66,8 +74,12 @@ export const pushSubcommand = createSubcommand({
66
74
  pushed: 0,
67
75
  envCount: 0,
68
76
  secretCount: 0,
77
+ newCount: 0,
78
+ changedCount: 0,
79
+ unchangedCount: 0,
69
80
  source: envFilePath,
70
81
  scope: useOrgScope ? ('org' as const) : ('project' as const),
82
+ force: forceMode,
71
83
  };
72
84
  }
73
85
 
@@ -93,6 +105,38 @@ export const pushSubcommand = createSubcommand({
93
105
  // Organization scope
94
106
  const orgId = await resolveOrgId(apiClient, config, opts!.org!);
95
107
 
108
+ // Fetch remote state to compute diff
109
+ const orgData = await tui.spinner('Fetching remote variables', () => {
110
+ return orgEnvGet(apiClient, { id: orgId, mask: false });
111
+ });
112
+
113
+ const diff = computeEnvDiff(env, secrets, orgData.env || {}, orgData.secrets || {});
114
+
115
+ displayEnvDiff(diff, { direction: 'push' });
116
+
117
+ // Prompt for confirmation if there are changes to existing variables
118
+ if (diff.changedEntries.length > 0 && !forceMode) {
119
+ const confirmed = await tui.confirm(
120
+ `${diff.changedEntries.length} existing variable${diff.changedEntries.length !== 1 ? 's' : ''} will be overwritten. Continue?`,
121
+ true
122
+ );
123
+ if (!confirmed) {
124
+ tui.info('Push cancelled');
125
+ return {
126
+ success: false,
127
+ pushed: 0,
128
+ envCount: 0,
129
+ secretCount: 0,
130
+ newCount: 0,
131
+ changedCount: 0,
132
+ unchangedCount: 0,
133
+ source: envFilePath,
134
+ scope: 'org' as const,
135
+ force: forceMode,
136
+ };
137
+ }
138
+ }
139
+
96
140
  await tui.spinner('Pushing variables to organization', () => {
97
141
  return orgEnvUpdate(apiClient, {
98
142
  id: orgId,
@@ -106,7 +150,7 @@ export const pushSubcommand = createSubcommand({
106
150
  const totalCount = envCount + secretCount;
107
151
 
108
152
  tui.success(
109
- `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to organization (${envCount} env, ${secretCount} secret${secretCount !== 1 ? 's' : ''})`
153
+ `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to organization (${diff.newEntries.length} new, ${diff.changedEntries.length} updated, ${diff.unchangedEntries.length} unchanged)`
110
154
  );
111
155
 
112
156
  return {
@@ -114,17 +158,58 @@ export const pushSubcommand = createSubcommand({
114
158
  pushed: totalCount,
115
159
  envCount,
116
160
  secretCount,
161
+ newCount: diff.newEntries.length,
162
+ changedCount: diff.changedEntries.length,
163
+ unchangedCount: diff.unchangedEntries.length,
117
164
  source: envFilePath,
118
165
  scope: 'org' as const,
166
+ force: forceMode,
119
167
  };
120
168
  } else {
121
- // Project scope (existing behavior)
169
+ // Project scope
122
170
  if (!project) {
123
171
  tui.fatal(
124
172
  'Project context required. Run from a project directory or use --org for organization scope.'
125
173
  );
126
174
  }
127
175
 
176
+ // Fetch remote state to compute diff
177
+ const projectData = await tui.spinner('Fetching remote variables', () => {
178
+ return projectGet(apiClient, { id: project.projectId, mask: false });
179
+ });
180
+
181
+ const diff = computeEnvDiff(
182
+ env,
183
+ secrets,
184
+ projectData.env || {},
185
+ projectData.secrets || {}
186
+ );
187
+
188
+ displayEnvDiff(diff, { direction: 'push' });
189
+
190
+ // Prompt for confirmation if there are changes to existing variables
191
+ if (diff.changedEntries.length > 0 && !forceMode) {
192
+ const confirmed = await tui.confirm(
193
+ `${diff.changedEntries.length} existing variable${diff.changedEntries.length !== 1 ? 's' : ''} will be overwritten. Continue?`,
194
+ true
195
+ );
196
+ if (!confirmed) {
197
+ tui.info('Push cancelled');
198
+ return {
199
+ success: false,
200
+ pushed: 0,
201
+ envCount: 0,
202
+ secretCount: 0,
203
+ newCount: 0,
204
+ changedCount: 0,
205
+ unchangedCount: 0,
206
+ source: envFilePath,
207
+ scope: 'project' as const,
208
+ force: forceMode,
209
+ };
210
+ }
211
+ }
212
+
128
213
  await tui.spinner('Pushing variables to cloud', () => {
129
214
  return projectEnvUpdate(apiClient, {
130
215
  id: project.projectId,
@@ -138,7 +223,7 @@ export const pushSubcommand = createSubcommand({
138
223
  const totalCount = envCount + secretCount;
139
224
 
140
225
  tui.success(
141
- `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to cloud (${envCount} env, ${secretCount} secret${secretCount !== 1 ? 's' : ''})`
226
+ `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to cloud (${diff.newEntries.length} new, ${diff.changedEntries.length} updated, ${diff.unchangedEntries.length} unchanged)`
142
227
  );
143
228
 
144
229
  return {
@@ -146,8 +231,12 @@ export const pushSubcommand = createSubcommand({
146
231
  pushed: totalCount,
147
232
  envCount,
148
233
  secretCount,
234
+ newCount: diff.newEntries.length,
235
+ changedCount: diff.changedEntries.length,
236
+ unchangedCount: diff.unchangedEntries.length,
149
237
  source: envFilePath,
150
238
  scope: 'project' as const,
239
+ force: forceMode,
151
240
  };
152
241
  }
153
242
  },
@@ -13,20 +13,80 @@ import {
13
13
  import { getCommand } from '../../../command-prefix';
14
14
  import { resolveOrgId, isOrgScope } from './org-util';
15
15
 
16
+ interface ParsedEnvPair {
17
+ key: string;
18
+ value: string;
19
+ }
20
+
21
+ /**
22
+ * Parse env set arguments into key-value pairs.
23
+ * Supports two formats:
24
+ * - Legacy: KEY VALUE (exactly 2 args)
25
+ * - KEY=VALUE format: KEY1=VALUE1 [KEY2=VALUE2 ...]
26
+ */
27
+ function parseEnvArgs(rawArgs: string[]): ParsedEnvPair[] {
28
+ if (rawArgs.length === 0) {
29
+ tui.fatal(
30
+ 'No arguments provided. Usage: env set KEY VALUE or env set KEY=VALUE [KEY2=VALUE2 ...]'
31
+ );
32
+ }
33
+
34
+ // Check if first arg contains '=' — if so, treat ALL args as KEY=VALUE format
35
+ const firstArg = rawArgs[0]!;
36
+ if (firstArg.includes('=')) {
37
+ const pairs: ParsedEnvPair[] = [];
38
+ for (const arg of rawArgs) {
39
+ const eqIndex = arg.indexOf('=');
40
+ if (eqIndex === -1 || eqIndex === 0) {
41
+ tui.fatal(`Invalid format: '${arg}'. Expected KEY=VALUE format.`);
42
+ }
43
+ const key = arg.substring(0, eqIndex);
44
+ const value = arg.substring(eqIndex + 1);
45
+ if (!key) {
46
+ tui.fatal(`Invalid format: '${arg}'. Key cannot be empty.`);
47
+ }
48
+ pairs.push({ key, value });
49
+ }
50
+ return pairs;
51
+ }
52
+
53
+ // Legacy format: exactly 2 args = KEY VALUE
54
+ if (rawArgs.length === 2) {
55
+ const key = rawArgs[0]!.trim();
56
+ if (!key) {
57
+ tui.fatal(
58
+ 'Invalid format: key cannot be empty. Usage: env set KEY VALUE or env set KEY=VALUE'
59
+ );
60
+ }
61
+ return [{ key, value: rawArgs[1]! }];
62
+ }
63
+
64
+ // Ambiguous: 1 arg without '=' or 3+ args without '='
65
+ if (rawArgs.length === 1) {
66
+ tui.fatal(`Missing value for '${rawArgs[0]}'. Usage: env set KEY VALUE or env set KEY=VALUE`);
67
+ }
68
+
69
+ // 3+ args without '=' in first arg
70
+ tui.fatal(
71
+ 'Multiple variables must use KEY=VALUE format. Usage: env set KEY1=VALUE1 KEY2=VALUE2 ...'
72
+ );
73
+ }
74
+
16
75
  const EnvSetResponseSchema = z.object({
17
76
  success: z.boolean().describe('Whether the operation succeeded'),
18
- key: z.string().describe('Environment variable key'),
77
+ keys: z.array(z.string()).describe('Environment variable keys that were set'),
19
78
  path: z
20
79
  .string()
21
80
  .optional()
22
- .describe('Local file path where env var was saved (project scope only)'),
23
- secret: z.boolean().describe('Whether the value was stored as a secret'),
24
- scope: z.enum(['project', 'org']).describe('The scope where the variable was set'),
81
+ .describe('Local file path where env vars were saved (project scope only)'),
82
+ secretKeys: z.array(z.string()).describe('Keys that were stored as secrets'),
83
+ envKeys: z.array(z.string()).describe('Keys that were stored as env vars'),
84
+ scope: z.enum(['project', 'org']).describe('The scope where the variables were set'),
25
85
  });
26
86
 
27
87
  export const setSubcommand = createSubcommand({
28
88
  name: 'set',
29
- description: 'Set an environment variable or secret',
89
+ description: 'Set one or more environment variables or secrets',
30
90
  tags: ['mutating', 'updates-resource', 'slow', 'requires-auth'],
31
91
  idempotent: true,
32
92
  requires: { auth: true, apiClient: true },
@@ -36,7 +96,15 @@ export const setSubcommand = createSubcommand({
36
96
  command: getCommand('env set NODE_ENV production'),
37
97
  description: 'Set environment variable',
38
98
  },
99
+ {
100
+ command: getCommand('env set NODE_ENV=production'),
101
+ description: 'Set using KEY=VALUE format',
102
+ },
39
103
  { command: getCommand('env set PORT 3000'), description: 'Set port number' },
104
+ {
105
+ command: getCommand('env set NODE_ENV=production LOG_LEVEL=info PORT=3000'),
106
+ description: 'Set multiple variables at once',
107
+ },
40
108
  {
41
109
  command: getCommand('env set API_KEY "sk_..." --secret'),
42
110
  description: 'Set a secret value',
@@ -48,8 +116,7 @@ export const setSubcommand = createSubcommand({
48
116
  ],
49
117
  schema: {
50
118
  args: z.object({
51
- key: z.string().describe('the environment variable key'),
52
- value: z.string().describe('the environment variable value'),
119
+ args: z.array(z.string()).describe('KEY VALUE or KEY=VALUE [KEY2=VALUE2 ...]'),
53
120
  }),
54
121
  options: z.object({
55
122
  secret: z
@@ -67,8 +134,9 @@ export const setSubcommand = createSubcommand({
67
134
  },
68
135
 
69
136
  async handler(ctx) {
70
- const { args, opts, apiClient, project, projectDir, config } = ctx;
137
+ const { args: cmdArgs, opts, apiClient, project, projectDir, config } = ctx;
71
138
  const useOrgScope = isOrgScope(opts?.org);
139
+ const forceSecret = opts?.secret ?? false;
72
140
 
73
141
  // Require project context if not using org scope
74
142
  if (!useOrgScope && !project) {
@@ -77,86 +145,145 @@ export const setSubcommand = createSubcommand({
77
145
  );
78
146
  }
79
147
 
80
- let isSecret = opts?.secret ?? false;
81
- const isPublic = isPublicVarKey(args.key);
148
+ const pairs = parseEnvArgs(cmdArgs.args);
82
149
 
83
- // Validate key doesn't start with reserved AGENTUITY_ prefix (except AGENTUITY_PUBLIC_)
84
- if (isReservedAgentuityKey(args.key)) {
85
- tui.fatal('Cannot set AGENTUITY_ prefixed variables. These are reserved for system use.');
150
+ // Validate all keys first
151
+ for (const pair of pairs) {
152
+ if (isReservedAgentuityKey(pair.key)) {
153
+ tui.fatal(
154
+ `Cannot set AGENTUITY_ prefixed variables: '${pair.key}'. These are reserved for system use.`
155
+ );
156
+ }
86
157
  }
87
158
 
88
- // Validate public vars cannot be secrets
89
- if (isSecret && isPublic) {
90
- tui.fatal(
91
- `Cannot set public variables as secrets. Keys with prefixes (${PUBLIC_VAR_PREFIXES.join(', ')}) are exposed to the frontend.`
92
- );
159
+ // Reject duplicate keys
160
+ const seenKeys = new Set<string>();
161
+ for (const pair of pairs) {
162
+ if (seenKeys.has(pair.key)) {
163
+ tui.fatal(`Duplicate key '${pair.key}'. Each variable may only be specified once.`);
164
+ }
165
+ seenKeys.add(pair.key);
93
166
  }
94
167
 
95
- // Auto-detect if this looks like a secret and offer to store as secret
96
- // Skip auto-detect for public vars since they can never be secrets
97
- if (!isSecret && !isPublic && looksLikeSecret(args.key, args.value)) {
98
- tui.warning(`The variable '${args.key}' looks like it should be a secret.`);
168
+ // Classify each pair as env or secret
169
+ const envPairs: Record<string, string> = {};
170
+ const secretPairs: Record<string, string> = {};
171
+ const secretKeysList: string[] = [];
172
+ const envKeysList: string[] = [];
173
+
174
+ for (const pair of pairs) {
175
+ const isPublic = isPublicVarKey(pair.key);
176
+ let isSecret = forceSecret;
99
177
 
100
- const storeAsSecret = await tui.confirm('Store as a secret instead?', true);
178
+ // Validate public vars cannot be secrets
179
+ if (isSecret && isPublic) {
180
+ tui.fatal(
181
+ `Cannot set public variables as secrets. '${pair.key}' (prefix ${PUBLIC_VAR_PREFIXES.join(', ')}) is exposed to the frontend.`
182
+ );
183
+ }
184
+
185
+ // Auto-detect if this looks like a secret and offer to store as secret
186
+ // Skip auto-detect for public vars since they can never be secrets
187
+ if (!isSecret && !isPublic && looksLikeSecret(pair.key, pair.value)) {
188
+ if (pairs.length === 1) {
189
+ // Single pair: offer interactive prompt (existing behavior)
190
+ tui.warning(`The variable '${pair.key}' looks like it should be a secret.`);
191
+ const storeAsSecret = await tui.confirm('Store as a secret instead?', true);
192
+ if (storeAsSecret) {
193
+ isSecret = true;
194
+ }
195
+ } else {
196
+ // Multiple pairs: auto-detect silently
197
+ isSecret = true;
198
+ tui.info(`Auto-detected '${pair.key}' as a secret`);
199
+ }
200
+ }
101
201
 
102
- if (storeAsSecret) {
103
- isSecret = true;
202
+ if (isSecret) {
203
+ secretPairs[pair.key] = pair.value;
204
+ secretKeysList.push(pair.key);
205
+ } else {
206
+ envPairs[pair.key] = pair.value;
207
+ envKeysList.push(pair.key);
104
208
  }
105
209
  }
106
210
 
107
- const label = isSecret ? 'secret' : 'environment variable';
211
+ const totalCount = pairs.length;
212
+ const allKeys = [...envKeysList, ...secretKeysList];
213
+ const secretSuffix =
214
+ secretKeysList.length > 0
215
+ ? ` (${secretKeysList.length} secret${secretKeysList.length !== 1 ? 's' : ''})`
216
+ : '';
108
217
 
109
218
  if (useOrgScope) {
110
219
  // Organization scope
111
220
  const orgId = await resolveOrgId(apiClient, config, opts!.org!);
112
221
 
113
- const updatePayload = isSecret
114
- ? { id: orgId, secrets: { [args.key]: args.value } }
115
- : { id: orgId, env: { [args.key]: args.value } };
222
+ const updatePayload: {
223
+ id: string;
224
+ env?: Record<string, string>;
225
+ secrets?: Record<string, string>;
226
+ } = { id: orgId };
227
+ if (Object.keys(envPairs).length > 0) updatePayload.env = envPairs;
228
+ if (Object.keys(secretPairs).length > 0) updatePayload.secrets = secretPairs;
116
229
 
117
- await tui.spinner(`Setting organization ${label} in cloud`, () => {
118
- return orgEnvUpdate(apiClient, updatePayload);
119
- });
230
+ await tui.spinner(
231
+ `Setting ${totalCount} organization variable${totalCount !== 1 ? 's' : ''} in cloud`,
232
+ () => {
233
+ return orgEnvUpdate(apiClient, updatePayload);
234
+ }
235
+ );
120
236
 
121
237
  tui.success(
122
- `Organization ${isSecret ? 'secret' : 'environment variable'} '${args.key}' set successfully (affects all projects in org)`
238
+ `Organization variable${totalCount !== 1 ? 's' : ''} set successfully: ${allKeys.join(', ')}${secretSuffix}`
123
239
  );
124
240
 
125
241
  return {
126
242
  success: true,
127
- key: args.key,
128
- secret: isSecret,
243
+ keys: allKeys,
244
+ secretKeys: secretKeysList,
245
+ envKeys: envKeysList,
129
246
  scope: 'org' as const,
130
247
  };
131
248
  } else {
132
- // Project scope (existing behavior)
133
- const updatePayload = isSecret
134
- ? { id: project!.projectId, secrets: { [args.key]: args.value } }
135
- : { id: project!.projectId, env: { [args.key]: args.value } };
249
+ // Project scope
250
+ const updatePayload: {
251
+ id: string;
252
+ env?: Record<string, string>;
253
+ secrets?: Record<string, string>;
254
+ } = { id: project!.projectId };
255
+ if (Object.keys(envPairs).length > 0) updatePayload.env = envPairs;
256
+ if (Object.keys(secretPairs).length > 0) updatePayload.secrets = secretPairs;
136
257
 
137
- await tui.spinner(`Setting ${label} in cloud`, () => {
138
- return projectEnvUpdate(apiClient, updatePayload);
139
- });
258
+ await tui.spinner(
259
+ `Setting ${totalCount} variable${totalCount !== 1 ? 's' : ''} in cloud`,
260
+ () => {
261
+ return projectEnvUpdate(apiClient, updatePayload);
262
+ }
263
+ );
140
264
 
141
265
  // Update local .env file only if we have a project directory
142
- // (not when using --project-id without being in a project folder)
143
266
  let envFilePath: string | undefined;
144
267
  if (projectDir) {
145
268
  envFilePath = await findExistingEnvFile(projectDir);
146
- // Write only the new key - writeEnvFile preserves existing keys by default
147
- await writeEnvFile(envFilePath, { [args.key]: args.value });
269
+ const allPairsForLocal: Record<string, string> = {
270
+ ...envPairs,
271
+ ...secretPairs,
272
+ };
273
+ await writeEnvFile(envFilePath, allPairsForLocal);
148
274
  }
149
275
 
150
- const successMsg = envFilePath
151
- ? `${isSecret ? 'Secret' : 'Environment variable'} '${args.key}' set successfully (cloud + ${envFilePath})`
152
- : `${isSecret ? 'Secret' : 'Environment variable'} '${args.key}' set successfully (cloud only)`;
153
- tui.success(successMsg);
276
+ const locationMsg = envFilePath ? ` (cloud + ${envFilePath})` : ' (cloud only)';
277
+ tui.success(
278
+ `Variable${totalCount !== 1 ? 's' : ''} set successfully: ${allKeys.join(', ')}${secretSuffix}${locationMsg}`
279
+ );
154
280
 
155
281
  return {
156
282
  success: true,
157
- key: args.key,
283
+ keys: allKeys,
158
284
  path: envFilePath,
159
- secret: isSecret,
285
+ secretKeys: secretKeysList,
286
+ envKeys: envKeysList,
160
287
  scope: 'project' as const,
161
288
  };
162
289
  }