Compare commits

...

9 Commits

Author SHA1 Message Date
J
bf528928b8 docs: refresh skill import source map anchors
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 12:54:11 +08:00
J
97b478571e docs: explain skill import conflict handling
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 12:28:41 +08:00
J
ee44396094 fix(i18n): sync skill import locale keys
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 12:13:06 +08:00
J
bcfbee32c2 feat(cli): add skill import conflict strategies
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 16:12:11 +08:00
J
3d91e6fd58 Merge branch 'feat/skill-import-conflict-backend' into agent/niko/mul-2802-skill-import-conflict-cli 2026-06-10 16:04:18 +08:00
J
f810e9bc2b fix(skills): preserve bulk flow after conflict resolution
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 15:24:48 +08:00
J
c8bf74c1f3 feat(skills): resolve local import conflicts in desktop
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 15:09:57 +08:00
J
fe15052b49 fix(skills): gate structured conflict behind client opt-in; guard overwrite target name
Addresses review feedback on PR #3498 (MUL-2800).

Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.

Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.

Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.

Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.

MUL-2800

Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 11:46:40 +08:00
J
b1431b9056 feat(skills): structured conflict + overwrite path for local skill re-import
Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.

- New terminal import status `conflict` carrying { existing_skill_id,
  existing_created_by, can_overwrite }; can_overwrite = requester is the
  skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
  canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
  once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
  constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
  both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
  payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
  existence (workspace-scoped) and creator permission, then replaces
  description/content/config and fully replaces files (pruning files absent
  from the new bundle). Preserves id, created_by, created_at, name, and
  agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
  create-by-name); any mid-write error rolls back the tx, leaving the original
  skill untouched. Retrying a terminal request is a no-op.

Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.

Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).

MUL-2800

Co-authored-by: multica-agent <github@multica.ai>
2026-05-29 11:28:12 +08:00
26 changed files with 2348 additions and 273 deletions

View File

@@ -79,6 +79,19 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
### スキルインポートの競合
`multica skill import --url <url>` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。
既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## スクワッド
| コマンド | 用途 |

View File

@@ -79,6 +79,19 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
### 스킬 가져오기 충돌
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## 스쿼드
| 명령어 | 용도 |

View File

@@ -79,6 +79,25 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
| `multica skill files ...` | Nested: manage a skill's files |
### Skill import conflicts
`multica skill import --url <url>` defaults to `--on-conflict fail`. If a skill
with the same name already exists, the command exits with a structured
`conflict` result and does not change the workspace.
Use `--on-conflict overwrite` when you created the existing skill and want to
replace its content while preserving its ID and agent bindings. Use
`--on-conflict rename` to import a copy with an automatic suffix such as `-2`.
Use `--on-conflict skip` to leave the existing skill untouched and report
`skipped`.
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## Squads
| Command | Purpose |

View File

@@ -79,6 +79,19 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
### Skill 导入冲突
`multica skill import --url <url>` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill命令会返回结构化 `conflict` 结果并退出,不会修改工作区。
如果你是已有 Skill 的 creator并且想用新导入内容覆盖它同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。
```bash
multica skill import --url https://skills.sh/acme/repo/review-helper
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
```
## 小队
| 命令 | 用途 |

View File

@@ -67,6 +67,16 @@ export async function resolveRuntimeLocalSkillImport(
current = await api.getImportLocalSkillResult(runtimeId, initial.id);
}
if (current.status === "conflict") {
if (!current.conflict) {
throw new Error("runtime local skill import conflict missing details");
}
return {
status: "conflict",
conflict: current.conflict,
};
}
if (current.status === "failed" || current.status === "timeout") {
throw new Error(current.error || "runtime local skill import failed");
}
@@ -74,7 +84,10 @@ export async function resolveRuntimeLocalSkillImport(
throw new Error("runtime local skill import did not return a skill");
}
return { skill: current.skill };
return {
status: current.action === "overwrite" ? "updated" : "created",
skill: current.skill,
};
}
export function runtimeLocalSkillsOptions(runtimeId: string | null | undefined) {

View File

@@ -617,9 +617,18 @@ export type RuntimeLocalSkillStatus =
| "pending"
| "running"
| "completed"
| "conflict"
| "failed"
| "timeout";
export type RuntimeLocalSkillImportAction = "overwrite";
export interface RuntimeLocalSkillImportConflict {
existing_skill_id: string;
existing_created_by?: string;
can_overwrite: boolean;
}
export interface RuntimeLocalSkillSummary {
key: string;
name: string;
@@ -644,6 +653,9 @@ export interface CreateRuntimeLocalSkillImportRequest {
skill_key: string;
name?: string;
description?: string;
action?: RuntimeLocalSkillImportAction;
target_skill_id?: string;
supports_conflict?: boolean;
}
export interface RuntimeLocalSkillImportRequest {
@@ -652,8 +664,12 @@ export interface RuntimeLocalSkillImportRequest {
skill_key: string;
name?: string;
description?: string;
action?: RuntimeLocalSkillImportAction;
target_skill_id?: string;
supports_conflict?: boolean;
status: RuntimeLocalSkillStatus;
skill?: Skill;
conflict?: RuntimeLocalSkillImportConflict;
error?: string;
created_at: string;
updated_at: string;
@@ -665,5 +681,7 @@ export interface RuntimeLocalSkillsResult {
}
export interface RuntimeLocalSkillImportResult {
skill: Skill;
status: "created" | "updated" | "conflict";
skill?: Skill;
conflict?: RuntimeLocalSkillImportConflict;
}

View File

@@ -44,6 +44,8 @@ export type {
RuntimeModelListStatus,
RuntimeModelsResult,
RuntimeLocalSkillStatus,
RuntimeLocalSkillImportAction,
RuntimeLocalSkillImportConflict,
RuntimeLocalSkillSummary,
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,

View File

@@ -231,9 +231,27 @@
"bulk_complete_hint": "Import complete.",
"bulk_cancelled_hint": "Import cancelled.",
"bulk_done_button": "Done",
"bulk_summary_imported": "Imported",
"bulk_summary_created": "Created",
"bulk_summary_updated": "Updated",
"bulk_summary_conflicts": "Conflicts",
"bulk_summary_skipped": "Skipped",
"bulk_summary_failed": "Failed"
"bulk_summary_failed": "Failed",
"conflict_single_title": "A skill with this name already exists",
"conflict_bulk_title": "{{count}} skills need a decision",
"conflict_hint": "Choose whether to overwrite the existing workspace skill, import with a new name, or skip it.",
"conflict_locked_creator": "This skill was created by {{creator}}. Only the creator can overwrite it from local import; edit it from the Skill detail page if you need to change it.",
"conflict_locked": "Only the creator can overwrite this skill from local import; edit it from the Skill detail page if you need to change it.",
"conflict_overwrite": "Overwrite",
"conflict_rename": "Rename",
"conflict_skip": "Skip",
"conflict_cancel": "Cancel",
"conflict_overwrite_all": "Overwrite all",
"conflict_skip_all": "Skip all",
"conflict_rename_label": "New skill name",
"conflict_footer": "{{count}} conflict decisions pending.",
"conflict_apply_button": "Apply decisions",
"conflict_missing_resolution": "Choose how to resolve this conflict.",
"conflict_name_still_exists": "That name already exists. Choose another name or skip."
},
"file_tree": {
"no_files": "No files"

View File

@@ -228,9 +228,27 @@
"bulk_complete_hint": "インポートが完了しました。",
"bulk_cancelled_hint": "インポートをキャンセルしました。",
"bulk_done_button": "完了",
"bulk_summary_imported": "インポート済み",
"bulk_summary_created": "作成済み",
"bulk_summary_updated": "更新済み",
"bulk_summary_conflicts": "競合",
"bulk_summary_skipped": "スキップ",
"bulk_summary_failed": "失敗"
"bulk_summary_failed": "失敗",
"conflict_single_title": "同じ名前のスキルがすでに存在します",
"conflict_bulk_title": "{{count}} 個のスキルに判断が必要です",
"conflict_hint": "既存のワークスペーススキルを上書きするか、新しい名前でインポートするか、スキップするかを選択してください。",
"conflict_locked_creator": "このスキルは {{creator}} が作成しました。ローカルインポートで上書きできるのは作成者だけです。変更が必要な場合は、スキル詳細ページで編集してください。",
"conflict_locked": "ローカルインポートでこのスキルを上書きできるのは作成者だけです。変更が必要な場合は、スキル詳細ページで編集してください。",
"conflict_overwrite": "上書き",
"conflict_rename": "名前を変更",
"conflict_skip": "スキップ",
"conflict_cancel": "キャンセル",
"conflict_overwrite_all": "すべて上書き",
"conflict_skip_all": "すべてスキップ",
"conflict_rename_label": "新しいスキル名",
"conflict_footer": "{{count}} 件の競合判断が残っています。",
"conflict_apply_button": "決定を適用",
"conflict_missing_resolution": "この競合の処理方法を選択してください。",
"conflict_name_still_exists": "その名前はすでに存在します。別の名前を選択するか、スキップしてください。"
},
"file_tree": {
"no_files": "ファイルなし"

View File

@@ -231,9 +231,27 @@
"bulk_complete_hint": "가져오기가 완료되었습니다.",
"bulk_cancelled_hint": "가져오기가 취소되었습니다.",
"bulk_done_button": "완료",
"bulk_summary_imported": "가져옴",
"bulk_summary_created": "생성됨",
"bulk_summary_updated": "업데이트됨",
"bulk_summary_conflicts": "충돌",
"bulk_summary_skipped": "건너뜀",
"bulk_summary_failed": "실패"
"bulk_summary_failed": "실패",
"conflict_single_title": "이 이름의 스킬이 이미 있습니다",
"conflict_bulk_title": "{{count}}개의 스킬에 결정이 필요합니다",
"conflict_hint": "기존 워크스페이스 스킬을 덮어쓸지, 새 이름으로 가져올지, 건너뛸지 선택하세요.",
"conflict_locked_creator": "이 스킬은 {{creator}}님이 만들었습니다. 로컬 가져오기에서 덮어쓰기는 생성자만 할 수 있습니다. 변경이 필요하면 스킬 상세 페이지에서 편집하세요.",
"conflict_locked": "로컬 가져오기에서 이 스킬은 생성자만 덮어쓸 수 있습니다. 변경이 필요하면 스킬 상세 페이지에서 편집하세요.",
"conflict_overwrite": "덮어쓰기",
"conflict_rename": "이름 변경",
"conflict_skip": "건너뛰기",
"conflict_cancel": "취소",
"conflict_overwrite_all": "모두 덮어쓰기",
"conflict_skip_all": "모두 건너뛰기",
"conflict_rename_label": "새 스킬 이름",
"conflict_footer": "{{count}}개의 충돌 결정이 남아 있습니다.",
"conflict_apply_button": "결정 적용",
"conflict_missing_resolution": "이 충돌을 처리할 방법을 선택하세요.",
"conflict_name_still_exists": "해당 이름이 이미 있습니다. 다른 이름을 선택하거나 건너뛰세요."
},
"file_tree": {
"no_files": "파일 없음"

View File

@@ -243,9 +243,27 @@
"bulk_complete_hint": "导入完成。",
"bulk_cancelled_hint": "导入已取消。",
"bulk_done_button": "完成",
"bulk_summary_imported": "已导入",
"bulk_summary_created": "已创建",
"bulk_summary_updated": "已更新",
"bulk_summary_conflicts": "冲突",
"bulk_summary_skipped": "已跳过",
"bulk_summary_failed": "失败"
"bulk_summary_failed": "失败",
"conflict_single_title": "工作区里已存在同名 skill",
"conflict_bulk_title": "{{count}} 个 skill 需要处理冲突",
"conflict_hint": "选择覆盖现有工作区 skill、改名导入或跳过。",
"conflict_locked_creator": "该 skill 由 {{creator}} 创建,只能由创建者通过本地导入覆盖;如需修改请到 Skill 详情页编辑。",
"conflict_locked": "该 skill 只能由创建者通过本地导入覆盖;如需修改请到 Skill 详情页编辑。",
"conflict_overwrite": "覆盖",
"conflict_rename": "改名",
"conflict_skip": "跳过",
"conflict_cancel": "取消",
"conflict_overwrite_all": "全部覆盖",
"conflict_skip_all": "全部跳过",
"conflict_rename_label": "新的 skill 名称",
"conflict_footer": "还有 {{count}} 个冲突决策待处理。",
"conflict_apply_button": "应用决策",
"conflict_missing_resolution": "请选择如何处理这个冲突。",
"conflict_name_still_exists": "这个名称仍然已存在。请换一个名称或跳过。"
},
"file_tree": {
"no_files": "无文件"

View File

@@ -148,6 +148,7 @@ describe("RuntimeLocalSkillImportPanel", () => {
}),
});
mockResolveRuntimeLocalSkillImport.mockResolvedValue({
status: "created",
skill: MOCK_IMPORTED_SKILL_A,
});
});
@@ -183,6 +184,7 @@ describe("RuntimeLocalSkillImportPanel", () => {
skill_key: "review-helper",
name: "Review Helper",
description: "Review pull requests",
supports_conflict: true,
},
);
},
@@ -200,8 +202,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
}),
});
mockResolveRuntimeLocalSkillImport
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_A })
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_B });
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_A })
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_B });
renderPanel();
@@ -240,8 +242,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
expect(mockResolveRuntimeLocalSkillImport).toHaveBeenCalledTimes(2);
// Verify summary shows both as imported
expect(screen.getByText("Imported")).toBeInTheDocument();
// Verify summary shows both as created
expect(screen.getByText("Created")).toBeInTheDocument();
});
it("handles partial failures gracefully", async () => {
@@ -254,7 +256,7 @@ describe("RuntimeLocalSkillImportPanel", () => {
}),
});
mockResolveRuntimeLocalSkillImport
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_A })
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_A })
.mockRejectedValueOnce(new Error("409 conflict: already exists"));
renderPanel();
@@ -291,8 +293,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
{ timeout: 10000 },
);
// Summary should show imported and skipped
expect(screen.getByText("Imported")).toBeInTheDocument();
// Summary should show created and skipped
expect(screen.getByText("Created")).toBeInTheDocument();
expect(screen.getByText("Skipped")).toBeInTheDocument();
});
@@ -344,8 +346,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
}),
});
mockResolveRuntimeLocalSkillImport
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_A })
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_B });
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_A })
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_B });
const onImported = vi.fn();
const onBulkDone = vi.fn();
@@ -385,4 +387,145 @@ describe("RuntimeLocalSkillImportPanel", () => {
expect(onBulkDone).toHaveBeenCalledTimes(1);
expect(onImported).not.toHaveBeenCalled();
});
it("resolves a creator conflict by overwriting the existing skill", async () => {
mockResolveRuntimeLocalSkillImport
.mockResolvedValueOnce({
status: "conflict",
conflict: {
existing_skill_id: "existing-skill-1",
existing_created_by: "user-1",
can_overwrite: true,
},
})
.mockResolvedValueOnce({
status: "updated",
skill: {
...MOCK_IMPORTED_SKILL_A,
id: "existing-skill-1",
},
});
renderPanel();
expect(
await screen.findByText("Review Helper", {}, { timeout: 5000 }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Review Helper/i }));
const importButton = screen.getByRole("button", {
name: /Import to Workspace/i,
});
await waitFor(() => expect(importButton).not.toBeDisabled(), {
timeout: 5000,
});
fireEvent.click(importButton);
expect(
await screen.findByText(/A skill with this name already exists/i),
).toBeInTheDocument();
const applyButton = screen.getByRole("button", {
name: /Apply decisions/i,
});
await waitFor(() => expect(applyButton).not.toBeDisabled(), {
timeout: 5000,
});
fireEvent.click(applyButton);
await waitFor(
() => {
expect(mockResolveRuntimeLocalSkillImport).toHaveBeenLastCalledWith(
"runtime-1",
{
skill_key: "review-helper",
name: "Review Helper",
description: "Review pull requests",
supports_conflict: true,
action: "overwrite",
target_skill_id: "existing-skill-1",
},
);
},
{ timeout: 5000 },
);
expect(await screen.findByText("Updated")).toBeInTheDocument();
});
it("keeps bulk completion behavior when conflict resolution leaves one success", async () => {
mockRuntimeLocalSkillsOptions.mockReturnValue({
queryKey: ["runtimes", "local-skills", "runtime-1"],
queryFn: () =>
Promise.resolve({
supported: true,
skills: [MOCK_SKILL_A, MOCK_SKILL_B],
}),
});
mockResolveRuntimeLocalSkillImport
.mockResolvedValueOnce({
status: "conflict",
conflict: {
existing_skill_id: "existing-skill-1",
existing_created_by: "user-1",
can_overwrite: true,
},
})
.mockRejectedValueOnce(new Error("daemon failed"))
.mockResolvedValueOnce({
status: "updated",
skill: {
...MOCK_IMPORTED_SKILL_A,
id: "existing-skill-1",
},
});
const onImported = vi.fn();
const onBulkDone = vi.fn();
renderPanel({ onImported, onBulkDone });
expect(
await screen.findByText("Review Helper", {}, { timeout: 5000 }),
).toBeInTheDocument();
const selectAllLabel = screen.getByText(/Select all/i);
const selectAllCheckbox = selectAllLabel
.closest("label")!
.querySelector("input[type='checkbox']")!;
fireEvent.click(selectAllCheckbox);
const importButton = screen.getByRole("button", {
name: /Import 2 Skills/i,
});
await waitFor(() => expect(importButton).not.toBeDisabled(), {
timeout: 5000,
});
fireEvent.click(importButton);
expect(
await screen.findByText(/A skill with this name already exists/i),
).toBeInTheDocument();
const applyButton = screen.getByRole("button", {
name: /Apply decisions/i,
});
await waitFor(() => expect(applyButton).not.toBeDisabled(), {
timeout: 5000,
});
fireEvent.click(applyButton);
await waitFor(
() => {
expect(
screen.getByRole("button", { name: /Done/i }),
).toBeInTheDocument();
},
{ timeout: 10000 },
);
fireEvent.click(screen.getByRole("button", { name: /Done/i }));
expect(onBulkDone).toHaveBeenCalledTimes(1);
expect(onImported).not.toHaveBeenCalled();
});
});

