@gajae-code/coding-agent 0.6.3 → 0.6.5
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.
- package/CHANGELOG.md +50 -0
- package/README.md +73 -1
- package/dist/types/cli/migrate-cli.d.ts +20 -0
- package/dist/types/commands/migrate.d.ts +33 -0
- package/dist/types/config/keybindings.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -2
- package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
- package/dist/types/gjc-runtime/session-layout.d.ts +59 -0
- package/dist/types/gjc-runtime/session-resolution.d.ts +47 -0
- package/dist/types/gjc-runtime/state-graph.d.ts +1 -1
- package/dist/types/gjc-runtime/state-runtime.d.ts +5 -4
- package/dist/types/gjc-runtime/state-schema.d.ts +2 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +36 -7
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +7 -4
- package/dist/types/gjc-runtime/workflow-command-ref.d.ts +1 -1
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +1 -1
- package/dist/types/harness-control-plane/storage.d.ts +2 -1
- package/dist/types/hooks/skill-state.d.ts +12 -4
- package/dist/types/migrate/action-planner.d.ts +11 -0
- package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
- package/dist/types/migrate/adapters/codex.d.ts +5 -0
- package/dist/types/migrate/adapters/index.d.ts +45 -0
- package/dist/types/migrate/adapters/opencode.d.ts +2 -0
- package/dist/types/migrate/executor.d.ts +2 -0
- package/dist/types/migrate/mcp-mapper.d.ts +20 -0
- package/dist/types/migrate/report.d.ts +18 -0
- package/dist/types/migrate/skill-normalizer.d.ts +27 -0
- package/dist/types/migrate/types.d.ts +126 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/welcome.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
- package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
- package/dist/types/research-plan/index.d.ts +1 -0
- package/dist/types/research-plan/ledger.d.ts +33 -0
- package/dist/types/rlm/artifacts.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
- package/dist/types/skill-state/active-state.d.ts +6 -11
- package/dist/types/skill-state/canonical-skills.d.ts +3 -0
- package/dist/types/skill-state/workflow-hud.d.ts +2 -0
- package/dist/types/task/spawn-gate.d.ts +1 -10
- package/package.json +7 -7
- package/src/cli/migrate-cli.ts +106 -0
- package/src/cli/setup-cli.ts +14 -1
- package/src/cli.ts +1 -0
- package/src/commands/deep-interview.ts +2 -2
- package/src/commands/launch.ts +1 -1
- package/src/commands/migrate.ts +46 -0
- package/src/commands/state.ts +2 -1
- package/src/commands/team.ts +7 -3
- package/src/config/model-registry.ts +9 -2
- package/src/config/model-resolver.ts +13 -2
- package/src/config/settings-schema.ts +17 -0
- package/src/coordinator-mcp/policy.ts +10 -2
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +0 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +28 -24
- package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
- package/src/defaults/gjc/skills/team/SKILL.md +51 -47
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +17 -13
- package/src/exec/bash-executor.ts +3 -1
- package/src/extensibility/custom-commands/loader.ts +0 -7
- package/src/extensibility/gjc-plugins/injection.ts +23 -4
- package/src/extensibility/gjc-plugins/state.ts +16 -1
- package/src/gjc-runtime/deep-interview-recorder.ts +43 -18
- package/src/gjc-runtime/deep-interview-runtime.ts +49 -23
- package/src/gjc-runtime/goal-mode-request.ts +26 -11
- package/src/gjc-runtime/launch-tmux.ts +68 -15
- package/src/gjc-runtime/ralplan-runtime.ts +79 -50
- package/src/gjc-runtime/session-layout.ts +180 -0
- package/src/gjc-runtime/session-resolution.ts +217 -0
- package/src/gjc-runtime/state-graph.ts +1 -2
- package/src/gjc-runtime/state-migrations.ts +1 -0
- package/src/gjc-runtime/state-runtime.ts +230 -121
- package/src/gjc-runtime/state-schema.ts +2 -0
- package/src/gjc-runtime/state-writer.ts +289 -41
- package/src/gjc-runtime/team-runtime.ts +43 -19
- package/src/gjc-runtime/tmux-sessions.ts +43 -2
- package/src/gjc-runtime/ultragoal-guard.ts +45 -2
- package/src/gjc-runtime/ultragoal-runtime.ts +121 -41
- package/src/gjc-runtime/workflow-command-ref.ts +1 -2
- package/src/gjc-runtime/workflow-manifest.ts +1 -2
- package/src/harness-control-plane/storage.ts +14 -4
- package/src/hooks/native-skill-hook.ts +38 -12
- package/src/hooks/skill-state.ts +178 -83
- package/src/internal-urls/docs-index.generated.ts +9 -6
- package/src/migrate/action-planner.ts +318 -0
- package/src/migrate/adapters/claude-code.ts +39 -0
- package/src/migrate/adapters/codex.ts +70 -0
- package/src/migrate/adapters/index.ts +277 -0
- package/src/migrate/adapters/opencode.ts +52 -0
- package/src/migrate/executor.ts +81 -0
- package/src/migrate/mcp-mapper.ts +152 -0
- package/src/migrate/report.ts +104 -0
- package/src/migrate/skill-normalizer.ts +80 -0
- package/src/migrate/types.ts +163 -0
- package/src/modes/bridge/bridge-mode.ts +2 -2
- package/src/modes/components/custom-editor.ts +30 -20
- package/src/modes/components/welcome.ts +42 -9
- package/src/modes/controllers/input-controller.ts +21 -3
- package/src/modes/interactive-mode.ts +22 -1
- package/src/modes/prompt-action-autocomplete.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +2 -2
- package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
- package/src/prompts/agents/init.md +1 -1
- package/src/prompts/system/plan-mode-active.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/task.md +1 -2
- package/src/research-plan/index.ts +1 -0
- package/src/research-plan/ledger.ts +177 -0
- package/src/rlm/artifacts.ts +12 -3
- package/src/rlm/index.ts +7 -0
- package/src/runtime-mcp/config-writer.ts +46 -0
- package/src/session/agent-session.ts +15 -21
- package/src/session/session-manager.ts +19 -2
- package/src/setup/hermes/templates/operator-instructions.v1.md +8 -0
- package/src/setup/hermes-setup.ts +1 -1
- package/src/skill-state/active-state.ts +72 -108
- package/src/skill-state/canonical-skills.ts +4 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +28 -109
- package/src/skill-state/workflow-hud.ts +4 -2
- package/src/skill-state/workflow-state-contract.ts +3 -3
- package/src/slash-commands/builtin-registry.ts +8 -4
- package/src/system-prompt.ts +11 -9
- package/src/task/agents.ts +1 -22
- package/src/task/index.ts +1 -41
- package/src/task/spawn-gate.ts +1 -38
- package/src/task/types.ts +1 -1
- package/src/tools/ask.ts +34 -12
- package/src/tools/computer.ts +58 -4
- package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
- package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
- package/src/prompts/agents/explore.md +0 -58
- package/src/prompts/agents/plan.md +0 -49
- package/src/prompts/agents/reviewer.md +0 -141
- package/src/prompts/agents/task.md +0 -16
- package/src/prompts/review-request.md +0 -70
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central migration action planner.
|
|
3
|
+
*
|
|
4
|
+
* Reads destination state ONCE and produces an immutable list of actions that
|
|
5
|
+
* both dry-run and live execution consume unchanged. This is the single place
|
|
6
|
+
* that decides add/update/skip/fail, destinations, and warnings, guaranteeing
|
|
7
|
+
* dry-run/live parity.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { isEnoent, parseFrontmatter } from "@gajae-code/utils";
|
|
12
|
+
import { readMCPConfigFile, validateServerName } from "../runtime-mcp/config-writer";
|
|
13
|
+
import { mapMcpEntry } from "./mcp-mapper";
|
|
14
|
+
import { slugify } from "./skill-normalizer";
|
|
15
|
+
import type { AdapterResult, MigrateAction, MigrateDestinations, MigrateWarning } from "./types";
|
|
16
|
+
|
|
17
|
+
export interface PlanInput {
|
|
18
|
+
results: AdapterResult[];
|
|
19
|
+
destinations: MigrateDestinations;
|
|
20
|
+
force: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PlanOutput {
|
|
24
|
+
actions: MigrateAction[];
|
|
25
|
+
warnings: MigrateWarning[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DestSkillIndex {
|
|
29
|
+
/** slug -> kind of existing destination entry. */
|
|
30
|
+
slugs: Map<string, "dir-with-skill" | "stale-dir" | "occupied">;
|
|
31
|
+
/** effective loaded name -> owning slug. */
|
|
32
|
+
effectiveNames: Map<string, string>;
|
|
33
|
+
/** The skills root exists but is unsafe to write through. */
|
|
34
|
+
rootUnsafe?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function indexDestinationSkills(skillsDir: string): Promise<DestSkillIndex> {
|
|
38
|
+
const index: DestSkillIndex = { slugs: new Map(), effectiveNames: new Map() };
|
|
39
|
+
let rootStat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
40
|
+
try {
|
|
41
|
+
rootStat = await fs.lstat(skillsDir);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isEnoent(error)) return index;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return { ...index, rootUnsafe: true };
|
|
47
|
+
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const name = String(entry.name);
|
|
50
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
51
|
+
index.slugs.set(name, "occupied");
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const slug = name;
|
|
55
|
+
const skillFile = path.join(skillsDir, slug, "SKILL.md");
|
|
56
|
+
let content: string | undefined;
|
|
57
|
+
try {
|
|
58
|
+
const skillFileStat = await fs.lstat(skillFile);
|
|
59
|
+
if (!skillFileStat.isFile() || skillFileStat.isSymbolicLink()) {
|
|
60
|
+
index.slugs.set(slug, "occupied");
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
content = await fs.readFile(skillFile, "utf-8");
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isEnoent(error)) {
|
|
66
|
+
index.slugs.set(slug, "stale-dir");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
index.slugs.set(slug, "dir-with-skill");
|
|
72
|
+
const { frontmatter } = parseFrontmatter(content, { level: "off" });
|
|
73
|
+
const effective =
|
|
74
|
+
typeof frontmatter.name === "string" && frontmatter.name.trim() ? slugify(frontmatter.name) : slug;
|
|
75
|
+
index.effectiveNames.set(effective, slug);
|
|
76
|
+
}
|
|
77
|
+
return index;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function planMigration(input: PlanInput): Promise<PlanOutput> {
|
|
81
|
+
const actions: MigrateAction[] = [];
|
|
82
|
+
const warnings: MigrateWarning[] = [];
|
|
83
|
+
|
|
84
|
+
// 1. Source-level diagnostics become `source`-typed actions.
|
|
85
|
+
for (const result of input.results) {
|
|
86
|
+
for (const diag of result.diagnostics) {
|
|
87
|
+
actions.push({
|
|
88
|
+
source: diag.source,
|
|
89
|
+
type: "source",
|
|
90
|
+
operation: diag.status.startsWith("failed") ? "fail" : "skip",
|
|
91
|
+
status: diag.status,
|
|
92
|
+
reason: diag.message,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. MCP actions — read the destination config once.
|
|
98
|
+
const existingConfig = await readMCPConfigFile(input.destinations.mcpConfigPath).catch(() => null);
|
|
99
|
+
const mcpInvalid = existingConfig === null;
|
|
100
|
+
const existingServers = new Set(Object.keys(existingConfig?.mcpServers ?? {}));
|
|
101
|
+
const disabledServers = new Set(existingConfig?.disabledServers ?? []);
|
|
102
|
+
|
|
103
|
+
for (const result of input.results) {
|
|
104
|
+
for (const candidate of result.mcpCandidates) {
|
|
105
|
+
const nameError = validateServerName(candidate.name);
|
|
106
|
+
if (nameError) {
|
|
107
|
+
actions.push({
|
|
108
|
+
source: candidate.source,
|
|
109
|
+
type: "mcp",
|
|
110
|
+
name: candidate.name,
|
|
111
|
+
operation: "fail",
|
|
112
|
+
status: "failed_invalid_source",
|
|
113
|
+
reason: nameError,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const mapped = mapMcpEntry(candidate.source, candidate.name, candidate.raw);
|
|
118
|
+
if (!mapped.ok) {
|
|
119
|
+
actions.push({
|
|
120
|
+
source: candidate.source,
|
|
121
|
+
type: "mcp",
|
|
122
|
+
name: candidate.name,
|
|
123
|
+
operation: mapped.status.startsWith("failed") ? "fail" : "skip",
|
|
124
|
+
status: mapped.status,
|
|
125
|
+
reason: mapped.reason,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const w of mapped.warnings)
|
|
130
|
+
warnings.push({ source: candidate.source, type: "mcp", name: candidate.name, message: w });
|
|
131
|
+
|
|
132
|
+
if (mcpInvalid) {
|
|
133
|
+
actions.push({
|
|
134
|
+
source: candidate.source,
|
|
135
|
+
type: "mcp",
|
|
136
|
+
name: candidate.name,
|
|
137
|
+
destination: input.destinations.mcpConfigPath,
|
|
138
|
+
operation: "fail",
|
|
139
|
+
status: "failed_invalid_destination",
|
|
140
|
+
reason: "destination mcp.json is malformed",
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const exists = existingServers.has(candidate.name);
|
|
146
|
+
if (exists && !input.force) {
|
|
147
|
+
actions.push({
|
|
148
|
+
source: candidate.source,
|
|
149
|
+
type: "mcp",
|
|
150
|
+
name: candidate.name,
|
|
151
|
+
destination: input.destinations.mcpConfigPath,
|
|
152
|
+
operation: "skip",
|
|
153
|
+
status: "skipped_exists",
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const actionWarnings = [...mapped.warnings];
|
|
158
|
+
if (exists && disabledServers.has(candidate.name)) {
|
|
159
|
+
const msg = `disabled MCP state preserved for "${candidate.name}"`;
|
|
160
|
+
actionWarnings.push(msg);
|
|
161
|
+
warnings.push({ source: candidate.source, type: "mcp", name: candidate.name, message: msg });
|
|
162
|
+
}
|
|
163
|
+
actions.push({
|
|
164
|
+
source: candidate.source,
|
|
165
|
+
type: "mcp",
|
|
166
|
+
name: candidate.name,
|
|
167
|
+
destination: input.destinations.mcpConfigPath,
|
|
168
|
+
operation: exists ? "update" : "create",
|
|
169
|
+
status: exists ? "updated" : "imported",
|
|
170
|
+
warnings: actionWarnings.length > 0 ? actionWarnings : undefined,
|
|
171
|
+
mcp: { config: mapped.config, force: input.force },
|
|
172
|
+
});
|
|
173
|
+
// Track so an intra-run duplicate of the same name doesn't double-write.
|
|
174
|
+
existingServers.add(candidate.name);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3. Skill actions — index the destination skills tree once.
|
|
179
|
+
const destIndex = await indexDestinationSkills(input.destinations.skillsDir);
|
|
180
|
+
const plannedSlugs = new Map<string, string>(); // slug -> source (intra-run)
|
|
181
|
+
const plannedEffective = new Map<string, string>(); // effective -> slug (intra-run)
|
|
182
|
+
|
|
183
|
+
for (const result of input.results) {
|
|
184
|
+
for (const candidate of result.skillCandidates) {
|
|
185
|
+
for (const w of candidate.warnings) {
|
|
186
|
+
warnings.push({ source: candidate.source, type: "skill", name: candidate.slug, message: w });
|
|
187
|
+
}
|
|
188
|
+
const slug = candidate.slug;
|
|
189
|
+
const destination = path.join(input.destinations.skillsDir, slug, "SKILL.md");
|
|
190
|
+
const base = {
|
|
191
|
+
source: candidate.source,
|
|
192
|
+
type: "skill" as const,
|
|
193
|
+
name: slug,
|
|
194
|
+
effectiveName: slug,
|
|
195
|
+
destination,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Intra-run duplicate slug / effective name. Since effective name == slug, a
|
|
199
|
+
// duplicate always targets the same destination: skip by default; with --force
|
|
200
|
+
// the later (canonical-order) source overwrites the same destination.
|
|
201
|
+
if (plannedSlugs.has(slug) || plannedEffective.has(slug)) {
|
|
202
|
+
if (input.force) {
|
|
203
|
+
actions.push({
|
|
204
|
+
...base,
|
|
205
|
+
operation: "update",
|
|
206
|
+
status: "updated",
|
|
207
|
+
reason: "duplicate within this run (overwritten under --force)",
|
|
208
|
+
skill: { content: candidate.content },
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
actions.push({
|
|
212
|
+
...base,
|
|
213
|
+
operation: "skip",
|
|
214
|
+
status: "skipped_exists",
|
|
215
|
+
reason: "duplicate within this run",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const existingKind = destIndex.slugs.get(slug);
|
|
222
|
+
const effectiveOwner = destIndex.effectiveNames.get(slug);
|
|
223
|
+
|
|
224
|
+
if (destIndex.rootUnsafe) {
|
|
225
|
+
if (!input.force) {
|
|
226
|
+
actions.push({
|
|
227
|
+
...base,
|
|
228
|
+
operation: "skip",
|
|
229
|
+
status: "skipped_exists",
|
|
230
|
+
reason: "skills destination root is not a real directory",
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
actions.push({
|
|
234
|
+
...base,
|
|
235
|
+
operation: "fail",
|
|
236
|
+
status: "failed_invalid_destination",
|
|
237
|
+
reason: "skills destination root is not a real directory; refusing to write",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Effective-name collision with a different existing destination skill.
|
|
244
|
+
if (effectiveOwner && effectiveOwner !== slug) {
|
|
245
|
+
if (!input.force) {
|
|
246
|
+
actions.push({
|
|
247
|
+
...base,
|
|
248
|
+
operation: "skip",
|
|
249
|
+
status: "skipped_exists",
|
|
250
|
+
reason: `effective name collides with "${effectiveOwner}"`,
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
actions.push({
|
|
254
|
+
...base,
|
|
255
|
+
operation: "fail",
|
|
256
|
+
status: "failed_invalid_destination",
|
|
257
|
+
reason: `effective name collides with "${effectiveOwner}"`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (existingKind === "occupied") {
|
|
264
|
+
if (!input.force) {
|
|
265
|
+
actions.push({
|
|
266
|
+
...base,
|
|
267
|
+
operation: "skip",
|
|
268
|
+
status: "skipped_exists",
|
|
269
|
+
reason: "a non-directory or unsafe file occupies the destination path",
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
actions.push({
|
|
273
|
+
...base,
|
|
274
|
+
operation: "fail",
|
|
275
|
+
status: "failed_invalid_destination",
|
|
276
|
+
reason: "a non-directory or unsafe file occupies the destination path; refusing to delete",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (existingKind === "dir-with-skill") {
|
|
283
|
+
if (!input.force) {
|
|
284
|
+
actions.push({ ...base, operation: "skip", status: "skipped_exists" });
|
|
285
|
+
} else {
|
|
286
|
+
actions.push({ ...base, operation: "update", status: "updated", skill: { content: candidate.content } });
|
|
287
|
+
}
|
|
288
|
+
plannedSlugs.set(slug, candidate.source);
|
|
289
|
+
plannedEffective.set(slug, slug);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (existingKind === "stale-dir") {
|
|
294
|
+
if (!input.force) {
|
|
295
|
+
actions.push({ ...base, operation: "skip", status: "skipped_exists", reason: "stale skill directory" });
|
|
296
|
+
} else {
|
|
297
|
+
actions.push({
|
|
298
|
+
...base,
|
|
299
|
+
operation: "update",
|
|
300
|
+
status: "updated",
|
|
301
|
+
reason: "stale skill directory reused",
|
|
302
|
+
skill: { content: candidate.content },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
plannedSlugs.set(slug, candidate.source);
|
|
306
|
+
plannedEffective.set(slug, slug);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Fresh import.
|
|
311
|
+
actions.push({ ...base, operation: "create", status: "imported", skill: { content: candidate.content } });
|
|
312
|
+
plannedSlugs.set(slug, candidate.source);
|
|
313
|
+
plannedEffective.set(slug, slug);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { actions, warnings };
|
|
318
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code adapter: reads `~/.claude.json` (mcpServers) and `~/.claude/skills`.
|
|
3
|
+
*/
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type { AdapterResult, McpCandidate } from "../types";
|
|
6
|
+
import { type Adapter, type AdapterOptions, collectSkillDir, parseSourceJson, readSourceText } from "./index";
|
|
7
|
+
|
|
8
|
+
const SOURCE = "claude-code" as const;
|
|
9
|
+
|
|
10
|
+
export const claudeCodeAdapter: Adapter = {
|
|
11
|
+
source: SOURCE,
|
|
12
|
+
async collect({ homeDir }: AdapterOptions): Promise<AdapterResult> {
|
|
13
|
+
const result: AdapterResult = { mcpCandidates: [], skillCandidates: [], diagnostics: [] };
|
|
14
|
+
|
|
15
|
+
const configPath = path.join(homeDir, ".claude.json");
|
|
16
|
+
const read = await readSourceText(configPath, SOURCE, "mcp");
|
|
17
|
+
if ("diagnostic" in read) {
|
|
18
|
+
result.diagnostics.push(read.diagnostic);
|
|
19
|
+
} else {
|
|
20
|
+
const parsed = parseSourceJson(read.text, configPath, SOURCE, "mcp");
|
|
21
|
+
if ("diagnostic" in parsed) {
|
|
22
|
+
result.diagnostics.push(parsed.diagnostic);
|
|
23
|
+
} else {
|
|
24
|
+
const servers = parsed.data.mcpServers;
|
|
25
|
+
if (servers && typeof servers === "object" && !Array.isArray(servers)) {
|
|
26
|
+
for (const [name, raw] of Object.entries(servers as Record<string, unknown>)) {
|
|
27
|
+
result.mcpCandidates.push({ source: SOURCE, name, raw } satisfies McpCandidate);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const skills = await collectSkillDir(path.join(homeDir, ".claude", "skills"), SOURCE);
|
|
34
|
+
result.skillCandidates.push(...skills.candidates);
|
|
35
|
+
result.diagnostics.push(...skills.diagnostics);
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex adapter: reads `~/.codex/config.toml` ([mcp_servers]) and `~/.codex/prompts`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { TOML } from "bun";
|
|
7
|
+
import type { AdapterResult, McpCandidate, SourceDiagnostic } from "../types";
|
|
8
|
+
import { type Adapter, type AdapterOptions, collectMarkdownPrompts, readSourceText } from "./index";
|
|
9
|
+
|
|
10
|
+
const SOURCE = "codex" as const;
|
|
11
|
+
|
|
12
|
+
function parseToml(
|
|
13
|
+
text: string,
|
|
14
|
+
filePath: string,
|
|
15
|
+
): { data: Record<string, unknown> } | { diagnostic: SourceDiagnostic } {
|
|
16
|
+
try {
|
|
17
|
+
const data = TOML.parse(text) as unknown;
|
|
18
|
+
if (typeof data !== "object" || data === null) {
|
|
19
|
+
return {
|
|
20
|
+
diagnostic: {
|
|
21
|
+
source: SOURCE,
|
|
22
|
+
type: "mcp",
|
|
23
|
+
status: "failed_invalid_source",
|
|
24
|
+
message: `${filePath} is not a TOML table`,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { data: data as Record<string, unknown> };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return {
|
|
31
|
+
diagnostic: {
|
|
32
|
+
source: SOURCE,
|
|
33
|
+
type: "mcp",
|
|
34
|
+
status: "failed_invalid_source",
|
|
35
|
+
message: `invalid TOML in ${filePath}: ${(error as Error).message}`,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const codexAdapter: Adapter = {
|
|
42
|
+
source: SOURCE,
|
|
43
|
+
async collect({ homeDir }: AdapterOptions): Promise<AdapterResult> {
|
|
44
|
+
const result: AdapterResult = { mcpCandidates: [], skillCandidates: [], diagnostics: [] };
|
|
45
|
+
|
|
46
|
+
const configPath = path.join(homeDir, ".codex", "config.toml");
|
|
47
|
+
const read = await readSourceText(configPath, SOURCE, "mcp");
|
|
48
|
+
if ("diagnostic" in read) {
|
|
49
|
+
result.diagnostics.push(read.diagnostic);
|
|
50
|
+
} else {
|
|
51
|
+
const parsed = parseToml(read.text, configPath);
|
|
52
|
+
if ("diagnostic" in parsed) {
|
|
53
|
+
result.diagnostics.push(parsed.diagnostic);
|
|
54
|
+
} else {
|
|
55
|
+
const servers = parsed.data.mcp_servers;
|
|
56
|
+
if (servers && typeof servers === "object" && !Array.isArray(servers)) {
|
|
57
|
+
for (const [name, raw] of Object.entries(servers as Record<string, unknown>)) {
|
|
58
|
+
result.mcpCandidates.push({ source: SOURCE, name, raw } satisfies McpCandidate);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prompts = await collectMarkdownPrompts(path.join(homeDir, ".codex", "prompts"), SOURCE);
|
|
65
|
+
result.skillCandidates.push(...prompts.candidates);
|
|
66
|
+
result.diagnostics.push(...prompts.diagnostics);
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source adapters for `gjc migrate`.
|
|
3
|
+
*
|
|
4
|
+
* Each adapter reads GLOBAL/home config for one source agent and returns
|
|
5
|
+
* normalized MCP + skill candidates plus source-level diagnostics. Adapters never
|
|
6
|
+
* read project-level config and never connect to anything.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import { isEnoent } from "@gajae-code/utils";
|
|
10
|
+
import { normalizeSkill } from "../skill-normalizer";
|
|
11
|
+
import type { AdapterResult, MigrateSource, SkillCandidate, SourceDiagnostic } from "../types";
|
|
12
|
+
import { claudeCodeAdapter } from "./claude-code";
|
|
13
|
+
import { codexAdapter } from "./codex";
|
|
14
|
+
import { opencodeAdapter } from "./opencode";
|
|
15
|
+
|
|
16
|
+
export interface AdapterOptions {
|
|
17
|
+
/** Home directory root; overridable for tests. */
|
|
18
|
+
homeDir: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Adapter {
|
|
22
|
+
source: MigrateSource;
|
|
23
|
+
collect(options: AdapterOptions): Promise<AdapterResult>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ADAPTERS: Record<MigrateSource, Adapter> = {
|
|
27
|
+
"claude-code": claudeCodeAdapter,
|
|
28
|
+
codex: codexAdapter,
|
|
29
|
+
opencode: opencodeAdapter,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function getAdapter(source: MigrateSource): Adapter {
|
|
33
|
+
return ADAPTERS[source];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read a text file, classifying absence/IO errors into source diagnostics. */
|
|
37
|
+
export async function readSourceText(
|
|
38
|
+
filePath: string,
|
|
39
|
+
source: MigrateSource,
|
|
40
|
+
type: SourceDiagnostic["type"],
|
|
41
|
+
): Promise<{ text: string } | { diagnostic: SourceDiagnostic }> {
|
|
42
|
+
try {
|
|
43
|
+
return { text: await fs.readFile(filePath, "utf-8") };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (isEnoent(error)) {
|
|
46
|
+
return {
|
|
47
|
+
diagnostic: { source, type, status: "skipped_absent_source", message: `no ${type} config at ${filePath}` },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
diagnostic: {
|
|
52
|
+
source,
|
|
53
|
+
type,
|
|
54
|
+
status: "failed_io",
|
|
55
|
+
message: `failed to read ${filePath}: ${(error as Error).message}`,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse JSON text, classifying parse errors into a `failed_invalid_source` diagnostic. */
|
|
62
|
+
export function parseSourceJson(
|
|
63
|
+
text: string,
|
|
64
|
+
filePath: string,
|
|
65
|
+
source: MigrateSource,
|
|
66
|
+
type: SourceDiagnostic["type"],
|
|
67
|
+
): { data: Record<string, unknown> } | { diagnostic: SourceDiagnostic } {
|
|
68
|
+
try {
|
|
69
|
+
const data = JSON.parse(text) as unknown;
|
|
70
|
+
if (typeof data !== "object" || data === null) {
|
|
71
|
+
return {
|
|
72
|
+
diagnostic: { source, type, status: "failed_invalid_source", message: `${filePath} is not a JSON object` },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { data: data as Record<string, unknown> };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
diagnostic: {
|
|
79
|
+
source,
|
|
80
|
+
type,
|
|
81
|
+
status: "failed_invalid_source",
|
|
82
|
+
message: `invalid JSON in ${filePath}: ${(error as Error).message}`,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collect skill candidates from a directory of `<name>/SKILL.md` entries.
|
|
90
|
+
* A missing directory yields a `skipped_absent_source` diagnostic.
|
|
91
|
+
*/
|
|
92
|
+
export async function collectSkillDir(
|
|
93
|
+
dir: string,
|
|
94
|
+
source: MigrateSource,
|
|
95
|
+
): Promise<{ candidates: SkillCandidate[]; diagnostics: SourceDiagnostic[] }> {
|
|
96
|
+
const candidates: SkillCandidate[] = [];
|
|
97
|
+
const diagnostics: SourceDiagnostic[] = [];
|
|
98
|
+
let entries: string[];
|
|
99
|
+
try {
|
|
100
|
+
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
101
|
+
entries = dirents.filter(d => d.isDirectory()).map(d => d.name);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (isEnoent(error)) {
|
|
104
|
+
diagnostics.push({
|
|
105
|
+
source,
|
|
106
|
+
type: "skill",
|
|
107
|
+
status: "skipped_absent_source",
|
|
108
|
+
message: `no skills dir at ${dir}`,
|
|
109
|
+
});
|
|
110
|
+
} else {
|
|
111
|
+
diagnostics.push({
|
|
112
|
+
source,
|
|
113
|
+
type: "skill",
|
|
114
|
+
status: "failed_io",
|
|
115
|
+
message: `failed to read ${dir}: ${(error as Error).message}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return { candidates, diagnostics };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const name of entries.sort()) {
|
|
122
|
+
const skillFile = `${dir}/${name}/SKILL.md`;
|
|
123
|
+
const read = await readSourceText(skillFile, source, "skill");
|
|
124
|
+
if ("diagnostic" in read) {
|
|
125
|
+
// A subdir without SKILL.md is simply not a skill; only surface non-absent errors.
|
|
126
|
+
if (read.diagnostic.status !== "skipped_absent_source") diagnostics.push(read.diagnostic);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const normalized = normalizeSkill({ rawName: name, content: read.text });
|
|
131
|
+
candidates.push({ source, slug: normalized.slug, content: normalized.content, warnings: normalized.warnings });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
diagnostics.push({
|
|
134
|
+
source,
|
|
135
|
+
type: "skill",
|
|
136
|
+
status: "failed_invalid_source",
|
|
137
|
+
message: `failed to normalize skill ${skillFile}: ${(error as Error).message}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { candidates, diagnostics };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Collect skill candidates from a flat directory of `*.md` prompt/command files.
|
|
146
|
+
*/
|
|
147
|
+
export async function collectMarkdownPrompts(
|
|
148
|
+
dir: string,
|
|
149
|
+
source: MigrateSource,
|
|
150
|
+
): Promise<{ candidates: SkillCandidate[]; diagnostics: SourceDiagnostic[] }> {
|
|
151
|
+
const candidates: SkillCandidate[] = [];
|
|
152
|
+
const diagnostics: SourceDiagnostic[] = [];
|
|
153
|
+
let files: string[];
|
|
154
|
+
try {
|
|
155
|
+
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
156
|
+
files = dirents.filter(d => d.isFile() && d.name.endsWith(".md")).map(d => d.name);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (isEnoent(error)) {
|
|
159
|
+
diagnostics.push({
|
|
160
|
+
source,
|
|
161
|
+
type: "skill",
|
|
162
|
+
status: "skipped_absent_source",
|
|
163
|
+
message: `no prompts dir at ${dir}`,
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
diagnostics.push({
|
|
167
|
+
source,
|
|
168
|
+
type: "skill",
|
|
169
|
+
status: "failed_io",
|
|
170
|
+
message: `failed to read ${dir}: ${(error as Error).message}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return { candidates, diagnostics };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const file of files.sort()) {
|
|
177
|
+
const promptFile = `${dir}/${file}`;
|
|
178
|
+
const read = await readSourceText(promptFile, source, "skill");
|
|
179
|
+
if ("diagnostic" in read) {
|
|
180
|
+
diagnostics.push(read.diagnostic);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const rawName = file.replace(/\.md$/, "");
|
|
184
|
+
try {
|
|
185
|
+
const normalized = normalizeSkill({ rawName, content: read.text });
|
|
186
|
+
candidates.push({ source, slug: normalized.slug, content: normalized.content, warnings: normalized.warnings });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
diagnostics.push({
|
|
189
|
+
source,
|
|
190
|
+
type: "skill",
|
|
191
|
+
status: "failed_invalid_source",
|
|
192
|
+
message: `failed to convert prompt ${promptFile}: ${(error as Error).message}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { candidates, diagnostics };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Recursively collect skill candidates from any `**/SKILL.md` under `root`.
|
|
201
|
+
* The slug derives from the directory that directly contains the `SKILL.md`.
|
|
202
|
+
*/
|
|
203
|
+
export async function collectSkillTree(
|
|
204
|
+
root: string,
|
|
205
|
+
source: MigrateSource,
|
|
206
|
+
): Promise<{ candidates: SkillCandidate[]; diagnostics: SourceDiagnostic[] }> {
|
|
207
|
+
const candidates: SkillCandidate[] = [];
|
|
208
|
+
const diagnostics: SourceDiagnostic[] = [];
|
|
209
|
+
|
|
210
|
+
async function walk(dir: string): Promise<void> {
|
|
211
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch((error: unknown) => {
|
|
212
|
+
if (isEnoent(error)) return null;
|
|
213
|
+
diagnostics.push({
|
|
214
|
+
source,
|
|
215
|
+
type: "skill",
|
|
216
|
+
status: "failed_io",
|
|
217
|
+
message: `failed to read ${dir}: ${(error as Error).message}`,
|
|
218
|
+
});
|
|
219
|
+
return null;
|
|
220
|
+
});
|
|
221
|
+
if (!entries) return;
|
|
222
|
+
|
|
223
|
+
const hasSkill = entries.some(e => e.isFile() && String(e.name) === "SKILL.md");
|
|
224
|
+
if (hasSkill) {
|
|
225
|
+
const skillFile = `${dir}/SKILL.md`;
|
|
226
|
+
const read = await readSourceText(skillFile, source, "skill");
|
|
227
|
+
if ("diagnostic" in read) {
|
|
228
|
+
if (read.diagnostic.status !== "skipped_absent_source") diagnostics.push(read.diagnostic);
|
|
229
|
+
} else {
|
|
230
|
+
try {
|
|
231
|
+
const rawName = dir.split("/").pop() ?? dir;
|
|
232
|
+
const normalized = normalizeSkill({ rawName, content: read.text });
|
|
233
|
+
candidates.push({
|
|
234
|
+
source,
|
|
235
|
+
slug: normalized.slug,
|
|
236
|
+
content: normalized.content,
|
|
237
|
+
warnings: normalized.warnings,
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
diagnostics.push({
|
|
241
|
+
source,
|
|
242
|
+
type: "skill",
|
|
243
|
+
status: "failed_invalid_source",
|
|
244
|
+
message: `failed to normalize skill ${skillFile}: ${(error as Error).message}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
if (entry.isDirectory()) await walk(`${dir}/${String(entry.name)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Surface an absent root the same way the flat collectors do.
|
|
256
|
+
const rootEntries = await fs.readdir(root).catch((error: unknown) => {
|
|
257
|
+
if (isEnoent(error)) {
|
|
258
|
+
diagnostics.push({
|
|
259
|
+
source,
|
|
260
|
+
type: "skill",
|
|
261
|
+
status: "skipped_absent_source",
|
|
262
|
+
message: `no skills dir at ${root}`,
|
|
263
|
+
});
|
|
264
|
+
} else {
|
|
265
|
+
diagnostics.push({
|
|
266
|
+
source,
|
|
267
|
+
type: "skill",
|
|
268
|
+
status: "failed_io",
|
|
269
|
+
message: `failed to read ${root}: ${(error as Error).message}`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
});
|
|
274
|
+
if (rootEntries) await walk(root);
|
|
275
|
+
|
|
276
|
+
return { candidates, diagnostics };
|
|
277
|
+
}
|