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:
@@ -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));
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user