@eat-pray-ai/wingman 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.
Files changed (62) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/codeql.yml +103 -0
  3. package/.github/workflows/publish.yml +23 -0
  4. package/.github/workflows/release.yml +52 -0
  5. package/.github/workflows/test.yml +32 -0
  6. package/.idea/workspace.xml +125 -0
  7. package/AGENTS.md +34 -0
  8. package/README.md +145 -0
  9. package/bin/wingman.ts +2 -0
  10. package/dist/bin/wingman.mjs +3 -0
  11. package/dist/src/cli.mjs +2229 -0
  12. package/dist/src/cli.mjs.map +1 -0
  13. package/docs/AGENTS.md +172 -0
  14. package/docs/resume.yaml +68 -0
  15. package/docs/wingman.pdf +2544 -1
  16. package/docs/wingman.png +0 -0
  17. package/docs/wingman.svg +376 -0
  18. package/package.json +51 -0
  19. package/scripts/generate-demo.ts +265 -0
  20. package/src/AGENTS.md +50 -0
  21. package/src/agents/AGENTS.md +52 -0
  22. package/src/agents/claude-code.ts +160 -0
  23. package/src/agents/codex.ts +174 -0
  24. package/src/agents/gemini-cli.ts +100 -0
  25. package/src/agents/opencode.ts +117 -0
  26. package/src/agents/registry.ts +12 -0
  27. package/src/agents/skills.ts +51 -0
  28. package/src/aggregator.ts +142 -0
  29. package/src/cli.ts +194 -0
  30. package/src/inventory.ts +84 -0
  31. package/src/pricing/AGENTS.md +36 -0
  32. package/src/pricing/__tests__/model-info.test.ts +135 -0
  33. package/src/pricing/engine.ts +86 -0
  34. package/src/pricing/models-dev.ts +253 -0
  35. package/src/resume/AGENTS.md +34 -0
  36. package/src/resume/__tests__/renderer.test.ts +286 -0
  37. package/src/resume/renderer.ts +286 -0
  38. package/src/svg/AGENTS.md +22 -0
  39. package/src/svg/components.ts +266 -0
  40. package/src/svg/icons.ts +83 -0
  41. package/src/themes/AGENTS.md +60 -0
  42. package/src/themes/__tests__/themes.test.ts +187 -0
  43. package/src/themes/github-dark/index.ts +46 -0
  44. package/src/themes/github-dark/palette.ts +19 -0
  45. package/src/themes/github-light/index.ts +46 -0
  46. package/src/themes/github-light/palette.ts +19 -0
  47. package/src/themes/onedark/index.ts +46 -0
  48. package/src/themes/onedark/palette.ts +19 -0
  49. package/src/themes/registry.ts +18 -0
  50. package/src/themes/shared/charts.ts +112 -0
  51. package/src/themes/shared/context.ts +47 -0
  52. package/src/themes/shared/footer.ts +52 -0
  53. package/src/themes/shared/header.ts +29 -0
  54. package/src/themes/shared/heatmap.ts +229 -0
  55. package/src/themes/shared/helpers.ts +103 -0
  56. package/src/themes/shared/inventory.ts +105 -0
  57. package/src/themes/shared/legend.ts +91 -0
  58. package/src/themes/shared/sections.ts +20 -0
  59. package/src/themes/shared/stats.ts +60 -0
  60. package/src/types.ts +106 -0
  61. package/tsconfig.json +18 -0
  62. package/tsdown.config.ts +10 -0