View File

@@ -4,14 +4,18 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Download,
HardDrive,
Loader2,
RefreshCw,
SkipForward,
XCircle,
} from "lucide-react";
import type {
AgentRuntime,
RuntimeLocalSkillImportConflict,
RuntimeLocalSkillSummary,
Skill,
} from "@multica/core/types";
@@ -53,22 +57,33 @@ import { isNameConflictError } from "../lib/utils";
type BulkImportResult = {
key: string;
name: string;
status: "success" | "skipped" | "failed";
description?: string;
status: "created" | "updated" | "conflict" | "skipped" | "failed";
conflict?: RuntimeLocalSkillImportConflict;
error?: string;
skill?: Skill;
};
type BulkImportState = {
phase: "idle" | "importing" | "done" | "cancelled";
phase: "idle" | "importing" | "resolving" | "done" | "cancelled";
total: number;
completed: number;
selectedCount: number;
results: BulkImportResult[];
};
type ConflictResolutionAction = "overwrite" | "rename" | "skip";
type ConflictResolutionState = {
action: ConflictResolutionAction;
renameName: string;
};
const INITIAL_BULK_STATE: BulkImportState = {
phase: "idle",
total: 0,
completed: 0,
selectedCount: 0,
results: [],
};
@@ -90,6 +105,25 @@ function runtimeLabel(runtime: AgentRuntime): string {
return `${runtime.name} (${runtime.provider})`;
}
function defaultRenameName(name: string): string {
return `${name} copy`;
}
function ResultIcon({ status }: { status: BulkImportResult["status"] }) {
switch (status) {
case "created":
return <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-600" />;
case "updated":
return <RefreshCw className="h-3.5 w-3.5 shrink-0 text-blue-600" />;
case "conflict":
return <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-600" />;
case "skipped":
return <SkipForward className="h-3.5 w-3.5 shrink-0 text-yellow-600" />;
case "failed":
return <AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />;
}
}
// ---------------------------------------------------------------------------
// Skill row with checkbox
// ---------------------------------------------------------------------------
@@ -195,20 +229,38 @@ function SkillItem({
function BulkImportSummary({ results }: { results: BulkImportResult[] }) {
const { t } = useT("skills");
const succeeded = results.filter((r) => r.status === "success");
const created = results.filter((r) => r.status === "created");
const updated = results.filter((r) => r.status === "updated");
const conflicts = results.filter((r) => r.status === "conflict");
const skipped = results.filter((r) => r.status === "skipped");
const failed = results.filter((r) => r.status === "failed");
return (
<div className="space-y-4 py-2">
{/* Summary counts */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="grid grid-cols-2 gap-2 text-center sm:grid-cols-5">
<div className="rounded-md bg-green-50 px-3 py-2 dark:bg-green-950/30">
<div className="text-lg font-semibold text-green-700 dark:text-green-400">
{succeeded.length}
{created.length}
</div>
<div className="text-xs text-muted-foreground">
{t(($) => $.runtime_import.bulk_summary_imported)}
{t(($) => $.runtime_import.bulk_summary_created)}
</div>
</div>
<div className="rounded-md bg-blue-50 px-3 py-2 dark:bg-blue-950/30">
<div className="text-lg font-semibold text-blue-700 dark:text-blue-400">
{updated.length}
</div>
<div className="text-xs text-muted-foreground">
{t(($) => $.runtime_import.bulk_summary_updated)}
</div>
</div>
<div className="rounded-md bg-amber-50 px-3 py-2 dark:bg-amber-950/30">
<div className="text-lg font-semibold text-amber-700 dark:text-amber-400">
{conflicts.length}
</div>
<div className="text-xs text-muted-foreground">
{t(($) => $.runtime_import.bulk_summary_conflicts)}
</div>
</div>
<div className="rounded-md bg-yellow-50 px-3 py-2 dark:bg-yellow-950/30">
@@ -236,15 +288,7 @@ function BulkImportSummary({ results }: { results: BulkImportResult[] }) {
key={r.key}
className="flex items-center gap-2 rounded px-2 py-1.5 text-xs"
>
{r.status === "success" && (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-600" />
)}
{r.status === "skipped" && (
<SkipForward className="h-3.5 w-3.5 shrink-0 text-yellow-600" />
)}
{r.status === "failed" && (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
)}
<ResultIcon status={r.status} />
<span className="min-w-0 flex-1 truncate">{r.name}</span>
{r.error && (
<span className="max-w-[200px] shrink-0 truncate text-muted-foreground">
@@ -258,6 +302,166 @@ function BulkImportSummary({ results }: { results: BulkImportResult[] }) {
);
}
function ConflictResolutionPanel({
conflicts,
resolutions,
onChange,
onOverwriteAll,
onSkipAll,
}: {
conflicts: BulkImportResult[];
resolutions: Record<string, ConflictResolutionState>;
onChange: (key: string, next: ConflictResolutionState) => void;
onOverwriteAll: () => void;
onSkipAll: () => void;
}) {
const { t } = useT("skills");
const single = conflicts.length === 1;
const canOverwriteAny = conflicts.some((r) => r.conflict?.can_overwrite);
return (
<div className="space-y-4 py-2">
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
{single
? t(($) => $.runtime_import.conflict_single_title)
: t(($) => $.runtime_import.conflict_bulk_title, {
count: conflicts.length,
})}
</p>
<p className="mt-1 text-xs opacity-85">
{t(($) => $.runtime_import.conflict_hint)}
</p>
</div>
</div>
</div>
{!single && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={onOverwriteAll}
disabled={!canOverwriteAny}
>
<RefreshCw className="h-3 w-3" />
{t(($) => $.runtime_import.conflict_overwrite_all)}
</Button>
<Button type="button" size="sm" variant="outline" onClick={onSkipAll}>
<SkipForward className="h-3 w-3" />
{t(($) => $.runtime_import.conflict_skip_all)}
</Button>
</div>
)}
<div className="space-y-2">
{conflicts.map((r) => {
const resolution =
resolutions[r.key] ??
({
action: r.conflict?.can_overwrite ? "overwrite" : "rename",
renameName: defaultRenameName(r.name),
} satisfies ConflictResolutionState);
const creator = r.conflict?.existing_created_by;
return (
<div key={r.key} className="rounded-lg border bg-card p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{r.name}</div>
{r.error && (
<p className="mt-1 text-xs text-destructive">{r.error}</p>
)}
{!r.conflict?.can_overwrite && (
<p className="mt-1 text-xs text-muted-foreground">
{creator
? t(($) => $.runtime_import.conflict_locked_creator, {
creator,
})
: t(($) => $.runtime_import.conflict_locked)}
</p>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={
resolution.action === "overwrite" ? "default" : "outline"
}
onClick={() =>
onChange(r.key, {
action: "overwrite",
renameName: resolution.renameName,
})
}
disabled={!r.conflict?.can_overwrite}
>
<RefreshCw className="h-3 w-3" />
{t(($) => $.runtime_import.conflict_overwrite)}
</Button>
<Button
type="button"
size="sm"
variant={resolution.action === "rename" ? "default" : "outline"}
onClick={() =>
onChange(r.key, {
action: "rename",
renameName: resolution.renameName,
})
}
>
{t(($) => $.runtime_import.conflict_rename)}
</Button>
<Button
type="button"
size="sm"
variant={resolution.action === "skip" ? "default" : "outline"}
onClick={() =>
onChange(r.key, {
action: "skip",
renameName: resolution.renameName,
})
}
>
<XCircle className="h-3 w-3" />
{single
? t(($) => $.runtime_import.conflict_cancel)
: t(($) => $.runtime_import.conflict_skip)}
</Button>
</div>
{resolution.action === "rename" && (
<div className="mt-3 space-y-1">
<Label className="text-xs text-muted-foreground">
{t(($) => $.runtime_import.conflict_rename_label)}
</Label>
<Input
value={resolution.renameName}
onChange={(e) =>
onChange(r.key, {
action: "rename",
renameName: e.target.value,
})
}
className="h-8 text-sm"
/>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
@@ -288,12 +492,17 @@ export function RuntimeLocalSkillImportPanel({
const [selectedRuntimeId, setSelectedRuntimeId] = useState<string>("");
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [bulkState, setBulkState] = useState<BulkImportState>(INITIAL_BULK_STATE);
const [conflictResolutions, setConflictResolutions] = useState<
Record<string, ConflictResolutionState>
>({});
const cancelRef = useRef(false);
// Single-select inline edit fields (shown when exactly 1 skill is checked)
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const importing = bulkState.phase === "importing";
const resolvingConflicts = bulkState.phase === "resolving";
const busy = importing || resolvingConflicts;
useEffect(() => {
setSelectedRuntimeId((prev) => prev || localRuntimes[0]?.id || "");
@@ -302,6 +511,7 @@ export function RuntimeLocalSkillImportPanel({
useEffect(() => {
setSelectedKeys(new Set());
setBulkState(INITIAL_BULK_STATE);
setConflictResolutions({});
setEditName("");
setEditDescription("");
}, [selectedRuntimeId]);
@@ -354,6 +564,56 @@ export function RuntimeLocalSkillImportPanel({
const allSelected =
runtimeSkills.length > 0 && selectedKeys.size === runtimeSkills.length;
const someSelected = selectedKeys.size > 0 && !allSelected;
const pendingConflicts = bulkState.results.filter(
(r) => r.status === "conflict" && r.conflict,
);
const canApplyConflictResolutions =
pendingConflicts.length > 0 &&
pendingConflicts.every((r) => {
const resolution = conflictResolutions[r.key];
if (!resolution) return false;
if (resolution.action === "overwrite") return !!r.conflict?.can_overwrite;
if (resolution.action === "rename") return !!resolution.renameName.trim();
return true;
});
const setConflictResolution = (
key: string,
next: ConflictResolutionState,
) => {
setConflictResolutions((prev) => ({ ...prev, [key]: next }));
};
const seedConflictResolutions = (results: BulkImportResult[]) => {
const next: Record<string, ConflictResolutionState> = {};
for (const r of results) {
if (r.status !== "conflict" || !r.conflict) continue;
next[r.key] = {
action: r.conflict.can_overwrite ? "overwrite" : "rename",
renameName: defaultRenameName(r.name),
};
}
setConflictResolutions(next);
};
const refreshImportedSkills = async (results: BulkImportResult[]) => {
await Promise.all([
qc.invalidateQueries({
queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId),
}),
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }),
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }),
]);
for (const r of results) {
if ((r.status === "created" || r.status === "updated") && r.skill) {
qc.setQueryData(
skillDetailOptions(wsId, r.skill.id).queryKey,
r.skill,
);
}
}
};
// -- Bulk import handler --
@@ -364,7 +624,13 @@ export function RuntimeLocalSkillImportPanel({
const total = skillsToImport.length;
cancelRef.current = false;
setBulkState({ phase: "importing", total, completed: 0, results: [] });
setBulkState({
phase: "importing",
total,
completed: 0,
selectedCount: total,
results: [],
});
const results: BulkImportResult[] = [];
@@ -381,19 +647,32 @@ export function RuntimeLocalSkillImportPanel({
skill_key: skill.key,
name: importName,
description: importDescription,
supports_conflict: true,
});
results.push({
key: skill.key,
name: importName,
status: "success",
skill: result.skill,
});
if (result.status === "conflict") {
results.push({
key: skill.key,
name: importName,
description: importDescription,
status: "conflict",
conflict: result.conflict,
});
} else {
results.push({
key: skill.key,
name: result.skill?.name ?? importName,
description: importDescription,
status: result.status,
skill: result.skill,
});
}
} catch (error) {
const msg = error instanceof Error ? error.message : "";
const isSkipped = isNameConflictError(msg);
results.push({
key: skill.key,
name: skill.name,
description: importDescription,
status: isSkipped ? "skipped" : "failed",
error: msg || t(($) => $.runtime_import.toast_import_failed),
});
@@ -420,23 +699,16 @@ export function RuntimeLocalSkillImportPanel({
}
await Promise.all(executing);
// Invalidate queries ONCE at the end
await Promise.all([
qc.invalidateQueries({
queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId),
}),
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }),
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }),
]);
await refreshImportedSkills(results);
// Seed query cache for each successfully imported skill
for (const r of results) {
if (r.status === "success" && r.skill) {
qc.setQueryData(
skillDetailOptions(wsId, r.skill.id).queryKey,
r.skill,
);
}
const conflicts = results.filter((r) => r.status === "conflict");
if (!cancelRef.current && conflicts.length > 0) {
seedConflictResolutions(results);
setBulkState((prev) => ({
...prev,
phase: "resolving",
}));
return;
}
setBulkState((prev) => ({
@@ -445,13 +717,120 @@ export function RuntimeLocalSkillImportPanel({
}));
};
const handleApplyConflictResolutions = async () => {
if (!selectedRuntimeId || pendingConflicts.length === 0) return;
const conflicts = [...pendingConflicts];
let nextResults = bulkState.results;
const applyResult = (key: string, next: Partial<BulkImportResult>) => {
nextResults = nextResults.map((r) =>
r.key === key ? { ...r, ...next } : r,
);
setBulkState((prev) => ({
...prev,
results: prev.results.map((r) =>
r.key === key ? { ...r, ...next } : r,
),
}));
};
setBulkState((prev) => ({
...prev,
phase: "importing",
total: conflicts.length,
completed: 0,
}));
for (const r of conflicts) {
const resolution = conflictResolutions[r.key];
if (!resolution) {
applyResult(r.key, {
status: "failed",
error: t(($) => $.runtime_import.conflict_missing_resolution),
});
setBulkState((prev) => ({ ...prev, completed: prev.completed + 1 }));
continue;
}
if (resolution.action === "skip") {
applyResult(r.key, { status: "skipped", error: undefined });
setBulkState((prev) => ({ ...prev, completed: prev.completed + 1 }));
continue;
}
try {
const result = await resolveRuntimeLocalSkillImport(selectedRuntimeId, {
skill_key: r.key,
name:
resolution.action === "rename"
? resolution.renameName.trim()
: r.name,
description: r.description,
supports_conflict: true,
...(resolution.action === "overwrite" && r.conflict
? {
action: "overwrite" as const,
target_skill_id: r.conflict.existing_skill_id,
}
: {}),
});
if (result.status === "conflict") {
applyResult(r.key, {
name:
resolution.action === "rename"
? resolution.renameName.trim()
: r.name,
status: "conflict",
conflict: result.conflict,
error: t(($) => $.runtime_import.conflict_name_still_exists),
});
if (result.conflict) {
setConflictResolution(r.key, {
action: result.conflict.can_overwrite ? "overwrite" : "rename",
renameName: defaultRenameName(
resolution.action === "rename"
? resolution.renameName.trim()
: r.name,
),
});
}
} else {
applyResult(r.key, {
name: result.skill?.name ?? r.name,
status: result.status,
skill: result.skill,
conflict: undefined,
error: undefined,
});
}
} catch (error) {
const msg = error instanceof Error ? error.message : "";
applyResult(r.key, {
status: "failed",
error: msg || t(($) => $.runtime_import.toast_import_failed),
});
}
setBulkState((prev) => ({ ...prev, completed: prev.completed + 1 }));
}
await refreshImportedSkills(nextResults);
const unresolved = nextResults.some((r) => r.status === "conflict");
setBulkState((prev) => ({
...prev,
results: nextResults,
phase: unresolved ? "resolving" : "done",
}));
};
const canImport =
!!selectedRuntime &&
selectedRuntime.status === "online" &&
selectedKeys.size > 0 &&
// Single-select requires a non-empty name (user may be renaming)
(selectedKeys.size > 1 || !!editName.trim()) &&
!importing;
!busy;
const handleCancel = () => {
cancelRef.current = true;
@@ -488,15 +867,7 @@ export function RuntimeLocalSkillImportPanel({
key={r.key}
className="flex items-center gap-2 rounded px-2 py-1 text-xs"
>
{r.status === "success" && (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-600" />
)}
{r.status === "skipped" && (
<SkipForward className="h-3.5 w-3.5 shrink-0 text-yellow-600" />
)}
{r.status === "failed" && (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
)}
<ResultIcon status={r.status} />
<span className="truncate">{r.name}</span>
</div>
))}
@@ -510,6 +881,41 @@ export function RuntimeLocalSkillImportPanel({
return <BulkImportSummary results={bulkState.results} />;
}
if (bulkState.phase === "resolving") {
return (
<ConflictResolutionPanel
conflicts={pendingConflicts}
resolutions={conflictResolutions}
onChange={setConflictResolution}
onOverwriteAll={() => {
setConflictResolutions((prev) => {
const next = { ...prev };
for (const r of pendingConflicts) {
if (!r.conflict?.can_overwrite) continue;
next[r.key] = {
action: "overwrite",
renameName: prev[r.key]?.renameName ?? defaultRenameName(r.name),
};
}
return next;
});
}}
onSkipAll={() => {
setConflictResolutions((prev) => {
const next = { ...prev };
for (const r of pendingConflicts) {
next[r.key] = {
action: "skip",
renameName: prev[r.key]?.renameName ?? defaultRenameName(r.name),
};
}
return next;
});
}}
/>
);
}
// --- Idle phase: skill selection list ---
if (localRuntimes.length === 0) {
@@ -626,10 +1032,16 @@ export function RuntimeLocalSkillImportPanel({
// -- Handle "Done" button after import --
const handleDone = () => {
const succeeded = bulkState.results.filter((r) => r.status === "success");
const succeeded = bulkState.results.filter(
(r) => r.status === "created" || r.status === "updated",
);
// Single-import flow: navigate to the imported skill detail page.
// Multi-import flow: close the dialog even if only one succeeded.
if (bulkState.total === 1 && succeeded.length === 1 && succeeded[0]!.skill) {
if (
bulkState.selectedCount === 1 &&
succeeded.length === 1 &&
succeeded[0]!.skill
) {
onImported?.(succeeded[0]!.skill);
} else {
onBulkDone?.();
@@ -640,9 +1052,9 @@ export function RuntimeLocalSkillImportPanel({
<div className="flex min-h-0 flex-1 flex-col">
{/* Sticky top: runtime picker + status */}
<div
aria-disabled={importing || undefined}
aria-disabled={busy || undefined}
className={`shrink-0 space-y-2 border-b px-5 py-3 ${
importing ? "pointer-events-none opacity-60" : ""
busy ? "pointer-events-none opacity-60" : ""
}`}
>
<div className="space-y-1.5">
@@ -691,9 +1103,7 @@ export function RuntimeLocalSkillImportPanel({
style={fadeStyle}
aria-disabled={importing || undefined}
className={`min-h-0 flex-1 overflow-y-auto px-5 py-3 ${
importing && bulkState.phase !== "importing"
? "pointer-events-none opacity-60"
: ""
importing && bulkState.phase !== "importing" ? "pointer-events-none opacity-60" : ""
}`}
>
{middle}
@@ -717,6 +1127,22 @@ export function RuntimeLocalSkillImportPanel({
{t(($) => $.runtime_import.bulk_done_button)}
</Button>
</>
) : resolvingConflicts ? (
<>
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
{t(($) => $.runtime_import.conflict_footer, {
count: pendingConflicts.length,
})}
</div>
<Button
type="button"
size="sm"
onClick={handleApplyConflictResolutions}
disabled={!canApplyConflictResolutions}
>
{t(($) => $.runtime_import.conflict_apply_button)}
</Button>
</>
) : importing ? (
<>
<div className="min-w-0 flex-1 text-xs text-muted-foreground">

View File

@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
@@ -141,6 +140,7 @@ func init() {
// skill import
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
skillImportCmd.Flags().String("on-conflict", "fail", "Conflict strategy when a skill with the same name exists: fail, overwrite, rename, or skip")
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
// skill search
@@ -419,9 +419,14 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
if importURL == "" {
return fmt.Errorf("--url is required")
}
onConflict, _ := cmd.Flags().GetString("on-conflict")
if !validSkillImportConflictStrategy(onConflict) {
return fmt.Errorf("--on-conflict must be one of: fail, overwrite, rename, skip")
}
body := map[string]any{
"url": importURL,
"url": importURL,
"on_conflict": onConflict,
}
ctx, cancel := context.WithTimeout(context.Background(), cli.AtLeastAPITimeout(60*time.Second))
@@ -429,44 +434,107 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
var result map[string]any
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
if handleSkillImportConflict(cmd, err) {
return nil
if handledErr := handleSkillImportError(cmd, err); handledErr != nil {
return handledErr
}
return fmt.Errorf("import skill: %w", err)
}
return printSkillImportResult(cmd, result)
}
func validSkillImportConflictStrategy(strategy string) bool {
switch strategy {
case "fail", "overwrite", "rename", "skip":
return true
}
return false
}
func handleSkillImportError(cmd *cobra.Command, err error) error {
var httpErr *cli.HTTPError
if !errors.As(err, &httpErr) || strings.TrimSpace(httpErr.Body) == "" {
return nil
}
var body map[string]any
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
return nil
}
if _, ok := body["status"]; !ok {
if _, hasExisting := body["existing_skill"]; !hasExisting {
return nil
}
body = normalizeLegacySkillImportConflict(body)
}
if err := printSkillImportResult(cmd, body); err != nil {
return err
}
reason := strVal(body, "reason")
if reason == "" {
reason = strVal(body, "error")
}
if reason == "" {
reason = "skill import conflict"
}
return errors.New(reason)
}
func normalizeLegacySkillImportConflict(body map[string]any) map[string]any {
reason := strVal(body, "error")
if reason == "" {
reason = "a skill with this name already exists"
}
reason += "; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy"
return map[string]any{
"status": "conflict",
"reason": reason,
"existing_skill": body["existing_skill"],
}
}
func printSkillImportResult(cmd *cobra.Command, result map[string]any) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, result)
}
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
status := strVal(result, "status")
if status == "" {
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
return nil
}
skill := nestedMap(result, "skill")
existing := nestedMap(result, "existing_skill")
reason := strVal(result, "reason")
switch status {
case "created":
fmt.Printf("Skill imported: %s (%s)\n", strVal(skill, "name"), strVal(skill, "id"))
case "updated":
fmt.Printf("Skill updated: %s (%s)\n", strVal(skill, "name"), strVal(skill, "id"))
case "skipped":
fmt.Printf("Skill skipped: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
case "conflict":
fmt.Printf("Skill import conflict: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
case "failed":
fmt.Printf("Skill import failed: %s\n", reason)
default:
fmt.Printf("Skill import %s\n", status)
}
if reason != "" && status != "failed" {
fmt.Printf("Reason: %s\n", reason)
}
return nil
}
func handleSkillImportConflict(cmd *cobra.Command, err error) bool {
var httpErr *cli.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict || strings.TrimSpace(httpErr.Body) == "" {
return false
func nestedMap(m map[string]any, key string) map[string]any {
nested, _ := m[key].(map[string]any)
if nested == nil {
return map[string]any{}
}
var body map[string]any
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
return false
}
if _, ok := body["existing_skill"]; !ok {
return false
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
_ = cli.PrintJSON(os.Stdout, body)
return true
}
existing, _ := body["existing_skill"].(map[string]any)
fmt.Printf("Skill already exists: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
return true
return nested
}
func runSkillSearch(cmd *cobra.Command, args []string) error {

View File

@@ -18,6 +18,7 @@ func newSkillImportTestCmd() *cobra.Command {
cmd.Flags().String("workspace-id", "", "")
cmd.Flags().String("profile", "", "")
cmd.Flags().String("url", "", "")
cmd.Flags().String("on-conflict", "fail", "")
cmd.Flags().String("output", "json", "")
return cmd
}
@@ -43,7 +44,7 @@ func captureStdout(t *testing.T, fn func() error) (string, error) {
return string(out), runErr
}
func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
func TestRunSkillImportJsonTreatsDuplicateAsConflictResult(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
@@ -58,10 +59,21 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
if r.Header.Get("X-Workspace-ID") != "workspace-123" {
t.Fatalf("X-Workspace-ID = %q, want workspace-123", r.Header.Get("X-Workspace-ID"))
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body["url"] != "https://skills.sh/acme/review-helper" {
t.Fatalf("url = %v", body["url"])
}
if body["on_conflict"] != "fail" {
t.Fatalf("on_conflict = %v, want fail", body["on_conflict"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "a skill with this name already exists",
"status": "conflict",
"reason": "a skill with this name already exists; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy",
"existing_skill": map[string]any{
"id": "skill-123",
"name": "review-helper",
@@ -78,16 +90,19 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
out, err := captureStdout(t, func() error {
return runSkillImport(cmd, nil)
})
if err != nil {
t.Fatalf("runSkillImport returned error for duplicate import: %v", err)
if err == nil {
t.Fatal("expected duplicate import to return an error")
}
var got map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode stdout JSON %q: %v", out, err)
}
if got["error"] != "a skill with this name already exists" {
t.Fatalf("error = %v", got["error"])
if got["status"] != "conflict" {
t.Fatalf("status = %v", got["status"])
}
if !strings.Contains(strVal(got, "reason"), "--on-conflict overwrite") {
t.Fatalf("reason = %v", got["reason"])
}
existing, ok := got["existing_skill"].(map[string]any)
if !ok {
@@ -98,6 +113,52 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
}
}
func TestRunSkillImportSendsOnConflictAndPrintsStructuredResult(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body["on_conflict"] != "overwrite" {
t.Fatalf("on_conflict = %v, want overwrite", body["on_conflict"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "updated",
"skill": map[string]any{
"id": "skill-123",
"name": "review-helper",
},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
cmd := newSkillImportTestCmd()
_ = cmd.Flags().Set("url", "https://skills.sh/acme/review-helper")
_ = cmd.Flags().Set("on-conflict", "overwrite")
_ = cmd.Flags().Set("output", "json")
out, err := captureStdout(t, func() error {
return runSkillImport(cmd, nil)
})
if err != nil {
t.Fatalf("runSkillImport returned error: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode stdout JSON %q: %v", out, err)
}
if got["status"] != "updated" {
t.Fatalf("status = %v", got["status"])
}
}
func TestRunSkillSearchRequestsSearchEndpoint(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sort"
@@ -11,6 +12,8 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@@ -24,8 +27,35 @@ const (
RuntimeLocalSkillCompleted RuntimeLocalSkillRequestStatus = "completed"
RuntimeLocalSkillFailed RuntimeLocalSkillRequestStatus = "failed"
RuntimeLocalSkillTimeout RuntimeLocalSkillRequestStatus = "timeout"
// RuntimeLocalSkillConflict is a terminal state set when a fresh import
// hits an existing same-name skill. It is not an error: the request carries
// structured Conflict metadata so the caller (Desktop UI / CLI) can offer
// overwrite / rename / skip instead of silently failing. See MUL-2800.
RuntimeLocalSkillConflict RuntimeLocalSkillRequestStatus = "conflict"
)
// LocalSkillImportAction selects how a runtime-local-skill import resolves when
// a skill with the same name already exists in the workspace.
type LocalSkillImportAction string
const (
// LocalSkillImportActionCreate is the default: create a new skill, and
// surface a structured `conflict` if the name is already taken.
LocalSkillImportActionCreate LocalSkillImportAction = ""
// LocalSkillImportActionOverwrite re-imports onto an existing skill,
// identified by TargetSkillID. Only the skill's creator may overwrite.
LocalSkillImportActionOverwrite LocalSkillImportAction = "overwrite"
)
// LocalSkillImportConflict is the structured result attached to a request that
// terminated in RuntimeLocalSkillConflict. CanOverwrite reflects the
// creator-only re-import policy (canOverwriteSkillByLocalImport).
type LocalSkillImportConflict struct {
ExistingSkillID string `json:"existing_skill_id"`
ExistingCreatedBy string `json:"existing_created_by,omitempty"`
CanOverwrite bool `json:"can_overwrite"`
}
const (
// runtimeLocalSkillPendingTimeout bounds how long a request can sit in
// pending before the server marks it timed out. The value must accommodate
@@ -62,11 +92,28 @@ type LocalSkillListStore interface {
Fail(ctx context.Context, id string, errMsg string) error
}
// LocalSkillImportRequestInput carries the fields needed to enqueue a
// runtime-local-skill import. SupportsConflict gates the structured-conflict
// contract: only clients that opt in receive the `conflict` terminal status;
// older clients keep the legacy `failed` ("a skill with this name already
// exists") behavior so an already-installed Desktop build doesn't regress when
// it talks to an upgraded backend. See MUL-2800.
type LocalSkillImportRequestInput struct {
RuntimeID string
CreatorID string
SkillKey string
Name *string
Description *string
Action LocalSkillImportAction
TargetSkillID string
SupportsConflict bool
}
// LocalSkillImportStore is the same contract as LocalSkillListStore but for
// runtime-local-skill import requests. Kept as a separate interface because the
// Create signature carries import-specific fields (skill_key, optional rename).
type LocalSkillImportStore interface {
Create(ctx context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error)
Create(ctx context.Context, input LocalSkillImportRequestInput) (*RuntimeLocalSkillImportRequest, error)
Get(ctx context.Context, id string) (*RuntimeLocalSkillImportRequest, error)
HasPending(ctx context.Context, runtimeID string) (bool, error)
PopPending(ctx context.Context, runtimeID string) (*RuntimeLocalSkillImportRequest, error)
@@ -75,6 +122,9 @@ type LocalSkillImportStore interface {
// multiple imports per heartbeat cycle.
PopPendingBatch(ctx context.Context, runtimeID string, limit int) ([]*RuntimeLocalSkillImportRequest, error)
Complete(ctx context.Context, id string, skill SkillResponse) error
// Conflict transitions a request to the terminal RuntimeLocalSkillConflict
// state, attaching structured conflict metadata for the caller to act on.
Conflict(ctx context.Context, id string, info LocalSkillImportConflict) error
Fail(ctx context.Context, id string, errMsg string) error
}
@@ -143,18 +193,25 @@ type RuntimeLocalSkillListRequest struct {
}
type RuntimeLocalSkillImportRequest struct {
ID string `json:"id"`
RuntimeID string `json:"runtime_id"`
SkillKey string `json:"skill_key"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Status RuntimeLocalSkillRequestStatus `json:"status"`
Skill *SkillResponse `json:"skill,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatorID string `json:"-"`
RunStartedAt *time.Time `json:"-"`
ID string `json:"id"`
RuntimeID string `json:"runtime_id"`
SkillKey string `json:"skill_key"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Action LocalSkillImportAction `json:"action,omitempty"`
TargetSkillID string `json:"target_skill_id,omitempty"`
// SupportsConflict records whether the initiating client opted into the
// structured-conflict contract; consulted at report time to decide between
// the new `conflict` status and the legacy `failed` behavior.
SupportsConflict bool `json:"supports_conflict,omitempty"`
Status RuntimeLocalSkillRequestStatus `json:"status"`
Skill *SkillResponse `json:"skill,omitempty"`
Conflict *LocalSkillImportConflict `json:"conflict,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatorID string `json:"-"`
RunStartedAt *time.Time `json:"-"`
}
// InMemoryLocalSkillListStore is the single-node implementation — good enough
@@ -277,7 +334,7 @@ func NewInMemoryLocalSkillImportStore() *InMemoryLocalSkillImportStore {
return &InMemoryLocalSkillImportStore{requests: make(map[string]*RuntimeLocalSkillImportRequest)}
}
func (s *InMemoryLocalSkillImportStore) Create(_ context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error) {
func (s *InMemoryLocalSkillImportStore) Create(_ context.Context, input LocalSkillImportRequestInput) (*RuntimeLocalSkillImportRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -288,15 +345,18 @@ func (s *InMemoryLocalSkillImportStore) Create(_ context.Context, runtimeID, cre
}
req := &RuntimeLocalSkillImportRequest{
ID: randomID(),
RuntimeID: runtimeID,
SkillKey: skillKey,
Name: name,
Description: description,
Status: RuntimeLocalSkillPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatorID: creatorID,
ID: randomID(),
RuntimeID: input.RuntimeID,
SkillKey: input.SkillKey,
Name: input.Name,
Description: input.Description,
Action: input.Action,
TargetSkillID: input.TargetSkillID,
SupportsConflict: input.SupportsConflict,
Status: RuntimeLocalSkillPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatorID: input.CreatorID,
}
s.requests[req.ID] = req
return req, nil
@@ -396,6 +456,19 @@ func (s *InMemoryLocalSkillImportStore) Complete(_ context.Context, id string, s
return nil
}
func (s *InMemoryLocalSkillImportStore) Conflict(_ context.Context, id string, info LocalSkillImportConflict) error {
s.mu.Lock()
defer s.mu.Unlock()
if req, ok := s.requests[id]; ok {
req.Status = RuntimeLocalSkillConflict
conflict := info
req.Conflict = &conflict
req.UpdatedAt = time.Now()
}
return nil
}
func (s *InMemoryLocalSkillImportStore) Fail(_ context.Context, id string, errMsg string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -412,6 +485,14 @@ type CreateRuntimeLocalSkillImportRequest struct {
SkillKey string `json:"skill_key"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
// Action selects create (default) vs overwrite. When overwrite,
// TargetSkillID must reference the existing same-name skill.
Action LocalSkillImportAction `json:"action,omitempty"`
TargetSkillID string `json:"target_skill_id,omitempty"`
// SupportsConflict opts the client into the structured-conflict contract.
// Omit it (older clients) to keep the legacy `failed` behavior on a
// same-name collision. An overwrite request implies the new contract.
SupportsConflict bool `json:"supports_conflict,omitempty"`
}
type reportedRuntimeLocalSkill struct {
@@ -435,7 +516,8 @@ func cleanOptionalString(value *string) *string {
}
func runtimeLocalSkillRequestTerminal(status RuntimeLocalSkillRequestStatus) bool {
return status == RuntimeLocalSkillCompleted || status == RuntimeLocalSkillFailed || status == RuntimeLocalSkillTimeout
return status == RuntimeLocalSkillCompleted || status == RuntimeLocalSkillFailed ||
status == RuntimeLocalSkillTimeout || status == RuntimeLocalSkillConflict
}
func (h *Handler) requireRuntimeLocalSkillAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (runtimeIDAndWorkspace, bool) {
@@ -542,14 +624,36 @@ func (h *Handler) InitiateImportLocalSkill(w http.ResponseWriter, r *http.Reques
return
}
importReq, err := h.LocalSkillImportStore.Create(
r.Context(),
rt.runtimeID,
creatorID,
strings.TrimSpace(req.SkillKey),
cleanOptionalString(req.Name),
cleanOptionalString(req.Description),
)
targetSkillID := ""
switch req.Action {
case LocalSkillImportActionCreate:
// nothing extra
case LocalSkillImportActionOverwrite:
// Existence + creator permission are re-verified authoritatively at
// report time (the skill may change between confirm and write); here we
// only require a well-formed target so we never enqueue a doomed write.
uuid, ok := parseUUIDOrBadRequest(w, strings.TrimSpace(req.TargetSkillID), "target_skill_id")
if !ok {
return
}
targetSkillID = uuidToString(uuid)
default:
writeError(w, http.StatusBadRequest, "invalid action")
return
}
importReq, err := h.LocalSkillImportStore.Create(r.Context(), LocalSkillImportRequestInput{
RuntimeID: rt.runtimeID,
CreatorID: creatorID,
SkillKey: strings.TrimSpace(req.SkillKey),
Name: cleanOptionalString(req.Name),
Description: cleanOptionalString(req.Description),
Action: req.Action,
TargetSkillID: targetSkillID,
// An overwrite request is inherently a new-client action, so it implies
// the structured-conflict contract even if the flag is omitted.
SupportsConflict: req.SupportsConflict || req.Action == LocalSkillImportActionOverwrite,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enqueue local skill import: "+err.Error())
return
@@ -671,21 +775,11 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
}
if body.Status != "completed" {
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, body.Error); err != nil {
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
h.failLocalSkillImport(w, r, requestID, body.Error)
return
}
if body.Skill == nil {
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, "daemon returned an empty skill bundle"); err != nil {
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
h.failLocalSkillImport(w, r, requestID, "daemon returned an empty skill bundle")
return
}
creatorUUID, err := util.ParseUUID(req.CreatorID)
@@ -715,33 +809,103 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
files = append(files, f)
}
config := map[string]any{
"origin": map[string]any{
"type": "runtime_local",
"runtime_id": runtimeID,
"provider": body.Skill.Provider,
"source_path": body.Skill.SourcePath,
},
}
// Overwrite path: re-import onto an existing skill. Existence and creator
// permission are re-verified inside overwriteSkillWithFiles, in the same tx
// as the write, so a target deleted (or a creator change) between the user's
// confirm and this report fails cleanly without falling back to create.
if req.Action == LocalSkillImportActionOverwrite {
targetUUID, perr := util.ParseUUID(req.TargetSkillID)
if perr != nil {
failMsg := "stored target_skill_id is invalid"
if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil {
slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID)
}
writeError(w, http.StatusInternalServerError, failMsg)
return
}
resp, oerr := h.overwriteSkillWithFiles(r.Context(), skillOverwriteInput{
WorkspaceID: rt.WorkspaceID,
TargetSkillID: targetUUID,
UserID: req.CreatorID,
ExpectedName: sanitizeNullBytes(name),
Description: description,
Content: body.Skill.Content,
Config: config,
Files: files,
})
if oerr != nil {
failMsg := oerr.Error()
switch {
case errors.Is(oerr, errSkillOverwriteNotFound):
failMsg = "target skill no longer exists"
case errors.Is(oerr, errSkillOverwriteForbidden):
failMsg = "you no longer have permission to overwrite this skill"
case errors.Is(oerr, errSkillOverwriteNameMismatch):
failMsg = "target skill name no longer matches the imported skill"
}
h.failLocalSkillImport(w, r, requestID, failMsg)
return
}
if err := h.LocalSkillImportStore.Complete(r.Context(), requestID, resp.SkillResponse); err != nil {
// The overwrite already committed; unlike the create path we must
// NOT delete the skill to "roll back" (that would destroy a
// pre-existing skill and its agent bindings). Surface 5xx so the
// daemon retries — the retry re-applies the same UPDATE idempotently.
slog.Error("local skill import overwrite Complete failed",
"error", err, "request_id", requestID, "skill_id", resp.ID)
writeError(w, http.StatusInternalServerError, "failed to persist import completion")
return
}
h.publish(protocol.EventSkillUpdated, uuidToString(rt.WorkspaceID), "member", req.CreatorID, map[string]any{"skill": resp})
slog.Debug("runtime local skill overwritten", "runtime_id", runtimeID, "request_id", requestID, "skill_id", resp.ID)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
// Create path: detect a same-name conflict before writing. For opted-in
// clients this is a structured terminal state (not a failure) so the caller
// can offer overwrite / rename / skip; older clients keep the legacy
// `failed` behavior (see resolveLocalSkillConflict).
if existing, found, lerr := h.lookupSkillByName(r.Context(), rt.WorkspaceID, sanitizeNullBytes(name)); lerr != nil {
h.failLocalSkillImport(w, r, requestID, "failed to check for existing skill: "+lerr.Error())
return
} else if found {
h.resolveLocalSkillConflict(w, r, req, existing)
return
}
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
WorkspaceID: rt.WorkspaceID,
CreatorID: creatorUUID,
Name: name,
Description: description,
Content: body.Skill.Content,
Config: map[string]any{
"origin": map[string]any{
"type": "runtime_local",
"runtime_id": runtimeID,
"provider": body.Skill.Provider,
"source_path": body.Skill.SourcePath,
},
},
Files: files,
Config: config,
Files: files,
})
if err != nil {
failMsg := err.Error()
// A unique-violation here means another import won the race between our
// lookup and the insert — surface it as a conflict, not a hard failure.
if isUniqueViolation(err) {
failMsg = "a skill with this name already exists"
}
if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil {
slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
if existing, found, lerr := h.lookupSkillByName(r.Context(), rt.WorkspaceID, sanitizeNullBytes(name)); lerr == nil && found {
h.resolveLocalSkillConflict(w, r, req, existing)
return
}
// Lost the row again (deleted between insert-fail and re-lookup):
// fall through to the legacy unique-violation message.
h.failLocalSkillImport(w, r, requestID, "a skill with this name already exists")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
h.failLocalSkillImport(w, r, requestID, err.Error())
return
}
@@ -766,3 +930,64 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
slog.Debug("runtime local skill imported", "runtime_id", runtimeID, "request_id", requestID, "skill_id", resp.ID)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// failLocalSkillImport marks the request failed and writes the standard daemon
// response (200 ok). If the store write itself fails it returns 500 so the
// daemon retries.
func (h *Handler) failLocalSkillImport(w http.ResponseWriter, r *http.Request, requestID, failMsg string) {
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); err != nil {
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist failure")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// resolveLocalSkillConflict terminates a same-name create import. Clients that
// opted into the structured-conflict contract (SupportsConflict) receive the
// `conflict` status plus metadata so they can offer overwrite / rename / skip;
// older clients keep the legacy `failed` ("a skill with this name already
// exists") behavior so an installed Desktop build that predates the contract
// doesn't regress when it hits an upgraded backend.
func (h *Handler) resolveLocalSkillConflict(w http.ResponseWriter, r *http.Request, req *RuntimeLocalSkillImportRequest, existing db.Skill) {
if req.SupportsConflict {
h.reportLocalSkillConflict(w, r, req.ID, req.CreatorID, existing)
return
}
h.failLocalSkillImport(w, r, req.ID, "a skill with this name already exists")
}
// reportLocalSkillConflict records a same-name conflict as the terminal
// RuntimeLocalSkillConflict state with structured metadata the caller uses to
// offer overwrite / rename / skip.
func (h *Handler) reportLocalSkillConflict(w http.ResponseWriter, r *http.Request, requestID, creatorID string, existing db.Skill) {
info := LocalSkillImportConflict{
ExistingSkillID: uuidToString(existing.ID),
CanOverwrite: canOverwriteSkillByLocalImport(creatorID, existing),
}
if existing.CreatedBy.Valid {
info.ExistingCreatedBy = uuidToString(existing.CreatedBy)
}
if err := h.LocalSkillImportStore.Conflict(r.Context(), requestID, info); err != nil {
slog.Error("local skill import Conflict failed", "error", err, "request_id", requestID)
writeError(w, http.StatusInternalServerError, "failed to persist conflict")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// lookupSkillByName resolves a skill by (workspace, name). found=false with a
// nil error means there is no such skill — i.e. no conflict.
func (h *Handler) lookupSkillByName(ctx context.Context, workspaceID pgtype.UUID, name string) (db.Skill, bool, error) {
skill, err := h.Queries.GetSkillByWorkspaceAndName(ctx, db.GetSkillByWorkspaceAndNameParams{
WorkspaceID: workspaceID,
Name: name,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.Skill{}, false, nil
}
return db.Skill{}, false, err
}
return skill, true, nil
}

View File

@@ -0,0 +1,434 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// createImportTargetSkill inserts a skill (owned by ownerID) plus the given
// path->content files directly into the DB, returning its id. Used as the
// pre-existing skill that conflict / overwrite imports collide with.
func createImportTargetSkill(t *testing.T, name, ownerID string, files map[string]string) string {
t.Helper()
var skillID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
VALUES ($1, $2, 'original description', '# original', '{}'::jsonb, $3)
RETURNING id
`, testWorkspaceID, name, ownerID).Scan(&skillID); err != nil {
t.Fatalf("create target skill: %v", err)
}
for path, content := range files {
if _, err := testPool.Exec(context.Background(), `
INSERT INTO skill_file (skill_id, path, content) VALUES ($1, $2, $3)
`, skillID, path, content); err != nil {
t.Fatalf("create skill file: %v", err)
}
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, skillID)
})
return skillID
}
// bindAgentToSkill creates a workspace agent and binds it to skillID via
// agent_skill, returning the agent id. Lets overwrite tests assert the binding
// survives the re-import.
func bindAgentToSkill(t *testing.T, skillID string) string {
t.Helper()
agentName := fmt.Sprintf("overwrite-test-agent-%d", time.Now().UnixNano())
var agentID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
RETURNING id
`, testWorkspaceID, agentName, testRuntimeID, testUserID).Scan(&agentID); err != nil {
t.Fatalf("create agent: %v", err)
}
if _, err := testPool.Exec(context.Background(), `
INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)
`, agentID, skillID); err != nil {
t.Fatalf("bind agent skill: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
return agentID
}
func countAgentSkillBindings(t *testing.T, skillID string) int {
t.Helper()
var count int
if err := testPool.QueryRow(context.Background(), `
SELECT count(*) FROM agent_skill WHERE skill_id = $1
`, skillID).Scan(&count); err != nil {
t.Fatalf("count agent_skill: %v", err)
}
return count
}
func getSkillRow(t *testing.T, skillID string) (name, description, content, createdBy string) {
t.Helper()
if err := testPool.QueryRow(context.Background(), `
SELECT name, description, content, COALESCE(created_by::text, '')
FROM skill WHERE id = $1
`, skillID).Scan(&name, &description, &content, &createdBy); err != nil {
t.Fatalf("get skill row: %v", err)
}
return
}
// reportBundleBody builds the daemon "completed" report body for an import.
func reportBundleBody(name, description, content string, files map[string]string) map[string]any {
fileList := make([]map[string]any, 0, len(files))
for p, c := range files {
fileList = append(fileList, map[string]any{"path": p, "content": c})
}
return map[string]any{
"status": "completed",
"skill": map[string]any{
"name": name,
"description": description,
"content": content,
"source_path": "~/.claude/skills/review-helper",
"provider": "claude",
"files": fileList,
},
}
}
func initiateLocalSkillImport(t *testing.T, runtimeID string, body map[string]any) string {
t.Helper()
w := httptest.NewRecorder()
req := withURLParams(
newRequestAsUser(testUserID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills/import", body),
"runtimeId", runtimeID,
)
testHandler.InitiateImportLocalSkill(w, req)
if w.Code != http.StatusOK {
t.Fatalf("InitiateImportLocalSkill: expected 200, got %d: %s", w.Code, w.Body.String())
}
var importReq RuntimeLocalSkillImportRequest
if err := json.NewDecoder(w.Body).Decode(&importReq); err != nil {
t.Fatalf("decode import request: %v", err)
}
return importReq.ID
}
func reportLocalSkillImport(t *testing.T, runtimeID, requestID string, body map[string]any) {
t.Helper()
w := httptest.NewRecorder()
req := withURLParams(
newDaemonTokenRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/local-skills/import/"+requestID+"/result", body, testWorkspaceID, "overwrite-test-daemon"),
"runtimeId", runtimeID,
"requestId", requestID,
)
testHandler.ReportLocalSkillImportResult(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ReportLocalSkillImportResult: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func pollLocalSkillImport(t *testing.T, runtimeID, requestID string) RuntimeLocalSkillImportRequest {
t.Helper()
w := httptest.NewRecorder()
req := withURLParams(
newRequestAsUser(testUserID, http.MethodGet, "/api/runtimes/"+runtimeID+"/local-skills/import/"+requestID, nil),
"runtimeId", runtimeID,
"requestId", requestID,
)
testHandler.GetLocalSkillImportRequest(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetLocalSkillImportRequest: expected 200, got %d: %s", w.Code, w.Body.String())
}
var got RuntimeLocalSkillImportRequest
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatalf("decode poll response: %v", err)
}
return got
}
// runLocalSkillImport drives initiate -> report -> poll and returns the
// terminal request.
func runLocalSkillImport(t *testing.T, runtimeID string, initBody, reportBody map[string]any) RuntimeLocalSkillImportRequest {
t.Helper()
requestID := initiateLocalSkillImport(t, runtimeID, initBody)
reportLocalSkillImport(t, runtimeID, requestID, reportBody)
return pollLocalSkillImport(t, runtimeID, requestID)
}
func TestRuntimeLocalSkillImport_ConflictCreatorCanOverwrite(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
name := fmt.Sprintf("conflict-creator-%d", time.Now().UnixNano())
existingID := createImportTargetSkill(t, name, testUserID, nil)
got := runLocalSkillImport(t, runtimeID,
map[string]any{"skill_key": "review-helper", "supports_conflict": true},
reportBundleBody(name, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
)
if got.Status != RuntimeLocalSkillConflict {
t.Fatalf("status = %s, want conflict", got.Status)
}
if got.Conflict == nil {
t.Fatal("expected conflict metadata")
}
if got.Conflict.ExistingSkillID != existingID {
t.Fatalf("existing_skill_id = %q, want %q", got.Conflict.ExistingSkillID, existingID)
}
if got.Conflict.ExistingCreatedBy != testUserID {
t.Fatalf("existing_created_by = %q, want %q", got.Conflict.ExistingCreatedBy, testUserID)
}
if !got.Conflict.CanOverwrite {
t.Fatal("creator should be allowed to overwrite")
}
// A conflict must neither create a second skill nor mutate the original.
if n := countSkillsByName(t, name); n != 1 {
t.Fatalf("expected exactly 1 skill named %q, got %d", name, n)
}
if _, desc, _, _ := getSkillRow(t, existingID); desc != "original description" {
t.Fatalf("conflict must not modify the existing skill, description = %q", desc)
}
}
func TestRuntimeLocalSkillImport_ConflictNonCreatorCannotOverwrite(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
otherUserID := createRuntimeLocalSkillTestMember(t, "member")
name := fmt.Sprintf("conflict-noncreator-%d", time.Now().UnixNano())
existingID := createImportTargetSkill(t, name, otherUserID, nil)
got := runLocalSkillImport(t, runtimeID,
map[string]any{"skill_key": "review-helper", "supports_conflict": true},
reportBundleBody(name, "incoming description", "# incoming", nil),
)
if got.Status != RuntimeLocalSkillConflict {
t.Fatalf("status = %s, want conflict", got.Status)
}
if got.Conflict == nil {
t.Fatal("expected conflict metadata")
}
if got.Conflict.ExistingSkillID != existingID {
t.Fatalf("existing_skill_id = %q, want %q", got.Conflict.ExistingSkillID, existingID)
}
if got.Conflict.ExistingCreatedBy != otherUserID {
t.Fatalf("existing_created_by = %q, want %q", got.Conflict.ExistingCreatedBy, otherUserID)
}
if got.Conflict.CanOverwrite {
t.Fatal("a non-creator must not be allowed to overwrite")
}
}
func TestRuntimeLocalSkillImport_OverwritePreservesIdentityAndBindings(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
name := fmt.Sprintf("overwrite-keep-%d", time.Now().UnixNano())
existingID := createImportTargetSkill(t, name, testUserID, map[string]string{
"keep.md": "old keep",
"prune.md": "should be removed",
})
bindAgentToSkill(t, existingID)
got := runLocalSkillImport(t, runtimeID,
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": existingID},
reportBundleBody(name, "overwritten description", "# overwritten", map[string]string{"keep.md": "new keep"}),
)
if got.Status != RuntimeLocalSkillCompleted {
t.Fatalf("status = %s, want completed (error=%q)", got.Status, got.Error)
}
if got.Skill == nil {
t.Fatal("expected overwritten skill in response")
}
// Same row: UUID and creator preserved.
if got.Skill.ID != existingID {
t.Fatalf("overwrite must preserve UUID: got %q, want %q", got.Skill.ID, existingID)
}
if got.Skill.CreatedBy == nil || *got.Skill.CreatedBy != testUserID {
t.Fatalf("created_by not preserved: %v", got.Skill.CreatedBy)
}
if got.Skill.Description != "overwritten description" {
t.Fatalf("description not replaced: %q", got.Skill.Description)
}
// Files fully replaced: prune.md (absent from the new bundle) is gone.
if n := countSkillFiles(t, existingID); n != 1 {
t.Fatalf("expected 1 file after overwrite, got %d", n)
}
// Agent binding preserved — the agent must NOT need to re-add the skill.
if n := countAgentSkillBindings(t, existingID); n != 1 {
t.Fatalf("expected agent binding to survive overwrite, got %d", n)
}
}
func TestRuntimeLocalSkillImport_OverwriteNonCreatorFails(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
otherUserID := createRuntimeLocalSkillTestMember(t, "member")
name := fmt.Sprintf("overwrite-forbidden-%d", time.Now().UnixNano())
existingID := createImportTargetSkill(t, name, otherUserID, nil)
got := runLocalSkillImport(t, runtimeID,
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": existingID},
reportBundleBody(name, "incoming description", "# incoming", nil),
)
if got.Status != RuntimeLocalSkillFailed {
t.Fatalf("status = %s, want failed", got.Status)
}
// Original skill (owned by someone else) must be untouched.
if _, desc, _, _ := getSkillRow(t, existingID); desc != "original description" {
t.Fatalf("forbidden overwrite must not mutate the skill, description = %q", desc)
}
}
func TestRuntimeLocalSkillImport_OverwriteTargetDeletedFails(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
name := fmt.Sprintf("overwrite-deleted-%d", time.Now().UnixNano())
deletedID := createImportTargetSkill(t, name, testUserID, nil)
// Simulate the target being deleted between the user's confirm and the
// daemon report.
if _, err := testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, deletedID); err != nil {
t.Fatalf("delete target skill: %v", err)
}
got := runLocalSkillImport(t, runtimeID,
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": deletedID},
reportBundleBody(name, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
)
if got.Status != RuntimeLocalSkillFailed {
t.Fatalf("status = %s, want failed", got.Status)
}
// Must NOT fall back to creating a new skill by name.
if n := countSkillsByName(t, name); n != 0 {
t.Fatalf("deleted-target overwrite must not create a skill, got %d", n)
}
}
func TestRuntimeLocalSkillImport_OverwriteRetryIsIdempotent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
name := fmt.Sprintf("overwrite-idempotent-%d", time.Now().UnixNano())
existingID := createImportTargetSkill(t, name, testUserID, map[string]string{"old.md": "old"})
requestID := initiateLocalSkillImport(t, runtimeID, map[string]any{
"skill_key": "review-helper",
"action": "overwrite",
"target_skill_id": existingID,
})
// First report wins and overwrites the skill.
reportLocalSkillImport(t, runtimeID, requestID,
reportBundleBody(name, "first overwrite", "# first", map[string]string{"first.md": "1"}))
// A retry of the SAME request id with a different bundle must be ignored
// (the request is already terminal) — no second write.
reportLocalSkillImport(t, runtimeID, requestID,
reportBundleBody(name, "second overwrite", "# second", map[string]string{"second.md": "2", "extra.md": "3"}))
got := pollLocalSkillImport(t, runtimeID, requestID)
if got.Status != RuntimeLocalSkillCompleted {
t.Fatalf("status = %s, want completed", got.Status)
}
if _, desc, _, _ := getSkillRow(t, existingID); desc != "first overwrite" {
t.Fatalf("retry must not re-apply, description = %q", desc)
}
if n := countSkillFiles(t, existingID); n != 1 {
t.Fatalf("retry must not re-write files, got %d files", n)
}
}
// TestRuntimeLocalSkillImport_LegacyClientGetsFailedOnConflict verifies the
// installed-app compatibility gate: a client that does NOT opt into the
// structured-conflict contract keeps the legacy `failed` + "already exists"
// behavior on a same-name collision, instead of the new `conflict` status its
// older poll loop wouldn't understand.
func TestRuntimeLocalSkillImport_LegacyClientGetsFailedOnConflict(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
name := fmt.Sprintf("legacy-conflict-%d", time.Now().UnixNano())
createImportTargetSkill(t, name, testUserID, nil)
got := runLocalSkillImport(t, runtimeID,
// No supports_conflict (and no action) — an old client.
map[string]any{"skill_key": "review-helper"},
reportBundleBody(name, "incoming description", "# incoming", nil),
)
if got.Status != RuntimeLocalSkillFailed {
t.Fatalf("status = %s, want failed (legacy contract)", got.Status)
}
if got.Conflict != nil {
t.Fatalf("legacy client must not receive structured conflict metadata: %+v", got.Conflict)
}
if got.Error != "a skill with this name already exists" {
t.Fatalf("error = %q, want legacy already-exists message", got.Error)
}
}
// TestRuntimeLocalSkillImport_OverwriteNameMismatchFails verifies the guard
// against a stale / wrong target_skill_id: if the target's name no longer
// matches the imported skill, the overwrite fails instead of writing one
// skill's content onto another.
func TestRuntimeLocalSkillImport_OverwriteNameMismatchFails(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
targetName := fmt.Sprintf("overwrite-target-%d", time.Now().UnixNano())
otherName := fmt.Sprintf("overwrite-other-%d", time.Now().UnixNano())
targetID := createImportTargetSkill(t, targetName, testUserID, nil)
// Overwrite targets targetID but the imported bundle is named otherName.
got := runLocalSkillImport(t, runtimeID,
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": targetID},
reportBundleBody(otherName, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
)
if got.Status != RuntimeLocalSkillFailed {
t.Fatalf("status = %s, want failed (name mismatch)", got.Status)
}
if _, desc, _, _ := getSkillRow(t, targetID); desc != "original description" {
t.Fatalf("name-mismatch overwrite must not mutate the target, description = %q", desc)
}
}

View File

@@ -262,18 +262,21 @@ func NewRedisLocalSkillImportStore(rdb *redis.Client) *RedisLocalSkillImportStor
return &RedisLocalSkillImportStore{rdb: rdb}
}
func (s *RedisLocalSkillImportStore) Create(ctx context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error) {
func (s *RedisLocalSkillImportStore) Create(ctx context.Context, input LocalSkillImportRequestInput) (*RuntimeLocalSkillImportRequest, error) {
now := time.Now()
req := &RuntimeLocalSkillImportRequest{
ID: randomID(),
RuntimeID: runtimeID,
SkillKey: skillKey,
Name: name,
Description: description,
Status: RuntimeLocalSkillPending,
CreatedAt: now,
UpdatedAt: now,
CreatorID: creatorID,
ID: randomID(),
RuntimeID: input.RuntimeID,
SkillKey: input.SkillKey,
Name: input.Name,
Description: input.Description,
Action: input.Action,
TargetSkillID: input.TargetSkillID,
SupportsConflict: input.SupportsConflict,
Status: RuntimeLocalSkillPending,
CreatedAt: now,
UpdatedAt: now,
CreatorID: input.CreatorID,
}
data, err := s.marshalImport(req)
if err != nil {
@@ -282,11 +285,11 @@ func (s *RedisLocalSkillImportStore) Create(ctx context.Context, runtimeID, crea
pipe := s.rdb.TxPipeline()
pipe.Set(ctx, localSkillImportKey(req.ID), data, runtimeLocalSkillStoreRetention)
pipe.ZAdd(ctx, localSkillImportPendingKey(runtimeID), redis.Z{
pipe.ZAdd(ctx, localSkillImportPendingKey(input.RuntimeID), redis.Z{
Score: float64(now.UnixNano()),
Member: req.ID,
})
pipe.Expire(ctx, localSkillImportPendingKey(runtimeID), runtimeLocalSkillStoreRetention*2)
pipe.Expire(ctx, localSkillImportPendingKey(input.RuntimeID), runtimeLocalSkillStoreRetention*2)
if _, err := pipe.Exec(ctx); err != nil {
return nil, fmt.Errorf("persist import request: %w", err)
}
@@ -494,6 +497,21 @@ func (s *RedisLocalSkillImportStore) Complete(ctx context.Context, id string, sk
return s.persistImportRequest(ctx, req)
}
func (s *RedisLocalSkillImportStore) Conflict(ctx context.Context, id string, info LocalSkillImportConflict) error {
req, err := s.loadImportRequest(ctx, id)
if err != nil {
return err
}
if req == nil {
return nil
}
req.Status = RuntimeLocalSkillConflict
conflict := info
req.Conflict = &conflict
req.UpdatedAt = time.Now()
return s.persistImportRequest(ctx, req)
}
func (s *RedisLocalSkillImportStore) Fail(ctx context.Context, id string, errMsg string) error {
req, err := s.loadImportRequest(ctx, id)
if err != nil {

View File

@@ -225,7 +225,15 @@ func TestRedisLocalSkillImportStore_PreservesCreatorID(t *testing.T) {
name := "Review Helper"
desc := "Desc"
req, err := store.Create(ctx, "runtime-1", "user-42", "review-helper", &name, &desc)
req, err := store.Create(ctx, LocalSkillImportRequestInput{
RuntimeID: "runtime-1",
CreatorID: "user-42",
SkillKey: "review-helper",
Name: &name,
Description: &desc,
Action: LocalSkillImportActionOverwrite,
TargetSkillID: "target-skill-99",
})
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -249,6 +257,47 @@ func TestRedisLocalSkillImportStore_PreservesCreatorID(t *testing.T) {
if got.Description == nil || *got.Description != desc {
t.Fatalf("description lost: %v", got.Description)
}
// The overwrite intent must survive the round trip — it is consumed at
// report time, not delivered to the daemon.
if got.Action != LocalSkillImportActionOverwrite {
t.Fatalf("action lost round trip: %q", got.Action)
}
if got.TargetSkillID != "target-skill-99" {
t.Fatalf("target_skill_id lost round trip: %q", got.TargetSkillID)
}
}
func TestRedisLocalSkillImportStore_PreservesConflict(t *testing.T) {
rdb := newRedisTestClient(t)
ctx := context.Background()
store := NewRedisLocalSkillImportStore(rdb)
req, err := store.Create(ctx, LocalSkillImportRequestInput{
RuntimeID: "runtime-1",
CreatorID: "user-1",
SkillKey: "review-helper",
})
if err != nil {
t.Fatalf("create: %v", err)
}
info := LocalSkillImportConflict{ExistingSkillID: "skill-7", ExistingCreatedBy: "user-2", CanOverwrite: false}
if err := store.Conflict(ctx, req.ID, info); err != nil {
t.Fatalf("conflict: %v", err)
}
got, err := store.Get(ctx, req.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Status != RuntimeLocalSkillConflict {
t.Fatalf("status = %s, want conflict", got.Status)
}
if got.Conflict == nil {
t.Fatalf("conflict metadata lost round trip")
}
if got.Conflict.ExistingSkillID != "skill-7" || got.Conflict.ExistingCreatedBy != "user-2" || got.Conflict.CanOverwrite {
t.Fatalf("conflict metadata corrupted: %+v", got.Conflict)
}
}
func TestRedisLocalSkillImportStore_PopPendingAcrossInstances(t *testing.T) {
@@ -258,7 +307,11 @@ func TestRedisLocalSkillImportStore_PopPendingAcrossInstances(t *testing.T) {
nodeA := NewRedisLocalSkillImportStore(rdb)
nodeB := NewRedisLocalSkillImportStore(rdb)
req, err := nodeA.Create(ctx, "runtime-import", "user-1", "review-helper", nil, nil)
req, err := nodeA.Create(ctx, LocalSkillImportRequestInput{
RuntimeID: "runtime-import",
CreatorID: "user-1",
SkillKey: "review-helper",
})
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -365,7 +418,11 @@ func TestRedisLocalSkillImportStore_PopPendingBatch(t *testing.T) {
// Create 5 pending imports.
ids := make([]string, 5)
for i := range ids {
req, err := store.Create(ctx, "runtime-batch", "user-1", fmt.Sprintf("skill-%d", i), nil, nil)
req, err := store.Create(ctx, LocalSkillImportRequestInput{
RuntimeID: "runtime-batch",
CreatorID: "user-1",
SkillKey: fmt.Sprintf("skill-%d", i),
})
if err != nil {
t.Fatalf("create %d: %v", i, err)
}

View File

@@ -187,7 +187,11 @@ func TestInMemoryLocalSkillListStore_TimesOutRunningRequests(t *testing.T) {
func TestInMemoryLocalSkillImportStore_TimesOutRunningRequests(t *testing.T) {
ctx := context.Background()
store := NewInMemoryLocalSkillImportStore()
req, err := store.Create(ctx, "runtime-xyz", "user-1", "review-helper", nil, nil)
req, err := store.Create(ctx, LocalSkillImportRequestInput{
RuntimeID: "runtime-xyz",
CreatorID: "user-1",
SkillKey: "review-helper",
})
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -237,7 +241,11 @@ func TestGetLocalSkillImportRequest_RequiresRuntimeOwner(t *testing.T) {
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
adminUserID := createRuntimeLocalSkillTestMember(t, "admin")
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), runtimeID, testUserID, "review-helper", nil, nil)
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), LocalSkillImportRequestInput{
RuntimeID: runtimeID,
CreatorID: testUserID,
SkillKey: "review-helper",
})
if err != nil {
t.Fatalf("create import request: %v", err)
}
@@ -477,14 +485,13 @@ func TestReportLocalSkillImportResult_IgnoresTimedOutRequests(t *testing.T) {
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
ctx := context.Background()
importReq, err := testHandler.LocalSkillImportStore.Create(
ctx,
runtimeID,
testUserID,
"review-helper",
cleanOptionalString(ptr("Timed Out Import")),
cleanOptionalString(ptr("Should not be created")),
)
importReq, err := testHandler.LocalSkillImportStore.Create(ctx, LocalSkillImportRequestInput{
RuntimeID: runtimeID,
CreatorID: testUserID,
SkillKey: "review-helper",
Name: cleanOptionalString(ptr("Timed Out Import")),
Description: cleanOptionalString(ptr("Should not be created")),
})
if err != nil {
t.Fatalf("create import request: %v", err)
}
@@ -534,7 +541,11 @@ func TestReportLocalSkillImportResult_RejectsCrossWorkspaceDaemonToken(t *testin
}
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), runtimeID, testUserID, "review-helper", nil, nil)
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), LocalSkillImportRequestInput{
RuntimeID: runtimeID,
CreatorID: testUserID,
SkillKey: "review-helper",
})
if err != nil {
t.Fatalf("create import request: %v", err)
}

View File

@@ -101,9 +101,18 @@ type SkillWithFilesResponse struct {
Files []SkillFileResponse `json:"files"`
}
type SkillImportResult struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Skill *SkillWithFilesResponse `json:"skill,omitempty"`
ExistingSkill *ExistingSkillIdentity `json:"existing_skill,omitempty"`
}
type ExistingSkillIdentity struct {
ID string `json:"id"`
Name string `json:"name"`
ID string `json:"id"`
Name string `json:"name"`
CreatedBy string `json:"created_by,omitempty"`
CanOverwrite bool `json:"can_overwrite,omitempty"`
}
func writeSkillImportDuplicateConflict(w http.ResponseWriter, existing ExistingSkillIdentity) {
@@ -138,7 +147,19 @@ func (h *Handler) existingSkillIdentityByName(ctx context.Context, workspaceID p
}
return ExistingSkillIdentity{}, false, err
}
return ExistingSkillIdentity{ID: uuidToString(skill.ID), Name: skill.Name}, true, nil
return existingSkillIdentity(skill, ""), true, nil
}
func existingSkillIdentity(skill db.Skill, userID string) ExistingSkillIdentity {
identity := ExistingSkillIdentity{
ID: uuidToString(skill.ID),
Name: skill.Name,
CanOverwrite: canOverwriteSkillByLocalImport(userID, skill),
}
if skill.CreatedBy.Valid {
identity.CreatedBy = uuidToString(skill.CreatedBy)
}
return identity
}
// decodeSkillConfig decodes a JSONB skill.config blob, defaulting to {} when
@@ -390,6 +411,15 @@ func (h *Handler) canManageSkill(w http.ResponseWriter, r *http.Request, skill d
return true
}
// canOverwriteSkillByLocalImport reports whether userID may overwrite skill via
// a runtime-local-skill re-import. This is intentionally NARROWER than
// canManageSkill: only the original creator may overwrite by re-importing.
// Workspace owners/admins who want to change a skill they did not create must
// edit it in-app instead. See MUL-2701 / MUL-2800.
func canOverwriteSkillByLocalImport(userID string, skill db.Skill) bool {
return skill.CreatedBy.Valid && uuidToString(skill.CreatedBy) == userID
}
func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
@@ -521,7 +551,25 @@ func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
// --- Skill import ---
type ImportSkillRequest struct {
URL string `json:"url"`
URL string `json:"url"`
OnConflict string `json:"on_conflict,omitempty"`
}
const (
importOnConflictFail = "fail"
importOnConflictOverwrite = "overwrite"
importOnConflictRename = "rename"
importOnConflictSkip = "skip"
)
const maxImportRenameAttempts = 50
func validImportOnConflict(strategy string) bool {
switch strategy {
case "", importOnConflictFail, importOnConflictOverwrite, importOnConflictRename, importOnConflictSkip:
return true
}
return false
}
// Per-import bundle limits. These mirror the local-runtime importer so that
@@ -1719,6 +1767,116 @@ func skillMdNotFoundError(owner, repo, skillName string) error {
return fmt.Errorf("SKILL.md not found in repository %s/%s for skill %s", owner, repo, skillName)
}
func skillImportConflictReason() string {
return "a skill with this name already exists; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy"
}
func (h *Handler) createImportedSkillWithName(ctx context.Context, workspaceID, creatorID pgtype.UUID, name string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest) (SkillWithFilesResponse, error) {
return h.createSkillWithFiles(ctx, skillCreateInput{
WorkspaceID: workspaceID,
CreatorID: creatorID,
Name: name,
Description: imported.description,
Content: imported.content,
Config: config,
Files: files,
})
}
func (h *Handler) createRenamedImportedSkill(ctx context.Context, workspaceID, creatorID pgtype.UUID, baseName string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest) (SkillWithFilesResponse, error) {
for suffix := 2; suffix < maxImportRenameAttempts+2; suffix++ {
candidate := fmt.Sprintf("%s-%d", baseName, suffix)
resp, err := h.createImportedSkillWithName(ctx, workspaceID, creatorID, candidate, imported, config, files)
if err == nil {
return resp, nil
}
if !isUniqueViolation(err) {
return SkillWithFilesResponse{}, err
}
}
return SkillWithFilesResponse{}, fmt.Errorf("failed to find an available renamed skill name after %d attempts", maxImportRenameAttempts)
}
func skillImportOverwriteFailure(err error) (int, string) {
switch {
case errors.Is(err, errSkillOverwriteNotFound):
return http.StatusConflict, "target skill no longer exists"
case errors.Is(err, errSkillOverwriteForbidden):
return http.StatusForbidden, "only the skill creator can overwrite this skill"
case errors.Is(err, errSkillOverwriteNameMismatch):
return http.StatusConflict, "target skill name no longer matches the imported skill"
default:
return http.StatusInternalServerError, "failed to overwrite skill: " + err.Error()
}
}
func (h *Handler) resolveImportSkillConflict(w http.ResponseWriter, r *http.Request, strategy string, workspaceID string, workspaceUUID, creatorUUID pgtype.UUID, creatorID string, name string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest, existing db.Skill) {
existingInfo := existingSkillIdentity(existing, creatorID)
switch strategy {
case importOnConflictSkip:
writeJSON(w, http.StatusOK, SkillImportResult{
Status: "skipped",
Reason: "a skill with this name already exists",
ExistingSkill: &existingInfo,
})
case importOnConflictOverwrite:
if !canOverwriteSkillByLocalImport(creatorID, existing) {
writeJSON(w, http.StatusForbidden, SkillImportResult{
Status: "failed",
Reason: "only the skill creator can overwrite this skill",
ExistingSkill: &existingInfo,
})
return
}
resp, err := h.overwriteSkillWithFiles(r.Context(), skillOverwriteInput{
WorkspaceID: workspaceUUID,
TargetSkillID: existing.ID,
UserID: creatorID,
ExpectedName: name,
Description: imported.description,
Content: imported.content,
Config: config,
Files: files,
})
if err != nil {
status, reason := skillImportOverwriteFailure(err)
writeJSON(w, status, SkillImportResult{
Status: "failed",
Reason: reason,
ExistingSkill: &existingInfo,
})
return
}
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
h.publish(protocol.EventSkillUpdated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
writeJSON(w, http.StatusOK, SkillImportResult{Status: "updated", Skill: &resp})
case importOnConflictRename:
resp, err := h.createRenamedImportedSkill(r.Context(), workspaceUUID, creatorUUID, name, imported, config, files)
if err != nil {
writeJSON(w, http.StatusInternalServerError, SkillImportResult{
Status: "failed",
Reason: "failed to create renamed skill: " + err.Error(),
ExistingSkill: &existingInfo,
})
return
}
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
writeJSON(w, http.StatusCreated, SkillImportResult{
Status: "created",
Reason: "renamed to avoid an existing skill",
Skill: &resp,
ExistingSkill: &existingInfo,
})
default:
writeJSON(w, http.StatusConflict, SkillImportResult{
Status: "conflict",
Reason: skillImportConflictReason(),
ExistingSkill: &existingInfo,
})
}
}
// --- Import handler ---
func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
@@ -1739,6 +1897,15 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validImportOnConflict(req.OnConflict) {
writeError(w, http.StatusBadRequest, "on_conflict must be one of: fail, overwrite, rename, skip")
return
}
structuredResult := req.OnConflict != ""
strategy := req.OnConflict
if strategy == "" {
strategy = importOnConflictFail
}
source, normalized, err := detectImportSource(req.URL)
if err != nil {
@@ -1779,19 +1946,31 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
if imported.origin != nil {
config["origin"] = imported.origin
}
name := sanitizeNullBytes(imported.name)
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
WorkspaceID: workspaceUUID,
CreatorID: creatorUUID,
Name: imported.name,
Description: imported.description,
Content: imported.content,
Config: config,
Files: files,
})
if structuredResult {
if existing, found, lerr := h.lookupSkillByName(r.Context(), workspaceUUID, name); lerr != nil {
writeJSON(w, http.StatusInternalServerError, SkillImportResult{
Status: "failed",
Reason: "failed to check for existing skill: " + lerr.Error(),
})
return
} else if found {
h.resolveImportSkillConflict(w, r, strategy, workspaceID, workspaceUUID, creatorUUID, creatorID, name, imported, config, files, existing)
return
}
}
resp, err := h.createImportedSkillWithName(r.Context(), workspaceUUID, creatorUUID, name, imported, config, files)
if err != nil {
if isUniqueViolation(err) {
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, imported.name); findErr == nil && found {
if structuredResult {
if existing, found, lerr := h.lookupSkillByName(r.Context(), workspaceUUID, name); lerr == nil && found {
h.resolveImportSkillConflict(w, r, strategy, workspaceID, workspaceUUID, creatorUUID, creatorID, name, imported, config, files, existing)
return
}
}
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, name); findErr == nil && found {
writeSkillImportDuplicateConflict(w, existing)
} else {
writeError(w, http.StatusConflict, "a skill with this name already exists")
@@ -1803,6 +1982,10 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
}
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
if structuredResult {
writeJSON(w, http.StatusCreated, SkillImportResult{Status: "created", Skill: &resp})
return
}
writeJSON(w, http.StatusCreated, resp)
}

View File

@@ -3,7 +3,9 @@ package handler
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
skillpkg "github.com/multica-ai/multica/server/internal/skill"
db "github.com/multica-ai/multica/server/pkg/db/generated"
@@ -89,3 +91,126 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
return result, nil
}
// errSkillOverwriteNotFound / errSkillOverwriteForbidden are the terminal
// boundary cases of overwriteSkillWithFiles: the target was deleted (or moved
// out of the workspace) or the caller lost overwrite permission between the
// user's confirm and this write. Callers map them to a failed import and must
// NOT fall back to creating a new skill.
var (
errSkillOverwriteNotFound = errors.New("target skill not found")
errSkillOverwriteForbidden = errors.New("not permitted to overwrite target skill")
errSkillOverwriteNameMismatch = errors.New("target skill name does not match the imported skill")
)
type skillOverwriteInput struct {
WorkspaceID pgtype.UUID
TargetSkillID pgtype.UUID
UserID string // re-checked against the skill creator inside the tx
// ExpectedName, when non-empty, must equal the target's current name. Guards
// against a client sending the wrong target_skill_id and overwriting a
// different skill than the one the conflict dialog showed the user. The
// caller passes the sanitized effective import name.
ExpectedName string
Description string
Content string
Config any
Files []CreateSkillFileRequest
}
// overwriteSkillWithFiles re-imports a bundle onto an existing skill in a single
// transaction. It re-verifies, inside that tx, that the target still exists in
// the workspace and that UserID may overwrite it (creator-only — see
// canOverwriteSkillByLocalImport). A target deleted or a creator change between
// the user's confirm and this write fails cleanly via errSkillOverwriteNotFound
// / errSkillOverwriteForbidden rather than falling back to create.
//
// Preserved: id, created_by, created_at, name, and agent_skill bindings (the
// row identity and the binding table are never touched). Replaced: description,
// content, config (origin), and the full file set — files absent from the new
// bundle are pruned via DeleteSkillFilesBySkill. On any error the tx rolls back,
// leaving the original skill unchanged.
func (h *Handler) overwriteSkillWithFiles(ctx context.Context, input skillOverwriteInput) (SkillWithFilesResponse, error) {
config, err := json.Marshal(input.Config)
if err != nil {
return SkillWithFilesResponse{}, err
}
if input.Config == nil {
config = []byte("{}")
}
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return SkillWithFilesResponse{}, err
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
existing, err := qtx.GetSkillInWorkspace(ctx, db.GetSkillInWorkspaceParams{
ID: input.TargetSkillID,
WorkspaceID: input.WorkspaceID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return SkillWithFilesResponse{}, errSkillOverwriteNotFound
}
return SkillWithFilesResponse{}, err
}
if !canOverwriteSkillByLocalImport(input.UserID, existing) {
return SkillWithFilesResponse{}, errSkillOverwriteForbidden
}
// The overwrite is keyed on target_skill_id, but the conflict the user
// confirmed was a same-name collision; reject if the target's name no longer
// matches the imported skill so a stale/wrong target_skill_id can't write
// one skill's content onto another.
if input.ExpectedName != "" && existing.Name != input.ExpectedName {
return SkillWithFilesResponse{}, errSkillOverwriteNameMismatch
}
// Name is intentionally left unset (COALESCE keeps the existing name): the
// overwrite targets the same-name skill, so preserving it avoids any
// unique-name churn.
skill, err := qtx.UpdateSkill(ctx, db.UpdateSkillParams{
ID: existing.ID,
Description: pgtype.Text{String: sanitizeNullBytes(input.Description), Valid: true},
Content: pgtype.Text{String: sanitizeNullBytes(input.Content), Valid: true},
Config: config,
})
if err != nil {
// A committed concurrent DELETE can land between the read above and this
// UPDATE (READ COMMITTED), so UpdateSkill matches 0 rows. Classify it as
// the same "target gone" terminal case rather than a generic failure.
if errors.Is(err, pgx.ErrNoRows) {
return SkillWithFilesResponse{}, errSkillOverwriteNotFound
}
return SkillWithFilesResponse{}, err
}
// Full replace: drop every existing file, then re-insert the new set so
// files no longer present in the local source are removed.
if err := qtx.DeleteSkillFilesBySkill(ctx, skill.ID); err != nil {
return SkillWithFilesResponse{}, err
}
fileResps := make([]SkillFileResponse, 0, len(input.Files))
for _, f := range input.Files {
sf, err := qtx.UpsertSkillFile(ctx, db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: sanitizeNullBytes(f.Path),
Content: sanitizeNullBytes(f.Content),
})
if err != nil {
return SkillWithFilesResponse{}, err
}
fileResps = append(fileResps, skillFileToResponse(sf))
}
if err := tx.Commit(ctx); err != nil {
return SkillWithFilesResponse{}, err
}
return SkillWithFilesResponse{
SkillResponse: skillToResponse(skill),
Files: fileResps,
}, nil
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
@@ -46,3 +47,103 @@ func TestWriteSkillImportDuplicateConflictIncludesExistingSkill(t *testing.T) {
t.Fatalf("existing_skill = %#v", existing)
}
}
func withMockClawHubImport(t *testing.T, skillName string) string {
t.Helper()
slug := "review-helper"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/skills/" + slug:
_ = json.NewEncoder(w).Encode(map[string]any{
"skill": map[string]any{
"slug": slug,
"displayName": skillName,
"summary": "Imported test skill",
"tags": map[string]string{"latest": "1.0.0"},
},
})
case "/api/v1/skills/" + slug + "/versions/1.0.0":
_ = json.NewEncoder(w).Encode(map[string]any{
"version": map[string]any{
"version": "1.0.0",
"files": []map[string]any{
{"path": "SKILL.md", "size": 16},
},
},
})
case "/api/v1/skills/" + slug + "/file":
_, _ = w.Write([]byte("# Imported\n"))
default:
t.Fatalf("unexpected ClawHub path: %s", r.URL.String())
}
}))
prev := clawHubAPIBase
clawHubAPIBase = srv.URL + "/api/v1"
t.Cleanup(func() {
clawHubAPIBase = prev
srv.Close()
})
return "https://clawhub.ai/acme/" + slug
}
func TestImportSkillOnConflictSkipReturnsStructuredResult(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("handler test DB not configured")
}
namePrefix := "url-import-skip"
skillName := namePrefix + "-" + t.Name()
existingID := insertHandlerTestSkill(t, namePrefix, "# Existing")
importURL := withMockClawHubImport(t, skillName)
w := httptest.NewRecorder()
req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import", map[string]any{
"url": importURL,
"on_conflict": "skip",
})
testHandler.ImportSkill(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var body SkillImportResult
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Status != "skipped" {
t.Fatalf("status = %q", body.Status)
}
if body.ExistingSkill == nil || body.ExistingSkill.ID != existingID || body.ExistingSkill.Name != skillName {
t.Fatalf("existing_skill = %#v", body.ExistingSkill)
}
}
func TestImportSkillOnConflictRenameCreatesSuffixedSkill(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("handler test DB not configured")
}
namePrefix := "url-import-rename"
skillName := namePrefix + "-" + t.Name()
insertHandlerTestSkill(t, namePrefix, "# Existing")
importURL := withMockClawHubImport(t, skillName)
w := httptest.NewRecorder()
req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import", map[string]any{
"url": importURL,
"on_conflict": "rename",
})
testHandler.ImportSkill(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201: %s", w.Code, w.Body.String())
}
var body SkillImportResult
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Status != "created" || body.Skill == nil {
t.Fatalf("body = %#v", body)
}
if body.Skill.Name != skillName+"-2" {
t.Fatalf("created skill name = %q, want %q", body.Skill.Name, skillName+"-2")
}
}

View File

@@ -1,6 +1,6 @@
---
name: multica-skill-importing
description: "Use when a user provides a skill URL, slug, or clear intent to import/install a specific skill into the current Multica workspace. Teaches the workspace import API/CLI path (POST /api/skills/import), the supported URL source families, the SkillWithFilesResponse shape returned on success, duplicate 409 handling with the existing_skill body, additive agent binding vs replace-all, and the reserved SKILL.md supporting-file rule. Do not use it to decide which skill the user needs, and never treat an external local installer like npx skills add as the final Multica install."
description: "Use when a user provides a skill URL, slug, or clear intent to import/install a specific skill into the current Multica workspace. Teaches the workspace import API/CLI path (POST /api/skills/import), the supported URL source families, --on-conflict fail|overwrite|rename|skip behavior and structured import results, additive agent binding vs replace-all, and the reserved SKILL.md supporting-file rule. Do not use it to decide which skill the user needs, and never treat an external local installer like npx skills add as the final Multica install."
user-invocable: false
allowed-tools: Bash(multica *)
---
@@ -28,11 +28,11 @@ import endpoint, driven by this CLI:
multica skill import --url <url> --output json
```
That CLI sends:
The CLI defaults to `--on-conflict fail`. Current CLIs send:
```text
POST /api/skills/import
body: { "url": "<url>" }
body: { "url": "<url>", "on_conflict": "fail" }
```
Do not finish with `npx skills add`. That installs into an external/local skill
@@ -66,17 +66,30 @@ directly; search is not required by the API:
multica skill import --url <url> --output json
```
2. Treat a successful response as the source of truth. The body is a workspace
`SkillWithFilesResponse` — it embeds the standard `SkillResponse` and adds the
supporting `files` array. Report the relevant fields:
2. Treat the response as the source of truth. Current CLI imports use the
structured import result envelope:
- `id`
- `name`
- `description`
- `config.origin` (provenance: which source the skill was imported from — set
only when the source supplied an origin, so treat it as possibly absent)
- `files` / files count
- `created_at` / `updated_at`
```json
{
"status": "created|updated|conflict|skipped|failed",
"reason": "...",
"skill": { "...": "SkillWithFilesResponse when created/updated" },
"existing_skill": { "id": "...", "name": "...", "can_overwrite": true }
}
```
For `created` / `updated`, `skill` is a workspace `SkillWithFilesResponse`: it
embeds the standard `SkillResponse` and adds the supporting `files` array. Report
the relevant fields:
- `status` and `reason` when present.
- `skill.id` / `skill.name` / `skill.description`.
- `skill.config.origin` (provenance: which source the skill was imported from —
set only when the source supplied an origin, so treat it as possibly absent).
- `skill.files` / files count.
- `skill.created_at` / `skill.updated_at`.
- `existing_skill.id` / `existing_skill.name` when status is `conflict`,
`skipped`, or `failed` due to an existing skill.
Because the response is structured, read these returned fields instead of guessing
whether the import succeeded.
@@ -118,9 +131,46 @@ rename it to a non-reserved path. (The hard `400` rejection — "SKILL.md is res
for the primary skill content" — only fires on the dedicated single-file endpoint
`PUT /api/skills/{id}/files`, not on import.)
## Duplicate imports (409)
## Same-name conflicts: `--on-conflict`
A duplicate import returns `409`. On current servers the body carries the existing
Default behavior is safe: `multica skill import --url <url>` is equivalent to
`--on-conflict fail`. If the imported skill name already exists, the command
prints a structured `conflict` result and exits non-zero; no skill is created or
updated.
Choose an explicit strategy only when the user asked for it or the intent is
clear:
- `--on-conflict fail` (default): do nothing on conflict; report `status:
conflict` with a reason that suggests overwrite or rename.
- `--on-conflict overwrite`: update the existing same-name skill in place, but
only if the current user is the skill's original creator. This preserves the
skill ID, `created_by`, `created_at`, and agent-skill bindings; it replaces
description, content, provenance config, and supporting files. Non-creators get
`status: failed`.
- `--on-conflict rename`: create a new skill with an automatic suffix such as
`-2` / `-3`; the existing skill is untouched.
- `--on-conflict skip`: leave the existing skill untouched and report `status:
skipped`.
Concrete examples:
```bash
# Safe default. Fails with status=conflict if review-helper already exists.
multica skill import --url https://skills.sh/acme/repo/review-helper --output json
# Replace the existing same-name skill, preserving its ID and agent bindings.
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite --output json
# Keep the existing skill and import a copy such as review-helper-2.
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename --output json
# Batch-friendly behavior: leave the existing skill alone and mark it skipped.
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip --output json
```
Legacy compatibility: clients that do not send `on_conflict` keep the old
contract. A duplicate import returns `409` and the body carries the existing
workspace skill identity:
```json
@@ -133,19 +183,17 @@ workspace skill identity:
}
```
`multica skill import --url <url> --output json` prints that structured conflict
body and exits successfully (exit 0) for this duplicate case. Treat
`existing_skill.id` and `existing_skill.name` as the source of truth, then fetch
details if needed:
Current CLI normalizes that legacy shape into `status: conflict` and exits
non-zero for the default `fail` strategy. Treat `existing_skill.id` and
`existing_skill.name` as the source of truth, then fetch details if needed:
```bash
multica skill get <skill-id> --output json
```
Legacy fallback: a legacy server or old CLI may return a `409` whose body is only a
string like `a skill with this name already exists`, with no `existing_skill` key.
The CLI cannot recognize that as the duplicate case, so it exits non-zero. Recover
by finding the existing workspace skill yourself:
Older servers may return a `409` whose body is only a string like `a skill with
this name already exists`, with no `existing_skill` key. Recover by finding the
existing workspace skill yourself:
```bash
multica skill list --output json

View File

@@ -17,44 +17,48 @@ grep -n "func IsReservedContentPath" server/internal/skill/reserved.go
| Behavior | File:line |
|---|---|
| `ImportSkill` handler (`POST /api/skills/import`) | `server/internal/handler/skill.go:1724` |
| Decodes `ImportSkillRequest` (`{ "url": ... }`) | `server/internal/handler/skill.go:1737-1741`, struct at `:523` |
| Detects source family + normalizes URL | `server/internal/handler/skill.go:1743` (calls `detectImportSource`) |
| Persists provenance into `config.origin` | `server/internal/handler/skill.go:1776-1781` — set at `:1780` **only when** `imported.origin != nil` (`:1779`); otherwise `config` stays `{}` and `origin` is absent |
| Builds skill + files via `createSkillWithFiles` (def `server/internal/handler/skill_create.go:72`, tx body `:27`) | call site `server/internal/handler/skill.go:1783` |
| Success: `201 Created` with the response body | `server/internal/handler/skill.go:1806` |
| Route registration `r.Post("/import", h.ImportSkill)` | `server/cmd/server/router.go:621` (under `/api/skills`, `:617`) |
| `ImportSkill` handler (`POST /api/skills/import`) | `server/internal/handler/skill.go:1882` |
| Decodes `ImportSkillRequest` (`{ "url": ..., "on_conflict": ... }`) | `server/internal/handler/skill.go:1895-1899`, struct at `:553` |
| Validates `on_conflict` (`fail`, `overwrite`, `rename`, `skip`) | `server/internal/handler/skill.go:1900-1908`, helper `validImportOnConflict` at `:566` |
| Detects source family + normalizes URL | `server/internal/handler/skill.go:1910` (calls `detectImportSource`) |
| Persists provenance into `config.origin` | `server/internal/handler/skill.go:1944-1948` — set only when `imported.origin != nil`; otherwise `config` stays `{}` and `origin` is absent |
| Structured conflict dispatcher | `server/internal/handler/skill.go:1813-1878` |
| Builds skill + files via `createSkillWithFiles` (def `server/internal/handler/skill_create.go:77`, tx body `:29`) | wrapped by `createImportedSkillWithName` at `server/internal/handler/skill.go:1774` |
| Structured success: `201 Created` with `{status:"created", skill}` when `on_conflict` was sent | `server/internal/handler/skill.go:1985-1988` |
| Legacy success: `201 Created` with bare `SkillWithFilesResponse` when `on_conflict` was omitted | `server/internal/handler/skill.go:1990` |
| Route registration `r.Post("/import", h.ImportSkill)` | `server/cmd/server/router.go:874` |
## CLI: `multica skill import --url`
| Behavior | File:line |
|---|---|
| `skill import` command def | `server/cmd/multica/cmd_skill.go:60-64` |
| `--url` flag | `server/cmd/multica/cmd_skill.go:143` |
| `--url` flag | `server/cmd/multica/cmd_skill.go:142` |
| `--on-conflict` flag (default `fail`) | `server/cmd/multica/cmd_skill.go:143` |
| `--output` flag (default `json`) | `server/cmd/multica/cmd_skill.go:144` |
| `runSkillImport` | `server/cmd/multica/cmd_skill.go:412` |
| Requires `--url` | `server/cmd/multica/cmd_skill.go:418-421` |
| `POST /api/skills/import` | `server/cmd/multica/cmd_skill.go:431` |
| On error, tries conflict handler; returns `nil` (exit 0) when handled | `server/cmd/multica/cmd_skill.go:432-434` |
| Reads and validates `--on-conflict` | `server/cmd/multica/cmd_skill.go:422-425` |
| Sends `on_conflict` in the request body | `server/cmd/multica/cmd_skill.go:428-431` |
| `POST /api/skills/import` | `server/cmd/multica/cmd_skill.go:436` |
| Structured HTTP error body handling | `server/cmd/multica/cmd_skill.go:437-440`, `handleSkillImportError` at `:454` |
| Prints structured result (`json` or table) | `server/cmd/multica/cmd_skill.go:443`, helper at `:497` |
## Duplicate 409 handling
## Same-name conflict handling
| Behavior | File:line |
|---|---|
| `ImportSkill` unique-violation branch | `server/internal/handler/skill.go:1793-1800` |
| Structured 409 via `writeSkillImportDuplicateConflict` (looks up existing skill) | `server/internal/handler/skill.go:1794-1795` |
| `writeSkillImportDuplicateConflict` writes `{error, existing_skill}` at status 409 | `server/internal/handler/skill.go:109-114` |
| `ExistingSkillIdentity` (`{id,name}`) | `server/internal/handler/skill.go:104-107` |
| `existingSkillIdentityByName` (GetSkillByWorkspaceAndName) | `server/internal/handler/skill.go:130-142` |
| Defensive fallback: plain-string 409 when lookup misses (delete-race) | `server/internal/handler/skill.go:1796-1798` |
| CLI `handleSkillImportConflict` | `server/cmd/multica/cmd_skill.go:447` |
| Requires HTTP 409 + non-empty body | `server/cmd/multica/cmd_skill.go:449-451` |
| Requires `existing_skill` key (else `false` → non-zero exit) | `server/cmd/multica/cmd_skill.go:457-459` |
| Under `--output json`: prints body, returns `true` (caller exits 0) | `server/cmd/multica/cmd_skill.go:461-465` |
A legacy plain-string 409 (no `existing_skill` key) fails the `:457-459` guard,
so `handleSkillImportConflict` returns `false`, `runSkillImport` falls through to
`return fmt.Errorf("import skill: ...")` at `:435` → non-zero exit.
| `SkillImportResult` (`status`, `reason`, `skill`, `existing_skill`) | `server/internal/handler/skill.go:104-109` |
| `ExistingSkillIdentity` (`id`, `name`, `created_by`, `can_overwrite`) | `server/internal/handler/skill.go:112-117` |
| Pre-create lookup for structured conflict flow | `server/internal/handler/skill.go:1951-1962` |
| Race-safe unique-violation fallback into structured conflict flow | `server/internal/handler/skill.go:1966-1971` |
| Default `fail`: `status:"conflict"` and HTTP 409 | `server/internal/handler/skill.go:1872-1877` |
| `overwrite`: creator-only update, preserves skill identity/bindings via `overwriteSkillWithFiles` | `server/internal/handler/skill.go:1823-1852`, tx helper at `server/internal/handler/skill_create.go:133` |
| `rename`: creates suffixed name with bounded attempts | `server/internal/handler/skill.go:1854-1870`, helper at `:1786` |
| `skip`: returns `status:"skipped"` and leaves existing skill untouched | `server/internal/handler/skill.go:1816-1821` |
| Legacy duplicate branch when `on_conflict` was omitted | `server/internal/handler/skill.go:1973-1978` |
| Legacy duplicate response `{error, existing_skill}` | `server/internal/handler/skill.go:118-123` |
| CLI normalizes legacy `{existing_skill}` body into `status:"conflict"` | `server/cmd/multica/cmd_skill.go:454-482`, helper at `:484` |
## Response shape: `SkillWithFilesResponse`
@@ -64,35 +68,36 @@ so `handleSkillImportConflict` returns `false`, `runSkillImport` falls through t
| `SkillResponse` fields (`id, workspace_id, name, description, content, config, created_by, created_at, updated_at`) | `server/internal/handler/skill.go:41-51` |
| `SkillFileResponse` fields | `server/internal/handler/skill.go:80-87` |
| `createSkillWithFilesInTx` returns `SkillWithFilesResponse{SkillResponse, Files}` | `server/internal/handler/skill_create.go:66-69` |
| `config.origin` set on import | `server/internal/handler/skill.go:1780` |
| `config.origin` set on import | `server/internal/handler/skill.go:1947` |
The import response is a `SkillWithFilesResponse` (not a bare `SkillResponse`):
it carries every `SkillResponse` field plus the `files` array.
For current CLI imports, `SkillWithFilesResponse` appears under
`SkillImportResult.skill` when status is `created` or `updated`. Legacy clients
that omit `on_conflict` still receive a bare `SkillWithFilesResponse`.
## URL source families (`detectImportSource`)
| Behavior | File:line |
|---|---|
| `detectImportSource` | `server/internal/handler/skill.go:723-756` |
| `skills.sh` / `www.skills.sh` | `server/internal/handler/skill.go:743-744` |
| `clawhub.ai` / `www.clawhub.ai` | `server/internal/handler/skill.go:745-746` |
| `github.com` / `www.github.com` | `server/internal/handler/skill.go:747-748` |
| Bare slug (no host) defaults to ClawHub | `server/internal/handler/skill.go:750-753` |
| `parseGitHubURL` handles `/tree/{ref}/...` and `/blob/{ref}/.../SKILL.md` | `server/internal/handler/skill.go:1402-1455` (tree/blob check `:1415-1432`) |
| `detectImportSource` | `server/internal/handler/skill.go:773-804` |
| `skills.sh` / `www.skills.sh` | `server/internal/handler/skill.go:791-792` |
| `clawhub.ai` / `www.clawhub.ai` | `server/internal/handler/skill.go:793-794` |
| `github.com` / `www.github.com` | `server/internal/handler/skill.go:795-796` |
| Bare slug (no host) defaults to ClawHub | `server/internal/handler/skill.go:798-800` |
| `parseGitHubURL` handles `/tree/{ref}/...` and `/blob/{ref}/.../SKILL.md` | `server/internal/handler/skill.go:1450-1503` (tree/blob check `:1463-1480`) |
## Additive add vs replace-all set
| Behavior | File:line |
|---|---|
| `AddAgentSkills` (additive: AddAgentSkill loop, no RemoveAll) | `server/internal/handler/skill.go:1978`; loop `:2009-2017` |
| Route `POST /api/agents/{id}/skills/add` | `server/cmd/server/router.go:598` |
| `SetAgentSkills` (replace-all: RemoveAllAgentSkills then re-add) | `server/internal/handler/skill.go:1923`; `RemoveAllAgentSkills` `:1955`; re-add `:1960-1968` |
| Route `PUT /api/agents/{id}/skills` | `server/cmd/server/router.go:597` |
| `AddAgentSkills` (additive: AddAgentSkill loop, no RemoveAll) | `server/internal/handler/skill.go:2161`; loop `:2192-2200` |
| Route `POST /api/agents/{id}/skills/add` | `server/cmd/server/router.go:851` |
| `SetAgentSkills` (replace-all: RemoveAllAgentSkills then re-add) | `server/internal/handler/skill.go:2106`; `RemoveAllAgentSkills` `:2138`; re-add `:2143-2151` |
| Route `PUT /api/agents/{id}/skills` | `server/cmd/server/router.go:850` |
| CLI `agent skills add` def ("without replacing existing assignments") | `server/cmd/multica/cmd_agent.go:125-130` |
| `runAgentSkillsAdd``POST .../skills/add` | `server/cmd/multica/cmd_agent.go:839`; POST `:860` |
| `runAgentSkillsAdd``POST .../skills/add` | `server/cmd/multica/cmd_agent.go:797`; POST `:818` |
| CLI `agent skills set` def ("replaces all current assignments") | `server/cmd/multica/cmd_agent.go:118-123` |
| `runAgentSkillsSet``PUT .../skills` | `server/cmd/multica/cmd_agent.go:814`; PUT `:832` |
| CLI `agent skills list` | `server/cmd/multica/cmd_agent.go:782`; GET `:792` |
| `runAgentSkillsSet``PUT .../skills` | `server/cmd/multica/cmd_agent.go:772`; PUT `:790` |
| CLI `agent skills list` | `server/cmd/multica/cmd_agent.go:740`; GET `:750` |
## Reserved primary-content filename (`SKILL.md`)
@@ -101,8 +106,8 @@ it carries every `SkillResponse` field plus the `files` array.
| `ContentFilename = "SKILL.md"` | `server/internal/skill/reserved.go:12` |
| `IsReservedContentPath` (cleans path, case-insensitive compare) | `server/internal/skill/reserved.go:25-27` |
| Import/create path: reserved supporting file is **silently skipped** (`continue`) | `server/internal/handler/skill_create.go:50-54` |
| `UpdateSkill` (PUT `/api/skills/{id}`) replace-files path: also silently skips | `server/internal/handler/skill.go:461-464` |
| `UpsertSkillFile` (PUT `/api/skills/{id}/files`): **rejects 400** "SKILL.md is reserved for the primary skill content" | `server/internal/handler/skill.go:1851-1854` |
| `UpdateSkill` (PUT `/api/skills/{id}`) replace-files path: also silently skips | `server/internal/handler/skill.go:490-494` |
| `UpsertSkillFile` (PUT `/api/skills/{id}/files`): **rejects 400** "SKILL.md is reserved for the primary skill content" | `server/internal/handler/skill.go:2014`; reserved check `:2034-2036` |
Reason `SKILL.md` is reserved: the daemon writes the skill's `Content` to that path
itself when preparing the execution environment, so a supporting file may not also

View File

@@ -276,6 +276,13 @@ func TestSkillImportingSkillCoversWorkspaceImportContracts(t *testing.T) {
"skills.sh",
"github.com",
"config.origin",
"--on-conflict fail",
"--on-conflict overwrite",
"--on-conflict rename",
"--on-conflict skip",
"status",
"conflict",
"skipped",
"409",
"existing_skill",
"id",