Add AI persona profiles for prompt injection

Profiles allow users to inject system instructions into prompts,
customizing the AI's behavior and persona for tool execution.

Features:
- Profile dataclass with name, description, system_prompt
- 8 built-in profiles: None, Comedian, Technical Writer, Teacher,
  Concise, Creative, Code Reviewer, Analyst
- Custom profile creation and storage in ~/.cmdforge/profiles/
- Profile selector in Prompt Step dialog
- Profile injection during tool execution
- Profiles page in GUI (Ctrl+4) for viewing and managing profiles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-14 05:22:31 -04:00
parent 9d56f703cd
commit 9588c3f8b0
6 changed files with 473 additions and 3 deletions

View File

@ -5,9 +5,11 @@ from PySide6.QtWidgets import (
QComboBox, QPushButton, QHBoxLayout, QLabel,
QPlainTextEdit
)
from PySide6.QtCore import Qt
from ...tool import PromptStep, CodeStep
from ...providers import load_providers
from ...profiles import list_profiles
class PromptStepDialog(QDialog):
@ -43,6 +45,16 @@ class PromptStepDialog(QDialog):
self.provider_combo.addItem(default)
form.addRow("Provider:", self.provider_combo)
# Profile selection
self.profile_combo = QComboBox()
profiles = list_profiles()
for profile in profiles:
self.profile_combo.addItem(profile.name)
# Set tooltip with description
idx = self.profile_combo.count() - 1
self.profile_combo.setItemData(idx, profile.description, Qt.ToolTipRole)
form.addRow("Profile:", self.profile_combo)
# Output variable
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("response")
@ -87,6 +99,12 @@ class PromptStepDialog(QDialog):
else:
self.provider_combo.setCurrentText(step.provider)
# Load profile
if step.profile:
idx = self.profile_combo.findText(step.profile)
if idx >= 0:
self.profile_combo.setCurrentIndex(idx)
self.output_input.setText(step.output_var)
self.prompt_input.setPlainText(step.prompt)
@ -106,10 +124,15 @@ class PromptStepDialog(QDialog):
def get_step(self) -> PromptStep:
"""Get the step from form data."""
profile = self.profile_combo.currentText()
# Don't store "None" profile
if profile == "None":
profile = None
return PromptStep(
prompt=self.prompt_input.toPlainText(),
provider=self.provider_combo.currentText(),
output_var=self.output_input.text().strip()
output_var=self.output_input.text().strip(),
profile=profile
)

View File

@ -74,6 +74,7 @@ class MainWindow(QMainWindow):
("Tools", "Manage your tools"),
("Registry", "Browse and install tools"),
("Providers", "Configure AI providers"),
("Profiles", "AI persona profiles"),
]
font = QFont()
@ -92,14 +93,17 @@ class MainWindow(QMainWindow):
from .pages.tools_page import ToolsPage
from .pages.registry_page import RegistryPage
from .pages.providers_page import ProvidersPage
from .pages.profiles_page import ProfilesPage
self.tools_page = ToolsPage(self)
self.registry_page = RegistryPage(self)
self.providers_page = ProvidersPage(self)
self.profiles_page = ProfilesPage(self)
self.pages.addWidget(self.tools_page)
self.pages.addWidget(self.registry_page)
self.pages.addWidget(self.providers_page)
self.pages.addWidget(self.profiles_page)
def _on_page_changed(self, index: int):
"""Handle page changes."""
@ -120,6 +124,7 @@ class MainWindow(QMainWindow):
"tools": 0,
"registry": 1,
"providers": 2,
"profiles": 3,
}
if page_name.lower() in page_map:
self.sidebar.setCurrentRow(page_map[page_name.lower()])
@ -164,7 +169,7 @@ class MainWindow(QMainWindow):
shortcut_escape = QShortcut(QKeySequence("Escape"), self)
shortcut_escape.activated.connect(self._shortcut_escape)
# Ctrl+1/2/3: Navigate pages
# Ctrl+1/2/3/4: Navigate pages
shortcut_page1 = QShortcut(QKeySequence("Ctrl+1"), self)
shortcut_page1.activated.connect(lambda: self.sidebar.setCurrentRow(0))
@ -174,6 +179,9 @@ class MainWindow(QMainWindow):
shortcut_page3 = QShortcut(QKeySequence("Ctrl+3"), self)
shortcut_page3.activated.connect(lambda: self.sidebar.setCurrentRow(2))
shortcut_page4 = QShortcut(QKeySequence("Ctrl+4"), self)
shortcut_page4.activated.connect(lambda: self.sidebar.setCurrentRow(3))
# Ctrl+R: Refresh current page
shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self)
shortcut_refresh.activated.connect(self._shortcut_refresh)

