@every-env/compound-plugin 0.5.2 → 0.7.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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +20 -3
  4. package/docs/plans/2026-02-14-feat-add-gemini-cli-target-provider-plan.md +370 -0
  5. package/docs/specs/gemini.md +122 -0
  6. package/package.json +1 -1
  7. package/plugins/compound-engineering/.claude-plugin/plugin.json +1 -1
  8. package/plugins/compound-engineering/CHANGELOG.md +17 -0
  9. package/plugins/compound-engineering/commands/workflows/plan.md +3 -0
  10. package/plugins/compound-engineering/commands/workflows/work.md +8 -1
  11. package/src/commands/convert.ts +14 -25
  12. package/src/commands/install.ts +23 -25
  13. package/src/commands/sync.ts +44 -21
  14. package/src/converters/claude-to-gemini.ts +193 -0
  15. package/src/converters/claude-to-opencode.ts +16 -0
  16. package/src/converters/claude-to-pi.ts +205 -0
  17. package/src/sync/cursor.ts +78 -0
  18. package/src/sync/droid.ts +21 -0
  19. package/src/sync/pi.ts +88 -0
  20. package/src/targets/gemini.ts +68 -0
  21. package/src/targets/index.ts +18 -0
  22. package/src/targets/pi.ts +131 -0
  23. package/src/templates/pi/compat-extension.ts +452 -0
  24. package/src/types/gemini.ts +29 -0
  25. package/src/types/pi.ts +40 -0
  26. package/src/utils/resolve-home.ts +17 -0
  27. package/tests/cli.test.ts +76 -0
  28. package/tests/converter.test.ts +29 -0
  29. package/tests/gemini-converter.test.ts +373 -0
  30. package/tests/gemini-writer.test.ts +181 -0
  31. package/tests/pi-converter.test.ts +116 -0
  32. package/tests/pi-writer.test.ts +99 -0
  33. package/tests/sync-cursor.test.ts +92 -0
  34. package/tests/sync-droid.test.ts +57 -0
  35. package/tests/sync-pi.test.ts +68 -0
@@ -5,6 +5,7 @@ import { loadClaudePlugin } from "../parsers/claude"
5
5
  import { targets } from "../targets"
6
6
  import type { PermissionMode } from "../converters/claude-to-opencode"
7
7
  import { ensureCodexAgentsFile } from "../utils/codex-agents"
8
+ import { expandHome, resolveTargetHome } from "../utils/resolve-home"
8
9
 
9
10
  const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
10
11
 
