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

@@ -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}`);