@guildai/cli 0.6.2 → 0.7.1

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 (55) hide show
  1. package/README.md +3 -1
  2. package/dist/commands/agent/chat.js +41 -99
  3. package/dist/commands/agent/clone.js +1 -1
  4. package/dist/commands/agent/code.js +1 -1
  5. package/dist/commands/agent/fork.js +2 -2
  6. package/dist/commands/agent/get.js +1 -1
  7. package/dist/commands/agent/grep.js +2 -2
  8. package/dist/commands/agent/init.js +10 -3
  9. package/dist/commands/agent/list.js +9 -1
  10. package/dist/commands/agent/publish.js +1 -1
  11. package/dist/commands/agent/revalidate.js +1 -1
  12. package/dist/commands/agent/save.js +23 -2
  13. package/dist/commands/agent/test.js +70 -130
  14. package/dist/commands/agent/unpublish.js +1 -1
  15. package/dist/commands/agent/update.js +1 -1
  16. package/dist/commands/agent/versions.js +1 -1
  17. package/dist/commands/agent/workspaces.js +1 -1
  18. package/dist/commands/chat.d.ts +2 -1
  19. package/dist/commands/chat.js +189 -88
  20. package/dist/commands/config/list.js +2 -2
  21. package/dist/commands/integration/operation/create.js +2 -2
  22. package/dist/commands/integration/operation/list.js +2 -2
  23. package/dist/commands/integration/update.js +1 -1
  24. package/dist/commands/integration/version/get.js +2 -2
  25. package/dist/commands/integration/version/publish.js +2 -2
  26. package/dist/commands/integration/version/test.js +2 -2
  27. package/dist/commands/session/events.js +7 -3
  28. package/dist/commands/session/interrupt.d.ts +3 -0
  29. package/dist/commands/session/interrupt.js +33 -0
  30. package/dist/commands/setup.js +70 -11
  31. package/dist/commands/workspace/get.js +1 -1
  32. package/dist/commands/workspace/list.js +28 -6
  33. package/dist/commands/workspace/select.js +40 -9
  34. package/dist/components/TaskView.js +2 -2
  35. package/dist/index.js +2 -0
  36. package/dist/lib/agent-helpers.d.ts +59 -2
  37. package/dist/lib/agent-helpers.js +153 -8
  38. package/dist/lib/alternate-screen.js +2 -0
  39. package/dist/lib/api-client.js +2 -1
  40. package/dist/lib/config.d.ts +3 -0
  41. package/dist/lib/config.js +33 -0
  42. package/dist/lib/event-filter.d.ts +50 -0
  43. package/dist/lib/event-filter.js +91 -0
  44. package/dist/lib/generated-types.d.ts +2 -0
  45. package/dist/lib/generated-types.js +20 -0
  46. package/dist/lib/session-events.d.ts +27 -2
  47. package/dist/lib/session-events.js +5 -3
  48. package/dist/lib/session-polling.d.ts +8 -0
  49. package/dist/lib/session-polling.js +49 -0
  50. package/dist/lib/spinners.js +4 -1
  51. package/docs/CLI_WORKFLOW.md +7 -1
  52. package/docs/DESIGN.md +1 -1
  53. package/docs/skills/codex-agent-dev.md +155 -0
  54. package/docs/skills/integrations.md +338 -0
  55. package/package.json +1 -1
@@ -11,7 +11,7 @@ const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
12
  const packageRoot = path.resolve(__dirname, '..', '..');
13
13
  const docsDir = path.join(packageRoot, 'docs');
