Files
mcpctl/src/cli/src/commands/shared.ts
Michal 9050918a83 feat(cli): personality flag + create/get/edit/delete personalities (Stage 4)
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>
2026-04-26 19:32:48 +01:00

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;
}