@teammates/cli 0.1.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.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Codex adapter — runs each teammate as a `codex exec` subprocess.
3
+ *
4
+ * Uses the OpenAI Codex CLI in non-interactive mode:
5
+ * codex exec "<prompt>" --full-auto -C <cwd> -s <sandbox> -m <model>
6
+ *
7
+ * Each execution is stateless (no thread continuity). The teammate's full
8
+ * identity, memory, and handoff context are injected into the prompt every time.
9
+ *
10
+ * Requirements:
11
+ * - `codex` CLI installed and on PATH
12
+ * - OPENAI_API_KEY or CODEX_API_KEY set in environment
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { writeFile, unlink } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { randomUUID } from "node:crypto";
19
+ import { buildTeammatePrompt } from "../adapter.js";
20
+ let nextId = 1;
21
+ export class CodexAdapter {
22
+ name = "codex";
23
+ options;
24
+ constructor(options = {}) {
25
+ this.options = {
26
+ model: options.model ?? "",
27
+ defaultSandbox: options.defaultSandbox ?? "workspace-write",
28
+ fullAuto: options.fullAuto ?? true,
29
+ ephemeral: options.ephemeral ?? true,
30
+ extraFlags: options.extraFlags ?? [],
31
+ timeout: options.timeout ?? 300_000,
32
+ codexPath: options.codexPath ?? "codex",
33
+ };
34
+ }
35
+ async startSession(teammate) {
36
+ // Codex exec is stateless — sessions are just logical IDs
37
+ return `codex-${teammate.name}-${nextId++}`;
38
+ }
39
+ async executeTask(sessionId, teammate, prompt) {
40
+ const fullPrompt = buildTeammatePrompt(teammate, prompt);
41
+ // Write prompt to a temp file to avoid shell escaping issues with long prompts
42
+ const promptFile = join(tmpdir(), `teammates-codex-${randomUUID()}.md`);
43
+ await writeFile(promptFile, fullPrompt, "utf-8");
44
+ try {
45
+ const output = await this.runCodex(teammate, promptFile);
46
+ return this.parseResult(teammate.name, output);
47
+ }
48
+ finally {
49
+ // Clean up temp file
50
+ await unlink(promptFile).catch(() => { });
51
+ }
52
+ }
53
+ /**
54
+ * Spawn `codex exec` and capture its output.
55
+ * Prompt is passed via a temp file read with shell substitution.
56
+ */
57
+ runCodex(teammate, promptFile) {
58
+ return new Promise((resolve, reject) => {
59
+ const args = this.buildArgs(teammate, promptFile);
60
+ const child = spawn(this.options.codexPath, args, {
61
+ cwd: teammate.cwd ?? process.cwd(),
62
+ env: { ...process.env },
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ timeout: this.options.timeout,
65
+ shell: true,
66
+ });
67
+ const stdout = [];
68
+ const stderr = [];
69
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
70
+ child.stderr.on("data", (chunk) => {
71
+ stderr.push(chunk);
72
+ // Stream stderr to parent stderr for real-time progress
73
+ process.stderr.write(chunk);
74
+ });
75
+ child.on("close", (code) => {
76
+ const out = Buffer.concat(stdout).toString("utf-8");
77
+ const err = Buffer.concat(stderr).toString("utf-8");
78
+ if (code === 0) {
79
+ resolve(out);
80
+ }
81
+ else {
82
+ reject(new Error(`codex exec exited with code ${code}\nstderr: ${err}\nstdout: ${out}`));
83
+ }
84
+ });
85
+ child.on("error", (err) => {
86
+ reject(new Error(`Failed to spawn codex: ${err.message}`));
87
+ });
88
+ });
89
+ }
90
+ /** Build the argument list for codex exec */
91
+ buildArgs(teammate, promptFile) {
92
+ const args = ["exec"];
93
+ // Read prompt from file — avoids shell escaping issues
94
+ // Use shell substitution: $(cat <file>)
95
+ args.push(`"$(cat '${promptFile}')"`);
96
+ // Working directory
97
+ if (teammate.cwd) {
98
+ args.push("-C", teammate.cwd);
99
+ }
100
+ // Sandbox
101
+ const sandbox = teammate.sandbox ?? this.options.defaultSandbox;
102
+ args.push("-s", sandbox);
103
+ // Full auto
104
+ if (this.options.fullAuto) {
105
+ args.push("--full-auto");
106
+ }
107
+ // Ephemeral
108
+ if (this.options.ephemeral) {
109
+ args.push("--ephemeral");
110
+ }
111
+ // Model
112
+ if (this.options.model) {
113
+ args.push("-m", this.options.model);
114
+ }
115
+ // Extra flags
116
+ args.push(...this.options.extraFlags);
117
+ return args;
118
+ }
119
+ /**
120
+ * Parse codex output into a TaskResult.
121
+ * Looks for changed files and handoff envelopes in the output.
122
+ */
123
+ parseResult(teammateName, output) {
124
+ const changedFiles = parseChangedFiles(output);
125
+ const handoff = parseHandoffEnvelope(output);
126
+ const summary = extractSummary(output);
127
+ return {
128
+ teammate: teammateName,
129
+ success: true,
130
+ summary,
131
+ changedFiles,
132
+ handoff: handoff ?? undefined,
133
+ rawOutput: output,
134
+ };
135
+ }
136
+ }
137
+ /**
138
+ * Extract file paths from codex output.
139
+ * Looks for common patterns like "Created file: ...", "Modified: ...",
140
+ * or git-style diff headers.
141
+ */
142
+ function parseChangedFiles(output) {
143
+ const files = new Set();
144
+ // Match diff headers: diff --git a/path b/path
145
+ for (const match of output.matchAll(/diff --git a\/(.+?) b\//g)) {
146
+ files.add(match[1]);
147
+ }
148
+ // Match "Created/Modified/Updated <path>" patterns
149
+ for (const match of output.matchAll(/(?:Created|Modified|Updated|Wrote|Edited)\s+(?:file:\s*)?[`"]?([^\s`"]+\.\w+)[`"]?/gi)) {
150
+ files.add(match[1]);
151
+ }
152
+ return Array.from(files);
153
+ }
154
+ /**
155
+ * Look for a JSON handoff envelope in the output.
156
+ * Teammates can request handoffs by including a fenced JSON block
157
+ * with a "handoff" key:
158
+ *
159
+ * ```json
160
+ * { "handoff": { "to": "tester", "task": "...", ... } }
161
+ * ```
162
+ */
163
+ function parseHandoffEnvelope(output) {
164
+ // Look for ```json blocks containing "handoff"
165
+ const jsonBlocks = output.matchAll(/```json\s*\n([\s\S]*?)```/g);
166
+ for (const match of jsonBlocks) {
167
+ const block = match[1].trim();
168
+ if (!block.includes('"handoff"') && !block.includes('"to"'))
169
+ continue;
170
+ try {
171
+ const parsed = JSON.parse(block);
172
+ const envelope = parsed.handoff ?? parsed;
173
+ if (envelope.to && envelope.task) {
174
+ return {
175
+ from: envelope.from ?? "",
176
+ to: envelope.to,
177
+ task: envelope.task,
178
+ changedFiles: envelope.changedFiles ?? envelope.changed_files,
179
+ acceptanceCriteria: envelope.acceptanceCriteria ?? envelope.acceptance_criteria,
180
+ openQuestions: envelope.openQuestions ?? envelope.open_questions,
181
+ context: envelope.context,
182
+ };
183
+ }
184
+ }
185
+ catch {
186
+ // Not valid JSON, skip
187
+ }
188
+ }
189
+ return null;
190
+ }
191
+ /**
192
+ * Extract the first meaningful paragraph as a summary.
193
+ * Falls back to the first 200 chars if no clear summary is found.
194
+ */
195
+ function extractSummary(output) {
196
+ // Look for a "## Summary" or "Summary:" section
197
+ const summaryMatch = output.match(/(?:##?\s*Summary|Summary:)\s*\n([\s\S]*?)(?:\n##|\n---|\n```|$)/i);
198
+ if (summaryMatch) {
199
+ const summary = summaryMatch[1].trim();
200
+ if (summary.length > 0)
201
+ return summary.slice(0, 500);
202
+ }
203
+ // Fall back to last non-empty paragraph (codex prints final message last)
204
+ const paragraphs = output
205
+ .split(/\n\s*\n/)
206
+ .map((p) => p.trim())
207
+ .filter((p) => p.length > 0 && !p.startsWith("```"));
208
+ if (paragraphs.length > 0) {
209
+ const last = paragraphs[paragraphs.length - 1];
210
+ return last.length > 500 ? last.slice(0, 497) + "..." : last;
211
+ }
212
+ return output.slice(0, 200).trim();
213
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Echo adapter — a no-op adapter for testing and development.
3
+ *
4
+ * Echoes back the prompt it receives without calling any external agent.
5
+ * Useful for verifying orchestrator wiring, handoff logic, and CLI behavior.
6
+ */
7
+ import type { AgentAdapter } from "../adapter.js";
8
+ import type { TeammateConfig, TaskResult } from "../types.js";
9
+ export declare class EchoAdapter implements AgentAdapter {
10
+ readonly name = "echo";
11
+ startSession(teammate: TeammateConfig): Promise<string>;
12
+ executeTask(sessionId: string, teammate: TeammateConfig, prompt: string): Promise<TaskResult>;
13
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Echo adapter — a no-op adapter for testing and development.
3
+ *
4
+ * Echoes back the prompt it receives without calling any external agent.
5
+ * Useful for verifying orchestrator wiring, handoff logic, and CLI behavior.
6
+ */
7
+ import { buildTeammatePrompt } from "../adapter.js";
8
+ let nextId = 1;
9
+ export class EchoAdapter {
10
+ name = "echo";
11
+ async startSession(teammate) {
12
+ return `echo-${teammate.name}-${nextId++}`;
13
+ }
14
+ async executeTask(sessionId, teammate, prompt) {
15
+ const fullPrompt = buildTeammatePrompt(teammate, prompt);
16
+ return {
17
+ teammate: teammate.name,
18
+ success: true,
19
+ summary: `[echo] ${teammate.name} received task (${prompt.length} chars)`,
20
+ changedFiles: [],
21
+ rawOutput: fullPrompt,
22
+ };
23
+ }
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { EchoAdapter } from "./echo.js";
3
+ const teammate = {
4
+ name: "beacon",
5
+ role: "Platform engineer.",
6
+ soul: "# Beacon\n\nBeacon owns the recall package.",
7
+ memories: "",
8
+ dailyLogs: [],
9
+ ownership: { primary: [], secondary: [] },
10
+ };
11
+ describe("EchoAdapter", () => {
12
+ it("has name 'echo'", () => {
13
+ const adapter = new EchoAdapter();
14
+ expect(adapter.name).toBe("echo");
15
+ });
16
+ it("returns unique session IDs", async () => {
17
+ const adapter = new EchoAdapter();
18
+ const s1 = await adapter.startSession(teammate);
19
+ const s2 = await adapter.startSession(teammate);
20
+ expect(s1).not.toBe(s2);
21
+ expect(s1).toContain("echo-beacon-");
22
+ });
23
+ it("returns success with prompt length in summary", async () => {
24
+ const adapter = new EchoAdapter();
25
+ const sessionId = await adapter.startSession(teammate);
26
+ const result = await adapter.executeTask(sessionId, teammate, "do the thing");
27
+ expect(result.success).toBe(true);
28
+ expect(result.teammate).toBe("beacon");
29
+ expect(result.summary).toContain("[echo]");
30
+ expect(result.summary).toContain("beacon");
31
+ expect(result.changedFiles).toEqual([]);
32
+ });
33
+ it("includes full built prompt in rawOutput", async () => {
34
+ const adapter = new EchoAdapter();
35
+ const sessionId = await adapter.startSession(teammate);
36
+ const result = await adapter.executeTask(sessionId, teammate, "test task");
37
+ expect(result.rawOutput).toContain("# You are beacon");
38
+ expect(result.rawOutput).toContain("test task");
39
+ });
40
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @teammates/cli — Interactive teammate orchestrator.
4
+ *
5
+ * Start a session:
6
+ * teammates Launch interactive REPL
7
+ * teammates --adapter codex Use a specific agent adapter
8
+ * teammates --dir <path> Override .teammates/ location
9
+ */
10
+ export {};