@agentuity/cli 2.0.6 → 2.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.
Files changed (94) hide show
  1. package/README.md +11 -0
  2. package/dist/cmd/cloud/task/close.d.ts +3 -0
  3. package/dist/cmd/cloud/task/close.d.ts.map +1 -0
  4. package/dist/cmd/cloud/task/close.js +286 -0
  5. package/dist/cmd/cloud/task/close.js.map +1 -0
  6. package/dist/cmd/cloud/task/delete.d.ts +1 -5
  7. package/dist/cmd/cloud/task/delete.d.ts.map +1 -1
  8. package/dist/cmd/cloud/task/delete.js +15 -38
  9. package/dist/cmd/cloud/task/delete.js.map +1 -1
  10. package/dist/cmd/cloud/task/index.d.ts.map +1 -1
  11. package/dist/cmd/cloud/task/index.js +10 -0
  12. package/dist/cmd/cloud/task/index.js.map +1 -1
  13. package/dist/cmd/cloud/task/list.d.ts.map +1 -1
  14. package/dist/cmd/cloud/task/list.js +97 -3
  15. package/dist/cmd/cloud/task/list.js.map +1 -1
  16. package/dist/cmd/cloud/task/util.d.ts +10 -0
  17. package/dist/cmd/cloud/task/util.d.ts.map +1 -1
  18. package/dist/cmd/cloud/task/util.js +47 -3
  19. package/dist/cmd/cloud/task/util.js.map +1 -1
  20. package/dist/cmd/coder/config/index.d.ts +2 -0
  21. package/dist/cmd/coder/config/index.d.ts.map +1 -0
  22. package/dist/cmd/coder/config/index.js +20 -0
  23. package/dist/cmd/coder/config/index.js.map +1 -0
  24. package/dist/cmd/coder/config/set.d.ts +2 -0
  25. package/dist/cmd/coder/config/set.d.ts.map +1 -0
  26. package/dist/cmd/coder/config/set.js +100 -0
  27. package/dist/cmd/coder/config/set.js.map +1 -0
  28. package/dist/cmd/coder/hub-url.d.ts +21 -10
  29. package/dist/cmd/coder/hub-url.d.ts.map +1 -1
  30. package/dist/cmd/coder/hub-url.js +97 -55
  31. package/dist/cmd/coder/hub-url.js.map +1 -1
  32. package/dist/cmd/coder/index.d.ts.map +1 -1
  33. package/dist/cmd/coder/index.js +6 -1
  34. package/dist/cmd/coder/index.js.map +1 -1
  35. package/dist/cmd/coder/inspect.d.ts.map +1 -1
  36. package/dist/cmd/coder/inspect.js +15 -7
  37. package/dist/cmd/coder/inspect.js.map +1 -1
  38. package/dist/cmd/coder/list.d.ts.map +1 -1
  39. package/dist/cmd/coder/list.js +14 -7
  40. package/dist/cmd/coder/list.js.map +1 -1
  41. package/dist/cmd/coder/start.d.ts.map +1 -1
  42. package/dist/cmd/coder/start.js +38 -23
  43. package/dist/cmd/coder/start.js.map +1 -1
  44. package/dist/cmd/coder/tui-init.d.ts +4 -1
  45. package/dist/cmd/coder/tui-init.d.ts.map +1 -1
  46. package/dist/cmd/coder/tui-init.js +3 -2
  47. package/dist/cmd/coder/tui-init.js.map +1 -1
  48. package/dist/cmd/dev/sync.js +5 -5
  49. package/dist/cmd/dev/sync.js.map +1 -1
  50. package/dist/coder-config.d.ts +14 -0
  51. package/dist/coder-config.d.ts.map +1 -0
  52. package/dist/coder-config.js +119 -0
  53. package/dist/coder-config.js.map +1 -0
  54. package/dist/coder-hub-url.d.ts +3 -0
  55. package/dist/coder-hub-url.d.ts.map +1 -0
  56. package/dist/coder-hub-url.js +32 -0
  57. package/dist/coder-hub-url.js.map +1 -0
  58. package/dist/config.d.ts +1 -0
  59. package/dist/config.d.ts.map +1 -1
  60. package/dist/config.js +11 -0
  61. package/dist/config.js.map +1 -1
  62. package/dist/internal-logger.d.ts +4 -0
  63. package/dist/internal-logger.d.ts.map +1 -1
  64. package/dist/internal-logger.js +64 -2
  65. package/dist/internal-logger.js.map +1 -1
  66. package/dist/keychain.d.ts +3 -0
  67. package/dist/keychain.d.ts.map +1 -1
  68. package/dist/keychain.js +47 -28
  69. package/dist/keychain.js.map +1 -1
  70. package/dist/types.d.ts +4 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js +10 -0
  73. package/dist/types.js.map +1 -1
  74. package/package.json +6 -6
  75. package/src/cmd/cloud/task/close.ts +319 -0
  76. package/src/cmd/cloud/task/delete.ts +15 -43
  77. package/src/cmd/cloud/task/index.ts +10 -0
  78. package/src/cmd/cloud/task/list.ts +111 -4
  79. package/src/cmd/cloud/task/util.ts +59 -5
  80. package/src/cmd/coder/config/index.ts +20 -0
  81. package/src/cmd/coder/config/set.ts +112 -0
  82. package/src/cmd/coder/hub-url.ts +147 -53
  83. package/src/cmd/coder/index.ts +6 -1
  84. package/src/cmd/coder/inspect.ts +33 -10
  85. package/src/cmd/coder/list.ts +33 -10
  86. package/src/cmd/coder/start.ts +62 -26
  87. package/src/cmd/coder/tui-init.ts +7 -2
  88. package/src/cmd/dev/sync.ts +5 -5
  89. package/src/coder-config.ts +141 -0
  90. package/src/coder-hub-url.ts +32 -0
  91. package/src/config.ts +13 -0
  92. package/src/internal-logger.ts +83 -2
  93. package/src/keychain.ts +68 -39
  94. package/src/types.ts +10 -0