14
- const SKILL_FILES = [
14
+ const CLAUDE_SKILL_FILES = [
15
15
  {
16
16
  src: path.join(docsDir, 'skills', 'agent-dev.md'),
17
17
  dest: path.join('.claude', 'skills', 'agent-dev', 'skill.md'),
@@ -22,6 +22,18 @@ const SKILL_FILES = [
22
22
  dest: path.join('.claude', 'skills', 'guild-cli-workflow', 'skill.md'),
23
23
  label: '.claude/skills/guild-cli-workflow/skill.md',
24
24
  },
25
+ {
26
+ src: path.join(docsDir, 'skills', 'integrations.md'),
27
+ dest: path.join('.claude', 'skills', 'integrations', 'skill.md'),
28
+ label: '.claude/skills/integrations/skill.md',
29
+ },
30
+ ];
31
+ const CODEX_SKILL_FILES = [
32
+ {
33
+ src: path.join(docsDir, 'skills', 'codex-agent-dev.md'),
34
+ dest: path.join('.agents', 'skills', 'guild-agent-dev', 'SKILL.md'),
35
+ label: '.agents/skills/guild-agent-dev/SKILL.md',
36
+ },
25
37
  ];
26
38
  const CLAUDE_MD_TEMPLATE = `# CLAUDE.md
27
39
 
@@ -38,6 +50,22 @@ guild agent save --message "Initial version" --wait --publish
38
50
 
39
51
  See \`.claude/skills/agent-dev/skill.md\` for SDK reference, patterns, and anti-hallucination guide.
40
52
  `;
53
+ const AGENTS_MD_TEMPLATE = `# AGENTS.md
54
+
55
+ ## Guild Agent Development
56
+
57
+ This project uses Guild for agent development. Codex instructions are installed in \`.agents/skills/guild-agent-dev/SKILL.md\`.
58
+
59
+ ### Quick Start
60
+
61
+ \`\`\`bash
62
+ guild agent init --name my-agent --template LLM
63
+ guild agent test --ephemeral
64
+ guild agent save -A --message "Initial version"
65
+ \`\`\`
66
+
67
+ Use \`guild doctor\` to check authentication, server connectivity, workspace selection, and git setup.
68
+ `;
41
69
  async function fileExists(filePath) {
42
70
  try {
43
71
  await fs.access(filePath);
@@ -82,21 +110,29 @@ async function setupMcp(options) {
82
110
  }
83
111
  async function setup(options) {
84
112
  const output = createOutputWriter();
85
- // Verify source docs exist
86
- const docsExist = await fileExists(path.join(docsDir, 'skills', 'agent-dev.md'));
87
- if (!docsExist) {
88
- output.error('Could not find Guild CLI docs. Reinstall the CLI: npm install -g @guildai/cli');
113
+ if (options.agentsMd && !options.codex) {
114
+ output.error('--agents-md requires --codex', 'Create Codex setup files with:\n guild setup --codex --agents-md');
115
+ process.exit(1);
116
+ }
117
+ if (options.claudeMd && options.codex) {
118
+ output.error('--claude-md cannot be used with --codex', 'Create Claude setup with:\n guild setup --claude-md\n\nCreate Codex setup with:\n guild setup --codex --agents-md');
89
119
  process.exit(1);
90
120
  }
121
+ const skillFiles = options.codex ? CODEX_SKILL_FILES : CLAUDE_SKILL_FILES;
122
+ // Verify source docs exist
123
+ for (const file of skillFiles) {
124
+ if (!(await fileExists(file.src))) {
125
+ output.error('Could not find Guild CLI docs. Reinstall the CLI: npm install -g @guildai/cli');
126
+ process.exit(1);
127
+ }
128
+ }
91
129
  output.progress('Setting up Guild CLI skills...');
92
130
  output.progress('');
93
- // Ensure .claude/skills/ directory exists
94
- const skillsDir = path.join(process.cwd(), '.claude', 'skills');
95
- await fs.mkdir(skillsDir, { recursive: true });
96
131
  let filesCreated = 0;
97
132
  let filesSkipped = 0;
133
+ let codexProjectFilesChanged = false;
98
134
  // Copy skill files
99
- for (const file of SKILL_FILES) {
135
+ for (const file of skillFiles) {
100
136
  const destPath = path.join(process.cwd(), file.dest);
101
137
  const exists = await fileExists(destPath);
102
138
  if (exists && !options.force) {
@@ -113,6 +149,9 @@ async function setup(options) {
113
149
  output.success(`Created ${file.label}`);
114
150
  }
115
151
  filesCreated++;
152
+ if (options.codex) {
153
+ codexProjectFilesChanged = true;
154
+ }
116
155
  }
117
156
  }
118
157
  // Handle --mcp flag
@@ -139,6 +178,21 @@ async function setup(options) {
139
178
  filesCreated++;
140
179
  }
141
180
  }
181
+ // Handle AGENTS.md template for Codex setup
182
+ if (options.agentsMd) {
183
+ const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
184
+ const exists = await fileExists(agentsMdPath);
185
+ if (exists) {
186
+ output.progress('AGENTS.md already exists (not overwriting)');
187
+ filesSkipped++;
188
+ }
189
+ else {
190
+ await fs.writeFile(agentsMdPath, AGENTS_MD_TEMPLATE, 'utf-8');
191
+ output.success('Created AGENTS.md');
192
+ filesCreated++;
193
+ codexProjectFilesChanged = true;
194
+ }
195
+ }
142
196
  // Summary
143
197
  output.progress('');
144
198
  if (filesCreated > 0 && filesSkipped === 0) {
@@ -155,13 +209,18 @@ async function setup(options) {
155
209
  else {
156
210
  output.success('Guild CLI skills installed.');
157
211
  }
212
+ if (codexProjectFilesChanged) {
213
+ output.progress('Restart Codex to pick up the new project instructions.');
214
+ }
158
215
  }
159
216
  export function createSetupCommand() {
160
217
  const cmd = new Command('setup');
161
218
  cmd
162
- .description('Set up Guild CLI skills for coding assistants (Claude Code, etc.)')
163
- .option('--force', 'Overwrite existing skill files', false)
219
+ .description('Set up Guild CLI skills for coding assistants (Claude Code, Codex, etc.)')
220
+ .option('--force', 'Overwrite existing skill files and Guild MCP config', false)
221
+ .option('--codex', 'Install Codex skill files instead of Claude Code skills', false)
164
222
  .option('--claude-md', 'Also create a CLAUDE.md template in the project root', false)
223
+ .option('--agents-md', 'With --codex, also create an AGENTS.md template in the project root', false)
165
224
  .option('--no-mcp', 'Skip MCP server configuration')
166
225
  .action(async (options) => {
167
226
  await setup(options);
@@ -8,7 +8,7 @@ export function createWorkspaceGetCommand() {
8
8
  const cmd = new Command('get');
9
9
  cmd
10
10
  .description('Get workspace details')
11
- .argument('<identifier>', 'Workspace ID or full name (e.g., owner/workspace-name)')
11
+ .argument('<identifier>', 'Workspace ID or full name (e.g., owner~workspace-name)')
12
12
  .action(async (id) => {
13
13
  const output = createOutputWriter();
14
14
  try {
@@ -11,16 +11,38 @@ export function createWorkspaceListCommand() {
11
11
  .description('List workspaces')
12
12
  .option('--limit <number>', 'Number of results to return', '20')
13
13
  .option('--offset <number>', 'Offset for pagination', '0')
14
+ .option('--owner <name>', 'Filter workspaces by owner name (case-insensitive)')
14
15
  .action(async (options) => {
15
16
  const output = createOutputWriter();
16
17
  try {
17
18
  const client = new GuildAPIClient();
18
- const params = new URLSearchParams();
19
- // Use filter=all to get workspaces from all orgs user is a member of
20
- params.append('filter', 'all');
21
- params.append('limit', options.limit);
22
- params.append('offset', options.offset);
23
- const response = await client.get(`/me/workspaces?${params.toString()}`);
19
+ let response;
20
+ if (options.owner) {
21
+ // When filtering by owner, fetch ALL workspaces first so the
22
+ // client-side filter is applied to the complete dataset, not just
23
+ // one page. The backend's `filter` param only recognises "all" vs
24
+ // personal; owner-name filtering is not supported server-side.
25
+ const allWorkspaces = await client.fetchAll('/me/workspaces?filter=all');
26
+ const ownerLower = options.owner.toLowerCase();
27
+ const filtered = allWorkspaces.filter((w) => w.owner?.name?.toLowerCase() === ownerLower);
28
+ response = {
29
+ items: filtered,
30
+ pagination: {
31
+ total_count: filtered.length,
32
+ limit: filtered.length,
33
+ offset: 0,
34
+ has_more: false,
35
+ },
36
+ };
37
+ }
38
+ else {
39
+ const params = new URLSearchParams();
40
+ // Use filter=all to get workspaces from all orgs the user is a member of.
41
+ params.append('filter', 'all');
42
+ params.append('limit', options.limit);
43
+ params.append('offset', options.offset);
44
+ response = await client.get(`/me/workspaces?${params.toString()}`);
45
+ }
24
46
  if (getOutputMode() === 'json') {
25
47
  console.log(JSON.stringify(response, null, 2));
26
48
  }
@@ -35,8 +35,10 @@ export function createWorkspaceSelectCommand() {
35
35
  cmd
36
36
  .description('Select default workspace for agent testing')
37
37
  .argument('[workspace]', 'Workspace name or ID to select directly')
38
- .action(async (workspaceArg) => {
38
+ .option('--owner <name>', 'Filter workspaces by owner name (case-insensitive)')
39
+ .action(async (workspaceArg, options = {}) => {
39
40
  const output = createOutputWriter();
41
+ const ownerFilter = options.owner;
40
42
  try {
41
43
  const client = new GuildAPIClient();
42
44
  // If a workspace argument was provided, use server-side search to find it
@@ -45,19 +47,34 @@ export function createWorkspaceSelectCommand() {
45
47
  // The backend searches only the "name" column via ILIKE, so full_name (owner/name)
46
48
  // and ID lookups may not return results. Extract just the name part for search,
47
49
  // and if still no match, fall back to fetching the full list.
50
+ // Always use filter=all — the backend's `filter` param only recognises "all"
51
+ // vs personal; owner-name filtering is applied client-side below.
48
52
  const searchTerm = workspaceArg.includes('/')
49
- ? workspaceArg.split('/').pop()
53
+ ? (workspaceArg.split('/').pop() ?? workspaceArg)
50
54
  : workspaceArg;
51
55
  const searchResults = await client.get(`/me/workspaces?filter=all&search=${encodeURIComponent(searchTerm)}&limit=100`);
52
- let workspace = searchResults.items.find((w) => matchesWorkspaceArg(w, workspaceArg));
56
+ let candidates = searchResults.items;
57
+ // Apply client-side owner filter on search results
58
+ if (ownerFilter) {
59
+ candidates = candidates.filter((w) => w.owner?.name?.toLowerCase() === ownerFilter.toLowerCase());
60
+ }
53
61
  // Search didn't find it (could be an ID lookup, or search term didn't match).
54
62
  // Fall back to fetching all workspaces via pagination.
55
- if (!workspace) {
63
+ const directMatch = candidates.find((w) => matchesWorkspaceArg(w, workspaceArg));
64
+ if (!directMatch) {
56
65
  const allWorkspaces = await client.fetchAll('/me/workspaces?filter=all');
57
- workspace = allWorkspaces.find((w) => matchesWorkspaceArg(w, workspaceArg));
66
+ candidates = ownerFilter
67
+ ? allWorkspaces.filter((w) => w.owner?.name?.toLowerCase() === ownerFilter.toLowerCase())
68
+ : allWorkspaces;
58
69
  }
70
+ const workspace = candidates.find((w) => matchesWorkspaceArg(w, workspaceArg));
59
71
  if (!workspace) {
60
- output.error(`Workspace "${workspaceArg}" not found.`, 'Run: guild workspace list');
72
+ if (ownerFilter) {
73
+ output.error(`Workspace "${workspaceArg}" not found for owner "${ownerFilter}".`, 'Run: guild workspace list');
74
+ }
75
+ else {
76
+ output.error(`Workspace "${workspaceArg}" not found.`, 'Run: guild workspace list');
77
+ }
61
78
  process.exit(1);
62
79
  }
63
80
  const target = await saveWorkspaceConfig(workspace.id, workspace.name);
@@ -73,10 +90,20 @@ export function createWorkspaceSelectCommand() {
73
90
  output.error('Interactive mode requires a terminal.', 'Provide a workspace argument:\n guild workspace select <name|id>');
74
91
  process.exit(1);
75
92
  }
76
- // Interactive mode: fetch all workspaces across all pages
77
- const workspaces = await client.fetchAll('/me/workspaces?filter=all');
93
+ // Interactive mode: fetch all workspaces across all pages.
94
+ // Always use filter=all; owner-name filtering is applied client-side
95
+ // (the backend's `filter` param only recognises "all" vs personal).
96
+ let workspaces = await client.fetchAll('/me/workspaces?filter=all');
97
+ if (ownerFilter) {
98
+ workspaces = workspaces.filter((w) => w.owner?.name?.toLowerCase() === ownerFilter.toLowerCase());
99
+ }
78
100
  if (workspaces.length === 0) {
79
- output.error('No workspaces found.', 'Create a workspace first:\n guild workspace create <name>');
101
+ if (ownerFilter) {
102
+ output.error(`No workspaces found for owner "${ownerFilter}".`, 'Run: guild workspace list');
103
+ }
104
+ else {
105
+ output.error('No workspaces found.', 'Create a workspace first:\n guild workspace create <name>');
106
+ }
80
107
  process.exit(1);
81
108
  }
82
109
  // Resolve the currently selected workspace (if any)
@@ -101,6 +128,10 @@ export function createWorkspaceSelectCommand() {
101
128
  },
102
129
  });
103
130
  const selectedWorkspace = workspaces.find((w) => w.id === selectedId);
131
+ if (!selectedWorkspace) {
132
+ output.error('Selected workspace not found');
133
+ process.exit(1);
134
+ }
104
135
  const target = await saveWorkspaceConfig(selectedWorkspace.id, selectedWorkspace.name);
105
136
  if (target === 'local') {
106
137
  output.success(`Workspace set for this agent: ${formatWorkspaceDisplay(selectedWorkspace)}`);
@@ -87,7 +87,7 @@ function calculateTaskDepths(tasks) {
87
87
  const taskMap = new Map(tasks.map((t) => [t.id, t]));
88
88
  function getDepth(taskId) {
89
89
  if (depthMap.has(taskId))
90
- return depthMap.get(taskId);
90
+ return depthMap.get(taskId) ?? 0;
91
91
  const task = taskMap.get(taskId);
92
92
  const parentId = task?.parent_task?.id;
93
93
  if (!parentId) {
@@ -167,7 +167,7 @@ export function TaskView({ tasks }) {
167
167
  if (!tasksByParent.has(parentId)) {
168
168
  tasksByParent.set(parentId, []);
169
169
  }
170
- tasksByParent.get(parentId).push(task);
170
+ tasksByParent.get(parentId)?.push(task);
171
171
  });
172
172
  // Process root tasks first, then children
173
173
  const processedIds = new Set();
package/dist/index.js CHANGED
@@ -59,6 +59,7 @@ import { createSessionEventsCommand } from './commands/session/events.js';
59
59
  import { createSessionTasksCommand } from './commands/session/tasks.js';
60
60
  import { createSessionCreateCommand } from './commands/session/create.js';
61
61
  import { createSessionSendCommand } from './commands/session/send.js';
62
+ import { createSessionInterruptCommand } from './commands/session/interrupt.js';
62
63
  import { createJobGetCommand } from './commands/job/get.js';
63
64
  import { createJobStepGetCommand } from './commands/job/step-get.js';
64
65
  import { createConfigListCommand } from './commands/config/list.js';
@@ -276,6 +277,7 @@ sessionCmd.addCommand(createSessionEventsCommand());
276
277
  sessionCmd.addCommand(createSessionTasksCommand());
277
278
  sessionCmd.addCommand(createSessionCreateCommand());
278
279
  sessionCmd.addCommand(createSessionSendCommand());
280
+ sessionCmd.addCommand(createSessionInterruptCommand());
279
281
  // Job command group
280
282
  const jobCmd = program.command('job').description('Job management');
281
283
  jobCmd.addCommand(createJobGetCommand());
@@ -1,10 +1,26 @@
1
1
  import { LocalConfig } from './guild-config.js';
2
2
  import { GuildAPIClient } from './api-client.js';
3
+ import type { AgentVersion } from './api-types.js';
4
+ /** Thrown when the ephemeral version build times out or polling fails. */
5
+ export declare class BuildTimeoutError extends Error {
6
+ constructor(message?: string);
7
+ }
8
+ /** Thrown when the ephemeral version build fails validation. */
9
+ export declare class BuildFailedError extends Error {
10
+ readonly failedSteps: {
11
+ name: string;
12
+ content?: string;
13
+ }[];
14
+ constructor(failedSteps: {
15
+ name: string;
16
+ content?: string;
17
+ }[]);
18
+ }
3
19
  /**
4
20
  * Resolve an agent identifier to a fully-qualified form.
5
21
  *
6
- * If the identifier already contains `/` or is a UUID, return it verbatim.
7
- * Otherwise, resolve the default owner and prepend `owner.name/`.
22
+ * If the identifier already contains `~` or is a UUID, return it verbatim.
23
+ * Otherwise, resolve the default owner and prepend `owner.name~`.
8
24
  */
9
25
  export declare function resolveAgentRef(client: GuildAPIClient, identifier: string): Promise<string>;
10
26
  /**
@@ -27,4 +43,45 @@ export declare function readAgentFiles(cwd: string): Promise<{
27
43
  path: string;
28
44
  content: string;
29
45
  }[]>;
46
+ /**
47
+ * Compute a deterministic hash of agent files.
48
+ * Sorts by path to ensure consistent ordering.
49
+ */
50
+ export declare function hashAgentFiles(files: {
51
+ path: string;
52
+ content: string;
53
+ }[]): string;
54
+ /**
55
+ * Get or create an ephemeral version, skipping the build if files haven't
56
+ * changed since the last successful ephemeral build.
57
+ *
58
+ * Returns the version (cached or newly created), a cache hit flag, and the
59
+ * computed hash (used by buildEphemeralVersion to write the cache on success).
60
+ */
61
+ export declare function getOrCreateEphemeral(client: GuildAPIClient, agentId: string, files: {
62
+ path: string;
63
+ content: string;
64
+ }[], cwd: string, summary: string, options?: {
65
+ noCache?: boolean;
66
+ }): Promise<{
67
+ version: AgentVersion;
68
+ cached: boolean;
69
+ hash: string;
70
+ }>;
71
+ /**
72
+ * Build an ephemeral version end-to-end: get-or-create, poll for validation,
73
+ * cache on success, and report build failures.
74
+ *
75
+ * Consolidates the build/poll/cache pattern used by both `guild agent test`
76
+ * and `guild agent chat`.
77
+ */
78
+ export declare function buildEphemeralVersion(client: GuildAPIClient, agentId: string, files: {
79
+ path: string;
80
+ content: string;
81
+ }[], cwd: string, summary: string, options?: {
82
+ noCache?: boolean;
83
+ }): Promise<{
84
+ version: AgentVersion;
85
+ cached: boolean;
86
+ }>;
30
87
  //# sourceMappingURL=agent-helpers.d.ts.map
@@ -1,23 +1,52 @@
1
1
  // Copyright 2026 Guild.ai
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ import { createHash } from 'crypto';
3
4
  import * as fs from 'fs/promises';
4
5
  import * as path from 'path';
5
6
  import { loadLocalConfig } from './guild-config.js';
6
7
  import { runGit } from './git.js';
7
8
  import { resolveOwnerId } from './owner-helpers.js';
9
+ import { pollUntilComplete } from './polling.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Build error types
12
+ // ---------------------------------------------------------------------------
13
+ /** Thrown when the ephemeral version build times out or polling fails. */
14
+ export class BuildTimeoutError extends Error {
15
+ constructor(message = 'The agent version build timed out or failed to report status.') {
16
+ super(message);
17
+ this.name = 'BuildTimeoutError';
18
+ }
19
+ }
20
+ /** Thrown when the ephemeral version build fails validation. */
21
+ export class BuildFailedError extends Error {
22
+ failedSteps;
23
+ constructor(failedSteps) {
24
+ const stepSummary = failedSteps.length > 0
25
+ ? failedSteps
26
+ .map((s) => `Step "${s.name}" failed:${s.content ? `\n${s.content}` : ''}`)
27
+ .join('\n')
28
+ : 'No failed steps found. Check validation logs for details.';
29
+ super(`Build failed\n\n${stepSummary}\n\nFix the issues and retry.`);
30
+ this.name = 'BuildFailedError';
31
+ this.failedSteps = failedSteps;
32
+ }
33
+ }
8
34
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
35
  /**
10
36
  * Resolve an agent identifier to a fully-qualified form.
11
37
  *
12
- * If the identifier already contains `/` or is a UUID, return it verbatim.
13
- * Otherwise, resolve the default owner and prepend `owner.name/`.
38
+ * If the identifier already contains `~` or is a UUID, return it verbatim.
39
+ * Otherwise, resolve the default owner and prepend `owner.name~`.
14
40
  */
15
41
  export async function resolveAgentRef(client, identifier) {
16
- if (identifier.includes('/') || UUID_RE.test(identifier)) {
42
+ if (identifier.includes('~') || UUID_RE.test(identifier)) {
17
43
  return identifier;
18
44
  }
45
+ if (identifier.includes('/')) {
46
+ return identifier.replace('/', '~');
47
+ }
19
48
  const owner = await resolveOwnerId({ client, interactive: false });
20
- return `${owner.name}/${identifier}`;
49
+ return `${owner.name}~${identifier}`;
21
50
  }
22
51
  /**
23
52
  * Get agent ID from argument or guild.json in current directory
@@ -72,10 +101,7 @@ const REQUIRED_AGENT_FILES = ['agent.ts', 'package.json'];
72
101
  export async function readAgentFiles(cwd) {
73
102
  const { stdout } = await runGit(['ls-files', '--cached', '--others', '--exclude-standard'], { cwd });
74
103
  const gitFiles = stdout.split('\n').filter((f) => f.trim().length > 0);
75
- const relevantFiles = gitFiles.filter((f) => {
76
- const ext = path.extname(f);
77
- return ['.ts', '.json', '.md'].includes(ext);
78
- });
104
+ const relevantFiles = gitFiles.filter((f) => !f.startsWith('.guild/'));
79
105
  const files = [];
80
106
  for (const filePath of relevantFiles) {
81
107
  const fullPath = path.join(cwd, filePath);
@@ -89,4 +115,123 @@ export async function readAgentFiles(cwd) {
89
115
  }
90
116
  return files;
91
117
  }
118
+ const CACHE_DIR = path.join('.guild', 'cache');
119
+ const CACHE_FILE = 'last-ephemeral.json';
120
+ /**
121
+ * Compute a deterministic hash of agent files.
122
+ * Sorts by path to ensure consistent ordering.
123
+ */
124
+ export function hashAgentFiles(files) {
125
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
126
+ const hash = createHash('sha256');
127
+ for (const file of sorted) {
128
+ hash.update(file.path);
129
+ hash.update('\0');
130
+ hash.update(file.content);
131
+ hash.update('\0');
132
+ }
133
+ return hash.digest('hex');
134
+ }
135
+ async function readEphemeralCache(cwd) {
136
+ try {
137
+ const cachePath = path.join(cwd, CACHE_DIR, CACHE_FILE);
138
+ const raw = await fs.readFile(cachePath, 'utf-8');
139
+ const parsed = JSON.parse(raw);
140
+ if (typeof parsed.hash === 'string' && typeof parsed.version_id === 'string') {
141
+ return { hash: parsed.hash, version_id: parsed.version_id };
142
+ }
143
+ return null;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ async function writeEphemeralCache(cwd, cache) {
150
+ const dir = path.join(cwd, CACHE_DIR);
151
+ await fs.mkdir(dir, { recursive: true });
152
+ await fs.writeFile(path.join(dir, CACHE_FILE), JSON.stringify(cache, null, 2) + '\n');
153
+ }
154
+ /**
155
+ * Get or create an ephemeral version, skipping the build if files haven't
156
+ * changed since the last successful ephemeral build.
157
+ *
158
+ * Returns the version (cached or newly created), a cache hit flag, and the
159
+ * computed hash (used by buildEphemeralVersion to write the cache on success).
160
+ */
161
+ export async function getOrCreateEphemeral(client, agentId, files, cwd, summary, options) {
162
+ const hash = hashAgentFiles(files);
163
+ if (options?.noCache) {
164
+ const version = (await client.post(`/agents/${agentId}/versions`, {
165
+ files,
166
+ summary,
167
+ version_type: 'EPHEMERAL',
168
+ }));
169
+ return { version, cached: false, hash };
170
+ }
171
+ const cache = await readEphemeralCache(cwd);
172
+ if (cache && cache.hash === hash) {
173
+ // Verify the cached version still exists on the server
174
+ try {
175
+ const version = (await client.get(`/versions/${cache.version_id}`));
176
+ if (version.validation_status === 'PASSED') {
177
+ return { version, cached: true, hash };
178
+ }
179
+ }
180
+ catch {
181
+ // Version gone or inaccessible — fall through to create new one
182
+ }
183
+ }
184
+ const version = (await client.post(`/agents/${agentId}/versions`, {
185
+ files,
186
+ summary,
187
+ version_type: 'EPHEMERAL',
188
+ }));
189
+ return { version, cached: false, hash };
190
+ }
191
+ /**
192
+ * Build an ephemeral version end-to-end: get-or-create, poll for validation,
193
+ * cache on success, and report build failures.
194
+ *
195
+ * Consolidates the build/poll/cache pattern used by both `guild agent test`
196
+ * and `guild agent chat`.
197
+ */
198
+ export async function buildEphemeralVersion(client, agentId, files, cwd, summary, options) {
199
+ const { version: initial, cached, hash, } = await getOrCreateEphemeral(client, agentId, files, cwd, summary, options);
200
+ if (cached) {
201
+ return { version: initial, cached: true };
202
+ }
203
+ // Poll for validation to complete
204
+ const pollResult = await pollUntilComplete({
205
+ resourceId: initial.id,
206
+ endpoint: `/versions/${initial.id}`,
207
+ isComplete: (r) => r.validation_status !== 'PENDING' && r.validation_status !== 'RUNNING',
208
+ message: 'Building...',
209
+ successMessage: 'Build finished',
210
+ timeoutMessage: 'Build timed out',
211
+ maxAttempts: 120,
212
+ delayMs: 1000,
213
+ });
214
+ if (!pollResult.success || !pollResult.response) {
215
+ throw new BuildTimeoutError();
216
+ }
217
+ const version = pollResult.response;
218
+ // Cache the successful build so we can skip it next time
219
+ if (version.validation_status === 'PASSED') {
220
+ await writeEphemeralCache(cwd, { hash, version_id: version.id });
221
+ }
222
+ if (version.validation_status === 'FAILED') {
223
+ let failedSteps = [];
224
+ try {
225
+ const stepsResponse = await client.get(`/versions/${version.id}/validation/steps`);
226
+ failedSteps = stepsResponse.steps
227
+ .filter((step) => step.status === 'FAILED')
228
+ .map((step) => ({ name: step.name, content: step.content ?? undefined }));
229
+ }
230
+ catch {
231
+ // Could not fetch validation details — throw with empty steps
232
+ }
233
+ throw new BuildFailedError(failedSteps);
234
+ }
235
+ return { version, cached: false };
236
+ }
92
237
  //# sourceMappingURL=agent-helpers.js.map
@@ -50,6 +50,8 @@ export function suppressScrollbackClear() {
50
50
  chunk = chunk.replace(CLEAR_TERMINAL, CLEAR_SUBSEQUENT);
51
51
  }
52
52
  }
53
+ if (!originalWrite)
54
+ return false;
53
55
  return originalWrite.apply(this, [chunk, ...args]);
54
56
  };
55
57
  process.stdout.write = patched;
@@ -3,7 +3,7 @@
3
3
  import axios from 'axios';
4
4
  import { getAuthToken, clearAuthToken } from './auth.js';
5
5
  import { retry, debug, GuildCLIError, ErrorCodes } from './errors.js';
6
- import { getGuildcoreUrl } from './config.js';
6
+ import { getUserAgent, getGuildcoreUrl } from './config.js';
7
7
  import { getIapHeaders } from './iap.js';
8
8
  /**
9
9
  * HTTP client for Guild API
@@ -24,6 +24,7 @@ export class GuildAPIClient {
24
24
  headers: {
25
25
  'Content-Type': 'application/json',
26
26
  Accept: 'application/json',
27
+ 'User-Agent': getUserAgent(),
27
28
  },
28
29
  });
29
30
  debug(`API Client initialized: ${this.baseUrl}, retry: ${this.enableRetry}, max retries: ${this.maxRetries}`);
@@ -1,3 +1,6 @@
1
+ export declare function getCliVersion(): string;
2
+ export declare function isDevBuild(): boolean;
3
+ export declare function getUserAgent(): string;
1
4
  /**
2
5
  * IAP (Identity-Aware Proxy) configuration for internal *.guildai.dev hosts.
3
6
  * Users must run `gcloud auth login` with an authorized Google account to access.
@@ -1,5 +1,38 @@
1
1
  // Copyright 2026 Guild.ai
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ import { readFileSync } from 'fs';
4
+ import path from 'path';
5
+ const FALLBACK_VERSION = 'unknown';
6
+ let cachedVersion;
7
+ export function getCliVersion() {
8
+ if (!cachedVersion) {
9
+ try {
10
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf-8'));
11
+ cachedVersion = pkg.version ?? FALLBACK_VERSION;
12
+ }
13
+ catch {
14
+ cachedVersion = FALLBACK_VERSION;
15
+ }
16
+ }
17
+ return cachedVersion;
18
+ }
19
+ export function isDevBuild() {
20
+ try {
21
+ return !__dirname.includes('node_modules');
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export function getUserAgent() {
28
+ try {
29
+ const base = `GuildCLI/${getCliVersion()}`;
30
+ return isDevBuild() ? `${base}/dev` : base;
31
+ }
32
+ catch {
33
+ return 'GuildCLI';
34
+ }
35
+ }
3
36
  /**
4
37
  * IAP configuration for shared.guildai.dev
5
38
  */