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>
This commit is contained in:
Michal
2026-04-26 19:32:48 +01:00
parent faef1e732d
commit 9050918a83
11 changed files with 171 additions and 26 deletions

View File

@@ -45,6 +45,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command {
.option('--system <text>', 'Replace agent.systemPrompt for this session')
.option('--system-file <path>', 'Read --system text from a file')
.option('--system-append <text>', 'Append to the agent system block for this session')
.option('--personality <name>', 'Personality overlay (additive prompts on top of the agent)')
.option('--temperature <n>', 'Sampling temperature (0..2)', parseFloat)
.option('--top-p <n>', 'Nucleus sampling cutoff (0..1)', parseFloat)
.option('--top-k <n>', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt)
@@ -71,6 +72,7 @@ interface ChatOpts {
system?: string;
systemFile?: string;
systemAppend?: string;
personality?: string;
temperature?: number;
topP?: number;
topK?: number;
@@ -85,6 +87,7 @@ interface ChatOpts {
interface Overrides {
systemOverride?: string;
systemAppend?: string;
personality?: string;
temperature?: number;
top_p?: number;
top_k?: number;
@@ -103,6 +106,7 @@ async function buildInitialOverrides(opts: ChatOpts): Promise<Overrides> {
}
if (system !== undefined) out.systemOverride = system;
if (opts.systemAppend !== undefined) out.systemAppend = opts.systemAppend;
if (opts.personality !== undefined) out.personality = opts.personality;
if (opts.temperature !== undefined) out.temperature = opts.temperature;
if (opts.topP !== undefined) out.top_p = opts.topP;
if (opts.topK !== undefined) out.top_k = opts.topK;
@@ -298,10 +302,14 @@ async function handleSlash(
}
function stripSession(o: Overrides): Record<string, unknown> {
// /save persists sampling defaults but not the per-session systemOverride / systemAppend.
// /save persists sampling defaults but not per-session persona controls
// (--system / --system-append) or the per-turn --personality overlay.
// The agent's defaultPersonality is set via PATCH /agents (or `mcpctl edit
// agent`) — it is NOT a sampling param.
const out: Record<string, unknown> = { ...o };
delete out.systemOverride;
delete out.systemAppend;
delete out.personality;
return out;
}
@@ -669,6 +677,7 @@ interface AgentInfo {
systemPrompt: string;
llm: { name: string };
project: { name: string } | null;
defaultPersonality?: { name: string } | null;
}
/**
@@ -702,6 +711,15 @@ async function printChatHeader(
const tail = info.project !== null ? ` Project: ${info.project.name}` : '';
out(`LLM: ${info.llm.name}${tail}`);
// Personality overlay: explicit --personality wins; otherwise agent's
// defaultPersonality (if set). Tells the user which prompt bundle is
// active before they type anything.
if (overrides.personality !== undefined) {
out(`Personality: ${overrides.personality} (--personality)`);
} else if (info.defaultPersonality) {
out(`Personality: ${info.defaultPersonality.name} (agent default)`);
}
if (overrides.systemOverride !== undefined) {
out(`System prompt (--system replaces agent.systemPrompt):`);
out(indent(overrides.systemOverride));

View File

@@ -727,14 +727,18 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
// --- create prompt ---
cmd.command('prompt')
.description('Create an approved prompt')
.description('Create an approved prompt (scope: project, agent, or global)')
.argument('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
.option('-p, --project <name>', 'Project name to scope the prompt to')
.option('-p, --project <name>', 'Project to scope the prompt to')
.option('--agent <name>', 'Agent to attach the prompt to directly (XOR with --project)')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
.option('--link <target>', 'Link to MCP resource (format: project/server:uri)')
.action(async (name: string, opts) => {
if (opts.project && opts.agent) {
throw new Error('--project and --agent are mutually exclusive');
}
let content = opts.content as string | undefined;
if (opts.contentFile) {
const fs = await import('node:fs/promises');
@@ -756,6 +760,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (!project) throw new Error(`Project '${opts.project as string}' not found`);
body.projectId = project.id;
}
if (opts.agent) {
// Send agent name; mcpd resolves it server-side.
body.agent = opts.agent;
}
if (opts.priority) {
const priority = Number(opts.priority);
if (isNaN(priority) || priority < 1 || priority > 10) {
@@ -771,6 +779,34 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
});
// --- create personality ---
cmd.command('personality')
.description('Create a personality overlay on an agent')
.argument('<name>', 'Personality name (lowercase alphanumeric with hyphens)')
.option('--agent <name>', 'Agent that owns this personality (required)')
.option('--description <text>', 'Description shown in `mcpctl get personalities`')
.option('--priority <number>', 'Priority 1-10 (default: 5)')
.action(async (name: string, opts) => {
const agentName = opts.agent as string | undefined;
if (!agentName) {
throw new Error('--agent is required');
}
const body: Record<string, unknown> = { name };
if (opts.description) body.description = opts.description;
if (opts.priority) {
const priority = Number(opts.priority);
if (isNaN(priority) || priority < 1 || priority > 10) {
throw new Error('--priority must be a number between 1 and 10');
}
body.priority = priority;
}
const personality = await client.post<{ id: string; name: string }>(
`/api/v1/agents/${encodeURIComponent(agentName)}/personalities`,
body,
);
log(`personality '${personality.name}' created on agent '${agentName}' (id: ${personality.id})`);
});
// --- create serverattachment ---
cmd.command('serverattachment')
.alias('sa')

View File

@@ -11,11 +11,12 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
const { client, log } = deps;
return new Command('delete')
.description('Delete a resource (server, instance, secret, project, user, group, rbac)')
.description('Delete a resource (server, instance, secret, project, user, group, rbac, personality)')
.argument('<resource>', 'resource type')
.argument('<id>', 'resource ID or name')
.option('-p, --project <name>', 'Project name (for serverattachment)')
.action(async (resourceArg: string, idOrName: string, opts: { project?: string }) => {
.option('--agent <name>', 'Agent name (for personality delete-by-name)')
.action(async (resourceArg: string, idOrName: string, opts: { project?: string; agent?: string }) => {
const resource = resolveResource(resourceArg);
// Serverattachments: delete serverattachment <server> --project <project>
@@ -29,6 +30,28 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
return;
}
// Personalities: names are unique per-agent, so by-name delete requires --agent
// (or pass a CUID directly).
if (resource === 'personalities') {
let personalityId: string;
if (/^c[a-z0-9]{24}/.test(idOrName)) {
personalityId = idOrName;
} else {
if (!opts.agent) {
throw new Error('--agent is required to delete a personality by name (or pass the id).');
}
const items = await client.get<Array<{ id: string; name: string }>>(
`/api/v1/agents/${encodeURIComponent(opts.agent)}/personalities`,
);
const match = items.find((i) => i.name === idOrName);
if (!match) throw new Error(`personality '${idOrName}' not found on agent '${opts.agent}'`);
personalityId = match.id;
}
await client.delete(`/api/v1/personalities/${personalityId}`);
log(`personality '${idOrName}' deleted.`);
return;
}
// Mcptokens: names are scoped to a project, so require --project unless the caller passes a CUID
if (resource === 'mcptokens') {
let tokenId: string;

View File

@@ -48,7 +48,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
return;
}
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests'];
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities'];
if (!validResources.includes(resource)) {
log(`Error: unknown resource type '${resourceArg}'`);
process.exitCode = 1;

View File

@@ -159,6 +159,25 @@ const agentColumns: Column<AgentRow>[] = [
{ header: 'ID', key: 'id' },
];
interface PersonalityRow {
id: string;
name: string;
description: string;
agentId: string;
agentName: string;
priority: number;
promptCount: number;
}
const personalityColumns: Column<PersonalityRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'AGENT', key: 'agentName', width: 24 },
{ header: 'PROMPTS', key: (r) => String(r.promptCount), width: 8 },
{ header: 'PRIORITY', key: (r) => String(r.priority), width: 8 },
{ header: 'DESCRIPTION', key: (r) => truncate(r.description, 40) || '-', width: 40 },
{ header: 'ID', key: 'id' },
];
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max - 1) + '…';
@@ -345,6 +364,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return llmColumns as unknown as Column<Record<string, unknown>>[];
case 'agents':
return agentColumns as unknown as Column<Record<string, unknown>>[];
case 'personalities':
return personalityColumns as unknown as Column<Record<string, unknown>>[];
default:
return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
@@ -370,6 +391,7 @@ const RESOURCE_KIND: Record<string, string> = {
secretbackends: 'secretbackend',
llms: 'llm',
agents: 'agent',
personalities: 'personality',
};
/**

View File

@@ -38,6 +38,8 @@ export const RESOURCE_ALIASES: Record<string, string> = {
llms: 'llms',
agent: 'agents',
agents: 'agents',
personality: 'personalities',
personalities: 'personalities',
thread: 'threads',
threads: 'threads',
all: 'all',

View File

@@ -14,6 +14,24 @@ export function registerPersonalityRoutes(
app: FastifyInstance,
service: PersonalityService,
): void {
app.get<{ Querystring: { agent?: string } }>(
'/api/v1/personalities',
async (request, reply) => {
try {
if (request.query.agent !== undefined) {
return await service.listForAgent(request.query.agent);
}
return await service.listAll();
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
},
);
app.get<{ Params: { agentName: string } }>(
'/api/v1/agents/:agentName/personalities',
async (request, reply) => {

View File

@@ -54,6 +54,18 @@ export class PersonalityService {
private readonly promptRepo: IPromptRepository,
) {}
async listAll(): Promise<PersonalityView[]> {
const rows = await this.repo.findAll();
const agents = new Map<string, string>();
for (const r of rows) {
if (!agents.has(r.agentId)) {
const agent = await this.agentRepo.findById(r.agentId);
agents.set(r.agentId, agent?.name ?? r.agentId);
}
}
return Promise.all(rows.map((r) => this.toView(r, agents.get(r.agentId) ?? r.agentId)));
}
async listForAgent(agentName: string): Promise<PersonalityView[]> {
const agent = await this.agentRepo.findByName(agentName);
if (agent === null) throw new NotFoundError(`Agent not found: ${agentName}`);