End-to-end CLI surface for the personality overlay: mcpctl create personality grumpy --agent reviewer --description "be terse" mcpctl create prompt tone --agent reviewer --content "Be very terse." mcpctl get personalities mcpctl get personalities --agent reviewer mcpctl edit personality <id> mcpctl delete personality grumpy --agent reviewer mcpctl chat reviewer --personality grumpy Chat banner gains a "Personality:" line that shows either the active flag value or the agent's `defaultPersonality` (when no flag given), so the user knows which overlay is in effect before sending a message. `--personality` is stripped from `/save` (it's a per-turn override, not a `defaultParams` field — the agent's defaultPersonality lives on its own column and is set via PUT /agents). Backend (small additions to land Stage 4 cleanly): - `GET /api/v1/personalities[?agent=name]` so `mcpctl get personalities` doesn't require an agent filter. - PersonalityService.listAll() aggregates across agents. Completions: regenerated fish + bash. `personalities` added as a canonical resource with `personality` alias; edit-resource list extended; the per-resource argument completers pick up the new type automatically. CLI suite: 430/430. mcpd: 801/801. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
import type { ApiClient } from '../api-client.js';
|
|
|
|
export const RESOURCE_ALIASES: Record<string, string> = {
|
|
server: 'servers',
|
|
srv: 'servers',
|
|
project: 'projects',
|
|
proj: 'projects',
|
|
instance: 'instances',
|
|
inst: 'instances',
|
|
secret: 'secrets',
|
|
sec: 'secrets',
|
|
template: 'templates',
|
|
tpl: 'templates',
|
|
user: 'users',
|
|
group: 'groups',
|
|
rbac: 'rbac',
|
|
'rbac-definition': 'rbac',
|
|
'rbac-binding': 'rbac',
|
|
prompt: 'prompts',
|
|
prompts: 'prompts',
|
|
promptrequest: 'promptrequests',
|
|
promptrequests: 'promptrequests',
|
|
pr: 'promptrequests',
|
|
serverattachment: 'serverattachments',
|
|
serverattachments: 'serverattachments',
|
|
sa: 'serverattachments',
|
|
proxymodel: 'proxymodels',
|
|
proxymodels: 'proxymodels',
|
|
pm: 'proxymodels',
|
|
mcptoken: 'mcptokens',
|
|
mcptokens: 'mcptokens',
|
|
token: 'mcptokens',
|
|
tokens: 'mcptokens',
|
|
secretbackend: 'secretbackends',
|
|
secretbackends: 'secretbackends',
|
|
sb: 'secretbackends',
|
|
llm: 'llms',
|
|
llms: 'llms',
|
|
agent: 'agents',
|
|
agents: 'agents',
|
|
personality: 'personalities',
|
|
personalities: 'personalities',
|
|
thread: 'threads',
|
|
threads: 'threads',
|
|
all: 'all',
|
|
};
|
|
|
|
export function resolveResource(name: string): string {
|
|
const lower = name.toLowerCase();
|
|
return RESOURCE_ALIASES[lower] ?? lower;
|
|
}
|
|
|
|
/** Resolve a name-or-ID to an ID. CUIDs pass through; names are looked up. */
|
|
export async function resolveNameOrId(
|
|
client: ApiClient,
|
|
resource: string,
|
|
nameOrId: string,
|
|
): Promise<string> {
|
|
// CUIDs start with 'c' followed by 24+ alphanumeric chars
|
|
if (/^c[a-z0-9]{24}/.test(nameOrId)) {
|
|
return nameOrId;
|
|
}
|
|
// Users resolve by email, not name
|
|
if (resource === 'users') {
|
|
const items = await client.get<Array<{ id: string; email: string }>>(`/api/v1/${resource}`);
|
|
const match = items.find((item) => item.email === nameOrId);
|
|
if (match) return match.id;
|
|
throw new Error(`user '${nameOrId}' not found`);
|
|
}
|
|
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
|
|
const match = items.find((item) => {
|
|
// Instances use server.name, other resources use name directly
|
|
if (resource === 'instances') {
|
|
const server = item.server as { name?: string } | undefined;
|
|
return server?.name === nameOrId;
|
|
}
|
|
return item.name === nameOrId;
|
|
});
|
|
if (match) return match.id as string;
|
|
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`);
|
|
}
|
|
|
|
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
|
|
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
|
|
const result = { ...obj };
|
|
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters', 'linkStatus', 'serverId']) {
|
|
delete result[key];
|
|
}
|
|
|
|
// McpToken-specific: promote projectName → project; drop secret/derived fields
|
|
if ('tokenHash' in result || 'tokenPrefix' in result) {
|
|
delete result.tokenHash;
|
|
delete result.tokenPrefix;
|
|
delete result.lastUsedAt;
|
|
delete result.revokedAt;
|
|
delete result.status;
|
|
delete result.ownerEmail;
|
|
if (typeof result.projectName === 'string') {
|
|
result.project = result.projectName;
|
|
delete result.projectName;
|
|
delete result.projectId;
|
|
}
|
|
}
|
|
|
|
// Rename linkTarget → link for cleaner YAML
|
|
if ('linkTarget' in result) {
|
|
result.link = result.linkTarget;
|
|
delete result.linkTarget;
|
|
// Linked prompts: strip content (it's fetched from the link source, not static)
|
|
if (result.link) {
|
|
delete result.content;
|
|
}
|
|
}
|
|
|
|
// Convert project servers join array → string[] of server names
|
|
if ('servers' in result && Array.isArray(result.servers)) {
|
|
const entries = result.servers as Array<{ server?: { name: string } }>;
|
|
if (entries.length > 0 && entries[0]?.server) {
|
|
result.servers = entries.map((e) => e.server!.name);
|
|
} else if (entries.length === 0) {
|
|
result.servers = [];
|
|
} else {
|
|
delete result.servers;
|
|
}
|
|
}
|
|
|
|
// Convert prompt projectId CUID → project name string
|
|
if ('project' in result && typeof result.project === 'object' && result.project !== null) {
|
|
const proj = result.project as { name: string };
|
|
result.project = proj.name;
|
|
delete result.projectId;
|
|
}
|
|
|
|
// Strip remaining relationship objects
|
|
if ('owner' in result && typeof result.owner === 'object') {
|
|
delete result.owner;
|
|
}
|
|
if ('members' in result && Array.isArray(result.members)) {
|
|
delete result.members;
|
|
}
|
|
|
|
// Normalize proxyModel: resolve from gated when empty, then drop deprecated gated field
|
|
if ('gated' in result || 'proxyModel' in result) {
|
|
if (!result.proxyModel) {
|
|
result.proxyModel = result.gated === false ? 'content-pipeline' : 'default';
|
|
}
|
|
delete result.gated;
|
|
}
|
|
|
|
// Strip null values last (null = unset, omitting from YAML is cleaner and equivalent)
|
|
for (const key of Object.keys(result)) {
|
|
if (result[key] === null) {
|
|
delete result[key];
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|