Compare commits

..

2 Commits

Author SHA1 Message Date
rob 3f259449d4 Add tool composition UI with auto-dependency management
- Add ToolStepDialog for configuring tool steps (tool selection, input mapping, args)
- Add "Add Tool" button in Tool Builder alongside Add Prompt/Add Code
- Add ToolNode in flow graph visualization (purple puzzle piece icon)
- Auto-populate dependencies when adding ToolStep in GUI
- Add --auto-install flag for automatic runtime dependency installation
- Add check_dependencies() and auto_install_dependencies() functions
- Update runner to pass auto_install parameter through execution chain

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 05:27:54 -04:00
rob 05a2fae94c Add password reset flow with email-based token verification
Implement secure password reset functionality:
- Add password_reset_tokens table for storing hashed reset tokens
- Create email utility module (dev mode logs to console)
- Add API endpoints: request, validate, and complete password reset
- Add web routes and templates for forgot-password and reset-password
- Security: 1-hour token expiry, single-use, rate limiting, session invalidation
- Prevent email enumeration by always returning success message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 04:34:43 -04:00
12 changed files with 1012 additions and 18 deletions

View File

@ -9,7 +9,7 @@ from PySide6.QtWidgets import (
)
from PySide6.QtCore import Qt, QThread, Signal
from ...tool import PromptStep, CodeStep
from ...tool import PromptStep, CodeStep, ToolStep, list_tools, load_tool
from ...providers import load_providers, call_provider
from ...profiles import list_profiles
@ -424,3 +424,218 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
output_var=self.output_input.text().strip(),
name=name
)
class ToolStepDialog(QDialog):
"""Dialog for adding/editing tool steps (calling another tool)."""
def __init__(self, parent, step: ToolStep = None, available_vars: list = None, current_tool_name: str = None):
super().__init__(parent)
self.setWindowTitle("Edit Tool Step" if step else "Add Tool Step")
self.setMinimumSize(550, 500)
self._step = step
self._available_vars = available_vars or ["input"]
self._current_tool_name = current_tool_name # To prevent self-referencing
self._tool_args = {} # Cache of tool arguments
self._setup_ui()
if step:
self._load_step(step)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(16)
# Form
form = QFormLayout()
form.setSpacing(12)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
# Tool selection
self.tool_combo = QComboBox()
self._populate_tools()
self.tool_combo.currentTextChanged.connect(self._on_tool_changed)
form.addRow("Tool:", self.tool_combo)
# Tool description (read-only)
self.tool_desc = QLabel("")
self.tool_desc.setStyleSheet("color: #718096; font-size: 11px;")
self.tool_desc.setWordWrap(True)
form.addRow("", self.tool_desc)
# Output variable
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("tool_output")
self.output_input.setText("tool_output")
form.addRow("Output variable:", self.output_input)
# Provider override (optional)
self.provider_combo = QComboBox()
self.provider_combo.addItem("(use tool's default)")
providers = load_providers()
for provider in sorted(providers, key=lambda p: p.name):
self.provider_combo.addItem(provider.name)
form.addRow("Provider override:", self.provider_combo)
layout.addLayout(form)
# Input template
input_group = QGroupBox("Input")
input_layout = QVBoxLayout(input_group)
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
input_help = QLabel(f"Available variables: {vars_text}")
input_help.setStyleSheet("color: #718096; font-size: 11px;")
input_layout.addWidget(input_help)
self.input_template = QPlainTextEdit()
self.input_template.setPlaceholderText("{input}")
self.input_template.setPlainText("{input}")
self.input_template.setMaximumHeight(80)
input_layout.addWidget(self.input_template)
layout.addWidget(input_group)
# Tool arguments
self.args_group = QGroupBox("Tool Arguments")
self.args_layout = QFormLayout(self.args_group)
self.args_layout.setSpacing(8)
self._arg_inputs = {} # variable -> QLineEdit
layout.addWidget(self.args_group)
# Buttons
buttons = QHBoxLayout()
buttons.addStretch()
self.btn_cancel = QPushButton("Cancel")
self.btn_cancel.setObjectName("secondary")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("OK")
self.btn_ok.clicked.connect(self._validate_and_accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
# Trigger initial tool selection
if self.tool_combo.count() > 0:
self._on_tool_changed(self.tool_combo.currentText())
def _populate_tools(self):
"""Populate the tool dropdown."""
tools = list_tools()
for tool_name in sorted(tools):
# Skip the current tool to prevent self-referencing
if tool_name != self._current_tool_name:
self.tool_combo.addItem(tool_name)
def _on_tool_changed(self, tool_name: str):
"""Handle tool selection change."""
# Clear existing arg inputs
for widget in self._arg_inputs.values():
self.args_layout.removeRow(widget)
self._arg_inputs.clear()
if not tool_name:
self.tool_desc.setText("")
return
tool = load_tool(tool_name)
if not tool:
self.tool_desc.setText("(Tool not found)")
return
# Update description
self.tool_desc.setText(tool.description or "(No description)")
# Cache arguments
self._tool_args[tool_name] = tool.arguments
# Create input fields for each argument
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
for arg in tool.arguments:
line = QLineEdit()
line.setPlaceholderText(f"Default: {arg.default}" if arg.default else f"Variable: {vars_text}")
if arg.default:
line.setText(arg.default)
self._arg_inputs[arg.variable] = line
label = f"{arg.flag}:"
if arg.description:
label = f"{arg.flag} ({arg.description}):"
self.args_layout.addRow(label, line)
def _load_step(self, step: ToolStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
# Select tool
idx = self.tool_combo.findText(step.tool)
if idx >= 0:
self.tool_combo.setCurrentIndex(idx)
else:
# Tool might be qualified name - try to add it
self.tool_combo.addItem(step.tool)
self.tool_combo.setCurrentText(step.tool)
self.output_input.setText(step.output_var)
self.input_template.setPlainText(step.input_template)
# Set provider override
if step.provider:
idx = self.provider_combo.findText(step.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
# Set argument values (after tool is selected and args are loaded)
for var, value in step.args.items():
if var in self._arg_inputs:
self._arg_inputs[var].setText(str(value))
def _validate_and_accept(self):
"""Validate and accept."""
tool = self.tool_combo.currentText().strip()
if not tool:
self.tool_combo.setFocus()
return
output = self.output_input.text().strip()
if not output:
self.output_input.setFocus()
return
self.accept()
def get_step(self) -> ToolStep:
"""Get the step from form data."""
# Collect arguments
args = {}
for var, line in self._arg_inputs.items():
value = line.text().strip()
if value:
args[var] = value
# Get provider override
provider = self.provider_combo.currentText()
if provider == "(use tool's default)":
provider = None
# Get name, use None if empty
name = self.name_input.text().strip() or None
return ToolStep(
tool=self.tool_combo.currentText(),
output_var=self.output_input.text().strip(),
input_template=self.input_template.toPlainText() or "{input}",
args=args,
provider=provider,
name=name
)

View File

@ -10,10 +10,10 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt
from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep,
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
)
from ..widgets.icons import get_prompt_icon, get_code_icon
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
class ToolBuilderPage(QWidget):
@ -190,6 +190,10 @@ class ToolBuilderPage(QWidget):
self.btn_add_code.clicked.connect(self._add_code_step)
steps_btns.addWidget(self.btn_add_code)
self.btn_add_tool = QPushButton("Add Tool")
self.btn_add_tool.clicked.connect(self._add_tool_step)
steps_btns.addWidget(self.btn_add_tool)
self.btn_edit_step = QPushButton("Edit")
self.btn_edit_step.setObjectName("secondary")
self.btn_edit_step.clicked.connect(self._edit_step)
@ -379,6 +383,9 @@ class ToolBuilderPage(QWidget):
if arg.variable:
available_vars.add(arg.variable)
# Count for default naming
tool_count = 0
for i, step in enumerate(self._tool.steps, 1):
if isinstance(step, PromptStep):
prompt_count += 1
@ -391,6 +398,11 @@ class ToolBuilderPage(QWidget):
step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20)
elif isinstance(step, ToolStep):
tool_count += 1
step_name = step.name if step.name else f"Tool {tool_count}"
text = f"{step_name} [{step.tool}] → ${step.output_var}"
icon = get_tool_icon(20)
else:
text = f"Unknown step"
icon = None
@ -430,6 +442,7 @@ class ToolBuilderPage(QWidget):
# Count steps by type for default naming
prompt_count = 0
code_count = 0
tool_count = 0
# Track available variables for dependency checking
available_vars = {"input"}
@ -449,6 +462,11 @@ class ToolBuilderPage(QWidget):
step_name = step.name if step.name else f"Code {code_count}"
text = f"{step_name} [python] → ${step.output_var}"
icon = get_code_icon(20)
elif isinstance(step, ToolStep):
tool_count += 1
step_name = step.name if step.name else f"Tool {tool_count}"
text = f"{step_name} [{step.tool}] → ${step.output_var}"
icon = get_tool_icon(20)
else:
text = f"Unknown step"
icon = None
@ -508,6 +526,23 @@ class ToolBuilderPage(QWidget):
del self._tool.arguments[idx]
self._refresh_arguments()
def _add_tool_dependency(self, tool_ref: str):
"""Add a tool reference to the dependencies list if not already present.
Args:
tool_ref: Tool reference (e.g., 'my-tool' or 'owner/tool-name')
"""
if not self._tool or not tool_ref:
return
# Initialize dependencies list if needed
if not hasattr(self._tool, 'dependencies') or self._tool.dependencies is None:
self._tool.dependencies = []
# Add if not already present
if tool_ref not in self._tool.dependencies:
self._tool.dependencies.append(tool_ref)
def _get_available_vars(self, up_to_step: int = -1) -> list:
"""Get available variables for a step.
@ -559,6 +594,21 @@ class ToolBuilderPage(QWidget):
self._tool.steps.append(step)
self._refresh_steps()
def _add_tool_step(self):
"""Add a tool step (call another tool)."""
from ..dialogs.step_dialog import ToolStepDialog
available_vars = self._get_available_vars()
current_name = self._tool.name if self._tool else None
dialog = ToolStepDialog(self, available_vars=available_vars, current_tool_name=current_name)
if dialog.exec():
step = dialog.get_step()
if not self._tool:
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
self._tool.steps.append(step)
# Auto-add to dependencies if not already present
self._add_tool_dependency(step.tool)
self._refresh_steps()
def _edit_step(self):
"""Edit selected step from list view."""
items = self.steps_list.selectedItems()
@ -578,11 +628,20 @@ class ToolBuilderPage(QWidget):
from ..dialogs.step_dialog import CodeStepDialog
available_vars = self._get_available_vars(up_to_step=idx)
dialog = CodeStepDialog(self, step, available_vars=available_vars)
elif isinstance(step, ToolStep):
from ..dialogs.step_dialog import ToolStepDialog
available_vars = self._get_available_vars(up_to_step=idx)
current_name = self._tool.name if self._tool else None
dialog = ToolStepDialog(self, step, available_vars=available_vars, current_tool_name=current_name)
else:
return
if dialog.exec():
self._tool.steps[idx] = dialog.get_step()
new_step = dialog.get_step()
self._tool.steps[idx] = new_step
# Auto-add to dependencies if it's a ToolStep
if isinstance(new_step, ToolStep):
self._add_tool_dependency(new_step.tool)
self._refresh_steps()
def _delete_step(self):

View File

@ -8,10 +8,10 @@ from PySide6.QtGui import QKeyEvent, QAction
from NodeGraphQt import NodeGraph, BaseNode
from ...tool import Tool, PromptStep, CodeStep
from ...tool import Tool, PromptStep, CodeStep, ToolStep
from .icons import (
get_prompt_icon_path, get_code_icon_path,
get_input_icon_path, get_output_icon_path
get_input_icon_path, get_output_icon_path, get_tool_icon_path
)
@ -105,6 +105,33 @@ class CodeNode(CmdForgeBaseNode):
self.output_ports()[0]._name = step.output_var or 'result'
class ToolNode(CmdForgeBaseNode):
"""Node representing a tool step (calling another tool)."""
NODE_NAME = 'Tool'
def __init__(self):
super().__init__()
self.set_color(159, 122, 234) # Purple
self.set_icon(get_tool_icon_path(16))
self.add_input('in', color=(220, 180, 250))
self.add_output('out', color=(250, 180, 220))
# Add properties
self.add_text_input('tool_ref', 'Tool', text='')
self.add_text_input('output_var', 'Output', text='tool_output')
def set_step(self, step: ToolStep, index: int):
"""Configure node from a ToolStep."""
self._step_data = step
self._step_index = index
self.set_property('tool_ref', step.tool or '')
self.set_property('output_var', step.output_var or 'tool_output')
# Update output port name
if self.output_ports():
self.output_ports()[0]._name = step.output_var or 'tool_output'
class OutputNode(CmdForgeBaseNode):
"""Node representing tool output."""
@ -168,6 +195,7 @@ class FlowGraphWidget(QWidget):
self._graph.register_node(InputNode)
self._graph.register_node(PromptNode)
self._graph.register_node(CodeNode)
self._graph.register_node(ToolNode)
self._graph.register_node(OutputNode)
# Connect signals
@ -265,6 +293,7 @@ class FlowGraphWidget(QWidget):
# Count steps by type for default naming
prompt_count = 0
code_count = 0
tool_count = 0
for i, step in enumerate(self._tool.steps or []):
if isinstance(step, PromptStep):
@ -287,6 +316,16 @@ class FlowGraphWidget(QWidget):
pos=[x_pos, 0]
)
node.set_step(step, i)
elif isinstance(step, ToolStep):
tool_count += 1
# Use custom name if set, otherwise default to "Tool N" (per type)
node_name = step.name if step.name else f'Tool {tool_count}'
node = self._graph.create_node(
'cmdforge.ToolNode',
name=node_name,
pos=[x_pos, 0]
)
node.set_step(step, i)
else:
continue
@ -443,7 +482,12 @@ class FlowGraphWidget(QWidget):
def _on_node_double_clicked(self, node):
"""Handle node double-click."""
if hasattr(node, '_step_index') and node._step_index >= 0:
step_type = 'prompt' if isinstance(node, PromptNode) else 'code'
if isinstance(node, PromptNode):
step_type = 'prompt'
elif isinstance(node, ToolNode):
step_type = 'tool'
else:
step_type = 'code'
self.node_double_clicked.emit(node._step_index, step_type)
def _on_nodes_deleted(self, nodes):

View File

@ -273,3 +273,63 @@ def get_output_icon(size: int = 24) -> QIcon:
if key not in _icon_cache:
_icon_cache[key] = create_output_icon(size)
return _icon_cache[key]
def create_tool_icon(size: int = 24, color: QColor = None) -> QIcon:
"""Create a puzzle piece icon for tool steps (calling another tool).
Args:
size: Icon size in pixels
color: Icon color (defaults to purple #9f7aea)
"""
if color is None:
color = QColor(159, 122, 234) # Purple
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
pen = QPen(color, 1.5)
painter.setPen(pen)
painter.setBrush(QBrush(color))
# Draw a simple puzzle piece shape
margin = size * 0.12
w = size - 2 * margin
h = size - 2 * margin
path = QPainterPath()
# Start from top-left, going clockwise
path.moveTo(margin, margin + h * 0.3)
# Top edge with bump
path.lineTo(margin + w * 0.35, margin + h * 0.3)
path.arcTo(QRectF(margin + w * 0.35 - w * 0.1, margin, w * 0.3, h * 0.3), 180, -180)
path.lineTo(margin + w, margin + h * 0.3)
# Right edge
path.lineTo(margin + w, margin + h * 0.7)
# Bottom edge with notch
path.lineTo(margin + w * 0.65, margin + h * 0.7)
path.arcTo(QRectF(margin + w * 0.35, margin + h * 0.7, w * 0.3, h * 0.3), 0, 180)
path.lineTo(margin, margin + h * 0.7)
# Left edge back to start
path.closeSubpath()
painter.drawPath(path)
painter.end()
return QIcon(pixmap)
def get_tool_icon(size: int = 24) -> QIcon:
"""Get cached tool icon."""
key = ('tool', size)
if key not in _icon_cache:
_icon_cache[key] = create_tool_icon(size)
return _icon_cache[key]
def get_tool_icon_path(size: int = 16) -> str:
"""Get file path to tool icon (for NodeGraphQt)."""
return _get_icon_path('tool', size, create_tool_icon)

View File

@ -1741,6 +1741,177 @@ def create_app() -> Flask:
}
})
@app.route("/api/v1/password-reset/request", methods=["POST"])
def request_password_reset() -> Response:
"""Request a password reset email.
Always returns success to prevent email enumeration.
"""
if request.content_length and request.content_length > MAX_BODY_BYTES:
return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413)
# Rate limit by IP
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown")
rate_key = f"{ip}:password_reset"
allowed, _ = rate_limiter.check(rate_key, 5, 3600) # 5 requests per hour per IP
if not allowed:
return error_response("RATE_LIMITED", "Too many password reset requests. Try again later.", 429)
payload = request.get_json(silent=True) or {}
email = (payload.get("email") or "").strip().lower()
# Always return success message (prevent email enumeration)
success_response = jsonify({
"data": {
"status": "success",
"message": "If an account with that email exists, a password reset link has been sent.",
}
})
if not email or not EMAIL_RE.match(email):
return success_response
publisher = query_one(g.db, "SELECT id, email FROM publishers WHERE email = ?", [email])
if not publisher:
return success_response
# Rate limit by email as well
email_rate_key = f"email:{email}:password_reset"
email_allowed, _ = rate_limiter.check(email_rate_key, 3, 3600) # 3 requests per hour per email
if not email_allowed:
return success_response # Still return success to prevent enumeration
# Invalidate any existing unused tokens for this publisher
g.db.execute(
"UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE publisher_id = ? AND used_at IS NULL",
[publisher["id"]],
)
# Generate a secure token
token, token_hash = generate_token()
expires_at = datetime.utcnow() + timedelta(hours=1)
g.db.execute(
"""
INSERT INTO password_reset_tokens (publisher_id, token_hash, expires_at)
VALUES (?, ?, ?)
""",
[publisher["id"], token_hash, expires_at.isoformat()],
)
g.db.commit()
# Send email (logs to console in dev mode)
from cmdforge.web.email import send_password_reset_email
base_url = request.url_root.rstrip("/")
send_password_reset_email(publisher["email"], token, base_url)
return success_response
@app.route("/api/v1/password-reset/validate", methods=["POST"])
def validate_reset_token() -> Response:
"""Validate a password reset token (optional, for UX)."""
payload = request.get_json(silent=True) or {}
token = payload.get("token", "")
if not token:
return error_response("VALIDATION_ERROR", "Token is required", 400)
token_hash = hashlib.sha256(token.encode()).hexdigest()
row = query_one(
g.db,
"""
SELECT id, expires_at, used_at
FROM password_reset_tokens
WHERE token_hash = ?
""",
[token_hash],
)
if not row:
return error_response("INVALID_TOKEN", "Invalid or expired reset token", 400)
if row["used_at"]:
return error_response("TOKEN_USED", "This reset token has already been used", 400)
try:
expires_at = datetime.fromisoformat(row["expires_at"])
if datetime.utcnow() > expires_at:
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
except ValueError:
return error_response("INVALID_TOKEN", "Invalid token data", 400)
return jsonify({"data": {"valid": True}})
@app.route("/api/v1/password-reset/complete", methods=["POST"])
def complete_password_reset() -> Response:
"""Complete password reset with token and new password."""
if request.content_length and request.content_length > MAX_BODY_BYTES:
return error_response("PAYLOAD_TOO_LARGE", "Request body exceeds 512KB limit", 413)
payload = request.get_json(silent=True) or {}
token = payload.get("token", "")
new_password = payload.get("new_password", "")
if not token:
return error_response("VALIDATION_ERROR", "Token is required", 400)
if not new_password or len(new_password) < 8:
return error_response("VALIDATION_ERROR", "Password must be at least 8 characters", 400)
token_hash = hashlib.sha256(token.encode()).hexdigest()
row = query_one(
g.db,
"""
SELECT prt.id, prt.publisher_id, prt.expires_at, prt.used_at, p.email
FROM password_reset_tokens prt
JOIN publishers p ON prt.publisher_id = p.id
WHERE prt.token_hash = ?
""",
[token_hash],
)
if not row:
return error_response("INVALID_TOKEN", "Invalid or expired reset token", 400)
if row["used_at"]:
return error_response("TOKEN_USED", "This reset token has already been used", 400)
try:
expires_at = datetime.fromisoformat(row["expires_at"])
if datetime.utcnow() > expires_at:
return error_response("TOKEN_EXPIRED", "This reset token has expired", 400)
except ValueError:
return error_response("INVALID_TOKEN", "Invalid token data", 400)
# Hash and save new password
new_hash = password_hasher.hash(new_password)
# Update password
g.db.execute(
"UPDATE publishers SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[new_hash, row["publisher_id"]],
)
# Mark token as used
g.db.execute(
"UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
[row["id"]],
)
# Invalidate all existing sessions/tokens for this user
g.db.execute(
"UPDATE api_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE publisher_id = ? AND revoked_at IS NULL",
[row["publisher_id"]],
)
g.db.commit()
return jsonify({
"data": {
"status": "success",
"message": "Password has been reset successfully. Please log in with your new password.",
}
})
@app.route("/api/v1/tokens", methods=["POST"])
@require_token
def create_token() -> Response:

View File

@ -399,6 +399,19 @@ CREATE TABLE IF NOT EXISTS registry_settings (
);
CREATE INDEX IF NOT EXISTS idx_settings_category ON registry_settings(category);
-- Password Reset Tokens
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
token_hash TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_reset_tokens_hash ON password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_reset_tokens_publisher ON password_reset_tokens(publisher_id);
"""

View File

@ -7,7 +7,7 @@ from typing import Optional
from .tool import Tool, PromptStep, CodeStep, ToolStep
from .providers import call_provider, mock_provider
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec
from .resolver import resolve_tool, ToolNotFoundError, ToolSpec, install_from_registry
from .manifest import load_manifest
from .profiles import load_profile
@ -15,6 +15,36 @@ from .profiles import load_profile
MAX_TOOL_DEPTH = 10
def auto_install_dependencies(tool: Tool, verbose: bool = False) -> list[str]:
"""
Automatically install missing dependencies for a tool.
Args:
tool: Tool to check and install dependencies for
verbose: Show installation progress
Returns:
List of successfully installed tool references
"""
missing = check_dependencies(tool)
if not missing:
return []
installed = []
for dep in missing:
try:
if verbose:
print(f"[auto-install] Installing {dep}...", file=sys.stderr)
install_from_registry(dep)
installed.append(dep)
if verbose:
print(f"[auto-install] Installed {dep}", file=sys.stderr)
except Exception as e:
print(f"Warning: Failed to auto-install {dep}: {e}", file=sys.stderr)
return installed
def check_dependencies(tool: Tool, checked: set = None) -> list[str]:
"""
Check if all dependencies for a tool are available.
@ -293,6 +323,7 @@ def run_tool(
dry_run: bool = False,
show_prompt: bool = False,
verbose: bool = False,
auto_install: bool = False,
_depth: int = 0,
_call_stack: Optional[list] = None
) -> tuple[str, int]:
@ -307,6 +338,7 @@ def run_tool(
dry_run: Just show what would happen
show_prompt: Show prompts in addition to output
verbose: Show debug info
auto_install: Automatically install missing dependencies
_depth: Internal recursion depth tracker
_call_stack: Internal call stack for error reporting
@ -322,8 +354,19 @@ def run_tool(
if _depth == 0 and (tool.dependencies or any(isinstance(s, ToolStep) for s in tool.steps)):
missing = check_dependencies(tool)
if missing:
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr)
if auto_install:
# Try to auto-install missing dependencies
installed = auto_install_dependencies(tool, verbose=verbose)
# Re-check after installation
still_missing = check_dependencies(tool)
if still_missing:
print(f"Warning: Could not install all dependencies. Missing: {', '.join(still_missing)}", file=sys.stderr)
elif installed:
print(f"Auto-installed dependencies: {', '.join(installed)}", file=sys.stderr)
else:
print(f"Warning: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
print(f"Install with: cmdforge install {' '.join(missing)}", file=sys.stderr)
print(f"Or use --auto-install to install automatically", file=sys.stderr)
# Continue anyway - the actual step execution will fail with a better error
# Initialize variables with input and arguments
@ -448,6 +491,8 @@ def create_argument_parser(tool: Tool) -> argparse.ArgumentParser:
help="Override provider (e.g., --provider mock)")
parser.add_argument("-v", "--verbose", action="store_true",
help="Show debug information")
parser.add_argument("--auto-install", action="store_true",
help="Automatically install missing tool dependencies")
# Tool-specific flags from arguments
for arg in tool.arguments:
@ -527,7 +572,8 @@ def main():
provider_override=effective_provider,
dry_run=args.dry_run,
show_prompt=args.show_prompt,
verbose=args.verbose
verbose=args.verbose,
auto_install=args.auto_install
)
# Write output

View File

@ -101,3 +101,116 @@ def logout():
return redirect(url_for("web.login"))
session.clear()
return redirect(url_for("web.login"))
@web_bp.route("/forgot-password", methods=["GET", "POST"], endpoint="forgot_password")
def forgot_password():
success_message = None
errors = []
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/forgot_password.html",
errors=["Invalid CSRF token"],
)
email = request.form.get("email", "").strip()
result = _api_post("/api/v1/password-reset/request", {"email": email})
if result["status"] == 429:
errors.append("Too many password reset requests. Please try again later.")
else:
# Always show success message to prevent email enumeration
success_message = "If an account with that email exists, a password reset link has been sent. Please check your email."
return render_template(
"pages/forgot_password.html",
success_message=success_message,
errors=errors,
email=email if errors else "",
)
return render_template("pages/forgot_password.html")
@web_bp.route("/reset-password", methods=["GET", "POST"], endpoint="reset_password")
def reset_password():
token = request.args.get("token") or request.form.get("token")
errors = []
success_message = None
if not token:
return render_template(
"pages/reset_password.html",
errors=["Invalid password reset link. Please request a new one."],
token_valid=False,
)
if request.method == "POST":
if not _validate_csrf():
return render_template(
"pages/reset_password.html",
errors=["Invalid CSRF token"],
token=token,
token_valid=True,
)
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not new_password or len(new_password) < 8:
errors.append("Password must be at least 8 characters.")
elif new_password != confirm_password:
errors.append("Passwords do not match.")
else:
result = _api_post("/api/v1/password-reset/complete", {
"token": token,
"new_password": new_password,
})
if result["status"] == 200:
success_message = "Your password has been reset successfully."
return render_template(
"pages/reset_password.html",
success_message=success_message,
token_valid=False,
)
else:
error = result["data"].get("error", {})
error_code = error.get("code", "")
if error_code == "TOKEN_EXPIRED":
errors.append("This password reset link has expired. Please request a new one.")
elif error_code == "TOKEN_USED":
errors.append("This password reset link has already been used. Please request a new one.")
else:
errors.append(error.get("message", "Failed to reset password."))
return render_template(
"pages/reset_password.html",
errors=errors,
token=token,
token_valid=True,
)
# Validate token on GET
result = _api_post("/api/v1/password-reset/validate", {"token": token})
if result["status"] != 200:
error = result["data"].get("error", {})
error_code = error.get("code", "")
if error_code == "TOKEN_EXPIRED":
errors.append("This password reset link has expired. Please request a new one.")
elif error_code == "TOKEN_USED":
errors.append("This password reset link has already been used. Please request a new one.")
else:
errors.append("Invalid password reset link. Please request a new one.")
return render_template(
"pages/reset_password.html",
errors=errors,
token_valid=False,
)
return render_template(
"pages/reset_password.html",
token=token,
token_valid=True,
)

134
src/cmdforge/web/email.py Normal file
View File

@ -0,0 +1,134 @@
"""Email sending utilities for CmdForge web.
In development mode, emails are logged to the console instead of being sent.
To enable real email sending, set MAIL_ENABLED=true and configure SMTP settings.
"""
from __future__ import annotations
import logging
from typing import Optional
from flask import current_app
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, html_body: str, text_body: Optional[str] = None) -> bool:
"""Send an email.
In dev mode (MAIL_ENABLED=false or unset), logs the email to console.
In production (MAIL_ENABLED=true), sends via SMTP.
Args:
to: Recipient email address
subject: Email subject
html_body: HTML content of the email
text_body: Plain text content (optional)
Returns:
True if email was sent/logged successfully, False otherwise
"""
mail_enabled = current_app.config.get("MAIL_ENABLED", False)
if mail_enabled:
# Future: implement real SMTP sending
# smtp_host = current_app.config.get("MAIL_SERVER", "localhost")
# smtp_port = current_app.config.get("MAIL_PORT", 587)
# smtp_user = current_app.config.get("MAIL_USERNAME")
# smtp_pass = current_app.config.get("MAIL_PASSWORD")
# smtp_tls = current_app.config.get("MAIL_USE_TLS", True)
logger.warning("MAIL_ENABLED is true but SMTP is not implemented yet. Falling back to console logging.")
# Dev mode: log to console
logger.info("=" * 60)
logger.info("[EMAIL] To: %s", to)
logger.info("[EMAIL] Subject: %s", subject)
logger.info("[EMAIL] Body:")
if text_body:
logger.info(text_body)
else:
logger.info(html_body)
logger.info("=" * 60)
return True
def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
"""Send a password reset email.
Args:
to: Recipient email address
token: The password reset token
base_url: Base URL of the application (e.g., https://cmdforge.brrd.tech)
Returns:
True if email was sent/logged successfully
"""
reset_url = f"{base_url.rstrip('/')}/reset-password?token={token}"
subject = "Reset your CmdForge password"
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ text-align: center; margin-bottom: 30px; }}
.logo {{ font-size: 24px; font-weight: bold; color: #4F46E5; }}
.button {{ display: inline-block; padding: 12px 24px; background-color: #4F46E5; color: white; text-decoration: none; border-radius: 6px; font-weight: 500; }}
.footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }}
.link {{ word-break: break-all; color: #4F46E5; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">CmdForge</div>
</div>
<p>Hello,</p>
<p>You requested to reset your password for your CmdForge account. Click the button below to set a new password:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" class="button">Reset Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p class="link">{reset_url}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.</p>
<div class="footer">
<p>This email was sent by CmdForge. If you have questions, visit our website or contact support.</p>
</div>
</div>
</body>
</html>
"""
text_body = f"""Reset your CmdForge password
Hello,
You requested to reset your password for your CmdForge account.
Reset your password by visiting this link:
{reset_url}
This link will expire in 1 hour.
If you didn't request this password reset, you can safely ignore this email.
Your password will remain unchanged.
---
CmdForge
"""
return send_email(to, subject, html_body, text_body)

View File

@ -884,13 +884,6 @@ def terms():
return render_template("pages/terms.html")
@web_bp.route("/forgot-password", endpoint="forgot_password")
def forgot_password():
return render_template(
"pages/content.html",
title="Reset Password",
body="Password resets are not available yet. Please contact support if needed.",
)
@web_bp.route("/robots.txt", endpoint="robots")

View File

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Reset Password - CmdForge{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Reset your password</h1>
<p class="mt-2 text-gray-600">
Enter your email address and we'll send you a link to reset your password.
</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{% if success_message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm text-green-800">{{ success_message }}</p>
</div>
</div>
</div>
{% else %}
{{ form_errors(errors) }}
<form action="{{ url_for('web.forgot_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ text_input(
name='email',
label='Email address',
type='email',
placeholder='you@example.com',
required=true,
value=email or ''
) }}
{{ button_primary('Send reset link', full_width=true) }}
</form>
{% endif %}
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Remember your password?
<a href="{{ url_for('web.login') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Sign in
</a>
</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% from "components/forms.html" import text_input, button_primary, form_errors %}
{% block title %}Set New Password - CmdForge{% endblock %}
{% block content %}
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ url_for('web.home') }}" class="inline-flex items-center justify-center">
<svg class="w-10 h-10 text-indigo-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</a>
<h1 class="mt-4 text-2xl font-bold text-gray-900">Set new password</h1>
{% if token_valid %}
<p class="mt-2 text-gray-600">
Enter your new password below.
</p>
{% endif %}
</div>
<div class="bg-white rounded-lg border border-gray-200 p-8 shadow-sm">
{% if success_message %}
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<svg class="w-5 h-5 text-green-500 mr-3 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm text-green-800">{{ success_message }}</p>
<p class="mt-2 text-sm text-green-700">
<a href="{{ url_for('web.login') }}" class="font-medium underline hover:text-green-800">
Sign in with your new password
</a>
</p>
</div>
</div>
</div>
{% elif not token_valid %}
{{ form_errors(errors) }}
<div class="text-center">
<p class="text-gray-600 mb-4">Need to reset your password?</p>
<a href="{{ url_for('web.forgot_password') }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Request new reset link
</a>
</div>
{% else %}
{{ form_errors(errors) }}
<form action="{{ url_for('web.reset_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="token" value="{{ token }}">
{{ text_input(
name='new_password',
label='New password',
type='password',
placeholder='At least 8 characters',
required=true,
help='Use a mix of letters, numbers, and symbols'
) }}
{{ text_input(
name='confirm_password',
label='Confirm new password',
type='password',
placeholder='Confirm your new password',
required=true
) }}
{{ button_primary('Set new password', full_width=true) }}
</form>
{% endif %}
</div>
<p class="mt-6 text-center text-sm text-gray-600">
Remember your password?
<a href="{{ url_for('web.login') }}" class="text-indigo-600 hover:text-indigo-800 font-medium">
Sign in
</a>
</p>
</div>
</div>
{% endblock %}