basic-memory 0.14.3__py3-none-any.whl → 0.15.0__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 (90) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  3. basic_memory/api/app.py +10 -4
  4. basic_memory/api/routers/knowledge_router.py +25 -8
  5. basic_memory/api/routers/project_router.py +99 -4
  6. basic_memory/api/routers/resource_router.py +3 -3
  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 +60 -0
  17. basic_memory/cli/commands/import_memory_json.py +0 -4
  18. basic_memory/cli/commands/mcp.py +16 -4
  19. basic_memory/cli/commands/project.py +141 -145
  20. basic_memory/cli/commands/status.py +34 -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 +96 -20
  25. basic_memory/db.py +104 -3
  26. basic_memory/deps.py +20 -3
  27. basic_memory/file_utils.py +89 -0
  28. basic_memory/ignore_utils.py +295 -0
  29. basic_memory/importers/chatgpt_importer.py +1 -1
  30. basic_memory/importers/utils.py +2 -2
  31. basic_memory/markdown/entity_parser.py +2 -2
  32. basic_memory/markdown/markdown_processor.py +2 -2
  33. basic_memory/markdown/plugins.py +39 -21
  34. basic_memory/markdown/utils.py +1 -1
  35. basic_memory/mcp/async_client.py +22 -10
  36. basic_memory/mcp/project_context.py +141 -0
  37. basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
  38. basic_memory/mcp/prompts/continue_conversation.py +1 -1
  39. basic_memory/mcp/prompts/recent_activity.py +116 -32
  40. basic_memory/mcp/prompts/search.py +1 -1
  41. basic_memory/mcp/prompts/utils.py +11 -4
  42. basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
  43. basic_memory/mcp/resources/project_info.py +20 -6
  44. basic_memory/mcp/server.py +0 -37
  45. basic_memory/mcp/tools/__init__.py +5 -6
  46. basic_memory/mcp/tools/build_context.py +39 -19
  47. basic_memory/mcp/tools/canvas.py +19 -8
  48. basic_memory/mcp/tools/chatgpt_tools.py +178 -0
  49. basic_memory/mcp/tools/delete_note.py +67 -34
  50. basic_memory/mcp/tools/edit_note.py +55 -39
  51. basic_memory/mcp/tools/headers.py +44 -0
  52. basic_memory/mcp/tools/list_directory.py +18 -8
  53. basic_memory/mcp/tools/move_note.py +119 -41
  54. basic_memory/mcp/tools/project_management.py +77 -229
  55. basic_memory/mcp/tools/read_content.py +28 -12
  56. basic_memory/mcp/tools/read_note.py +97 -57
  57. basic_memory/mcp/tools/recent_activity.py +441 -42
  58. basic_memory/mcp/tools/search.py +82 -70
  59. basic_memory/mcp/tools/sync_status.py +5 -4
  60. basic_memory/mcp/tools/utils.py +19 -0
  61. basic_memory/mcp/tools/view_note.py +31 -6
  62. basic_memory/mcp/tools/write_note.py +65 -14
  63. basic_memory/models/knowledge.py +19 -2
  64. basic_memory/models/project.py +6 -2
  65. basic_memory/repository/entity_repository.py +31 -84
  66. basic_memory/repository/project_repository.py +1 -1
  67. basic_memory/repository/relation_repository.py +13 -0
  68. basic_memory/repository/repository.py +2 -2
  69. basic_memory/repository/search_repository.py +9 -3
  70. basic_memory/schemas/__init__.py +6 -0
  71. basic_memory/schemas/base.py +70 -12
  72. basic_memory/schemas/cloud.py +46 -0
  73. basic_memory/schemas/memory.py +99 -18
  74. basic_memory/schemas/project_info.py +9 -10
  75. basic_memory/schemas/sync_report.py +48 -0
  76. basic_memory/services/context_service.py +35 -11
  77. basic_memory/services/directory_service.py +7 -0
  78. basic_memory/services/entity_service.py +82 -52
  79. basic_memory/services/initialization.py +30 -11
  80. basic_memory/services/project_service.py +23 -33
  81. basic_memory/sync/sync_service.py +148 -24
  82. basic_memory/sync/watch_service.py +128 -44
  83. basic_memory/utils.py +181 -109
  84. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/METADATA +26 -96
  85. basic_memory-0.15.0.dist-info/RECORD +147 -0
  86. basic_memory/mcp/project_session.py +0 -120
  87. basic_memory-0.14.3.dist-info/RECORD +0 -132
  88. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
  89. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
  90. {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.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.3"
4
+ __version__ = "0.15.0"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v0"
@@ -0,0 +1,49 @@
1
+ """fix project foreign keys
2
+
3
+ Revision ID: a1b2c3d4e5f6
4
+ Revises: 647e7a75e2cd
5
+ Create Date: 2025-08-19 22:06:00.000000
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "a1b2c3d4e5f6"
16
+ down_revision: Union[str, None] = "647e7a75e2cd"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Re-establish foreign key constraints that were lost during project table recreation.
23
+
24
+ The migration 647e7a75e2cd recreated the project table but did not re-establish
25
+ the foreign key constraint from entity.project_id to project.id, causing
26
+ foreign key constraint failures when trying to delete projects with related entities.
27
+ """
28
+ # SQLite doesn't allow adding foreign key constraints to existing tables easily
29
+ # We need to be careful and handle the case where the constraint might already exist
30
+
31
+ with op.batch_alter_table("entity", schema=None) as batch_op:
32
+ # Try to drop existing foreign key constraint (may not exist)
33
+ try:
34
+ batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
35
+ except Exception:
36
+ # Constraint may not exist, which is fine - we'll create it next
37
+ pass
38
+
39
+ # Add the foreign key constraint with CASCADE DELETE
40
+ # This ensures that when a project is deleted, all related entities are also deleted
41
+ batch_op.create_foreign_key(
42
+ "fk_entity_project_id", "project", ["project_id"], ["id"], ondelete="CASCADE"
43
+ )
44
+
45
+
46
+ def downgrade() -> None:
47
+ """Remove the foreign key constraint."""
48
+ with op.batch_alter_table("entity", schema=None) as batch_op:
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
@@ -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(
@@ -217,8 +291,29 @@ async def set_default_project(
217
291
  raise HTTPException(status_code=400, detail=str(e))
218
292
 
219
293
 
294
+ # Get the default project
295
+ @project_resource_router.get("/default", response_model=ProjectItem)
296
+ async def get_default_project(
297
+ project_service: ProjectServiceDep,
298
+ ) -> ProjectItem:
299
+ """Get the default project.
300
+
301
+ Returns:
302
+ Response with project default information
303
+ """
304
+ # Get the old default project
305
+ default_name = project_service.default_project
306
+ default_project = await project_service.get_project(default_name)
307
+ if not default_project: # pragma: no cover
308
+ raise HTTPException( # pragma: no cover
309
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
310
+ )
311
+
312
+ return ProjectItem(name=default_project.name, path=default_project.path, is_default=True)
313
+
314
+
220
315
  # Synchronize projects between config and database
221
- @project_resource_router.post("/sync", response_model=ProjectStatusResponse)
316
+ @project_resource_router.post("/config/sync", response_model=ProjectStatusResponse)
222
317
  async def synchronize_projects(
223
318
  project_service: ProjectServiceDep,
224
319
  ) -> ProjectStatusResponse:
@@ -188,7 +188,7 @@ async def write_resource(
188
188
  "content_type": content_type,
189
189
  "file_path": file_path,
190
190
  "checksum": checksum,
191
- "updated_at": datetime.fromtimestamp(file_stats.st_mtime),
191
+ "updated_at": datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
192
192
  },
193
193
  )
194
194
  status_code = 200
@@ -200,8 +200,8 @@ async def write_resource(
200
200
  content_type=content_type,
201
201
  file_path=file_path,
202
202
  checksum=checksum,
203
- created_at=datetime.fromtimestamp(file_stats.st_ctime),
204
- updated_at=datetime.fromtimestamp(file_stats.st_mtime),
203
+ created_at=datetime.fromtimestamp(file_stats.st_ctime).astimezone(),
204
+ updated_at=datetime.fromtimestamp(file_stats.st_mtime).astimezone(),
205
205
  )
206
206
  entity = await entity_repository.add(entity)
207
207
  status_code = 201
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 WorkOS 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 WorkOS![/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