basic-memory 0.14.4__py3-none-any.whl → 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (84) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +5 -9
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/directory_router.py +23 -2
  5. basic_memory/api/routers/knowledge_router.py +25 -8
  6. basic_memory/api/routers/project_router.py +100 -4
  7. basic_memory/cli/app.py +9 -28
  8. basic_memory/cli/auth.py +277 -0
  9. basic_memory/cli/commands/cloud/__init__.py +5 -0
  10. basic_memory/cli/commands/cloud/api_client.py +112 -0
  11. basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
  12. basic_memory/cli/commands/cloud/core_commands.py +288 -0
  13. basic_memory/cli/commands/cloud/mount_commands.py +295 -0
  14. basic_memory/cli/commands/cloud/rclone_config.py +288 -0
  15. basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
  16. basic_memory/cli/commands/command_utils.py +43 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +77 -60
  19. basic_memory/cli/commands/project.py +154 -152
  20. basic_memory/cli/commands/status.py +25 -22
  21. basic_memory/cli/commands/sync.py +45 -228
  22. basic_memory/cli/commands/tool.py +87 -16
  23. basic_memory/cli/main.py +1 -0
  24. basic_memory/config.py +131 -21
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +27 -8
  27. basic_memory/file_utils.py +37 -13
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/markdown/plugins.py +9 -7
  30. basic_memory/mcp/async_client.py +124 -14
  31. basic_memory/mcp/project_context.py +141 -0
  32. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  33. basic_memory/mcp/prompts/continue_conversation.py +17 -16
  34. basic_memory/mcp/prompts/recent_activity.py +116 -32
  35. basic_memory/mcp/prompts/search.py +13 -12
  36. basic_memory/mcp/prompts/utils.py +11 -4
  37. basic_memory/mcp/resources/ai_assistant_guide.md +211 -341
  38. basic_memory/mcp/resources/project_info.py +27 -11
  39. basic_memory/mcp/server.py +0 -37
  40. basic_memory/mcp/tools/__init__.py +5 -6
  41. basic_memory/mcp/tools/build_context.py +67 -56
  42. basic_memory/mcp/tools/canvas.py +38 -26
  43. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  44. basic_memory/mcp/tools/delete_note.py +81 -47
  45. basic_memory/mcp/tools/edit_note.py +155 -138
  46. basic_memory/mcp/tools/list_directory.py +112 -99
  47. basic_memory/mcp/tools/move_note.py +181 -101
  48. basic_memory/mcp/tools/project_management.py +113 -277
  49. basic_memory/mcp/tools/read_content.py +91 -74
  50. basic_memory/mcp/tools/read_note.py +152 -115
  51. basic_memory/mcp/tools/recent_activity.py +471 -68
  52. basic_memory/mcp/tools/search.py +105 -92
  53. basic_memory/mcp/tools/sync_status.py +136 -130
  54. basic_memory/mcp/tools/utils.py +4 -0
  55. basic_memory/mcp/tools/view_note.py +44 -33
  56. basic_memory/mcp/tools/write_note.py +151 -90
  57. basic_memory/models/knowledge.py +12 -6
  58. basic_memory/models/project.py +6 -2
  59. basic_memory/repository/entity_repository.py +89 -82
  60. basic_memory/repository/relation_repository.py +13 -0
  61. basic_memory/repository/repository.py +18 -5
  62. basic_memory/repository/search_repository.py +46 -2
  63. basic_memory/schemas/__init__.py +6 -0
  64. basic_memory/schemas/base.py +39 -11
  65. basic_memory/schemas/cloud.py +46 -0
  66. basic_memory/schemas/memory.py +90 -21
  67. basic_memory/schemas/project_info.py +9 -10
  68. basic_memory/schemas/sync_report.py +48 -0
  69. basic_memory/services/context_service.py +25 -11
  70. basic_memory/services/directory_service.py +124 -3
  71. basic_memory/services/entity_service.py +100 -48
  72. basic_memory/services/initialization.py +30 -11
  73. basic_memory/services/project_service.py +101 -24
  74. basic_memory/services/search_service.py +16 -8
  75. basic_memory/sync/sync_service.py +173 -34
  76. basic_memory/sync/watch_service.py +101 -40
  77. basic_memory/utils.py +14 -4
  78. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/METADATA +57 -9
  79. basic_memory-0.15.1.dist-info/RECORD +146 -0
  80. basic_memory/mcp/project_session.py +0 -120
  81. basic_memory-0.14.4.dist-info/RECORD +0 -133
  82. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/WHEEL +0 -0
  83. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/entry_points.txt +0 -0
  84. {basic_memory-0.14.4.dist-info → basic_memory-0.15.1.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
3
  # Package version - updated by release automation
4
- __version__ = "0.14.4"
4
+ __version__ = "0.15.1"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v0"
@@ -20,14 +20,14 @@ depends_on: Union[str, Sequence[str], None] = None
20
20
 
21
21
  def upgrade() -> None:
22
22
  """Re-establish foreign key constraints that were lost during project table recreation.
23
-
23
+
24
24
  The migration 647e7a75e2cd recreated the project table but did not re-establish
25
25
  the foreign key constraint from entity.project_id to project.id, causing
26
26
  foreign key constraint failures when trying to delete projects with related entities.
27
27
  """
28
28
  # SQLite doesn't allow adding foreign key constraints to existing tables easily
29
29
  # We need to be careful and handle the case where the constraint might already exist
30
-
30
+
31
31
  with op.batch_alter_table("entity", schema=None) as batch_op:
32
32
  # Try to drop existing foreign key constraint (may not exist)
33
33
  try:
@@ -35,19 +35,15 @@ def upgrade() -> None:
35
35
  except Exception:
36
36
  # Constraint may not exist, which is fine - we'll create it next
37
37
  pass
38
-
38
+
39
39
  # Add the foreign key constraint with CASCADE DELETE
40
40
  # This ensures that when a project is deleted, all related entities are also deleted
41
41
  batch_op.create_foreign_key(
42
- "fk_entity_project_id",
43
- "project",
44
- ["project_id"],
45
- ["id"],
46
- ondelete="CASCADE"
42
+ "fk_entity_project_id", "project", ["project_id"], ["id"], ondelete="CASCADE"
47
43
  )
48
44
 
49
45
 
50
46
  def downgrade() -> None:
51
47
  """Remove the foreign key constraint."""
52
48
  with op.batch_alter_table("entity", schema=None) as batch_op:
53
- batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
49
+ batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
basic_memory/api/app.py CHANGED
@@ -21,19 +21,25 @@ from basic_memory.api.routers import (
21
21
  prompt_router,
22
22
  )
23
23
  from basic_memory.config import ConfigManager
24
- from basic_memory.services.initialization import initialize_app, initialize_file_sync
24
+ from basic_memory.services.initialization import initialize_file_sync, initialize_app
25
25
 
26
26
 
27
27
  @asynccontextmanager
28
28
  async def lifespan(app: FastAPI): # pragma: no cover
29
- """Lifecycle manager for the FastAPI app."""
29
+ """Lifecycle manager for the FastAPI app. Not called in stdio mcp mode"""
30
30
 
31
31
  app_config = ConfigManager().config
32
- # Initialize app and database
33
32
  logger.info("Starting Basic Memory API")
34
- print(f"fastapi {app_config.projects}")
33
+
35
34
  await initialize_app(app_config)
36
35
 
36
+ # Cache database connections in app state for performance
37
+ logger.info("Initializing database and caching connections...")
38
+ engine, session_maker = await db.get_or_create_db(app_config.database_path)
39
+ app.state.engine = engine
40
+ app.state.session_maker = session_maker
41
+ logger.info("Database connections cached in app state")
42
+
37
43
  logger.info(f"Sync changes enabled: {app_config.sync_changes}")
38
44
  if app_config.sync_changes:
39
45
  # start file sync task in background
@@ -10,7 +10,7 @@ from basic_memory.schemas.directory import DirectoryNode
10
10
  router = APIRouter(prefix="/directory", tags=["directory"])
11
11
 
12
12
 
13
- @router.get("/tree", response_model=DirectoryNode)
13
+ @router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
14
14
  async def get_directory_tree(
15
15
  directory_service: DirectoryServiceDep,
16
16
  project_id: ProjectIdDep,
@@ -31,7 +31,28 @@ async def get_directory_tree(
31
31
  return tree
32
32
 
33
33
 
34
- @router.get("/list", response_model=List[DirectoryNode])
34
+ @router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
35
+ async def get_directory_structure(
36
+ directory_service: DirectoryServiceDep,
37
+ project_id: ProjectIdDep,
38
+ ):
39
+ """Get folder structure for navigation (no files).
40
+
41
+ Optimized endpoint for folder tree navigation. Returns only directory nodes
42
+ without file metadata. For full tree with files, use /directory/tree.
43
+
44
+ Args:
45
+ directory_service: Service for directory operations
46
+ project_id: ID of the current project
47
+
48
+ Returns:
49
+ DirectoryNode tree containing only folders (type="directory")
50
+ """
51
+ structure = await directory_service.get_directory_structure()
52
+ return structure
53
+
54
+
55
+ @router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
35
56
  async def list_directory(
36
57
  directory_service: DirectoryServiceDep,
37
58
  project_id: ProjectIdDep,
@@ -27,6 +27,26 @@ from basic_memory.schemas.base import Permalink, Entity
27
27
 
28
28
  router = APIRouter(prefix="/knowledge", tags=["knowledge"])
29
29
 
30
+
31
+ async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
32
+ """Background task to resolve relations for a specific entity.
33
+
34
+ This runs asynchronously after the API response is sent, preventing
35
+ long delays when creating entities with many relations.
36
+ """
37
+ try:
38
+ # Only resolve relations for the newly created entity
39
+ await sync_service.resolve_relations(entity_id=entity_id)
40
+ logger.debug(
41
+ f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
42
+ )
43
+ except Exception as e:
44
+ # Log but don't fail - this is a background task
45
+ logger.warning(
46
+ f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
47
+ )
48
+
49
+
30
50
  ## Create endpoints
31
51
 
32
52
 
@@ -88,15 +108,12 @@ async def create_or_update_entity(
88
108
  # reindex
89
109
  await search_service.index_entity(entity, background_tasks=background_tasks)
90
110
 
91
- # Attempt immediate relation resolution when creating new entities
92
- # This helps resolve forward references when related entities are created in the same session
111
+ # Schedule relation resolution as a background task for new entities
112
+ # This prevents blocking the API response while resolving potentially many relations
93
113
  if created:
94
- try:
95
- await sync_service.resolve_relations()
96
- logger.debug(f"Resolved relations after creating entity: {entity.permalink}")
97
- except Exception as e: # pragma: no cover
98
- # Don't fail the entire request if relation resolution fails
99
- logger.warning(f"Failed to resolve relations after entity creation: {e}")
114
+ background_tasks.add_task(
115
+ resolve_relations_background, sync_service, entity.id, entity.permalink or ""
116
+ )
100
117
 
101
118
  result = EntityResponse.model_validate(entity)
102
119
 
@@ -1,11 +1,17 @@
1
1
  """Router for project management."""
2
2
 
3
3
  import os
4
- from fastapi import APIRouter, HTTPException, Path, Body
4
+ from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks
5
5
  from typing import Optional
6
+ from loguru import logger
6
7
 
7
- from basic_memory.deps import ProjectServiceDep, ProjectPathDep
8
- from basic_memory.schemas import ProjectInfoResponse
8
+ from basic_memory.deps import (
9
+ ProjectConfigDep,
10
+ ProjectServiceDep,
11
+ ProjectPathDep,
12
+ SyncServiceDep,
13
+ )
14
+ from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
9
15
  from basic_memory.schemas.project_info import (
10
16
  ProjectList,
11
17
  ProjectItem,
@@ -14,6 +20,7 @@ from basic_memory.schemas.project_info import (
14
20
  )
15
21
 
16
22
  # Router for resources in a specific project
23
+ # The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
17
24
  project_router = APIRouter(prefix="/project", tags=["project"])
18
25
 
19
26
  # Router for managing project resources
@@ -29,6 +36,25 @@ async def get_project_info(
29
36
  return await project_service.get_project_info(project)
30
37
 
31
38
 
39
+ @project_router.get("/item", response_model=ProjectItem)
40
+ async def get_project(
41
+ project_service: ProjectServiceDep,
42
+ project: ProjectPathDep,
43
+ ) -> ProjectItem:
44
+ """Get bassic info about the specified Basic Memory project."""
45
+ found_project = await project_service.get_project(project)
46
+ if not found_project:
47
+ raise HTTPException(
48
+ status_code=404, detail=f"Project: '{project}' does not exist"
49
+ ) # pragma: no cover
50
+
51
+ return ProjectItem(
52
+ name=found_project.name,
53
+ path=found_project.path,
54
+ is_default=found_project.is_default or False,
55
+ )
56
+
57
+
32
58
  # Update a project
33
59
  @project_router.patch("/{name}", response_model=ProjectStatusResponse)
34
60
  async def update_project(
@@ -77,6 +103,54 @@ async def update_project(
77
103
  raise HTTPException(status_code=400, detail=str(e))
78
104
 
79
105
 
106
+ # Sync project filesystem
107
+ @project_router.post("/sync")
108
+ async def sync_project(
109
+ background_tasks: BackgroundTasks,
110
+ sync_service: SyncServiceDep,
111
+ project_config: ProjectConfigDep,
112
+ ):
113
+ """Force project filesystem sync to database.
114
+
115
+ Scans the project directory and updates the database with any new or modified files.
116
+
117
+ Args:
118
+ background_tasks: FastAPI background tasks
119
+ sync_service: Sync service for this project
120
+ project_config: Project configuration
121
+
122
+ Returns:
123
+ Response confirming sync was initiated
124
+ """
125
+ background_tasks.add_task(sync_service.sync, project_config.home, project_config.name)
126
+ logger.info(f"Filesystem sync initiated for project: {project_config.name}")
127
+
128
+ return {
129
+ "status": "sync_started",
130
+ "message": f"Filesystem sync initiated for project '{project_config.name}'",
131
+ }
132
+
133
+
134
+ @project_router.post("/status", response_model=SyncReportResponse)
135
+ async def project_sync_status(
136
+ sync_service: SyncServiceDep,
137
+ project_config: ProjectConfigDep,
138
+ ) -> SyncReportResponse:
139
+ """Scan directory for changes compared to database state.
140
+
141
+ Args:
142
+ sync_service: Sync service for this project
143
+ project_config: Project configuration
144
+
145
+ Returns:
146
+ Scan report with details on files that need syncing
147
+ """
148
+ logger.info(f"Scanning filesystem for project: {project_config.name}")
149
+ sync_report = await sync_service.scan(project_config.home)
150
+
151
+ return SyncReportResponse.from_sync_report(sync_report)
152
+
153
+
80
154
  # List all available projects
81
155
  @project_resource_router.get("/projects", response_model=ProjectList)
82
156
  async def list_projects(
@@ -120,6 +194,7 @@ async def add_project(
120
194
  Response confirming the project was added
121
195
  """
122
196
  try: # pragma: no cover
197
+ # The service layer now handles cloud mode validation and path sanitization
123
198
  await project_service.add_project(
124
199
  project_data.name, project_data.path, set_default=project_data.set_default
125
200
  )
@@ -217,8 +292,29 @@ async def set_default_project(
217
292
  raise HTTPException(status_code=400, detail=str(e))
218
293
 
219
294
 
295
+ # Get the default project
296
+ @project_resource_router.get("/default", response_model=ProjectItem)
297
+ async def get_default_project(
298
+ project_service: ProjectServiceDep,
299
+ ) -> ProjectItem:
300
+ """Get the default project.
301
+
302
+ Returns:
303
+ Response with project default information
304
+ """
305
+ # Get the old default project
306
+ default_name = project_service.default_project
307
+ default_project = await project_service.get_project(default_name)
308
+ if not default_project: # pragma: no cover
309
+ raise HTTPException( # pragma: no cover
310
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
311
+ )
312
+
313
+ return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
314
+
315
+
220
316
  # Synchronize projects between config and database
221
- @project_resource_router.post("/sync", response_model=ProjectStatusResponse)
317
+ @project_resource_router.post("/config/sync", response_model=ProjectStatusResponse)
222
318
  async def synchronize_projects(
223
319
  project_service: ProjectServiceDep,
224
320
  ) -> ProjectStatusResponse:
basic_memory/cli/app.py CHANGED
@@ -2,8 +2,7 @@ from typing import Optional
2
2
 
3
3
  import typer
4
4
 
5
- from basic_memory.config import get_project_config, ConfigManager
6
- from basic_memory.mcp.project_session import session
5
+ from basic_memory.config import ConfigManager
7
6
 
8
7
 
9
8
  def version_callback(value: bool) -> None:
@@ -11,10 +10,7 @@ def version_callback(value: bool) -> None:
11
10
  if value: # pragma: no cover
12
11
  import basic_memory
13
12
 
14
- config = get_project_config()
15
13
  typer.echo(f"Basic Memory version: {basic_memory.__version__}")
16
- typer.echo(f"Current project: {config.project}")
17
- typer.echo(f"Project path: {config.home}")
18
14
  raise typer.Exit()
19
15
 
20
16
 
@@ -24,13 +20,6 @@ app = typer.Typer(name="basic-memory")
24
20
  @app.callback()
25
21
  def app_callback(
26
22
  ctx: typer.Context,
27
- project: Optional[str] = typer.Option(
28
- None,
29
- "--project",
30
- "-p",
31
- help="Specify which project to use 1",
32
- envvar="BASIC_MEMORY_PROJECT",
33
- ),
34
23
  version: Optional[bool] = typer.Option(
35
24
  None,
36
25
  "--version",
@@ -49,25 +38,17 @@ def app_callback(
49
38
  app_config = ConfigManager().config
50
39
  ensure_initialization(app_config)
51
40
 
52
- # Initialize MCP session with the specified project or default
53
- if project: # pragma: no cover
54
- # Use the project specified via --project flag
55
- current_project_config = get_project_config(project)
56
- session.set_current_project(current_project_config.name)
57
-
58
- # Update the global config to use this project
59
- from basic_memory.config import update_current_project
60
-
61
- update_current_project(project)
62
- else:
63
- # Use the default project
64
- current_project = app_config.default_project
65
- session.set_current_project(current_project)
66
-
67
41
 
42
+ ## import
68
43
  # Register sub-command groups
69
44
  import_app = typer.Typer(help="Import data from various sources")
70
45
  app.add_typer(import_app, name="import")
71
46
 
72
- claude_app = typer.Typer()
47
+ claude_app = typer.Typer(help="Import Conversations from Claude JSON export.")
73
48
  import_app.add_typer(claude_app, name="claude")
49
+
50
+
51
+ ## cloud
52
+
53
+ cloud_app = typer.Typer(help="Access Basic Memory Cloud")
54
+ app.add_typer(cloud_app, name="cloud")
@@ -0,0 +1,277 @@
1
+ """WorkOS OAuth Device Authorization for CLI."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import secrets
8
+ import time
9
+ import webbrowser
10
+
11
+ import httpx
12
+ from rich.console import Console
13
+
14
+ from basic_memory.config import ConfigManager
15
+
16
+ console = Console()
17
+
18
+
19
+ class CLIAuth:
20
+ """Handles WorkOS OAuth Device Authorization for CLI tools."""
21
+
22
+ def __init__(self, client_id: str, authkit_domain: str):
23
+ self.client_id = client_id
24
+ self.authkit_domain = authkit_domain
25
+ app_config = ConfigManager().config
26
+ # Store tokens in data dir
27
+ self.token_file = app_config.data_dir_path / "basic-memory-cloud.json"
28
+ # PKCE parameters
29
+ self.code_verifier = None
30
+ self.code_challenge = None
31
+
32
+ def generate_pkce_pair(self) -> tuple[str, str]:
33
+ """Generate PKCE code verifier and challenge."""
34
+ # Generate code verifier (43-128 characters)
35
+ code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8")
36
+ code_verifier = code_verifier.rstrip("=")
37
+
38
+ # Generate code challenge (SHA256 hash of verifier)
39
+ challenge_bytes = hashlib.sha256(code_verifier.encode("utf-8")).digest()
40
+ code_challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8")
41
+ code_challenge = code_challenge.rstrip("=")
42
+
43
+ return code_verifier, code_challenge
44
+
45
+ async def request_device_authorization(self) -> dict | None:
46
+ """Request device authorization from WorkOS with PKCE."""
47
+ device_auth_url = f"{self.authkit_domain}/oauth2/device_authorization"
48
+
49
+ # Generate PKCE pair
50
+ self.code_verifier, self.code_challenge = self.generate_pkce_pair()
51
+
52
+ data = {
53
+ "client_id": self.client_id,
54
+ "scope": "openid profile email offline_access",
55
+ "code_challenge": self.code_challenge,
56
+ "code_challenge_method": "S256",
57
+ }
58
+
59
+ try:
60
+ async with httpx.AsyncClient() as client:
61
+ response = await client.post(device_auth_url, data=data)
62
+
63
+ if response.status_code == 200:
64
+ return response.json()
65
+ else:
66
+ console.print(
67
+ f"[red]Device authorization failed: {response.status_code} - {response.text}[/red]"
68
+ )
69
+ return None
70
+ except Exception as e:
71
+ console.print(f"[red]Device authorization error: {e}[/red]")
72
+ return None
73
+
74
+ def display_user_instructions(self, device_response: dict) -> None:
75
+ """Display user instructions for device authorization."""
76
+ user_code = device_response["user_code"]
77
+ verification_uri = device_response["verification_uri"]
78
+ verification_uri_complete = device_response.get("verification_uri_complete")
79
+
80
+ console.print("\n[bold blue]🔐 Authentication Required[/bold blue]")
81
+ console.print("\nTo authenticate, please visit:")
82
+ console.print(f"[bold cyan]{verification_uri}[/bold cyan]")
83
+ console.print(f"\nAnd enter this code: [bold yellow]{user_code}[/bold yellow]")
84
+
85
+ if verification_uri_complete:
86
+ console.print("\nOr for one-click access, visit:")
87
+ console.print(f"[bold green]{verification_uri_complete}[/bold green]")
88
+
89
+ # Try to open browser automatically
90
+ try:
91
+ console.print("\n[dim]Opening browser automatically...[/dim]")
92
+ webbrowser.open(verification_uri_complete)
93
+ except Exception:
94
+ pass # Silently fail if browser can't be opened
95
+
96
+ console.print("\n[dim]Waiting for you to complete authentication in your browser...[/dim]")
97
+
98
+ async def poll_for_token(self, device_code: str, interval: int = 5) -> dict | None:
99
+ """Poll the token endpoint until user completes authentication."""
100
+ token_url = f"{self.authkit_domain}/oauth2/token"
101
+
102
+ data = {
103
+ "client_id": self.client_id,
104
+ "device_code": device_code,
105
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
106
+ "code_verifier": self.code_verifier,
107
+ }
108
+
109
+ max_attempts = 60 # 5 minutes with 5-second intervals
110
+ current_interval = interval
111
+
112
+ for _attempt in range(max_attempts):
113
+ try:
114
+ async with httpx.AsyncClient() as client:
115
+ response = await client.post(token_url, data=data)
116
+
117
+ if response.status_code == 200:
118
+ return response.json()
119
+
120
+ # Parse error response
121
+ try:
122
+ error_data = response.json()
123
+ error = error_data.get("error")
124
+ except Exception:
125
+ error = "unknown_error"
126
+
127
+ if error == "authorization_pending":
128
+ # User hasn't completed auth yet, keep polling
129
+ pass
130
+ elif error == "slow_down":
131
+ # Increase polling interval
132
+ current_interval += 5
133
+ console.print("[yellow]Slowing down polling rate...[/yellow]")
134
+ elif error == "access_denied":
135
+ console.print("[red]Authentication was denied by user[/red]")
136
+ return None
137
+ elif error == "expired_token":
138
+ console.print("[red]Device code has expired. Please try again.[/red]")
139
+ return None
140
+ else:
141
+ console.print(f"[red]Token polling error: {error}[/red]")
142
+ return None
143
+
144
+ except Exception as e:
145
+ console.print(f"[red]Token polling request error: {e}[/red]")
146
+
147
+ # Wait before next poll
148
+ await self._async_sleep(current_interval)
149
+
150
+ console.print("[red]Authentication timeout. Please try again.[/red]")
151
+ return None
152
+
153
+ async def _async_sleep(self, seconds: int) -> None:
154
+ """Async sleep utility."""
155
+ import asyncio
156
+
157
+ await asyncio.sleep(seconds)
158
+
159
+ def save_tokens(self, tokens: dict) -> None:
160
+ """Save tokens to project root as .bm-auth.json."""
161
+ token_data = {
162
+ "access_token": tokens["access_token"],
163
+ "refresh_token": tokens.get("refresh_token"),
164
+ "expires_at": int(time.time()) + tokens.get("expires_in", 3600),
165
+ "token_type": tokens.get("token_type", "Bearer"),
166
+ }
167
+
168
+ with open(self.token_file, "w") as f:
169
+ json.dump(token_data, f, indent=2)
170
+
171
+ # Secure the token file
172
+ os.chmod(self.token_file, 0o600)
173
+
174
+ console.print(f"[green]✓ Tokens saved to {self.token_file}[/green]")
175
+
176
+ def load_tokens(self) -> dict | None:
177
+ """Load tokens from .bm-auth.json file."""
178
+ if not self.token_file.exists():
179
+ return None
180
+
181
+ try:
182
+ with open(self.token_file) as f:
183
+ return json.load(f)
184
+ except (OSError, json.JSONDecodeError):
185
+ return None
186
+
187
+ def is_token_valid(self, tokens: dict) -> bool:
188
+ """Check if stored token is still valid."""
189
+ expires_at = tokens.get("expires_at", 0)
190
+ # Add 60 second buffer for clock skew
191
+ return time.time() < (expires_at - 60)
192
+
193
+ async def refresh_token(self, refresh_token: str) -> dict | None:
194
+ """Refresh access token using refresh token."""
195
+ token_url = f"{self.authkit_domain}/oauth2/token"
196
+
197
+ data = {
198
+ "client_id": self.client_id,
199
+ "grant_type": "refresh_token",
200
+ "refresh_token": refresh_token,
201
+ }
202
+
203
+ try:
204
+ async with httpx.AsyncClient() as client:
205
+ response = await client.post(token_url, data=data)
206
+
207
+ if response.status_code == 200:
208
+ return response.json()
209
+ else:
210
+ console.print(
211
+ f"[red]Token refresh failed: {response.status_code} - {response.text}[/red]"
212
+ )
213
+ return None
214
+ except Exception as e:
215
+ console.print(f"[red]Token refresh error: {e}[/red]")
216
+ return None
217
+
218
+ async def get_valid_token(self) -> str | None:
219
+ """Get valid access token, refresh if needed."""
220
+ tokens = self.load_tokens()
221
+ if not tokens:
222
+ return None
223
+
224
+ if self.is_token_valid(tokens):
225
+ return tokens["access_token"]
226
+
227
+ # Token expired - try to refresh if we have a refresh token
228
+ refresh_token = tokens.get("refresh_token")
229
+ if refresh_token:
230
+ console.print("[yellow]Access token expired, refreshing...[/yellow]")
231
+
232
+ new_tokens = await self.refresh_token(refresh_token)
233
+ if new_tokens:
234
+ # Save new tokens (may include rotated refresh token)
235
+ self.save_tokens(new_tokens)
236
+ console.print("[green]✓ Token refreshed successfully[/green]")
237
+ return new_tokens["access_token"]
238
+ else:
239
+ console.print("[yellow]Token refresh failed. Please run 'login' again.[/yellow]")
240
+ return None
241
+ else:
242
+ console.print("[yellow]No refresh token available. Please run 'login' again.[/yellow]")
243
+ return None
244
+
245
+ async def login(self) -> bool:
246
+ """Perform OAuth Device Authorization login flow."""
247
+ console.print("[blue]Initiating authentication...[/blue]")
248
+
249
+ # Step 1: Request device authorization
250
+ device_response = await self.request_device_authorization()
251
+ if not device_response:
252
+ return False
253
+
254
+ # Step 2: Display user instructions
255
+ self.display_user_instructions(device_response)
256
+
257
+ # Step 3: Poll for token
258
+ device_code = device_response["device_code"]
259
+ interval = device_response.get("interval", 5)
260
+
261
+ tokens = await self.poll_for_token(device_code, interval)
262
+ if not tokens:
263
+ return False
264
+
265
+ # Step 4: Save tokens
266
+ self.save_tokens(tokens)
267
+
268
+ console.print("\n[green]✅ Successfully authenticated with Basic Memory Cloud![/green]")
269
+ return True
270
+
271
+ def logout(self) -> None:
272
+ """Remove stored authentication tokens."""
273
+ if self.token_file.exists():
274
+ self.token_file.unlink()
275
+ console.print("[green]✓ Logged out successfully[/green]")
276
+ else:
277
+ console.print("[yellow]No stored authentication found[/yellow]")
@@ -0,0 +1,5 @@
1
+ """Cloud commands package."""
2
+
3
+ # Import all commands to register them with typer
4
+ from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
5
+ from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers # noqa: F401