View File

@ -0,0 +1,250 @@
"""Profiles page - manage AI persona profiles."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QLabel, QGroupBox, QTextEdit, QLineEdit,
QFormLayout, QMessageBox, QSplitter
)
from PySide6.QtCore import Qt
from ...profiles import list_profiles, save_profile, delete_profile, Profile
class ProfilesPage(QWidget):
"""Profiles management page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._setup_ui()
self.refresh()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QHBoxLayout()
title = QLabel("Profiles")
title.setObjectName("heading")
header.addWidget(title)
header.addStretch()
self.btn_new = QPushButton("New Profile")
self.btn_new.clicked.connect(self._new_profile)
header.addWidget(self.btn_new)
layout.addLayout(header)
# Description
desc = QLabel(
"AI persona profiles inject system instructions into prompts. "
"Select a profile when creating a prompt step to customize the AI's behavior."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #718096;")
layout.addWidget(desc)
# Main content splitter
splitter = QSplitter(Qt.Horizontal)
# Left: Profile list
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
self.profile_list = QListWidget()
self.profile_list.currentItemChanged.connect(self._on_profile_selected)
left_layout.addWidget(self.profile_list)
splitter.addWidget(left)
# Right: Profile details/editor
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
# Details group
details_box = QGroupBox("Profile Details")
details_layout = QFormLayout(details_box)
details_layout.setSpacing(12)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Profile name")
details_layout.addRow("Name:", self.name_input)
self.desc_input = QLineEdit()
self.desc_input.setPlaceholderText("Brief description")
details_layout.addRow("Description:", self.desc_input)
right_layout.addWidget(details_box)
# System prompt group
prompt_box = QGroupBox("System Prompt")
prompt_layout = QVBoxLayout(prompt_box)
self.prompt_input = QTextEdit()
self.prompt_input.setPlaceholderText(
"Enter the system instructions that will be injected into prompts.\n\n"
"Example: You are a helpful assistant who explains things clearly..."
)
prompt_layout.addWidget(self.prompt_input)
right_layout.addWidget(prompt_box, 1)
# Action buttons
actions = QHBoxLayout()
self.btn_save = QPushButton("Save")
self.btn_save.clicked.connect(self._save_profile)
self.btn_save.setEnabled(False)
actions.addWidget(self.btn_save)
self.btn_delete = QPushButton("Delete")
self.btn_delete.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_profile)
self.btn_delete.setEnabled(False)
actions.addWidget(self.btn_delete)
actions.addStretch()
right_layout.addLayout(actions)
splitter.addWidget(right)
splitter.setSizes([300, 500])
layout.addWidget(splitter, 1)
# Status label
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #718096; font-style: italic;")
layout.addWidget(self.status_label)
def refresh(self):
"""Refresh the profile list."""
self.profile_list.clear()
profiles = list_profiles()
for profile in profiles:
item = QListWidgetItem()
if profile.builtin:
item.setText(f"{profile.name} (built-in)")
else:
item.setText(profile.name)
item.setData(Qt.UserRole, profile)
self.profile_list.addItem(item)
self._clear_form()
self.status_label.setText(f"{len(profiles)} profiles available")
def _on_profile_selected(self, current, previous):
"""Handle profile selection."""
if not current:
self._clear_form()
return
profile = current.data(Qt.UserRole)
self._load_profile(profile)
def _load_profile(self, profile: Profile):
"""Load profile into form."""
self.name_input.setText(profile.name)
self.desc_input.setText(profile.description)
self.prompt_input.setPlainText(profile.system_prompt)
# Built-in profiles can't be edited
is_builtin = profile.builtin
self.name_input.setEnabled(not is_builtin)
self.desc_input.setEnabled(not is_builtin)
self.prompt_input.setEnabled(not is_builtin)
self.btn_save.setEnabled(not is_builtin)
self.btn_delete.setEnabled(not is_builtin and profile.name != "None")
if is_builtin:
self.status_label.setText("Built-in profile (read-only)")
else:
self.status_label.setText("Custom profile")
def _clear_form(self):
"""Clear the form."""
self.name_input.clear()
self.name_input.setEnabled(True)
self.desc_input.clear()
self.desc_input.setEnabled(True)
self.prompt_input.clear()
self.prompt_input.setEnabled(True)
self.btn_save.setEnabled(False)
self.btn_delete.setEnabled(False)
def _new_profile(self):
"""Start creating a new profile."""
self.profile_list.clearSelection()
self._clear_form()
self.name_input.setFocus()
self.btn_save.setEnabled(True)
self.status_label.setText("Creating new profile...")
def _save_profile(self):
"""Save the current profile."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Validation", "Profile name is required")
return
if name == "None":
QMessageBox.warning(self, "Validation", "'None' is a reserved profile name")
return
description = self.desc_input.text().strip()
system_prompt = self.prompt_input.toPlainText().strip()
if not system_prompt:
QMessageBox.warning(self, "Validation", "System prompt is required")
return
profile = Profile(
name=name,
description=description,
system_prompt=system_prompt
)
try:
save_profile(profile)
self.main_window.show_status(f"Saved profile '{name}'")
self.refresh()
# Select the saved profile
for i in range(self.profile_list.count()):
item = self.profile_list.item(i)
if item.data(Qt.UserRole).name == name:
self.profile_list.setCurrentItem(item)
break
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save profile:\n{e}")
def _delete_profile(self):
"""Delete the selected profile."""
current = self.profile_list.currentItem()
if not current:
return
profile = current.data(Qt.UserRole)
if profile.builtin:
QMessageBox.warning(self, "Error", "Cannot delete built-in profiles")
return
reply = QMessageBox.question(
self, "Confirm Delete",
f"Delete profile '{profile.name}'?\n\nThis cannot be undone.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
if delete_profile(profile.name):
self.main_window.show_status(f"Deleted profile '{profile.name}'")
self.refresh()
else:
QMessageBox.warning(self, "Error", "Failed to delete profile")

177
src/cmdforge/profiles.py Normal file
View File

@ -0,0 +1,177 @@
"""AI persona profiles for prompt injection."""
import os
from dataclasses import dataclass, field
from typing import Optional, List
from pathlib import Path
import yaml
from .config import get_config_dir
@dataclass
class Profile:
"""An AI persona profile with system instructions."""
name: str
description: str = ""
system_prompt: str = ""
tags: List[str] = field(default_factory=list)
builtin: bool = False # True for default profiles
def to_dict(self) -> dict:
d = {
"name": self.name,
"description": self.description,
"system_prompt": self.system_prompt,
}
if self.tags:
d["tags"] = self.tags
return d
@classmethod
def from_dict(cls, data: dict, builtin: bool = False) -> "Profile":
return cls(
name=data.get("name", ""),
description=data.get("description", ""),
system_prompt=data.get("system_prompt", ""),
tags=data.get("tags", []),
builtin=builtin
)
# Built-in default profiles
DEFAULT_PROFILES = [
Profile(
name="None",
description="No profile - use prompt as-is",
system_prompt="",
builtin=True
),
Profile(
name="Comedian",
description="Witty and humorous, but stays informative",
system_prompt="""You are a witty and humorous assistant who uses humor to make information more engaging and memorable. You're able to be serious when it matters, but you naturally inject levity and clever observations into your responses. You are an expert at explaining things clearly while keeping the reader entertained.""",
tags=["creative", "engaging"],
builtin=True
),
Profile(
name="Technical Writer",
description="Precise, structured, uses proper terminology",
system_prompt="""You are a professional technical writer who prioritizes clarity, precision, and proper structure. You use correct terminology, organize information logically with headers and bullet points when appropriate, and ensure accuracy in all technical details. You write documentation that is both comprehensive and accessible.""",
tags=["technical", "documentation"],
builtin=True
),
Profile(
name="Teacher",
description="Patient, explains step-by-step, uses analogies",
system_prompt="""You are a patient and experienced teacher who excels at breaking down complex topics into understandable parts. You explain concepts step-by-step, use relatable analogies and examples, and check for understanding. You adapt your explanations to the learner's level and never make them feel bad for not knowing something.""",
tags=["educational", "patient"],
builtin=True
),
Profile(
name="Concise",
description="Brief responses, bullet points, no fluff",
system_prompt="""You are a direct and efficient communicator who values brevity. You get straight to the point, use bullet points and short sentences, and avoid unnecessary filler words or lengthy explanations. Every word you write serves a purpose. You provide complete information in the most compact form possible.""",
tags=["brief", "efficient"],
builtin=True
),
Profile(
name="Creative",
description="Imaginative, thinks outside the box",
system_prompt="""You are a highly creative thinker who approaches problems from unexpected angles. You generate novel ideas, make unique connections between concepts, and aren't afraid to suggest unconventional solutions. You balance creativity with practicality, ensuring your ideas are both innovative and actionable.""",
tags=["creative", "innovative"],
builtin=True
),
Profile(
name="Code Reviewer",
description="Critical eye, suggests improvements, follows best practices",
system_prompt="""You are an experienced code reviewer with a keen eye for quality. You identify potential bugs, security issues, and performance problems. You suggest improvements based on best practices and design patterns. You explain the reasoning behind your suggestions and prioritize the most impactful changes. You're constructive, not harsh.""",
tags=["developer", "code"],
builtin=True
),
Profile(
name="Analyst",
description="Data-driven, objective, thorough analysis",
system_prompt="""You are a meticulous analyst who examines information thoroughly and objectively. You consider multiple perspectives, identify patterns and trends, and support your conclusions with evidence. You present findings in a clear, structured manner and acknowledge uncertainties or limitations in your analysis.""",
tags=["analytical", "objective"],
builtin=True
),
]
def get_profiles_dir() -> Path:
"""Get the profiles directory path."""
profiles_dir = Path(get_config_dir()) / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
return profiles_dir
def load_profile(name: str) -> Optional[Profile]:
"""Load a profile by name."""
# Check built-in profiles first
for profile in DEFAULT_PROFILES:
if profile.name.lower() == name.lower():
return profile
# Check user profiles
profile_path = get_profiles_dir() / f"{name}.yaml"
if profile_path.exists():
try:
with open(profile_path) as f:
data = yaml.safe_load(f)
return Profile.from_dict(data)
except Exception:
return None
return None
def save_profile(profile: Profile) -> None:
"""Save a user profile."""
if profile.builtin:
raise ValueError("Cannot save built-in profiles")
profile_path = get_profiles_dir() / f"{profile.name}.yaml"
with open(profile_path, "w") as f:
yaml.safe_dump(profile.to_dict(), f, default_flow_style=False)
def delete_profile(name: str) -> bool:
"""Delete a user profile."""
# Can't delete built-in profiles
for profile in DEFAULT_PROFILES:
if profile.name.lower() == name.lower():
return False
profile_path = get_profiles_dir() / f"{name}.yaml"
if profile_path.exists():
profile_path.unlink()
return True
return False
def list_profiles() -> List[Profile]:
"""List all available profiles (built-in + user)."""
profiles = list(DEFAULT_PROFILES)
# Load user profiles
profiles_dir = get_profiles_dir()
if profiles_dir.exists():
for path in profiles_dir.glob("*.yaml"):
try:
with open(path) as f:
data = yaml.safe_load(f)
profile = Profile.from_dict(data)
# Don't add duplicates of built-in names
if not any(p.name.lower() == profile.name.lower() for p in profiles):
profiles.append(profile)
except Exception:
continue
return profiles
def get_profile_names() -> List[str]:
"""Get list of all profile names."""
return [p.name for p in list_profiles()]

