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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/api/app.py +10 -4
- basic_memory/api/routers/knowledge_router.py +25 -8
- basic_memory/api/routers/project_router.py +99 -4
- basic_memory/api/routers/resource_router.py +3 -3
- basic_memory/cli/app.py +9 -28
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/cloud/__init__.py +5 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +818 -0
- basic_memory/cli/commands/cloud/core_commands.py +288 -0
- basic_memory/cli/commands/cloud/mount_commands.py +295 -0
- basic_memory/cli/commands/cloud/rclone_config.py +288 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +198 -0
- basic_memory/cli/commands/command_utils.py +60 -0
- basic_memory/cli/commands/import_memory_json.py +0 -4
- basic_memory/cli/commands/mcp.py +16 -4
- basic_memory/cli/commands/project.py +141 -145
- basic_memory/cli/commands/status.py +34 -22
- basic_memory/cli/commands/sync.py +45 -228
- basic_memory/cli/commands/tool.py +87 -16
- basic_memory/cli/main.py +1 -0
- basic_memory/config.py +96 -20
- basic_memory/db.py +104 -3
- basic_memory/deps.py +20 -3
- basic_memory/file_utils.py +89 -0
- basic_memory/ignore_utils.py +295 -0
- basic_memory/importers/chatgpt_importer.py +1 -1
- basic_memory/importers/utils.py +2 -2
- basic_memory/markdown/entity_parser.py +2 -2
- basic_memory/markdown/markdown_processor.py +2 -2
- basic_memory/markdown/plugins.py +39 -21
- basic_memory/markdown/utils.py +1 -1
- basic_memory/mcp/async_client.py +22 -10
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +49 -4
- basic_memory/mcp/prompts/continue_conversation.py +1 -1
- basic_memory/mcp/prompts/recent_activity.py +116 -32
- basic_memory/mcp/prompts/search.py +1 -1
- basic_memory/mcp/prompts/utils.py +11 -4
- basic_memory/mcp/resources/ai_assistant_guide.md +179 -41
- basic_memory/mcp/resources/project_info.py +20 -6
- basic_memory/mcp/server.py +0 -37
- basic_memory/mcp/tools/__init__.py +5 -6
- basic_memory/mcp/tools/build_context.py +39 -19
- basic_memory/mcp/tools/canvas.py +19 -8
- basic_memory/mcp/tools/chatgpt_tools.py +178 -0
- basic_memory/mcp/tools/delete_note.py +67 -34
- basic_memory/mcp/tools/edit_note.py +55 -39
- basic_memory/mcp/tools/headers.py +44 -0
- basic_memory/mcp/tools/list_directory.py +18 -8
- basic_memory/mcp/tools/move_note.py +119 -41
- basic_memory/mcp/tools/project_management.py +77 -229
- basic_memory/mcp/tools/read_content.py +28 -12
- basic_memory/mcp/tools/read_note.py +97 -57
- basic_memory/mcp/tools/recent_activity.py +441 -42
- basic_memory/mcp/tools/search.py +82 -70
- basic_memory/mcp/tools/sync_status.py +5 -4
- basic_memory/mcp/tools/utils.py +19 -0
- basic_memory/mcp/tools/view_note.py +31 -6
- basic_memory/mcp/tools/write_note.py +65 -14
- basic_memory/models/knowledge.py +19 -2
- basic_memory/models/project.py +6 -2
- basic_memory/repository/entity_repository.py +31 -84
- basic_memory/repository/project_repository.py +1 -1
- basic_memory/repository/relation_repository.py +13 -0
- basic_memory/repository/repository.py +2 -2
- basic_memory/repository/search_repository.py +9 -3
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +70 -12
- basic_memory/schemas/cloud.py +46 -0
- basic_memory/schemas/memory.py +99 -18
- basic_memory/schemas/project_info.py +9 -10
- basic_memory/schemas/sync_report.py +48 -0
- basic_memory/services/context_service.py +35 -11
- basic_memory/services/directory_service.py +7 -0
- basic_memory/services/entity_service.py +82 -52
- basic_memory/services/initialization.py +30 -11
- basic_memory/services/project_service.py +23 -33
- basic_memory/sync/sync_service.py +148 -24
- basic_memory/sync/watch_service.py +128 -44
- basic_memory/utils.py +181 -109
- {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/METADATA +26 -96
- basic_memory-0.15.0.dist-info/RECORD +147 -0
- basic_memory/mcp/project_session.py +0 -120
- basic_memory-0.14.3.dist-info/RECORD +0 -132
- {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.3.dist-info → basic_memory-0.15.0.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
92
|
-
# This
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
8
|
-
|
|
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
|
|
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")
|
basic_memory/cli/auth.py
ADDED
|
@@ -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]")
|