@@ -22,7 +23,7 @@ export default defineCommand({
22
23
  to: {
23
24
  type: "string",
24
25
  default: "opencode",
25
- description: "Target format (opencode | codex | droid | cursor)",
26
+ description: "Target format (opencode | codex | droid | cursor | pi | gemini)",
26
27
  },
27
28
  output: {
28
29
  type: "string",
@@ -34,6 +35,11 @@ export default defineCommand({
34
35
  alias: "codex-home",
35
36
  description: "Write Codex output to this .codex root (ex: ~/.codex)",
36
37
  },
38
+ piHome: {
39
+ type: "string",
40
+ alias: "pi-home",
41
+ description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
42
+ },
37
43
  also: {
38
44
  type: "string",
39
45
  description: "Comma-separated extra targets to generate (ex: codex)",
@@ -72,7 +78,8 @@ export default defineCommand({
72
78
 
73
79
  const plugin = await loadClaudePlugin(String(args.source))
74
80
  const outputRoot = resolveOutputRoot(args.output)
75
- const codexHome = resolveCodexRoot(args.codexHome)
81
+ const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
82
+ const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
76
83
 
77
84
  const options = {
78
85
  agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
@@ -80,7 +87,7 @@ export default defineCommand({
80
87
  permissions: permissions as PermissionMode,
81
88
  }
82
89
 
83
- const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome)
90
+ const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome)
84
91
  const bundle = target.convert(plugin, options)
85
92
  if (!bundle) {
86
93
  throw new Error(`Target ${targetName} did not return a bundle.`)
@@ -106,7 +113,7 @@ export default defineCommand({
106
113
  console.warn(`Skipping ${extra}: no output returned.`)
107
114
  continue
108
115
  }
109
- const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome)
116
+ const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome)
110
117
  await handler.write(extraRoot, extraBundle)
111
118
  console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`)
112
119
  }
@@ -125,26 +132,6 @@ function parseExtraTargets(value: unknown): string[] {
125
132
  .filter(Boolean)
126
133
  }
127
134
 
128
- function resolveCodexHome(value: unknown): string | null {
129
- if (!value) return null
130
- const raw = String(value).trim()
131
- if (!raw) return null
132
- const expanded = expandHome(raw)
133
- return path.resolve(expanded)
134
- }
135
-
136
- function resolveCodexRoot(value: unknown): string {
137
- return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex")
138
- }
139
-
140
- function expandHome(value: string): string {
141
- if (value === "~") return os.homedir()
142
- if (value.startsWith(`~${path.sep}`)) {
143
- return path.join(os.homedir(), value.slice(2))
144
- }
145
- return value
146
- }
147
-
148
135
  function resolveOutputRoot(value: unknown): string {
149
136
  if (value && String(value).trim()) {
150
137
  const expanded = expandHome(String(value).trim())
@@ -153,9 +140,11 @@ function resolveOutputRoot(value: unknown): string {
153
140
  return process.cwd()
154
141
  }
155
142
 
156
- function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string): string {
143
+ function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, piHome: string): string {
157
144
  if (targetName === "codex") return codexHome
145
+ if (targetName === "pi") return piHome
158
146
  if (targetName === "droid") return path.join(os.homedir(), ".factory")
159
147
  if (targetName === "cursor") return path.join(outputRoot, ".cursor")
148
+ if (targetName === "gemini") return path.join(outputRoot, ".gemini")
160
149
  return outputRoot
161
150
  }
@@ -7,6 +7,7 @@ import { targets } from "../targets"
7
7
  import { pathExists } from "../utils/files"
8
8
  import type { PermissionMode } from "../converters/claude-to-opencode"
9
9
  import { ensureCodexAgentsFile } from "../utils/codex-agents"
10
+ import { expandHome, resolveTargetHome } from "../utils/resolve-home"
10
11
 
11
12
  const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
12
13
 
@@ -24,7 +25,7 @@ export default defineCommand({
24
25
  to: {
25
26
  type: "string",
26
27
  default: "opencode",
27
- description: "Target format (opencode | codex | droid | cursor)",
28
+ description: "Target format (opencode | codex | droid | cursor | pi | gemini)",
28
29
  },
29
30
  output: {
30
31
  type: "string",
@@ -36,6 +37,11 @@ export default defineCommand({
36
37
  alias: "codex-home",
37
38
  description: "Write Codex output to this .codex root (ex: ~/.codex)",
38
39
  },
40
+ piHome: {
41
+ type: "string",
42
+ alias: "pi-home",
43
+ description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
44
+ },
39
45
  also: {
40
46
  type: "string",
41
47
  description: "Comma-separated extra targets to generate (ex: codex)",
@@ -76,7 +82,8 @@ export default defineCommand({
76
82
  try {
77
83
  const plugin = await loadClaudePlugin(resolvedPlugin.path)
78
84
  const outputRoot = resolveOutputRoot(args.output)
79
- const codexHome = resolveCodexRoot(args.codexHome)
85
+ const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
86
+ const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
80
87
 
81
88
  const options = {
82
89
  agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent",
@@ -89,7 +96,7 @@ export default defineCommand({
89
96
  throw new Error(`Target ${targetName} did not return a bundle.`)
90
97
  }
91
98
  const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
92
- const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, hasExplicitOutput)
99
+ const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput)
93
100
  await target.write(primaryOutputRoot, bundle)
94
101
  console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
95
102
 
@@ -110,7 +117,7 @@ export default defineCommand({
110
117
  console.warn(`Skipping ${extra}: no output returned.`)
111
118
  continue
112
119
  }
113
- const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, hasExplicitOutput)
120
+ const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput)
114
121
  await handler.write(extraRoot, extraBundle)
115
122
  console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`)
116
123
  }
@@ -152,26 +159,6 @@ function parseExtraTargets(value: unknown): string[] {
152
159
  .filter(Boolean)
153
160
  }
154
161
 
155
- function resolveCodexHome(value: unknown): string | null {
156
- if (!value) return null
157
- const raw = String(value).trim()
158
- if (!raw) return null
159
- const expanded = expandHome(raw)
160
- return path.resolve(expanded)
161
- }
162
-
163
- function resolveCodexRoot(value: unknown): string {
164
- return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex")
165
- }
166
-
167
- function expandHome(value: string): string {
168
- if (value === "~") return os.homedir()
169
- if (value.startsWith(`~${path.sep}`)) {
170
- return path.join(os.homedir(), value.slice(2))
171
- }
172
- return value
173
- }
174
-
175
162
  function resolveOutputRoot(value: unknown): string {
176
163
  if (value && String(value).trim()) {
177
164
  const expanded = expandHome(String(value).trim())
@@ -182,13 +169,24 @@ function resolveOutputRoot(value: unknown): string {
182
169
  return path.join(os.homedir(), ".config", "opencode")
183
170
  }
184
171
 
185
- function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, hasExplicitOutput: boolean): string {
172
+ function resolveTargetOutputRoot(
173
+ targetName: string,
174
+ outputRoot: string,
175
+ codexHome: string,
176
+ piHome: string,
177
+ hasExplicitOutput: boolean,
178
+ ): string {
186
179
  if (targetName === "codex") return codexHome
180
+ if (targetName === "pi") return piHome
187
181
  if (targetName === "droid") return path.join(os.homedir(), ".factory")
188
182
  if (targetName === "cursor") {
189
183
  const base = hasExplicitOutput ? outputRoot : process.cwd()
190
184
  return path.join(base, ".cursor")
191
185
  }
186
+ if (targetName === "gemini") {
187
+ const base = hasExplicitOutput ? outputRoot : process.cwd()
188
+ return path.join(base, ".gemini")
189
+ }
192
190
  return outputRoot
193
191
  }
194
192
 
@@ -4,9 +4,16 @@ import path from "path"
4
4
  import { loadClaudeHome } from "../parsers/claude-home"
5
5
  import { syncToOpenCode } from "../sync/opencode"
6
6
  import { syncToCodex } from "../sync/codex"
7
+ import { syncToPi } from "../sync/pi"
8
+ import { syncToDroid } from "../sync/droid"
9
+ import { syncToCursor } from "../sync/cursor"
10
+ import { expandHome } from "../utils/resolve-home"
7
11
 
8
- function isValidTarget(value: string): value is "opencode" | "codex" {
9
- return value === "opencode" || value === "codex"
12
+ const validTargets = ["opencode", "codex", "pi", "droid", "cursor"] as const
13
+ type SyncTarget = (typeof validTargets)[number]
14
+
15
+ function isValidTarget(value: string): value is SyncTarget {
16
+ return (validTargets as readonly string[]).includes(value)
10
17
  }
11
18
 
12
19
  /** Check if any MCP servers have env vars that might contain secrets */
@@ -23,16 +30,31 @@ function hasPotentialSecrets(mcpServers: Record<string, unknown>): boolean {
23
30
  return false
24
31
  }
25
32
 
33
+ function resolveOutputRoot(target: SyncTarget): string {
34
+ switch (target) {
35
+ case "opencode":
36
+ return path.join(os.homedir(), ".config", "opencode")
37
+ case "codex":
38
+ return path.join(os.homedir(), ".codex")
39
+ case "pi":
40
+ return path.join(os.homedir(), ".pi", "agent")
41
+ case "droid":
42
+ return path.join(os.homedir(), ".factory")
43
+ case "cursor":
44
+ return path.join(process.cwd(), ".cursor")
45
+ }
46
+ }
47
+
26
48
  export default defineCommand({
27
49
  meta: {
28
50
  name: "sync",
29
- description: "Sync Claude Code config (~/.claude/) to OpenCode or Codex",
51
+ description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Cursor",
30
52
  },
31
53
  args: {
32
54
  target: {
33
55
  type: "string",
34
56
  required: true,
35
- description: "Target: opencode | codex",
57
+ description: "Target: opencode | codex | pi | droid | cursor",
36
58
  },
37
59
  claudeHome: {
38
60
  type: "string",
@@ -42,7 +64,7 @@ export default defineCommand({
42
64
  },
43
65
  async run({ args }) {
44
66
  if (!isValidTarget(args.target)) {
45
- throw new Error(`Unknown target: ${args.target}. Use 'opencode' or 'codex'.`)
67
+ throw new Error(`Unknown target: ${args.target}. Use one of: ${validTargets.join(", ")}`)
46
68
  }
47
69
 
48
70
  const claudeHome = expandHome(args.claudeHome ?? path.join(os.homedir(), ".claude"))
@@ -60,25 +82,26 @@ export default defineCommand({
60
82
  `Syncing ${config.skills.length} skills, ${Object.keys(config.mcpServers).length} MCP servers...`,
61
83
  )
62
84
 
63
- const outputRoot =
64
- args.target === "opencode"
65
- ? path.join(os.homedir(), ".config", "opencode")
66
- : path.join(os.homedir(), ".codex")
85
+ const outputRoot = resolveOutputRoot(args.target)
67
86
 
68
- if (args.target === "opencode") {
69
- await syncToOpenCode(config, outputRoot)
70
- } else {
71
- await syncToCodex(config, outputRoot)
87
+ switch (args.target) {
88
+ case "opencode":
89
+ await syncToOpenCode(config, outputRoot)
90
+ break
91
+ case "codex":
92
+ await syncToCodex(config, outputRoot)
93
+ break
94
+ case "pi":
95
+ await syncToPi(config, outputRoot)
96
+ break
97
+ case "droid":
98
+ await syncToDroid(config, outputRoot)
99
+ break
100
+ case "cursor":
101
+ await syncToCursor(config, outputRoot)
102
+ break
72
103
  }
73
104
 
74
105
  console.log(`✓ Synced to ${args.target}: ${outputRoot}`)
75
106
  },
76
107
  })
77
-
78
- function expandHome(value: string): string {
79
- if (value === "~") return os.homedir()
80
- if (value.startsWith(`~${path.sep}`)) {
81
- return path.join(os.homedir(), value.slice(2))
82
- }
83
- return value
84
- }
@@ -0,0 +1,193 @@
1
+ import { formatFrontmatter } from "../utils/frontmatter"
2
+ import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
3
+ import type { GeminiBundle, GeminiCommand, GeminiMcpServer, GeminiSkill } from "../types/gemini"
4
+ import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode"
5
+
6
+ export type ClaudeToGeminiOptions = ClaudeToOpenCodeOptions
7
+
8
+ const GEMINI_DESCRIPTION_MAX_LENGTH = 1024
9
+
10
+ export function convertClaudeToGemini(
11
+ plugin: ClaudePlugin,
12
+ _options: ClaudeToGeminiOptions,
13
+ ): GeminiBundle {
14
+ const usedSkillNames = new Set<string>()
15
+ const usedCommandNames = new Set<string>()
16
+
17
+ const skillDirs = plugin.skills.map((skill) => ({
18
+ name: skill.name,
19
+ sourceDir: skill.sourceDir,
20
+ }))
21
+
22
+ // Reserve skill names from pass-through skills
23
+ for (const skill of skillDirs) {
24
+ usedSkillNames.add(normalizeName(skill.name))
25
+ }
26
+
27
+ const generatedSkills = plugin.agents.map((agent) => convertAgentToSkill(agent, usedSkillNames))
28
+
29
+ const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames))
30
+
31
+ const mcpServers = convertMcpServers(plugin.mcpServers)
32
+
33
+ if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
34
+ console.warn("Warning: Gemini CLI hooks use a different format (BeforeTool/AfterTool with matchers). Hooks were skipped during conversion.")
35
+ }
36
+
37
+ return { generatedSkills, skillDirs, commands, mcpServers }
38
+ }
39
+
40
+ function convertAgentToSkill(agent: ClaudeAgent, usedNames: Set<string>): GeminiSkill {
41
+ const name = uniqueName(normalizeName(agent.name), usedNames)
42
+ const description = sanitizeDescription(
43
+ agent.description ?? `Use this skill for ${agent.name} tasks`,
44
+ )
45
+
46
+ const frontmatter: Record<string, unknown> = { name, description }
47
+
48
+ let body = transformContentForGemini(agent.body.trim())
49
+ if (agent.capabilities && agent.capabilities.length > 0) {
50
+ const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n")
51
+ body = `## Capabilities\n${capabilities}\n\n${body}`.trim()
52
+ }
53
+ if (body.length === 0) {
54
+ body = `Instructions converted from the ${agent.name} agent.`
55
+ }
56
+
57
+ const content = formatFrontmatter(frontmatter, body)
58
+ return { name, content }
59
+ }
60
+
61
+ function convertCommand(command: ClaudeCommand, usedNames: Set<string>): GeminiCommand {
62
+ // Preserve namespace structure: workflows:plan -> workflows/plan
63
+ const commandPath = resolveCommandPath(command.name)
64
+ const pathKey = commandPath.join("/")
65
+ uniqueName(pathKey, usedNames) // Track for dedup
66
+
67
+ const description = command.description ?? `Converted from Claude command ${command.name}`
68
+ const transformedBody = transformContentForGemini(command.body.trim())
69
+
70
+ let prompt = transformedBody
71
+ if (command.argumentHint) {
72
+ prompt += `\n\nUser request: {{args}}`
73
+ }
74
+
75
+ const content = toToml(description, prompt)
76
+ return { name: pathKey, content }
77
+ }
78
+
79
+ /**
80
+ * Transform Claude Code content to Gemini-compatible content.
81
+ *
82
+ * 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args
83
+ * 2. Path rewriting: .claude/ -> .gemini/, ~/.claude/ -> ~/.gemini/
84
+ * 3. Agent references: @agent-name -> the agent-name skill
85
+ */
86
+ export function transformContentForGemini(body: string): string {
87
+ let result = body
88
+
89
+ // 1. Transform Task agent calls
90
+ const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
91
+ result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => {
92
+ const skillName = normalizeName(agentName)
93
+ return `${prefix}Use the ${skillName} skill to: ${args.trim()}`
94
+ })
95
+
96
+ // 2. Rewrite .claude/ paths to .gemini/
97
+ result = result
98
+ .replace(/~\/\.claude\//g, "~/.gemini/")
99
+ .replace(/\.claude\//g, ".gemini/")
100
+
101
+ // 3. Transform @agent-name references
102
+ const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi
103
+ result = result.replace(agentRefPattern, (_match, agentName: string) => {
104
+ return `the ${normalizeName(agentName)} skill`
105
+ })
106
+
107
+ return result
108
+ }
109
+
110
+ function convertMcpServers(
111
+ servers?: Record<string, ClaudeMcpServer>,
112
+ ): Record<string, GeminiMcpServer> | undefined {
113
+ if (!servers || Object.keys(servers).length === 0) return undefined
114
+
115
+ const result: Record<string, GeminiMcpServer> = {}
116
+ for (const [name, server] of Object.entries(servers)) {
117
+ const entry: GeminiMcpServer = {}
118
+ if (server.command) {
119
+ entry.command = server.command
120
+ if (server.args && server.args.length > 0) entry.args = server.args
121
+ if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
122
+ } else if (server.url) {
123
+ entry.url = server.url
124
+ if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
125
+ }
126
+ result[name] = entry
127
+ }
128
+ return result
129
+ }
130
+
131
+ /**
132
+ * Resolve command name to path segments.
133
+ * workflows:plan -> ["workflows", "plan"]
134
+ * plan -> ["plan"]
135
+ */
136
+ function resolveCommandPath(name: string): string[] {
137
+ return name.split(":").map((segment) => normalizeName(segment))
138
+ }
139
+
140
+ /**
141
+ * Serialize to TOML command format.
142
+ * Uses multi-line strings (""") for prompt field.
143
+ */
144
+ export function toToml(description: string, prompt: string): string {
145
+ const lines: string[] = []
146
+ lines.push(`description = ${formatTomlString(description)}`)
147
+
148
+ // Use multi-line string for prompt
149
+ const escapedPrompt = prompt.replace(/\\/g, "\\\\").replace(/"""/g, '\\"\\"\\"')
150
+ lines.push(`prompt = """`)
151
+ lines.push(escapedPrompt)
152
+ lines.push(`"""`)
153
+
154
+ return lines.join("\n")
155
+ }
156
+
157
+ function formatTomlString(value: string): string {
158
+ return JSON.stringify(value)
159
+ }
160
+
161
+ function normalizeName(value: string): string {
162
+ const trimmed = value.trim()
163
+ if (!trimmed) return "item"
164
+ const normalized = trimmed
165
+ .toLowerCase()
166
+ .replace(/[\\/]+/g, "-")
167
+ .replace(/[:\s]+/g, "-")
168
+ .replace(/[^a-z0-9_-]+/g, "-")
169
+ .replace(/-+/g, "-")
170
+ .replace(/^-+|-+$/g, "")
171
+ return normalized || "item"
172
+ }
173
+
174
+ function sanitizeDescription(value: string, maxLength = GEMINI_DESCRIPTION_MAX_LENGTH): string {
175
+ const normalized = value.replace(/\s+/g, " ").trim()
176
+ if (normalized.length <= maxLength) return normalized
177
+ const ellipsis = "..."
178
+ return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis
179
+ }
180
+
181
+ function uniqueName(base: string, used: Set<string>): string {
182
+ if (!used.has(base)) {
183
+ used.add(base)
184
+ return base
185
+ }
186
+ let index = 2
187
+ while (used.has(`${base}-${index}`)) {
188
+ index += 1
189
+ }
190
+ const name = `${base}-${index}`
191
+ used.add(name)
192
+ return name
193
+ }
@@ -250,8 +250,24 @@ function rewriteClaudePaths(body: string): string {
250
250
  .replace(/\.claude\//g, ".opencode/")
251
251
  }
252
252
 
253
+ // Bare Claude family aliases used in Claude Code (e.g. `model: haiku`).
254
+ // Update these when new model generations are released.
255
+ const CLAUDE_FAMILY_ALIASES: Record<string, string> = {
256
+ haiku: "claude-haiku-4-5",
257
+ sonnet: "claude-sonnet-4-5",
258
+ opus: "claude-opus-4-6",
259
+ }
260
+
253
261
  function normalizeModel(model: string): string {
254
262
  if (model.includes("/")) return model
263
+ if (CLAUDE_FAMILY_ALIASES[model]) {
264
+ const resolved = `anthropic/${CLAUDE_FAMILY_ALIASES[model]}`
265
+ console.warn(
266
+ `Warning: bare model alias "${model}" mapped to "${resolved}". ` +
267
+ `Update CLAUDE_FAMILY_ALIASES if a newer version is available.`,
268
+ )
269
+ return resolved
270
+ }
255
271
  if (/^claude-/.test(model)) return `anthropic/${model}`
256
272
  if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}`
257
273
  if (/^gemini-/.test(model)) return `google/${model}`