better-notion 0.9.9__py3-none-any.whl → 1.0.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.
@@ -4,10 +4,12 @@ Official plugins for Better Notion CLI.
4
4
  This package contains officially maintained plugins that extend
5
5
  the CLI with commonly-needed functionality.
6
6
  """
7
+ from better_notion.plugins.official.agents import AgentsPlugin
7
8
  from better_notion.plugins.official.productivity import ProductivityPlugin
8
9
 
9
- __all__ = ["ProductivityPlugin"]
10
+ __all__ = ["AgentsPlugin", "ProductivityPlugin"]
10
11
 
11
12
  OFFICIAL_PLUGINS = [
13
+ AgentsPlugin,
12
14
  ProductivityPlugin,
13
15
  ]
@@ -0,0 +1,356 @@
1
+ """Official agents workflow management plugin for Better Notion CLI.
2
+
3
+ This plugin provides comprehensive workflow management capabilities for coordinating
4
+ AI agents working on software development projects through Notion.
5
+
6
+ Features:
7
+ - Workspace initialization (creates all required databases)
8
+ - Project context management (.notion files)
9
+ - Role management (set and check current role)
10
+ - Task discovery and execution (claim, start, complete tasks)
11
+ - Idea capture and management
12
+ - Work issue tracking
13
+ - Dependency resolution
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ import typer
23
+
24
+ from better_notion._cli.config import Config
25
+ from better_notion._cli.response import format_error, format_success
26
+ from better_notion._sdk.client import NotionClient
27
+ from better_notion.plugins.base import PluginInterface
28
+ from better_notion.utils.agents import (
29
+ DependencyResolver,
30
+ ProjectContext,
31
+ RoleManager,
32
+ get_or_create_agent_id,
33
+ )
34
+ from better_notion.utils.agents.workspace import WorkspaceInitializer, initialize_workspace_command
35
+
36
+
37
+ def get_client() -> NotionClient:
38
+ """Get authenticated Notion client."""
39
+ config = Config.load()
40
+ return NotionClient(auth=config.token, timeout=config.timeout)
41
+
42
+
43
+ class AgentsPlugin(PluginInterface):
44
+ """
45
+ Official agents workflow management plugin.
46
+
47
+ This plugin provides tools for managing software development workflows
48
+ through Notion databases, enabling AI agents to coordinate work on projects.
49
+
50
+ Commands:
51
+ - init: Initialize a new workspace with all databases
52
+ - init-project: Initialize a new project with .notion file
53
+ - role: Manage project role
54
+ """
55
+
56
+ def register_commands(self, app: typer.Typer) -> None:
57
+ """Register plugin commands with the CLI app."""
58
+ # Create agents command group
59
+ agents_app = typer.Typer(
60
+ name="agents",
61
+ help="Agents workflow management commands",
62
+ )
63
+
64
+ # Register sub-commands
65
+ @agents_app.command("init")
66
+ def init_workspace(
67
+ parent_page_id: str = typer.Option(
68
+ ...,
69
+ "--parent-page",
70
+ "-p",
71
+ help="ID of the parent page where databases will be created",
72
+ ),
73
+ workspace_name: str = typer.Option(
74
+ "Agents Workspace",
75
+ "--name",
76
+ "-n",
77
+ help="Name for the workspace",
78
+ ),
79
+ ) -> None:
80
+ """
81
+ Initialize a new workspace with all required databases.
82
+
83
+ Creates 8 databases in Notion with proper relationships:
84
+ - Organizations
85
+ - Projects
86
+ - Versions
87
+ - Tasks
88
+ - Ideas
89
+ - Work Issues
90
+ - Incidents
91
+ - Tags
92
+
93
+ Example:
94
+ $ notion agents init --parent-page page123 --name "My Workspace"
95
+ """
96
+ async def _init() -> str:
97
+ try:
98
+ client = get_client()
99
+ initializer = WorkspaceInitializer(client)
100
+
101
+ database_ids = await initializer.initialize_workspace(
102
+ parent_page_id=parent_page_id,
103
+ workspace_name=workspace_name,
104
+ )
105
+
106
+ # Save database IDs
107
+ initializer.save_database_ids()
108
+
109
+ return format_success(
110
+ {
111
+ "message": "Workspace initialized successfully",
112
+ "databases_created": len(database_ids),
113
+ "database_ids": database_ids,
114
+ }
115
+ )
116
+
117
+ except Exception as e:
118
+ return format_error("INIT_ERROR", str(e), retry=False)
119
+
120
+ result = asyncio.run(_init())
121
+ typer.echo(result)
122
+
123
+ @agents_app.command("init-project")
124
+ def init_project(
125
+ project_id: str = typer.Option(
126
+ ...,
127
+ "--project-id",
128
+ "-i",
129
+ help="Notion page ID for the project",
130
+ ),
131
+ project_name: str = typer.Option(
132
+ ...,
133
+ "--name",
134
+ "-n",
135
+ help="Project name",
136
+ ),
137
+ org_id: str = typer.Option(
138
+ ...,
139
+ "--org-id",
140
+ "-o",
141
+ help="Notion page ID for the organization",
142
+ ),
143
+ role: str = typer.Option(
144
+ "Developer",
145
+ "--role",
146
+ "-r",
147
+ help="Project role (default: Developer)",
148
+ ),
149
+ ) -> None:
150
+ """
151
+ Initialize a new project with a .notion file.
152
+
153
+ Creates a .notion file in the current directory that identifies
154
+ the project context for all CLI commands.
155
+
156
+ Example:
157
+ $ notion agents init-project \\
158
+ --project-id page123 \\
159
+ --name "My Project" \\
160
+ --org-id org456 \\
161
+ --role Developer
162
+ """
163
+ try:
164
+ # Validate role
165
+ if not RoleManager.is_valid_role(role):
166
+ result = format_error(
167
+ "INVALID_ROLE",
168
+ f"Invalid role: {role}. Valid roles: {', '.join(RoleManager.get_all_roles())}",
169
+ retry=False,
170
+ )
171
+ else:
172
+ # Create .notion file
173
+ context = ProjectContext.create(
174
+ project_id=project_id,
175
+ project_name=project_name,
176
+ org_id=org_id,
177
+ role=role,
178
+ path=Path.cwd(),
179
+ )
180
+
181
+ result = format_success(
182
+ {
183
+ "message": "Project initialized successfully",
184
+ "project_id": context.project_id,
185
+ "project_name": context.project_name,
186
+ "org_id": context.org_id,
187
+ "role": context.role,
188
+ "notion_file": str(Path.cwd() / ".notion"),
189
+ }
190
+ )
191
+
192
+ except Exception as e:
193
+ result = format_error("INIT_PROJECT_ERROR", str(e), retry=False)
194
+
195
+ typer.echo(result)
196
+
197
+ # Role management commands
198
+ role_app = typer.Typer(
199
+ name="role",
200
+ help="Role management commands",
201
+ )
202
+ agents_app.add_typer(role_app)
203
+
204
+ @role_app.command("be")
205
+ def role_be(
206
+ new_role: str = typer.Argument(..., help="Role to set"),
207
+ path: Optional[Path] = typer.Option(
208
+ None, "--path", "-p", help="Path to project directory (default: cwd)"
209
+ ),
210
+ ) -> None:
211
+ """
212
+ Set the project role.
213
+
214
+ Updates the role in the .notion file. The role determines what
215
+ actions the agent can perform in this project.
216
+
217
+ Example:
218
+ $ notion agents role be PM
219
+ $ notion agents role be Developer --path /path/to/project
220
+ """
221
+ try:
222
+ # Validate role
223
+ if not RoleManager.is_valid_role(new_role):
224
+ result = format_error(
225
+ "INVALID_ROLE",
226
+ f"Invalid role: {new_role}. Valid roles: {', '.join(RoleManager.get_all_roles())}",
227
+ retry=False,
228
+ )
229
+ else:
230
+ # Load project context
231
+ if path:
232
+ context = ProjectContext.from_path(path)
233
+ else:
234
+ context = ProjectContext.from_current_directory()
235
+
236
+ if not context:
237
+ result = format_error(
238
+ "NO_PROJECT_CONTEXT",
239
+ "No .notion file found. Are you in a project directory?",
240
+ retry=False,
241
+ )
242
+ else:
243
+ # Update role
244
+ context.update_role(new_role, path=path or None)
245
+
246
+ result = format_success(
247
+ {
248
+ "message": f"Role updated to {new_role}",
249
+ "previous_role": context.role,
250
+ "new_role": new_role,
251
+ }
252
+ )
253
+
254
+ except Exception as e:
255
+ result = format_error("ROLE_UPDATE_ERROR", str(e), retry=False)
256
+
257
+ typer.echo(result)
258
+
259
+ @role_app.command("whoami")
260
+ def role_whoami(
261
+ path: Optional[Path] = typer.Option(
262
+ None, "--path", "-p", help="Path to project directory (default: cwd)"
263
+ ),
264
+ ) -> None:
265
+ """
266
+ Show the current project role.
267
+
268
+ Displays the role from the .notion file in the current or
269
+ specified project directory.
270
+
271
+ Example:
272
+ $ notion agents role whoami
273
+ $ notion agents role whoami --path /path/to/project
274
+ """
275
+ try:
276
+ # Load project context
277
+ if path:
278
+ context = ProjectContext.from_path(path)
279
+ else:
280
+ context = ProjectContext.from_current_directory()
281
+
282
+ if not context:
283
+ result = format_error(
284
+ "NO_PROJECT_CONTEXT",
285
+ "No .notion file found. Are you in a project directory?",
286
+ retry=False,
287
+ )
288
+ else:
289
+ # Get role description
290
+ description = RoleManager.get_role_description(context.role)
291
+
292
+ result = format_success(
293
+ {
294
+ "role": context.role,
295
+ "description": description,
296
+ "project": context.project_name,
297
+ "permissions": RoleManager.get_permissions(context.role),
298
+ }
299
+ )
300
+
301
+ except Exception as e:
302
+ result = format_error("ROLE_ERROR", str(e), retry=False)
303
+
304
+ typer.echo(result)
305
+
306
+ @role_app.command("list")
307
+ def role_list() -> None:
308
+ """
309
+ List all available roles.
310
+
311
+ Shows all valid roles that can be used in the workflow system.
312
+
313
+ Example:
314
+ $ notion agents role list
315
+ """
316
+ try:
317
+ roles = RoleManager.get_all_roles()
318
+
319
+ role_info = []
320
+ for role in roles:
321
+ description = RoleManager.get_role_description(role)
322
+ permissions = RoleManager.get_permissions(role)
323
+ role_info.append(
324
+ {
325
+ "role": role,
326
+ "description": description,
327
+ "permission_count": len(permissions),
328
+ }
329
+ )
330
+
331
+ result = format_success(
332
+ {
333
+ "roles": role_info,
334
+ "total": len(roles),
335
+ }
336
+ )
337
+
338
+ except Exception as e:
339
+ result = format_error("ROLE_LIST_ERROR", str(e), retry=False)
340
+
341
+ typer.echo(result)
342
+
343
+ # Register agents app to main CLI
344
+ app.add_typer(agents_app)
345
+
346
+ def get_info(self) -> dict[str, str | bool | list]:
347
+ """Return plugin metadata."""
348
+ return {
349
+ "name": "agents",
350
+ "version": "1.0.0",
351
+ "description": "Workflow management system for AI agents coordinating on software development projects",
352
+ "author": "Better Notion Team",
353
+ "official": True,
354
+ "category": "workflow",
355
+ "dependencies": [],
356
+ }
@@ -0,0 +1,65 @@
1
+ """Utility functions and classes for agents workflow management."""
2
+
3
+ from better_notion.utils.agents.auth import (
4
+ AgentContext,
5
+ clear_agent_id,
6
+ get_agent_id_path,
7
+ get_agent_info,
8
+ get_or_create_agent_id,
9
+ is_valid_agent_id,
10
+ set_agent_id,
11
+ )
12
+ from better_notion.utils.agents.dependency_resolver import DependencyResolver
13
+ from better_notion.utils.agents.project_context import ProjectContext
14
+ from better_notion.utils.agents.rbac import RoleManager
15
+ from better_notion.utils.agents.schemas import (
16
+ IncidentSchema,
17
+ IdeaSchema,
18
+ OrganizationSchema,
19
+ ProjectSchema,
20
+ PropertyBuilder,
21
+ SelectOption,
22
+ TagSchema,
23
+ TaskSchema,
24
+ VersionSchema,
25
+ WorkIssueSchema,
26
+ )
27
+ from better_notion.utils.agents.state_machine import TaskStatus, TaskStateMachine
28
+ from better_notion.utils.agents.workspace import (
29
+ WorkspaceInitializer,
30
+ initialize_workspace_command,
31
+ )
32
+
33
+ __all__ = [
34
+ # Agent authentication
35
+ "AgentContext",
36
+ "clear_agent_id",
37
+ "get_agent_id_path",
38
+ "get_agent_info",
39
+ "get_or_create_agent_id",
40
+ "is_valid_agent_id",
41
+ "set_agent_id",
42
+ # Dependency resolution
43
+ "DependencyResolver",
44
+ # Project context
45
+ "ProjectContext",
46
+ # Role-based access control
47
+ "RoleManager",
48
+ # Database schemas
49
+ "IncidentSchema",
50
+ "IdeaSchema",
51
+ "OrganizationSchema",
52
+ "ProjectSchema",
53
+ "PropertyBuilder",
54
+ "SelectOption",
55
+ "TagSchema",
56
+ "TaskSchema",
57
+ "VersionSchema",
58
+ "WorkIssueSchema",
59
+ # State machine
60
+ "TaskStatus",
61
+ "TaskStateMachine",
62
+ # Workspace initialization
63
+ "WorkspaceInitializer",
64
+ "initialize_workspace_command",
65
+ ]
@@ -0,0 +1,235 @@
1
+ """Agent authentication and identification for the agents workflow system.
2
+
3
+ This module provides agent identification and tracking functionality. Each agent
4
+ gets a unique ID that is stored locally and used for tracking operations across
5
+ workflow commands.
6
+ """
7
+
8
+ import json
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+
14
+ # Default path for agent ID storage
15
+ AGENT_ID_FILE = Path.home() / ".notion" / "agent_id"
16
+
17
+
18
+ def get_agent_id_path() -> Path:
19
+ """Get the path to the agent ID file.
20
+
21
+ Returns:
22
+ Path to the agent ID file
23
+
24
+ Example:
25
+ >>> path = get_agent_id_path()
26
+ >>> print(path)
27
+ PosixPath('/Users/user/.notion/agent_id')
28
+ """
29
+ return AGENT_ID_FILE
30
+
31
+
32
+ def get_or_create_agent_id() -> str:
33
+ """Get existing agent ID or create a new one.
34
+
35
+ This function checks if an agent ID file exists in ~/.notion/agent_id.
36
+ If it exists, it reads and returns the ID. If not, it generates a new
37
+ UUID-based agent ID and stores it.
38
+
39
+ Returns:
40
+ Agent ID string (format: "agent-{uuid}")
41
+
42
+ Example:
43
+ >>> agent_id = get_or_create_agent_id()
44
+ >>> print(agent_id)
45
+ 'agent-1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p'
46
+ """
47
+ # Ensure directory exists
48
+ AGENT_ID_FILE.parent.mkdir(parents=True, exist_ok=True)
49
+
50
+ # Check if agent ID file exists
51
+ if AGENT_ID_FILE.exists() and AGENT_ID_FILE.is_file():
52
+ try:
53
+ with open(AGENT_ID_FILE, encoding="utf-8") as f:
54
+ content = f.read().strip()
55
+
56
+ if content:
57
+ return content
58
+
59
+ except (IOError, OSError):
60
+ # File exists but can't be read
61
+ # Fall through to create new ID
62
+ pass
63
+
64
+ # Generate new agent ID
65
+ agent_id = f"agent-{uuid.uuid4()}"
66
+
67
+ # Save to file
68
+ try:
69
+ with open(AGENT_ID_FILE, "w", encoding="utf-8") as f:
70
+ f.write(agent_id)
71
+ except (IOError, OSError) as e:
72
+ # Can't save agent ID - log warning but continue
73
+ # The agent can still function with a temporary ID
74
+ pass
75
+
76
+ return agent_id
77
+
78
+
79
+ def set_agent_id(agent_id: str) -> bool:
80
+ """Set a specific agent ID (useful for testing or manual configuration).
81
+
82
+ Args:
83
+ agent_id: Agent ID to set
84
+
85
+ Returns:
86
+ True if agent ID was saved successfully, False otherwise
87
+
88
+ Example:
89
+ >>> success = set_agent_id("agent-custom-id-123")
90
+ >>> print(success)
91
+ True
92
+ """
93
+ # Ensure directory exists
94
+ AGENT_ID_FILE.parent.mkdir(parents=True, exist_ok=True)
95
+
96
+ try:
97
+ with open(AGENT_ID_FILE, "w", encoding="utf-8") as f:
98
+ f.write(agent_id.strip())
99
+
100
+ return True
101
+
102
+ except (IOError, OSError):
103
+ return False
104
+
105
+
106
+ def clear_agent_id() -> bool:
107
+ """Clear the stored agent ID.
108
+
109
+ This is useful for testing or when you want to generate a fresh agent ID.
110
+
111
+ Returns:
112
+ True if agent ID was cleared successfully, False otherwise
113
+
114
+ Example:
115
+ >>> clear_agent_id()
116
+ True
117
+ >>> new_id = get_or_create_agent_id()
118
+ >>> # Will generate a new ID
119
+ """
120
+ try:
121
+ if AGENT_ID_FILE.exists():
122
+ AGENT_ID_FILE.unlink()
123
+
124
+ return True
125
+
126
+ except (IOError, OSError):
127
+ return False
128
+
129
+
130
+ def is_valid_agent_id(agent_id: str) -> bool:
131
+ """Check if a string is a valid agent ID format.
132
+
133
+ Valid agent IDs must start with "agent-" followed by a UUID.
134
+
135
+ Args:
136
+ agent_id: Agent ID string to validate
137
+
138
+ Returns:
139
+ True if agent ID is valid format, False otherwise
140
+
141
+ Example:
142
+ >>> is_valid_agent_id("agent-1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p")
143
+ True
144
+
145
+ >>> is_valid_agent_id("invalid-id")
146
+ False
147
+ """
148
+ if not agent_id.startswith("agent-"):
149
+ return False
150
+
151
+ # Extract UUID part
152
+ uuid_part = agent_id[6:] # Remove "agent-" prefix
153
+
154
+ try:
155
+ # Try to parse as UUID
156
+ uuid.UUID(uuid_part)
157
+ return True
158
+
159
+ except ValueError:
160
+ return False
161
+
162
+
163
+ def get_agent_info() -> dict[str, str]:
164
+ """Get complete agent information including ID and metadata.
165
+
166
+ Returns:
167
+ Dict with agent information:
168
+ - agent_id: The agent's unique identifier
169
+ - agent_id_exists: Whether the agent ID file exists
170
+ - agent_id_path: Path to the agent ID file
171
+
172
+ Example:
173
+ >>> info = get_agent_info()
174
+ >>> print(info['agent_id'])
175
+ 'agent-1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p'
176
+ """
177
+ return {
178
+ "agent_id": get_or_create_agent_id(),
179
+ "agent_id_exists": AGENT_ID_FILE.exists(),
180
+ "agent_id_path": str(AGENT_ID_FILE),
181
+ }
182
+
183
+
184
+ class AgentContext:
185
+ """Context manager for temporary agent ID overrides.
186
+
187
+ This is useful for testing when you want to temporarily use a different
188
+ agent ID without affecting the stored ID.
189
+
190
+ Example:
191
+ >>> with AgentContext("agent-test-123"):
192
+ ... agent_id = get_or_create_agent_id()
193
+ ... print(agent_id) # "agent-test-123"
194
+ >>> # Back to normal agent ID
195
+ """
196
+
197
+ def __init__(self, temp_agent_id: str):
198
+ """Initialize the context manager.
199
+
200
+ Args:
201
+ temp_agent_id: Temporary agent ID to use during context
202
+ """
203
+ self.temp_agent_id = temp_agent_id
204
+ self.original_id: Optional[str] = None
205
+ self.file_existed: bool = False
206
+
207
+ def __enter__(self) -> "AgentContext":
208
+ """Enter the context and save original agent ID."""
209
+ # Save original ID if file exists
210
+ if AGENT_ID_FILE.exists():
211
+ self.file_existed = True
212
+ try:
213
+ with open(AGENT_ID_FILE, encoding="utf-8") as f:
214
+ self.original_id = f.read().strip()
215
+ except (IOError, OSError):
216
+ self.original_id = None
217
+
218
+ # Set temporary ID
219
+ set_agent_id(self.temp_agent_id)
220
+
221
+ return self
222
+
223
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
224
+ """Exit the context and restore original agent ID."""
225
+ if self.original_id:
226
+ # Restore original ID
227
+ set_agent_id(self.original_id)
228
+ elif self.file_existed:
229
+ # File existed but couldn't read - clear it
230
+ clear_agent_id()
231
+ else:
232
+ # File didn't exist - remove the temp file
233
+ clear_agent_id()
234
+
235
+ return None