@@ -5,7 +5,17 @@ import { createSubcommand } from '../../types';
5
5
  import * as tui from '../../tui';
6
6
  import { getCommand } from '../../command-prefix';
7
7
  import { ErrorCode } from '../../errors';
8
- import { toHubWsUrl, resolveHubUrl, hubFetchHeaders } from './hub-url';
8
+ import {
9
+ clearStoredHubApiKeyOnUnauthorized,
10
+ formatHubUnauthorizedMessage,
11
+ formatMissingHubUrlMessage,
12
+ getHubResponseErrorMessage,
13
+ hubFetchHeaders,
14
+ isHubUnauthorizedStatus,
15
+ resolveHubApiKey,
16
+ resolveHubUrl,
17
+ toHubWsUrl,
18
+ } from './hub-url';
9
19
  import { probeHubInitAccess } from './tui-init';
10
20
 
11
21
  /**
@@ -127,24 +137,44 @@ export const startSubcommand = createSubcommand({
127
137
  }),
128
138
  },
129
139
  async handler(ctx) {
130
- const { opts, options } = ctx;
140
+ const { opts, options, config } = ctx;
131
141
 
132
142
  // Resolve Hub URL
133
- const hubHttpUrl = await resolveHubUrl(opts?.hubUrl);
143
+ const hubHttpUrl = await resolveHubUrl(opts?.hubUrl, config);
134
144
  if (!hubHttpUrl) {
135
- tui.fatal(
136
- 'Could not find a running Coder Hub.\n\nEither:\n - Start the Hub with: bun run dev\n - Set AGENTUITY_CODER_HUB_URL environment variable\n - Pass --hub-url flag',
137
- ErrorCode.NETWORK_ERROR
138
- );
145
+ tui.fatal(formatMissingHubUrlMessage(), ErrorCode.NETWORK_ERROR);
139
146
  return;
140
147
  }
141
148
  const hubWsUrl = toHubWsUrl(hubHttpUrl);
149
+ const resolvedHubApiKey = await resolveHubApiKey(config);
150
+
151
+ const handleUnauthorizedResponse = async (
152
+ response: Response,
153
+ errorCode: ErrorCode = ErrorCode.NETWORK_ERROR
154
+ ): Promise<void> => {
155
+ const message = await getHubResponseErrorMessage(response);
156
+ const clearedStoredKey = await clearStoredHubApiKeyOnUnauthorized(
157
+ response.status,
158
+ resolvedHubApiKey,
159
+ config
160
+ );
161
+ tui.fatal(
162
+ formatHubUnauthorizedMessage(hubHttpUrl, message, { clearedStoredKey }),
163
+ errorCode
164
+ );
165
+ };
142
166
 
143
- const initProbe = await probeHubInitAccess(hubHttpUrl);
167
+ const initProbe = await probeHubInitAccess(hubHttpUrl, {
168
+ apiKey: resolvedHubApiKey.apiKey,
169
+ });
144
170
  if (!initProbe.ok) {
145
171
  if (initProbe.code === 'unauthorized') {
172
+ const clearedStoredKey =
173
+ resolvedHubApiKey.source === 'stored'
174
+ ? await clearStoredHubApiKeyOnUnauthorized(401, resolvedHubApiKey, config)
175
+ : false;
146
176
  tui.fatal(
147
- `Coder Hub at ${hubHttpUrl} requires authentication.\n\nSet AGENTUITY_CODER_API_KEY in your shell and retry.\n\nServer said: ${initProbe.message}`,
177
+ formatHubUnauthorizedMessage(hubHttpUrl, initProbe.message, { clearedStoredKey }),
148
178
  ErrorCode.NETWORK_ERROR
149
179
  );
150
180
  return;
@@ -192,11 +222,15 @@ export const startSubcommand = createSubcommand({
192
222
  message: 'Fetching connectable sessions…',
193
223
  callback: async () => {
194
224
  const resp = await fetch(`${hubHttpUrl}/api/hub/sessions/connectable`, {
195
- headers: hubFetchHeaders(),
225
+ headers: hubFetchHeaders(undefined, resolvedHubApiKey.apiKey),
196
226
  signal: AbortSignal.timeout(10_000),
197
227
  });
228
+ if (isHubUnauthorizedStatus(resp.status)) {
229
+ await handleUnauthorizedResponse(resp);
230
+ throw new Error('Hub authentication failed');
231
+ }
198
232
  if (!resp.ok) {
199
- throw new Error(`${resp.status} ${resp.statusText}`);
233
+ throw new Error(`${resp.status} ${await getHubResponseErrorMessage(resp)}`);
200
234
  }
201
235
  const data = (await resp.json()) as { sessions: SessionInfo[] };
202
236
  return data.sessions;
@@ -205,7 +239,7 @@ export const startSubcommand = createSubcommand({
205
239
 
206
240
  if (sessions.length === 0) {
207
241
  tui.fatal(
208
- 'No connectable sandbox sessions found.\n\nCreate one with: ag-dev coder session create --task "your task"',
242
+ `No connectable sandbox sessions found.\n\nCreate one with:\n ${getCommand('coder start --sandbox "your task"')}`,
209
243
  ErrorCode.CONFIG_INVALID
210
244
  );
211
245
  return;
@@ -229,7 +263,7 @@ export const startSubcommand = createSubcommand({
229
263
  });
230
264
  } catch (err) {
231
265
  const msg = err instanceof Error ? err.message : String(err);
232
- if (msg === 'User cancelled') return;
266
+ if (msg === 'User cancelled' || msg === 'Hub authentication failed') return;
233
267
  tui.fatal(`Failed to fetch connectable sessions: ${msg}`, ErrorCode.NETWORK_ERROR);
234
268
  return;
235
269
  }
@@ -273,12 +307,6 @@ export const startSubcommand = createSubcommand({
273
307
  return;
274
308
  }
275
309
 
276
- const hubHttpUrl = await resolveHubUrl(opts?.hubUrl);
277
- if (!hubHttpUrl) {
278
- tui.fatal('Could not find Hub URL for sandbox creation.', ErrorCode.NETWORK_ERROR);
279
- return;
280
- }
281
-
282
310
  // Build request body
283
311
  const body: Record<string, unknown> = { task };
284
312
  if (opts?.repo) {
@@ -293,14 +321,20 @@ export const startSubcommand = createSubcommand({
293
321
  try {
294
322
  const resp = await fetch(`${hubHttpUrl}/api/hub/session`, {
295
323
  method: 'POST',
296
- headers: hubFetchHeaders({ 'Content-Type': 'application/json' }),
324
+ headers: hubFetchHeaders(
325
+ { 'Content-Type': 'application/json' },
326
+ resolvedHubApiKey.apiKey
327
+ ),
297
328
  body: JSON.stringify(body),
298
329
  signal: AbortSignal.timeout(10_000),
299
330
  });
331
+ if (isHubUnauthorizedStatus(resp.status)) {
332
+ await handleUnauthorizedResponse(resp);
333
+ return;
334
+ }
300
335
  if (!resp.ok) {
301
- const errText = await resp.text();
302
336
  tui.fatal(
303
- `Failed to create sandbox session: ${resp.status} ${errText}`,
337
+ `Failed to create sandbox session: ${resp.status} ${await getHubResponseErrorMessage(resp)}`,
304
338
  ErrorCode.NETWORK_ERROR
305
339
  );
306
340
  return;
@@ -328,9 +362,13 @@ export const startSubcommand = createSubcommand({
328
362
  await new Promise((r) => setTimeout(r, POLL_INTERVAL));
329
363
  try {
330
364
  const pollResp = await fetch(`${hubHttpUrl}/api/hub/session/${sessionId}`, {
331
- headers: hubFetchHeaders(),
365
+ headers: hubFetchHeaders(undefined, resolvedHubApiKey.apiKey),
332
366
  signal: AbortSignal.timeout(5_000),
333
367
  });
368
+ if (isHubUnauthorizedStatus(pollResp.status)) {
369
+ await handleUnauthorizedResponse(pollResp);
370
+ return;
371
+ }
334
372
  if (pollResp.ok) {
335
373
  const data = (await pollResp.json()) as {
336
374
  participants?: Array<{ role: string }>;
@@ -374,9 +412,7 @@ export const startSubcommand = createSubcommand({
374
412
  ...(process.env as Record<string, string>),
375
413
  AGENTUITY_CODER_HUB_URL: hubWsUrl,
376
414
  };
377
- // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
378
- const cliApiKey = process.env.AGENTUITY_CODER_API_KEY;
379
- if (cliApiKey) env.AGENTUITY_CODER_API_KEY = cliApiKey;
415
+ if (resolvedHubApiKey.apiKey) env.AGENTUITY_CODER_API_KEY = resolvedHubApiKey.apiKey;
380
416
 
381
417
  if (opts?.agent) {
382
418
  env.AGENTUITY_CODER_AGENT = opts.agent;
@@ -21,11 +21,16 @@ function normalizeErrorMessage(payload: unknown, fallback: string): string {
21
21
 
22
22
  export async function probeHubInitAccess(
23
23
  hubHttpUrl: string,
24
- fetchImpl: typeof fetch = fetch
24
+ options?: {
25
+ apiKey?: string | null;
26
+ fetchImpl?: typeof fetch;
27
+ }
25
28
  ): Promise<HubInitProbeResult> {
29
+ const fetchImpl = options?.fetchImpl ?? fetch;
30
+
26
31
  try {
27
32
  const response = await fetchImpl(`${hubHttpUrl}/api/hub/init`, {
28
- headers: hubFetchHeaders({ accept: 'application/json' }),
33
+ headers: hubFetchHeaders({ accept: 'application/json' }, options?.apiKey),
29
34
  signal: AbortSignal.timeout(5_000),
30
35
  });
31
36
 
@@ -169,7 +169,7 @@ class DevmodeSyncService implements IDevmodeSyncService {
169
169
  }
170
170
  }
171
171
  }
172
- this.logger.debug('Previous metadata found with %d eval(s)', prevEvalCount);
172
+ this.logger.debug('Previous metadata found with %d evaluations', prevEvalCount);
173
173
  } else {
174
174
  this.logger.debug('No previous metadata, all evals will be treated as new');
175
175
  }
@@ -182,7 +182,7 @@ class DevmodeSyncService implements IDevmodeSyncService {
182
182
  if (agent.evals) {
183
183
  currentEvalCount += agent.evals.length;
184
184
  this.logger.debug(
185
- '[CLI EVAL SYNC] Agent "%s" has %d eval(s)',
185
+ '[CLI EVAL SYNC] Agent "%s" has %d evaluations',
186
186
  agent.name,
187
187
  agent.evals.length
188
188
  );
@@ -196,7 +196,7 @@ class DevmodeSyncService implements IDevmodeSyncService {
196
196
  }
197
197
  }
198
198
  }
199
- this.logger.debug('[CLI EVAL SYNC] Total current eval(s): %d', currentEvalCount);
199
+ this.logger.debug('[CLI EVAL SYNC] Total current evaluations: %d', currentEvalCount);
200
200
 
201
201
  // Get agents and evals to sync using shared diff logic
202
202
  const { create: agentsToCreate, delete: agentsToDelete } = getAgentsToSync(
@@ -241,7 +241,7 @@ class DevmodeSyncService implements IDevmodeSyncService {
241
241
  }
242
242
  if (evalsToCreate.length > 0 || evalsToDelete.length > 0) {
243
243
  this.logger.debug(
244
- 'Successfully bulk synced %d eval(s) to create, %d eval(s) to delete',
244
+ 'Successfully bulk synced %d evaluations to create, %d evaluations to delete',
245
245
  evalsToCreate.length,
246
246
  evalsToDelete.length
247
247
  );
@@ -369,7 +369,7 @@ class MockDevmodeSyncService implements IDevmodeSyncService {
369
369
 
370
370
  if (evalsToCreate.length > 0 || evalsToDelete.length > 0) {
371
371
  this.logger.debug(
372
- '[MOCK] Would make request: POST /cli/devmode/eval with %d eval(s) to create, %d eval(s) to delete',
372
+ '[MOCK] Would make request: POST /cli/devmode/eval with %d evaluations to create, %d evaluations to delete',
373
373
  evalsToCreate.length,
374
374
  evalsToDelete.length
375
375
  );
@@ -0,0 +1,141 @@
1
+ import { defaultProfileName, getOrInitConfig, loadConfig, saveConfig } from './config';
2
+ import { normalizeCoderHubHttpUrl } from './coder-hub-url';
3
+ import {
4
+ deleteCoderApiKeyFromKeychain,
5
+ getCoderApiKeyFromKeychain,
6
+ isMacOS,
7
+ saveCoderApiKeyToKeychain,
8
+ } from './keychain';
9
+ import type { Config } from './types';
10
+
11
+ function getProfileName(config?: Config | null): string {
12
+ return config?.name || defaultProfileName;
13
+ }
14
+
15
+ function pruneCoderConfig(config: Config): void {
16
+ if (!config.coder) return;
17
+
18
+ const nextCoder = { ...config.coder };
19
+ if (!nextCoder.hubUrl) delete nextCoder.hubUrl;
20
+ if (!nextCoder.apiKey) delete nextCoder.apiKey;
21
+
22
+ if (Object.keys(nextCoder).length === 0) {
23
+ delete config.coder;
24
+ return;
25
+ }
26
+
27
+ config.coder = nextCoder;
28
+ }
29
+
30
+ export async function saveCoderHubUrl(
31
+ hubUrl: string
32
+ ): Promise<{ profileName: string; hubUrl: string }> {
33
+ const normalized = normalizeCoderHubHttpUrl(hubUrl);
34
+ const config = await getOrInitConfig();
35
+ const profileName = getProfileName(config);
36
+
37
+ config.coder = {
38
+ ...config.coder,
39
+ hubUrl: normalized,
40
+ };
41
+
42
+ await saveConfig(config);
43
+ return { profileName, hubUrl: normalized };
44
+ }
45
+
46
+ export async function getStoredCoderHubUrl(config?: Config | null): Promise<string | null> {
47
+ const loadedConfig = config ?? (await loadConfig());
48
+ const hubUrl = loadedConfig?.coder?.hubUrl?.trim();
49
+ if (!hubUrl) return null;
50
+ return normalizeCoderHubHttpUrl(hubUrl);
51
+ }
52
+
53
+ export async function saveCoderApiKey(apiKey: string): Promise<{ profileName: string }> {
54
+ const trimmed = apiKey.trim();
55
+ const config = await getOrInitConfig();
56
+ const profileName = getProfileName(config);
57
+
58
+ if (isMacOS()) {
59
+ try {
60
+ await saveCoderApiKeyToKeychain(profileName, trimmed);
61
+ if (config.coder?.apiKey) {
62
+ config.coder = {
63
+ ...config.coder,
64
+ };
65
+ delete config.coder.apiKey;
66
+ }
67
+ pruneCoderConfig(config);
68
+ await saveConfig(config);
69
+ return { profileName };
70
+ } catch {
71
+ // Fall back to config-file storage below.
72
+ }
73
+ }
74
+
75
+ config.coder = {
76
+ ...config.coder,
77
+ apiKey: trimmed,
78
+ };
79
+
80
+ await saveConfig(config);
81
+ return { profileName };
82
+ }
83
+
84
+ export async function getStoredCoderApiKey(config?: Config | null): Promise<string | null> {
85
+ const loadedConfig = config ?? (await loadConfig());
86
+ const profileName = getProfileName(loadedConfig);
87
+
88
+ if (isMacOS()) {
89
+ try {
90
+ const keychainValue = await getCoderApiKeyFromKeychain(profileName);
91
+ if (keychainValue) {
92
+ if (loadedConfig?.coder?.apiKey) {
93
+ const configCopy = {
94
+ ...loadedConfig,
95
+ coder: {
96
+ ...loadedConfig.coder,
97
+ },
98
+ };
99
+ delete configCopy.coder.apiKey;
100
+ pruneCoderConfig(configCopy);
101
+ await saveConfig(configCopy);
102
+ }
103
+ return keychainValue.trim() || null;
104
+ }
105
+ } catch {
106
+ // Fall back to config-file storage below.
107
+ }
108
+ }
109
+
110
+ const storedValue = loadedConfig?.coder?.apiKey?.trim();
111
+ return storedValue || null;
112
+ }
113
+
114
+ export async function clearStoredCoderApiKey(
115
+ config?: Config | null
116
+ ): Promise<{ profileName: string }> {
117
+ const loadedConfig = config ?? (await getOrInitConfig());
118
+ const profileName = getProfileName(loadedConfig);
119
+
120
+ if (isMacOS()) {
121
+ try {
122
+ await deleteCoderApiKeyFromKeychain(profileName);
123
+ } catch {
124
+ // Ignore keychain cleanup errors.
125
+ }
126
+ }
127
+
128
+ if (loadedConfig.coder?.apiKey) {
129
+ const configToSave = {
130
+ ...loadedConfig,
131
+ coder: {
132
+ ...loadedConfig.coder,
133
+ },
134
+ };
135
+ delete configToSave.coder.apiKey;
136
+ pruneCoderConfig(configToSave);
137
+ await saveConfig(configToSave);
138
+ }
139
+
140
+ return { profileName };
141
+ }
@@ -0,0 +1,32 @@
1
+ export function normalizeCoderHubHttpUrl(url: string): string {
2
+ let normalized = url.trim().replace(/\/+$/, '');
3
+
4
+ if (normalized.startsWith('ws://')) normalized = 'http://' + normalized.slice(5);
5
+ else if (normalized.startsWith('wss://')) normalized = 'https://' + normalized.slice(6);
6
+
7
+ normalized = normalized.replace(/\/api\/ws\b.*$/, '');
8
+ normalized = normalized.replace(/\/ws\b.*$/, '');
9
+ normalized = normalized.replace(/\/api\/hub\b.*$/, '');
10
+
11
+ return normalized.replace(/\/+$/, '');
12
+ }
13
+
14
+ export function toCoderHubWsUrl(hubHttpUrl: string): string {
15
+ let wsUrl = hubHttpUrl;
16
+ if (wsUrl.startsWith('http://')) wsUrl = 'ws://' + wsUrl.slice(7);
17
+ else if (wsUrl.startsWith('https://')) wsUrl = 'wss://' + wsUrl.slice(8);
18
+
19
+ try {
20
+ const parsed = new URL(wsUrl);
21
+ if (parsed.pathname !== '/api/ws') {
22
+ parsed.pathname = '/api/ws';
23
+ wsUrl = parsed.toString().replace(/\/$/, '');
24
+ }
25
+ } catch {
26
+ if (!wsUrl.endsWith('/api/ws')) {
27
+ wsUrl = wsUrl.replace(/\/?$/, '/api/ws');
28
+ }
29
+ }
30
+
31
+ return wsUrl;
32
+ }
package/src/config.ts CHANGED
@@ -28,6 +28,14 @@ import { ConfigSchema, ProjectSchema } from './types';
28
28
  export const defaultProfileName = 'production';
29
29
 
30
30
  export function getDefaultConfigDir(): string {
31
+ const configDirOverride = process.env.AGENTUITY_CONFIG_DIR?.trim();
32
+ if (configDirOverride) {
33
+ if (configDirOverride.startsWith('~/')) {
34
+ return resolve(join(homedir(), configDirOverride.slice(2)));
35
+ }
36
+ return resolve(configDirOverride);
37
+ }
38
+
31
39
  return join(homedir(), '.config', 'agentuity');
32
40
  }
33
41
 
@@ -144,6 +152,11 @@ let cachedConfig: Config | null | undefined;
144
152
  // Track the resolved config path so saveConfig writes back to the same file
145
153
  let cachedConfigPath: string | undefined;
146
154
 
155
+ export function resetConfigCache(): void {
156
+ cachedConfig = undefined;
157
+ cachedConfigPath = undefined;
158
+ }
159
+
147
160
  export async function loadConfig(
148
161
  customPath?: string,
149
162
  skipCache = false,
@@ -43,6 +43,16 @@ const SENSITIVE_ENV_PATTERNS = [
43
43
  /AUTH/i,
44
44
  ];
45
45
 
46
+ const MASKED_ARG_VALUE = '***MASKED***';
47
+ const SENSITIVE_ARG_PATTERNS = [
48
+ /^--?api[-_]?key$/i,
49
+ /^--?token$/i,
50
+ /^--?secret$/i,
51
+ /^--?password$/i,
52
+ /^--?client[-_]?secret$/i,
53
+ /^--?auth[-_]?value$/i,
54
+ ];
55
+
46
56
  interface SessionMetadata {
47
57
  sessionId: string;
48
58
  bucket: number;
@@ -102,6 +112,75 @@ function maskEnvironment(): Record<string, string> {
102
112
  return masked;
103
113
  }
104
114
 
115
+ function isSensitiveArgFlag(arg: string): boolean {
116
+ return SENSITIVE_ARG_PATTERNS.some((pattern) => pattern.test(arg));
117
+ }
118
+
119
+ function sanitizeArgsForLogging(args: string[]): string[] {
120
+ const sanitized: string[] = [];
121
+ let maskNextValue = false;
122
+
123
+ for (let i = 0; i < args.length; i += 1) {
124
+ const arg = args[i]!;
125
+
126
+ if (maskNextValue) {
127
+ if (!arg.startsWith('-')) {
128
+ sanitized.push(MASKED_ARG_VALUE);
129
+ maskNextValue = false;
130
+ continue;
131
+ }
132
+
133
+ sanitized.push(MASKED_ARG_VALUE);
134
+ maskNextValue = false;
135
+ i -= 1;
136
+ continue;
137
+ }
138
+
139
+ if (!arg.startsWith('-')) {
140
+ sanitized.push(arg);
141
+ continue;
142
+ }
143
+
144
+ const equalsIndex = arg.indexOf('=');
145
+ if (equalsIndex > 0) {
146
+ const flag = arg.slice(0, equalsIndex);
147
+ if (isSensitiveArgFlag(flag)) {
148
+ sanitized.push(`${flag}=${MASKED_ARG_VALUE}`);
149
+ continue;
150
+ }
151
+ }
152
+
153
+ sanitized.push(arg);
154
+ if (isSensitiveArgFlag(arg)) {
155
+ maskNextValue = true;
156
+ }
157
+ }
158
+
159
+ return sanitized;
160
+ }
161
+
162
+ export function sanitizeCliCommandForLogging(
163
+ command: string,
164
+ args: string[]
165
+ ): { command: string; args: string[] } {
166
+ const commandTokens = command ? command.split(' ') : [];
167
+
168
+ if (
169
+ commandTokens.length >= 5 &&
170
+ commandTokens[0] === 'coder' &&
171
+ commandTokens[1] === 'config' &&
172
+ commandTokens[2] === 'set' &&
173
+ commandTokens[3] === 'apikey'
174
+ ) {
175
+ commandTokens[4] = MASKED_ARG_VALUE;
176
+ }
177
+
178
+ return {
179
+ command: commandTokens.join(' '),
180
+ args: sanitizeArgsForLogging(args),
181
+ };
182
+ }
183
+
105
184
  /**
106
185
  * Get the logs directory path
107
186
  */