View File

@ -9,6 +9,7 @@ from .tool import Tool, PromptStep, CodeStep, ToolStep
from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec
from .manifest import load_manifest
from .profiles import load_profile
# Maximum recursion depth for nested tool calls
MAX_TOOL_DEPTH = 10
@ -114,6 +115,13 @@ def execute_prompt_step(step: PromptStep, variables: dict, provider_override: st
# Build prompt with variable substitution
prompt = substitute_variables(step.prompt, variables)
# Inject profile system prompt if specified
if step.profile:
profile = load_profile(step.profile)
if profile and profile.system_prompt:
# Prepend system prompt to user prompt
prompt = f"{profile.system_prompt}\n\n---\n\n{prompt}"
# Call provider
provider = provider_override or step.provider

View File

@ -49,6 +49,7 @@ class PromptStep:
provider: str # Provider name
output_var: str # Variable to store output
prompt_file: Optional[str] = None # Optional filename for external prompt
profile: Optional[str] = None # Optional AI persona profile name
def to_dict(self) -> dict:
d = {
@ -59,6 +60,8 @@ class PromptStep:
}
if self.prompt_file:
d["prompt_file"] = self.prompt_file
if self.profile:
d["profile"] = self.profile
return d
@classmethod
@ -67,7 +70,8 @@ class PromptStep:
prompt=data["prompt"],
provider=data["provider"],
output_var=data["output_var"],
prompt_file=data.get("prompt_file")
prompt_file=data.get("prompt_file"),
profile=data.get("profile")
)