@@ -0,0 +1,253 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import type { ModelPricing } from "../types.js";
5
+
6
+ const MODELS_DEV_URL = "https://models.dev/api.json";
7
+ const CACHE_DIR = join(homedir(), ".wingman", "cache");
8
+ const CACHE_FILE = join(CACHE_DIR, "models.json");
9
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
10
+
11
+ export interface ModelInfo {
12
+ id: string;
13
+ name: string;
14
+ family?: string;
15
+ /** The API provider/distributor (e.g., "abacus", "nano-gpt") */
16
+ provider: string;
17
+ /** The official AI lab that created the model (e.g., "Anthropic", "Google") */
18
+ lab: string;
19
+ releaseDate?: string;
20
+ knowledge?: string;
21
+ modalities?: {
22
+ input: string[];
23
+ output: string[];
24
+ };
25
+ capabilities: string[];
26
+ limits?: {
27
+ context?: number;
28
+ output?: number;
29
+ };
30
+ }
31
+
32
+ /** Maps model family prefixes to the official AI lab name */
33
+ const FAMILY_TO_LAB: Record<string, string> = {
34
+ claude: "Anthropic",
35
+ gemini: "Google",
36
+ gemma: "Google",
37
+ gpt: "OpenAI",
38
+ o: "OpenAI",
39
+ dall: "OpenAI",
40
+ sora: "OpenAI",
41
+ deepseek: "DeepSeek",
42
+ qwen: "Alibaba",
43
+ llama: "Meta",
44
+ mistral: "Mistral",
45
+ mixtral: "Mistral",
46
+ codestral: "Mistral",
47
+ devstral: "Mistral",
48
+ pixtral: "Mistral",
49
+ ministral: "Mistral",
50
+ magistral: "Mistral",
51
+ command: "Cohere",
52
+ grok: "xAI",
53
+ phi: "Microsoft",
54
+ nova: "Amazon",
55
+ titan: "Amazon",
56
+ imagen: "Google",
57
+ glm: "Zhipu",
58
+ kimi: "Moonshot",
59
+ minimax: "MiniMax",
60
+ ernie: "Baidu",
61
+ hunyuan: "Tencent",
62
+ jamba: "AI21",
63
+ nemotron: "NVIDIA",
64
+ granite: "IBM",
65
+ };
66
+
67
+ /** Derive the official AI lab from a model's family prefix */
68
+ export function modelLab(familyOrId: string): string {
69
+ const prefix = familyOrId.split("-")[0];
70
+ return FAMILY_TO_LAB[prefix] ?? "AI";
71
+ }
72
+
73
+ interface ModelsDevCost {
74
+ input?: number;
75
+ output?: number;
76
+ cache_read?: number;
77
+ cache_write?: number;
78
+ }
79
+
80
+ interface ModelsDevModel {
81
+ id: string;
82
+ cost?: ModelsDevCost;
83
+ [key: string]: unknown;
84
+ }
85
+
86
+ interface ModelsDevProvider {
87
+ id: string;
88
+ models: Record<string, ModelsDevModel>;
89
+ [key: string]: unknown;
90
+ }
91
+
92
+ type ModelsDevResponse = Record<string, ModelsDevProvider>;
93
+
94
+ interface CacheEnvelope {
95
+ fetchedAt: number;
96
+ data: ModelsDevResponse;
97
+ }
98
+
99
+ /**
100
+ * Normalize a model ID for fuzzy matching by stripping preview suffixes,
101
+ * date suffixes (-YYYY-MM-DD or -YYYYMMDD), and trailing version suffixes.
102
+ */
103
+ export function normalizeModelId(id: string): string {
104
+ let normalized = id;
105
+
106
+ // Strip "-preview" suffix (with optional trailing content like "-preview-2024-01-01")
107
+ normalized = normalized.replace(/-preview(?:-.*)?$/, "");
108
+
109
+ // Strip date suffixes: -YYYY-MM-DD or -YYYYMMDD
110
+ normalized = normalized.replace(/-\d{4}-\d{2}-\d{2}$/, "");
111
+ normalized = normalized.replace(/-\d{8}$/, "");
112
+
113
+ // Strip trailing version suffixes like -v1, -v2, :latest, :v1.5
114
+ normalized = normalized.replace(/[:-]v[\d.]+$/, "");
115
+ normalized = normalized.replace(/:latest$/, "");
116
+
117
+ return normalized;
118
+ }
119
+
120
+ async function readCache(): Promise<ModelsDevResponse | null> {
121
+ try {
122
+ const raw = await readFile(CACHE_FILE, "utf-8");
123
+ const envelope: CacheEnvelope = JSON.parse(raw);
124
+ if (Date.now() - envelope.fetchedAt < CACHE_TTL_MS) {
125
+ return envelope.data;
126
+ }
127
+ } catch {
128
+ // Cache miss or corrupt — fall through
129
+ }
130
+ return null;
131
+ }
132
+
133
+ async function writeCache(data: ModelsDevResponse): Promise<void> {
134
+ const envelope: CacheEnvelope = { fetchedAt: Date.now(), data };
135
+ try {
136
+ await mkdir(CACHE_DIR, { recursive: true });
137
+ await writeFile(CACHE_FILE, JSON.stringify(envelope), "utf-8");
138
+ } catch {
139
+ // Non-fatal — we can always re-fetch
140
+ }
141
+ }
142
+
143
+ async function fetchFromApi(): Promise<ModelsDevResponse> {
144
+ const res = await fetch(MODELS_DEV_URL);
145
+ if (!res.ok) {
146
+ throw new Error(`Failed to fetch models.dev: ${res.status} ${res.statusText}`);
147
+ }
148
+ return (await res.json()) as ModelsDevResponse;
149
+ }
150
+
151
+ /**
152
+ * Fetch model pricing data from models.dev (with 24h disk cache).
153
+ *
154
+ * Returns a Map keyed by modelId. Each value is an array of ModelPricing
155
+ * objects — one per provider that offers the model.
156
+ */
157
+ export async function fetchModelPricing(): Promise<Map<string, ModelPricing[]>> {
158
+ let data = await readCache();
159
+ if (!data) {
160
+ data = await fetchFromApi();
161
+ await writeCache(data);
162
+ }
163
+
164
+ const result = new Map<string, ModelPricing[]>();
165
+
166
+ for (const [provider, providerData] of Object.entries(data)) {
167
+ const models = providerData?.models;
168
+ if (!models || typeof models !== "object") continue;
169
+
170
+ for (const [modelId, model] of Object.entries(models)) {
171
+ if (!model?.cost) continue;
172
+
173
+ const pricing: ModelPricing = {
174
+ modelId,
175
+ provider,
176
+ inputPerMillion: model.cost.input ?? 0,
177
+ outputPerMillion: model.cost.output ?? 0,
178
+ ...(model.cost.cache_read != null && { cacheReadPerMillion: model.cost.cache_read }),
179
+ ...(model.cost.cache_write != null && { cacheWritePerMillion: model.cost.cache_write }),
180
+ };
181
+
182
+ const existing = result.get(modelId);
183
+ if (existing) {
184
+ existing.push(pricing);
185
+ } else {
186
+ result.set(modelId, [pricing]);
187
+ }
188
+ }
189
+ }
190
+
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * Fetch model metadata from models.dev (reuses same 24h disk cache).
196
+ *
197
+ * Returns a Map keyed by model ID. Uses the same normalized-ID fallback
198
+ * as the pricing engine for fuzzy matching.
199
+ */
200
+ export async function fetchModelInfo(): Promise<Map<string, ModelInfo>> {
201
+ let data = await readCache();
202
+ if (!data) {
203
+ data = await fetchFromApi();
204
+ await writeCache(data);
205
+ }
206
+
207
+ const result = new Map<string, ModelInfo>();
208
+
209
+ for (const [providerId, providerData] of Object.entries(data)) {
210
+ const models = providerData?.models;
211
+ if (!models || typeof models !== "object") continue;
212
+
213
+ for (const [modelId, model] of Object.entries(models)) {
214
+ const raw = model as Record<string, unknown>;
215
+ const family = typeof raw.family === "string" ? raw.family : modelId;
216
+ const info: ModelInfo = {
217
+ id: modelId,
218
+ name: (raw.name as string) ?? modelId,
219
+ provider: providerId,
220
+ lab: modelLab(family),
221
+ capabilities: Array.isArray(raw.capabilities) ? raw.capabilities : [],
222
+ };
223
+
224
+ if (typeof raw.family === "string") info.family = raw.family;
225
+ if (typeof raw.release_date === "string") info.releaseDate = raw.release_date;
226
+ if (typeof raw.knowledge === "string") info.knowledge = raw.knowledge;
227
+
228
+ const mod = raw.modalities as Record<string, unknown> | undefined;
229
+ if (mod && Array.isArray(mod.input) && Array.isArray(mod.output)) {
230
+ info.modalities = { input: mod.input, output: mod.output };
231
+ }
232
+
233
+ const lim = raw.limits as Record<string, unknown> | undefined;
234
+ if (lim && typeof lim === "object") {
235
+ const limits: { context?: number; output?: number } = {};
236
+ if (typeof lim.context === "number") limits.context = lim.context;
237
+ if (typeof lim.output === "number") limits.output = lim.output;
238
+ if (limits.context !== undefined || limits.output !== undefined) {
239
+ info.limits = limits;
240
+ }
241
+ }
242
+
243
+ result.set(modelId, info);
244
+
245
+ const normalized = normalizeModelId(modelId);
246
+ if (normalized !== modelId && !result.has(normalized)) {
247
+ result.set(normalized, info);
248
+ }
249
+ }
250
+ }
251
+
252
+ return result;
253
+ }
@@ -0,0 +1,34 @@
1
+ # AGENTS.md — Resume (`src/resume/`)
2
+
3
+ Generates [rendercv](https://rendercv.com/)-compatible YAML from `ShowcaseData`.
4
+
5
+ ## Files
6
+
7
+ | File | Purpose |
8
+ |---|---|
9
+ | `renderer.ts` | `generateResumeYaml()` — converts `ShowcaseData` + `ModelInfo` → YAML string |
10
+
11
+ ## Key Exports
12
+
13
+ | Export | Purpose |
14
+ |---|---|
15
+ | `generateResumeYaml(data, modelInfo, opts)` | Main entry — produces rendercv YAML with `cv:` top-level key |
16
+ | `ResumeOptions` | Interface: required `name` and `headline` (defaults owned by Commander in `cli.ts`) |
17
+
18
+ ## Resume Sections
19
+
20
+ The YAML output contains four rendercv sections:
21
+
22
+ 1. **summary** — agent count, total tokens, sessions, date range, cost
23
+ 2. **experience** — one entry per agent (>1% token share), sorted by usage; highlights include model breakdowns
24
+ 3. **education** — models grouped by AI lab (via `modelLab()`), degree = most expensive per-token model per group
25
+ 4. **technologies** — inventory: plugins, MCP servers, skills
26
+
27
+ ## Key Conventions
28
+
29
+ - YAML is built via pure string concatenation — no YAML library
30
+ - `yamlValue()` handles smart quoting (bare strings when safe, single-quoted when special chars present)
31
+ - `yamlValue()` allows markdown `**bold**` at string start (rendercv text formatting)
32
+ - Models with <=3M tokens or <=$1 cost are filtered out (`isSignificantModel()`)
33
+ - Agents with <=1% of total tokens are excluded from experience
34
+ - Education groups use `modelLab()` from `pricing/models-dev.ts` for lab derivation
@@ -0,0 +1,286 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generateResumeYaml, type ResumeOptions } from "../renderer.js";
3
+ import type { ShowcaseData } from "../../types.js";
4
+ import type { ModelInfo } from "../../pricing/models-dev.js";
5
+
6
+ const defaultOpts: ResumeOptions = { name: "Wingman", headline: "AI Development Assistants" };
7
+
8
+ function makeTestData(): ShowcaseData {
9
+ const since = new Date("2026-03-01");
10
+ const until = new Date("2026-03-30");
11
+ return {
12
+ period: { since, until },
13
+ agents: [
14
+ {
15
+ agent: "claude-code",
16
+ displayName: "Claude Code",
17
+ totalTokens: 400_000_000,
18
+ totalCost: 1000,
19
+ unknownCost: false,
20
+ sessionCount: 8,
21
+ models: {
22
+ "claude-sonnet-4": { tokens: 320_000_000, cost: 960 },
23
+ "claude-haiku-4": { tokens: 80_000_000, cost: 40 },
24
+ },
25
+ dailyActivity: {
26
+ "2026-03-05": 50_000,
27
+ "2026-03-10": 100_000,
28
+ "2026-03-20": 150_000,
29
+ "2026-03-25": 100_000,
30
+ },
31
+ config: { mcpServers: ["filesystem", "github"], plugins: [], models: [], skills: ["debugging"] },
32
+ },
33
+ {
34
+ agent: "copilot",
35
+ displayName: "GitHub Copilot",
36
+ totalTokens: 100_000_000,
37
+ totalCost: 200,
38
+ unknownCost: false,
39
+ sessionCount: 5,
40
+ models: {
41
+ "gpt-4o": { tokens: 100_000_000, cost: 200 },
42
+ },
43
+ dailyActivity: {
44
+ "2026-03-10": 40_000_000,
45
+ "2026-03-15": 60_000_000,
46
+ },
47
+ config: { mcpServers: [], plugins: [], models: [], skills: [] },
48
+ },
49
+ ],
50
+ totals: {
51
+ tokens: 500_000_000,
52
+ inputTokens: 300_000_000,
53
+ outputTokens: 150_000_000,
54
+ cacheReadTokens: 40_000_000,
55
+ cacheWriteTokens: 10_000_000,
56
+ cost: 1200,
57
+ sessions: 13,
58
+ },
59
+ modelDailyActivity: {},
60
+ inventory: {
61
+ plugins: [{ name: "superpowers", version: "5.0", skills: [], agents: [], commands: [], sources: ["claude-code"] }],
62
+ mcpServers: [
63
+ { name: "filesystem", sources: ["claude-code"] },
64
+ { name: "github", sources: ["claude-code"] },
65
+ ],
66
+ skills: [{ name: "debugging", sources: ["claude-code"] }],
67
+ },
68
+ };
69
+ }
70
+
71
+ function makeModelInfo(): Map<string, ModelInfo> {
72
+ const map = new Map<string, ModelInfo>();
73
+ map.set("claude-sonnet-4", {
74
+ id: "claude-sonnet-4",
75
+ name: "Claude Sonnet 4",
76
+ family: "claude-sonnet",
77
+ provider: "anthropic",
78
+ lab: "Anthropic",
79
+ releaseDate: "2025-05-14",
80
+ knowledge: "2025-03-31",
81
+ modalities: { input: ["text", "image", "pdf"], output: ["text"] },
82
+ capabilities: ["reasoning", "tool_call", "attachment"],
83
+ limits: { context: 200000, output: 64000 },
84
+ });
85
+ map.set("claude-haiku-4", {
86
+ id: "claude-haiku-4",
87
+ name: "Claude Haiku 4",
88
+ family: "claude-haiku",
89
+ provider: "anthropic",
90
+ lab: "Anthropic",
91
+ releaseDate: "2025-05-01",
92
+ modalities: { input: ["text", "image"], output: ["text"] },
93
+ capabilities: ["tool_call"],
94
+ limits: { context: 200000, output: 64000 },
95
+ });
96
+ map.set("gpt-4o", {
97
+ id: "gpt-4o",
98
+ name: "GPT-4o",
99
+ family: "gpt-4o",
100
+ provider: "openai",
101
+ lab: "OpenAI",
102
+ releaseDate: "2024-05-13",
103
+ knowledge: "2024-10-01",
104
+ modalities: { input: ["text", "image"], output: ["text"] },
105
+ capabilities: ["reasoning", "tool_call"],
106
+ limits: { context: 128000, output: 16384 },
107
+ });
108
+ return map;
109
+ }
110
+
111
+ describe("generateResumeYaml", () => {
112
+ it("produces YAML with cv top-level key", () => {
113
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
114
+ expect(yaml).toMatch(/^cv:\n/);
115
+ });
116
+
117
+ it("includes name and headline", () => {
118
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
119
+ expect(yaml).toContain("name: Wingman");
120
+ expect(yaml).toContain("headline: AI Development Assistants");
121
+ });
122
+
123
+ it("allows overriding name and headline", () => {
124
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), {
125
+ name: "My Team",
126
+ headline: "Custom Headline",
127
+ });
128
+ expect(yaml).toContain("name: My Team");
129
+ expect(yaml).toContain("headline: Custom Headline");
130
+ });
131
+
132
+ it("has summary section with stats", () => {
133
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
134
+ expect(yaml).toContain("summary:");
135
+ expect(yaml).toContain("**2 agents**");
136
+ expect(yaml).toContain("**500M tokens**");
137
+ expect(yaml).toContain("**13 sessions**");
138
+ });
139
+
140
+ it("has experience section with agents sorted by token usage", () => {
141
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
142
+ expect(yaml).toContain("experience:");
143
+ expect(yaml).toContain("company: Claude Code");
144
+ expect(yaml).toContain("company: GitHub Copilot");
145
+ expect(yaml).toContain("Primary Agent");
146
+ expect(yaml).toContain("Secondary Agent");
147
+ });
148
+
149
+ it("has experience entries with date ranges from dailyActivity", () => {
150
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
151
+ // Claude Code: earliest=2026-03-05, latest=2026-03-25
152
+ expect(yaml).toContain("start_date: 2026-03-05");
153
+ expect(yaml).toContain("end_date: 2026-03-25");
154
+ });
155
+
156
+ it("has education section grouped by AI company", () => {
157
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
158
+ expect(yaml).toContain("education:");
159
+ // Grouped by company, not one per model
160
+ expect(yaml).toContain("institution: Anthropic");
161
+ expect(yaml).toContain("institution: OpenAI");
162
+ // Primary model as degree
163
+ expect(yaml).toContain("degree: Claude Sonnet 4");
164
+ expect(yaml).toContain("degree: GPT-4o");
165
+ // Merged modalities for Anthropic group (text, image from haiku + pdf from sonnet)
166
+ expect(yaml).toContain("'text, image, pdf'");
167
+ // Individual models in highlights (bold names)
168
+ expect(yaml).toContain("**Claude Sonnet 4**:");
169
+ expect(yaml).toContain("**Claude Haiku 4**:");
170
+ expect(yaml).toContain("**GPT-4o**:");
171
+ });
172
+
173
+ it("uses earliest knowledge as start_date and latest release as end_date per group", () => {
174
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
175
+ // Anthropic group: knowledge min(2025-03-31, undefined) = 2025-03-31
176
+ // release max(2025-05-14, 2025-05-01) = 2025-05-14
177
+ expect(yaml).toContain("start_date: 2025-03-31");
178
+ expect(yaml).toContain("end_date: 2025-05-14");
179
+ });
180
+
181
+ it("has technologies section with inventory items", () => {
182
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
183
+ expect(yaml).toContain("technologies:");
184
+ expect(yaml).toContain("label: Plugins");
185
+ expect(yaml).toContain("superpowers");
186
+ expect(yaml).toContain("label: MCP Servers");
187
+ expect(yaml).toContain("filesystem");
188
+ expect(yaml).toContain("label: Skills");
189
+ expect(yaml).toContain("debugging");
190
+ });
191
+
192
+ it("omits empty inventory categories", () => {
193
+ const data = makeTestData();
194
+ data.inventory = { plugins: [], mcpServers: [], skills: [] };
195
+ const yaml = generateResumeYaml(data, makeModelInfo(), defaultOpts);
196
+ expect(yaml).not.toContain("technologies:");
197
+ });
198
+
199
+ it("handles unknown models gracefully", () => {
200
+ const yaml = generateResumeYaml(makeTestData(), new Map(), defaultOpts);
201
+ expect(yaml).toContain("education:");
202
+ // Company derived from model ID prefix, not ModelInfo provider
203
+ expect(yaml).toContain("institution: Anthropic");
204
+ expect(yaml).toContain("institution: OpenAI");
205
+ expect(yaml).toContain("area: Language Models");
206
+ // Raw model ID used as degree when no ModelInfo
207
+ expect(yaml).toContain("degree: claude-sonnet-4");
208
+ expect(yaml).toContain("degree: gpt-4o");
209
+ expect(yaml).toContain("end_date: present");
210
+ });
211
+ });
212
+
213
+ describe("generateResumeYaml edge cases", () => {
214
+ it("handles single agent (Primary Agent only)", () => {
215
+ const data = makeTestData();
216
+ data.agents = [data.agents[0]];
217
+ data.totals.tokens = data.agents[0].totalTokens;
218
+ const yaml = generateResumeYaml(data, makeModelInfo(), defaultOpts);
219
+ expect(yaml).toContain("Primary Agent");
220
+ expect(yaml).not.toContain("Secondary Agent");
221
+ });
222
+
223
+ it("handles three+ agents with Supporting Agent label", () => {
224
+ const data = makeTestData();
225
+ data.agents.push({
226
+ agent: "cursor",
227
+ displayName: "Cursor",
228
+ totalTokens: 50_000_000,
229
+ totalCost: 100,
230
+ unknownCost: false,
231
+ sessionCount: 2,
232
+ models: { "gpt-4o-mini": { tokens: 50_000_000, cost: 100 } },
233
+ dailyActivity: { "2026-03-20": 50_000_000 },
234
+ config: { mcpServers: [], plugins: [], models: [], skills: [] },
235
+ });
236
+ data.totals.tokens = 550_000_000;
237
+ const yaml = generateResumeYaml(data, makeModelInfo(), defaultOpts);
238
+ expect(yaml).toContain("Primary Agent");
239
+ expect(yaml).toContain("Secondary Agent");
240
+ expect(yaml).toContain("Supporting Agent");
241
+ });
242
+
243
+ it("filters out agents with <= 1% token usage", () => {
244
+ const data = makeTestData();
245
+ data.agents.push({
246
+ agent: "gemini-cli",
247
+ displayName: "Gemini CLI",
248
+ totalTokens: 1_000,
249
+ totalCost: 0.01,
250
+ unknownCost: false,
251
+ sessionCount: 1,
252
+ models: { "gemini-flash": { tokens: 1_000, cost: 0.01 } },
253
+ dailyActivity: { "2026-03-20": 1_000 },
254
+ config: { mcpServers: [], plugins: [], models: [], skills: [] },
255
+ });
256
+ const yaml = generateResumeYaml(data, makeModelInfo(), defaultOpts);
257
+ expect(yaml).not.toContain("Gemini CLI");
258
+ });
259
+
260
+ it("falls back to period dates when agent has no dailyActivity", () => {
261
+ const data = makeTestData();
262
+ data.agents[0].dailyActivity = {};
263
+ const yaml = generateResumeYaml(data, makeModelInfo(), defaultOpts);
264
+ expect(yaml).toContain("start_date: 2026-03-01");
265
+ expect(yaml).toContain("end_date: 2026-03-30");
266
+ });
267
+
268
+ it("produces valid YAML structure (all lines properly indented)", () => {
269
+ const yaml = generateResumeYaml(makeTestData(), makeModelInfo(), defaultOpts);
270
+ const lines = yaml.split("\n");
271
+ expect(lines[0]).toBe("cv:");
272
+ for (let i = 1; i < lines.length; i++) {
273
+ if (lines[i].trim().length > 0) {
274
+ expect(lines[i]).toMatch(/^ /);
275
+ }
276
+ }
277
+ });
278
+
279
+ it("quotes strings with special characters", () => {
280
+ const data = makeTestData();
281
+ data.agents[0].displayName = "Agent: Special & Co.";
282
+ const yaml = generateResumeYaml(data, makeModelInfo(), defaultOpts);
283
+ // Contains colon so must be quoted
284
+ expect(yaml).toContain("'Agent: Special & Co.'");
285
+ });
286
+ });