@@ -266,14 +345,16 @@ export class InternalLogger implements Logger {
266
345
  // Use workingDir as cwd in session metadata
267
346
  const cwd = workingDir;
268
347
 
348
+ const sanitizedInvocation = sanitizeCliCommandForLogging(command, args);
349
+
269
350
  // Gather session metadata
270
351
  const sessionMetadata: SessionMetadata = {
271
352
  sessionId: this.sessionId,
272
353
  bucket: this.bucket,
273
354
  pid: process.pid,
274
355
  ppid: process.ppid,
275
- command,
276
- args,
356
+ command: sanitizedInvocation.command,
357
+ args: sanitizedInvocation.args,
277
358
  timestamp: new Date().toISOString(),
278
359
  cli: {
279
360
  version: this.cliVersion,
package/src/keychain.ts CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  const SERVICE_PREFIX = 'com.agentuity.cli';
9
9
  const KEY_ACCOUNT = 'aes-encryption-key';
10
+ const AUTH_ACCOUNT = 'auth-token';
11
+ const CODER_API_KEY_ACCOUNT = 'coder-hub-api-key';
10
12
 
11
13
  /**
12
14
  * Check if we're running on macOS
@@ -88,26 +90,15 @@ async function decrypt(combined: Uint8Array, keyBytes: Uint8Array): Promise<stri
88
90
  return new TextDecoder().decode(plaintext);
89
91
  }
90
92
 
91
- /**
92
- * Store auth data in macOS Keychain
93
- */
94
- export async function saveAuthToKeychain(
95
- profileName: string,
96
- authData: { api_key: string; user_id: string; expires: number }
93
+ async function saveEncryptedValueToKeychain(
94
+ service: string,
95
+ account: string,
96
+ value: string
97
97
  ): Promise<void> {
98
- const service = `${SERVICE_PREFIX}.${profileName}`;
99
- const account = 'auth-token';
100
-
101
- // Get or create encryption key
102
98
  const key = await ensureEncryptionKey(service);
103
-
104
- // Encrypt the auth data
105
- const json = JSON.stringify(authData);
106
- const encrypted = await encrypt(json, key);
99
+ const encrypted = await encrypt(value, key);
107
100
  const b64 = Buffer.from(encrypted).toString('base64');
108
101
 
109
- // Store encrypted auth in keychain
110
- // First try to delete if exists, then add
111
102
  const del = Bun.spawn(['security', 'delete-generic-password', '-s', service, '-a', account], {
112
103
  stderr: 'ignore',
113
104
  });
@@ -127,6 +118,43 @@ export async function saveAuthToKeychain(
127
118
  await add.exited;
128
119
  }
129
120
 
121
+ async function getEncryptedValueFromKeychain(
122
+ service: string,
123
+ account: string
124
+ ): Promise<string | null> {
125
+ const find = Bun.spawn(
126
+ ['security', 'find-generic-password', '-s', service, '-a', account, '-w'],
127
+ { stderr: 'ignore' }
128
+ );
129
+
130
+ const stdout = await new Response(find.stdout).text();
131
+ if (stdout.length === 0) {
132
+ return null;
133
+ }
134
+
135
+ const encrypted = Uint8Array.from(Buffer.from(stdout.trim(), 'base64'));
136
+ const key = await ensureEncryptionKey(service);
137
+ return decrypt(encrypted, key);
138
+ }
139
+
140
+ async function deleteValueFromKeychain(service: string, account: string): Promise<void> {
141
+ const del = Bun.spawn(['security', 'delete-generic-password', '-s', service, '-a', account], {
142
+ stderr: 'ignore',
143
+ });
144
+ await del.exited;
145
+ }
146
+
147
+ /**
148
+ * Store auth data in macOS Keychain
149
+ */
150
+ export async function saveAuthToKeychain(
151
+ profileName: string,
152
+ authData: { api_key: string; user_id: string; expires: number }
153
+ ): Promise<void> {
154
+ const service = `${SERVICE_PREFIX}.${profileName}`;
155
+ await saveEncryptedValueToKeychain(service, AUTH_ACCOUNT, JSON.stringify(authData));
156
+ }
157
+
130
158
  /**
131
159
  * Retrieve auth data from macOS Keychain
132
160
  */
@@ -134,28 +162,12 @@ export async function getAuthFromKeychain(
134
162
  profileName: string
135
163
  ): Promise<{ api_key: string; user_id: string; expires: number } | null> {
136
164
  const service = `${SERVICE_PREFIX}.${profileName}`;
137
- const account = 'auth-token';
138
165
 
139
166
  try {
140
- // Get the encrypted auth data
141
- const find = Bun.spawn(
142
- ['security', 'find-generic-password', '-s', service, '-a', account, '-w'],
143
- { stderr: 'ignore' }
144
- );
145
-
146
- const stdout = await new Response(find.stdout).text();
147
- if (stdout.length === 0) {
167
+ const json = await getEncryptedValueFromKeychain(service, AUTH_ACCOUNT);
168
+ if (!json) {
148
169
  return null;
149
170
  }
150
-
151
- const b64 = stdout.trim();
152
- const encrypted = Uint8Array.from(Buffer.from(b64, 'base64'));
153
-
154
- // Get the encryption key
155
- const key = await ensureEncryptionKey(service);
156
-
157
- // Decrypt the auth data
158
- const json = await decrypt(encrypted, key);
159
171
  return JSON.parse(json);
160
172
  } catch {
161
173
  return null;
@@ -167,10 +179,27 @@ export async function getAuthFromKeychain(
167
179
  */
168
180
  export async function deleteAuthFromKeychain(profileName: string): Promise<void> {
169
181
  const service = `${SERVICE_PREFIX}.${profileName}`;
170
- const account = 'auth-token';
182
+ await deleteValueFromKeychain(service, AUTH_ACCOUNT);
183
+ }
171
184
 
172
- const del = Bun.spawn(['security', 'delete-generic-password', '-s', service, '-a', account], {
173
- stderr: 'ignore',
174
- });
175
- await del.exited;
185
+ export async function saveCoderApiKeyToKeychain(
186
+ profileName: string,
187
+ apiKey: string
188
+ ): Promise<void> {
189
+ const service = `${SERVICE_PREFIX}.${profileName}`;
190
+ await saveEncryptedValueToKeychain(service, CODER_API_KEY_ACCOUNT, apiKey);
191
+ }
192
+
193
+ export async function getCoderApiKeyFromKeychain(profileName: string): Promise<string | null> {
194
+ const service = `${SERVICE_PREFIX}.${profileName}`;
195
+ try {
196
+ return await getEncryptedValueFromKeychain(service, CODER_API_KEY_ACCOUNT);
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ export async function deleteCoderApiKeyFromKeychain(profileName: string): Promise<void> {
203
+ const service = `${SERVICE_PREFIX}.${profileName}`;
204
+ await deleteValueFromKeychain(service, CODER_API_KEY_ACCOUNT);
176
205
  }