Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
123ad46
feat: Initial implementation of profiles with Bob
gabe-l-hart Dec 17, 2025
b8044eb
feat: Refactor Bob's version for cleaner centralization of base URL
gabe-l-hart Dec 17, 2025
44b188b
fix: Put whole id in profile table
gabe-l-hart Dec 17, 2025
ea282c5
fix: (unrelated) fix isActive -> enabled that got missed in prompts
gabe-l-hart Dec 17, 2025
759ad9a
fix: Only open the store file for reading if it exists
gabe-l-hart Dec 17, 2025
a915956
test: Update tests to mock get_base_url correctly
gabe-l-hart Dec 17, 2025
1c63595
fix: Refactor the structure of the profiles command to match the others
gabe-l-hart Dec 17, 2025
3cff323
test: Add tests for profile utils and new common helper
gabe-l-hart Dec 17, 2025
0cb9ca8
test: Add tests for profiles commands
gabe-l-hart Dec 17, 2025
912c17e
feat: Add validation logic to profile structures
gabe-l-hart Dec 18, 2025
7f256ed
test: Full test coverage for profiles commands
gabe-l-hart Dec 18, 2025
cb46a68
fix: Remove unreachable validation condition
gabe-l-hart Dec 18, 2025
8fc4e94
test: Unit tests for validation errors
gabe-l-hart Dec 18, 2025
96a41cc
feat: Use a suffix for per-profile tokens
gabe-l-hart Dec 18, 2025
938466a
feat: Report active profile in whoami
gabe-l-hart Dec 18, 2025
3b55c3a
feat: Implement auto-login matching Desktop app stored credentials
gabe-l-hart Dec 18, 2025
a6a9920
chore: Explicitly add cryptography dep
gabe-l-hart Dec 18, 2025
e1cc95e
test: Add unit tests for credential_store.py
gabe-l-hart Dec 18, 2025
5515d53
test: Add tests to cover auto-login in common
gabe-l-hart Dec 18, 2025
3347a06
feat: Add cforge profiles create
gabe-l-hart Dec 19, 2025
00cf980
feat: Add logic to manage a virtual default profile
gabe-l-hart Dec 19, 2025
7749adb
test(fix): Fix the mock_everywhere test util to also mock in conftest
gabe-l-hart Dec 19, 2025
027e286
fix: Remove unreachable code paths that handled no active profile
gabe-l-hart Dec 19, 2025
0062ba7
:
gabe-l-hart Dec 19, 2025
f7eaca3
feat: Don't show virtual default if desktop default is present
gabe-l-hart Dec 19, 2025
8bf63e0
fix: Remove profiles_current
gabe-l-hart Dec 19, 2025
7a688e5
fix: Remove unnecessary if guard for active profile in whoami
gabe-l-hart Dec 19, 2025
10cbf7f
fix: Fix get_active_profile to always return a profile
gabe-l-hart Dec 19, 2025
08b55d9
feat: Add the ability to create a profile from a data file
gabe-l-hart Dec 19, 2025
9d8ead4
fix: No need to handle bad state profile store
gabe-l-hart Dec 19, 2025
51fc54d
test: Cover the case of a bad data file for profiles_create
gabe-l-hart Dec 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions cforge/commands/resources/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ def prompts_list(
print_table(
prompts,
"Prompts",
["id", "name", "description", "arguments", "isActive"],
{"isActive": "enabled"},
["id", "name", "description", "arguments", "enabled"],
)
else:
console.print("[yellow]No prompts found[/yellow]")
Expand Down
4 changes: 2 additions & 2 deletions cforge/commands/settings/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import typer

# First-Party
from cforge.common import get_console, get_settings, make_authenticated_request
from cforge.common import get_base_url, get_console, make_authenticated_request


def export(
Expand All @@ -33,7 +33,7 @@ def export(
console = get_console()

try:
console.print(f"[cyan]Exporting configuration from gateway at http://{get_settings().host}:{get_settings().port}[/cyan]")
console.print(f"[cyan]Exporting configuration from gateway at {get_base_url()}[/cyan]")

# Build API parameters
params: Dict[str, Any] = {}
Expand Down
4 changes: 2 additions & 2 deletions cforge/commands/settings/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import typer

# First-Party
from cforge.common import get_console, get_settings, get_token_file, save_token
from cforge.common import get_base_url, get_console, get_token_file, save_token


def login(
Expand All @@ -28,7 +28,7 @@ def login(

try:
# Make login request
gateway_url = f"http://{get_settings().host}:{get_settings().port}"
gateway_url = get_base_url()
full_url = f"{gateway_url}/auth/login"

response = requests.post(full_url, json={"email": email, "password": password})
Expand Down
248 changes: 248 additions & 0 deletions cforge/commands/settings/profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
"""Location: ./cforge/commands/profiles.py
Copyright 2025
SPDX-License-Identifier: Apache-2.0
Authors: Gabe Goodhart

CLI commands for profile management
"""

# Standard
from datetime import datetime
from pathlib import Path
from typing import Optional
import json
import secrets
import string

# Third-Party
import typer

# First-Party
from cforge.common import get_console, print_table, print_json, prompt_for_schema
from cforge.config import get_settings
from cforge.profile_utils import (
AuthProfile,
ProfileStore,
get_all_profiles,
get_profile,
get_active_profile,
set_active_profile,
load_profile_store,
save_profile_store,
)


def profiles_list() -> None:
"""List all available profiles.

Displays all profiles configured in the Desktop app, showing their name,
email, API URL, and active status.
"""
console = get_console()

try:
profiles = get_all_profiles()

# Prepare data for table
profile_data = []
for profile in profiles:
profile_data.append(
{
"id": profile.id,
"name": profile.name,
"email": profile.email,
"api_url": profile.api_url,
"active": "✓" if profile.is_active else "",
"environment": profile.metadata.environment if profile.metadata else "",
}
)

print_table(
profile_data,
"Available Profiles",
["id", "name", "email", "api_url", "environment", "active"],
)

# Show which profile is currently active
active = get_active_profile()
console.print(f"\n[green]Currently using profile:[/green] [cyan]{active.name}[/cyan] ({active.email})")
console.print(f"[dim]Connected to: {active.api_url}[/dim]")

except Exception as e:
console.print(f"[red]Error listing profiles: {str(e)}[/red]")
raise typer.Exit(1)


def profiles_get(
profile_id: Optional[str] = typer.Argument(
None,
help="Profile ID to retrieve. If not provided, shows the active profile.",
),
json_output: bool = typer.Option(
False,
"--json",
help="Output in JSON format",
),
) -> None:
"""Get details of a specific profile or the active profile.

If no profile ID is provided, displays information about the currently
active profile.
"""
console = get_console()

try:
if profile_id:
profile = get_profile(profile_id)
if not profile:
console.print(f"[red]Profile not found: {profile_id}[/red]")
raise typer.Exit(1)
else:
profile = get_active_profile()

if json_output:
# Output as JSON
print_json(profile.model_dump(by_alias=True), title="Profile Details")
else:
# Pretty print profile details
console.print(f"\n[bold cyan]Profile: {profile.name}[/bold cyan]")
console.print(f"[dim]ID:[/dim] {profile.id}")
console.print(f"[dim]Email:[/dim] {profile.email}")
console.print(f"[dim]API URL:[/dim] {profile.api_url}")
console.print(f"[dim]Active:[/dim] {'[green]Yes[/green]' if profile.is_active else '[yellow]No[/yellow]'}")
console.print(f"[dim]Created:[/dim] {profile.created_at}")
if profile.last_used:
console.print(f"[dim]Last Used:[/dim] {profile.last_used}")

if profile.metadata:
console.print("\n[bold]Metadata:[/bold]")
if profile.metadata.description:
console.print(f" [dim]Description:[/dim] {profile.metadata.description}")
if profile.metadata.environment:
console.print(f" [dim]Environment:[/dim] {profile.metadata.environment}")
if profile.metadata.icon:
console.print(f" [dim]Icon:[/dim] {profile.metadata.icon}")

except Exception as e:
console.print(f"[red]Error retrieving profile: {str(e)}[/red]")
raise typer.Exit(1)


def profiles_switch(
profile_id: str = typer.Argument(
...,
help="Profile ID to switch to. Use 'cforge profiles list' to see available profiles.",
),
) -> None:
"""Switch to a different profile.

Sets the specified profile as the active profile. All subsequent CLI
commands will use this profile's API URL for connections.

Note: This only changes which profile the CLI uses. To fully authenticate
and manage profiles, use the Context Forge Desktop app.
"""
console = get_console()

try:
# Check if profile exists
profile = get_profile(profile_id)
if not profile:
console.print(f"[red]Profile not found: {profile_id}[/red]")
console.print("[dim]Use 'cforge profiles list' to see available profiles.[/dim]")
raise typer.Exit(1)

# Switch to the profile
success = set_active_profile(profile_id)
if not success:
console.print(f"[red]Failed to switch to profile: {profile_id}[/red]")
raise typer.Exit(1)

console.print(f"[green]✓ Switched to profile:[/green] [cyan]{profile.name}[/cyan]")
console.print(f"[dim]Email:[/dim] {profile.email}")
console.print(f"[dim]API URL:[/dim] {profile.api_url}")

# Clear the settings cache so the new profile takes effect
get_settings.cache_clear()

console.print("\n[yellow]Note:[/yellow] Profile switched successfully. " "The CLI will now connect to the selected profile's API URL.")

except Exception as e:
console.print(f"[red]Error switching profile: {str(e)}[/red]")
raise typer.Exit(1)


def profiles_create(
data_file: Optional[Path] = typer.Argument(None, help="JSON file containing prompt data (interactive mode if not provided)"),
) -> None:
"""Create a new profile interactively.

Walks the user through creating a new profile by prompting for all required
fields. The new profile will be created in an inactive state. After creation,
you will be asked if you want to enable the new profile.
"""
console = get_console()

try:
console.print("\n[bold cyan]Create New Profile[/bold cyan]")
console.print("[dim]You will be prompted for profile information.[/dim]\n")

# Generate a 16-character random ID (matching desktop app format)
alphabet = string.ascii_letters + string.digits
profile_id = "".join(secrets.choice(alphabet) for _ in range(16))
created_at = datetime.now()

# Pre-fill fields that should not be prompted
prefilled = {
"id": profile_id,
"is_active": False,
"created_at": created_at,
"last_used": None,
}

if data_file:
if not data_file.exists():
console.print(f"[red]File not found: {data_file}[/red]")
raise typer.Exit(1)
profile_data = json.loads(data_file.read_text())
profile_data.update(prefilled)
else:
profile_data = prompt_for_schema(AuthProfile, prefilled=prefilled)

# Create the AuthProfile instance
new_profile = AuthProfile.model_validate(profile_data)

# Load or create the profile store
store = load_profile_store()
if not store:
store = ProfileStore(profiles={}, active_profile_id=None)

# Add the new profile to the store
store.profiles[new_profile.id] = new_profile

# Save the profile store
save_profile_store(store)

console.print("\n[green]✓ Profile created successfully![/green]")
console.print(f"[dim]Profile ID:[/dim] {new_profile.id}")
console.print(f"[dim]Name:[/dim] {new_profile.name}")
console.print(f"[dim]Email:[/dim] {new_profile.email}")
console.print(f"[dim]API URL:[/dim] {new_profile.api_url}")

# Ask if the user wants to enable the new profile
console.print("\n[yellow]Enable this profile now?[/yellow]", end=" ")
if typer.confirm("", default=False):
success = set_active_profile(new_profile.id)
if success:
console.print(f"[green]✓ Profile enabled:[/green] [cyan]{new_profile.name}[/cyan]")
# Clear the settings cache so the new profile takes effect
get_settings.cache_clear()
else:
console.print(f"[red]Failed to enable profile: {new_profile.id}[/red]")
else:
console.print("[dim]Profile created but not enabled. Use 'cforge profiles switch' to enable it later.[/dim]")

except Exception as e:
console.print(f"[red]Error creating profile: {str(e)}[/red]")
raise typer.Exit(1)
17 changes: 16 additions & 1 deletion cforge/commands/settings/whoami.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,33 @@

# First-Party
from cforge.common import get_console, get_settings, get_token_file, load_token
from cforge.profile_utils import get_active_profile


def whoami() -> None:
"""Show current authentication status and token source.

Displays where the authentication token is coming from (if any).
Displays where the authentication token is coming from (if any) and
information about the active profile if one is set.
"""

console = get_console()
settings = get_settings()
env_token = settings.mcpgateway_bearer_token
stored_token = load_token()
active_profile = get_active_profile()

# Display active profile information
console.print("[bold cyan]Active Profile:[/bold cyan]")
console.print(f" [cyan]Name:[/cyan] {active_profile.name}")
console.print(f" [cyan]ID:[/cyan] {active_profile.id}")
console.print(f" [cyan]Email:[/cyan] {active_profile.email}")
console.print(f" [cyan]API URL:[/cyan] {active_profile.api_url}")
if active_profile.metadata and active_profile.metadata.environment:
console.print(f" [cyan]Environment:[/cyan] {active_profile.metadata.environment}")
console.print()

# Display authentication status
if env_token:
console.print("[green]✓ Authenticated via MCPGATEWAY_BEARER_TOKEN environment variable[/green]")
console.print(f"[cyan]Token:[/cyan] {env_token[:10]}...")
Expand Down
Loading