Tests / Test passed: 855
Adds SkillsView.vue (cloud-skill CRUD, per-host entitlement grant/revoke, read-only host local-skill inventory), skill API client methods + types, and wires it into App.vue behind the skills:admin scope. Console typecheck/build/tests green (20 passed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
409 lines
11 KiB
TypeScript
409 lines
11 KiB
TypeScript
import type {
|
|
CloudUser,
|
|
DeviceRecord,
|
|
HostGovernancePolicy,
|
|
HostTokenUsageSummary,
|
|
LlmProviderProfile,
|
|
LlmProviderProfileListResponse,
|
|
LlmProviderSettings,
|
|
LlmProviderType,
|
|
HostRecord,
|
|
PlannerDecisionItem,
|
|
PlannerDecisionListResponse,
|
|
PluginRecord,
|
|
PluginRegistrationPayload,
|
|
TaskAttempt,
|
|
TaskListResponse,
|
|
TaskSubmissionPayload,
|
|
TaskStatus,
|
|
TokenUsageEvent,
|
|
UserListResponse,
|
|
UserSubmissionPolicy,
|
|
CloudSkill,
|
|
CloudSkillListResponse,
|
|
CloudSkillEntitlementsResponse,
|
|
HostSkillInventoryResponse,
|
|
CloudSkillKind,
|
|
} from "./types";
|
|
|
|
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
|
| string
|
|
| undefined;
|
|
|
|
export const API_BASE_URL = (configuredBaseUrl || window.location.origin).replace(
|
|
/\/$/,
|
|
"",
|
|
);
|
|
|
|
const CSRF_COOKIE_NAME = "amcp_csrf";
|
|
|
|
export const AUTH_INVALID_EVENT = "cloud-console:auth-invalid";
|
|
|
|
export class CloudApiError extends Error {
|
|
readonly status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.name = "CloudApiError";
|
|
}
|
|
}
|
|
|
|
interface RequestInitLike {
|
|
method?: string;
|
|
body?: string | null;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
|
|
const method = init.method || "GET";
|
|
const headers: Record<string, string> = {
|
|
Accept: "application/json",
|
|
...init.headers,
|
|
};
|
|
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
|
const csrfToken = getCookie(CSRF_COOKIE_NAME);
|
|
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
|
|
}
|
|
if (init.body !== undefined && init.body !== null) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
|
method,
|
|
body: init.body ?? null,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
if (response.status === 401) {
|
|
window.dispatchEvent(new CustomEvent(AUTH_INVALID_EVENT));
|
|
throw new CloudApiError(401, await responseDetail(response, "authentication expired"));
|
|
}
|
|
if (!response.ok) {
|
|
throw new CloudApiError(
|
|
response.status,
|
|
await responseDetail(response, `${response.status} ${response.statusText}`),
|
|
);
|
|
}
|
|
if (response.status === 204) return undefined as T;
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function responseDetail(response: Response, fallback: string): Promise<string> {
|
|
try {
|
|
const payload = (await response.json()) as { detail?: unknown };
|
|
if (typeof payload.detail === "string") return payload.detail;
|
|
if (payload.detail) return JSON.stringify(payload.detail);
|
|
} catch {
|
|
// Preserve the fallback for empty or non-JSON responses.
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function getCookie(name: string): string | null {
|
|
const prefix = `${encodeURIComponent(name)}=`;
|
|
for (const part of document.cookie.split(";")) {
|
|
const value = part.trim();
|
|
if (value.startsWith(prefix)) return decodeURIComponent(value.slice(prefix.length));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function login(username: string, password: string): Promise<CloudUser> {
|
|
return request<CloudUser>("/v1/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
}
|
|
|
|
export function getCurrentUser(): Promise<CloudUser> {
|
|
return request<CloudUser>("/v1/auth/me");
|
|
}
|
|
|
|
export function logout(): Promise<void> {
|
|
return request<void>("/v1/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
export function changePassword(
|
|
currentPassword: string,
|
|
newPassword: string,
|
|
): Promise<void> {
|
|
return request<void>("/v1/auth/password", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
current_password: currentPassword,
|
|
new_password: newPassword,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function listUsers(): Promise<UserListResponse> {
|
|
return request<UserListResponse>("/v1/users?limit=100&offset=0");
|
|
}
|
|
|
|
export function createUser(payload: {
|
|
username: string;
|
|
display_name: string;
|
|
role: "viewer" | "operator" | "admin";
|
|
password: string;
|
|
}): Promise<CloudUser> {
|
|
return request<CloudUser>("/v1/users", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function updateUser(
|
|
userId: string,
|
|
payload: { display_name?: string; role?: "viewer" | "operator" | "admin"; enabled?: boolean },
|
|
): Promise<CloudUser> {
|
|
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function resetUserPassword(userId: string, password: string): Promise<CloudUser> {
|
|
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}/password`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
}
|
|
|
|
export function revokeUserSessions(userId: string): Promise<void> {
|
|
return request<void>(`/v1/users/${encodeURIComponent(userId)}/sessions`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export function getUserSubmissionPolicy(userId: string): Promise<UserSubmissionPolicy> {
|
|
return request<UserSubmissionPolicy>(
|
|
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
|
|
);
|
|
}
|
|
|
|
export function updateUserSubmissionPolicy(
|
|
userId: string,
|
|
payload: Omit<UserSubmissionPolicy, "user_id" | "revision" | "updated_at"> & {
|
|
expected_revision?: number;
|
|
},
|
|
): Promise<UserSubmissionPolicy> {
|
|
return request<UserSubmissionPolicy>(
|
|
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
|
|
{ method: "PUT", body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
export function getHostGovernancePolicy(hostId: string): Promise<HostGovernancePolicy> {
|
|
return request<HostGovernancePolicy>(
|
|
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
|
|
);
|
|
}
|
|
|
|
export function updateHostGovernancePolicy(
|
|
hostId: string,
|
|
payload: Omit<HostGovernancePolicy, "host_id" | "revision" | "updated_at"> & {
|
|
expected_revision?: number;
|
|
},
|
|
): Promise<HostGovernancePolicy> {
|
|
return request<HostGovernancePolicy>(
|
|
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
|
|
{ method: "PUT", body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
export function getHostTokenUsage(hostId: string): Promise<HostTokenUsageSummary> {
|
|
return request<HostTokenUsageSummary>(
|
|
`/v1/hosts/${encodeURIComponent(hostId)}/token-usage`,
|
|
);
|
|
}
|
|
|
|
export function listHostTokenUsageEvents(hostId: string): Promise<TokenUsageEvent[]> {
|
|
return request<TokenUsageEvent[]>(
|
|
`/v1/hosts/${encodeURIComponent(hostId)}/token-usage-events?limit=20&offset=0`,
|
|
);
|
|
}
|
|
|
|
export function listTasks(options?: {
|
|
status?: TaskStatus;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<TaskListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options?.status) params.set("status", options.status);
|
|
params.set("limit", String(options?.limit ?? 50));
|
|
params.set("offset", String(options?.offset ?? 0));
|
|
const query = params.toString();
|
|
return request<TaskListResponse>(`/v1/tasks${query ? `?${query}` : ""}`);
|
|
}
|
|
|
|
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
|
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
|
|
}
|
|
|
|
export function getTaskPlannerDecisions(
|
|
taskId: string,
|
|
attempt: number,
|
|
): Promise<PlannerDecisionItem[]> {
|
|
return request<PlannerDecisionListResponse>(
|
|
`/v1/tasks/${encodeURIComponent(taskId)}/planner-decisions?attempt=${attempt}`,
|
|
).then((resp) => resp.items);
|
|
}
|
|
|
|
export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> {
|
|
return request<{ task_id: string }>("/v1/tasks", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function listDevices(): Promise<DeviceRecord[]> {
|
|
return request<DeviceRecord[]>("/v1/devices");
|
|
}
|
|
|
|
export function listHosts(): Promise<HostRecord[]> {
|
|
return request<HostRecord[]>("/v1/hosts");
|
|
}
|
|
|
|
export function listPlugins(): Promise<PluginRecord[]> {
|
|
return request<PluginRecord[]>("/v1/plugins");
|
|
}
|
|
|
|
export function registerPlugin(payload: PluginRegistrationPayload): Promise<PluginRecord> {
|
|
return request<PluginRecord>("/v1/plugins", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function listLlmProviderProfiles(): Promise<LlmProviderProfileListResponse> {
|
|
return request<LlmProviderProfileListResponse>("/v1/planner/providers");
|
|
}
|
|
|
|
export function createLlmProviderProfile(payload: {
|
|
name: string;
|
|
provider_type: LlmProviderType;
|
|
model: string;
|
|
base_url?: string | null;
|
|
timeout_seconds: number;
|
|
api_key: string;
|
|
}): Promise<LlmProviderProfile> {
|
|
return request<LlmProviderProfile>("/v1/planner/providers", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function updateLlmProviderProfile(
|
|
profileId: string,
|
|
payload: {
|
|
name?: string;
|
|
provider_type?: LlmProviderType;
|
|
model?: string;
|
|
base_url?: string | null;
|
|
timeout_seconds?: number;
|
|
enabled?: boolean;
|
|
api_key?: string;
|
|
expected_revision?: number;
|
|
},
|
|
): Promise<LlmProviderProfile> {
|
|
return request<LlmProviderProfile>(`/v1/planner/providers/${encodeURIComponent(profileId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function activateLlmProviderProfile(
|
|
profileId: string,
|
|
expectedSettingsRevision: number,
|
|
): Promise<LlmProviderSettings> {
|
|
return request<LlmProviderSettings>(
|
|
`/v1/planner/providers/${encodeURIComponent(profileId)}/activate`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ expected_settings_revision: expectedSettingsRevision }),
|
|
},
|
|
);
|
|
}
|
|
|
|
export function deleteLlmProviderProfile(
|
|
profileId: string,
|
|
expectedRevision: number,
|
|
): Promise<void> {
|
|
return request<void>(
|
|
`/v1/planner/providers/${encodeURIComponent(profileId)}?expected_revision=${expectedRevision}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
}
|
|
|
|
export interface CloudSkillPayload {
|
|
name: string;
|
|
kind: CloudSkillKind;
|
|
description: string;
|
|
tags: string[];
|
|
content: string;
|
|
steps: Record<string, unknown>[];
|
|
parameters: Record<string, Record<string, unknown>>;
|
|
}
|
|
|
|
export function listCloudSkills(): Promise<CloudSkillListResponse> {
|
|
return request<CloudSkillListResponse>("/v1/skills");
|
|
}
|
|
|
|
export function createCloudSkill(payload: CloudSkillPayload): Promise<CloudSkill> {
|
|
return request<CloudSkill>("/v1/skills", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function updateCloudSkill(
|
|
skillId: string,
|
|
payload: CloudSkillPayload,
|
|
): Promise<CloudSkill> {
|
|
return request<CloudSkill>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function deleteCloudSkill(skillId: string): Promise<void> {
|
|
return request<void>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export function listCloudSkillEntitlements(
|
|
skillId: string,
|
|
): Promise<CloudSkillEntitlementsResponse> {
|
|
return request<CloudSkillEntitlementsResponse>(
|
|
`/v1/skills/${encodeURIComponent(skillId)}/entitlements`,
|
|
);
|
|
}
|
|
|
|
export function grantCloudSkillEntitlement(
|
|
skillId: string,
|
|
hostId: string,
|
|
): Promise<void> {
|
|
return request<void>(
|
|
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
|
{ method: "POST" },
|
|
);
|
|
}
|
|
|
|
export function revokeCloudSkillEntitlement(
|
|
skillId: string,
|
|
hostId: string,
|
|
): Promise<void> {
|
|
return request<void>(
|
|
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
}
|
|
|
|
export function getHostSkillInventory(
|
|
hostId: string,
|
|
): Promise<HostSkillInventoryResponse> {
|
|
return request<HostSkillInventoryResponse>(
|
|
`/v1/hosts/${encodeURIComponent(hostId)}/skill-inventory`,
|
|
);
|
|
}
|