dotai-cli 0.1.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.
Files changed (48) hide show
  1. dotai/__init__.py +3 -0
  2. dotai/__main__.py +5 -0
  3. dotai/cli/__init__.py +148 -0
  4. dotai/cli/__main__.py +5 -0
  5. dotai/cli/roles_cmd.py +62 -0
  6. dotai/cli/rules_cmd.py +202 -0
  7. dotai/cli/skills_cmd.py +570 -0
  8. dotai/cli/sync_cmd.py +190 -0
  9. dotai/cli/watch_cmd.py +106 -0
  10. dotai/converter.py +738 -0
  11. dotai/indexer.py +201 -0
  12. dotai/models.py +375 -0
  13. dotai/roles.py +174 -0
  14. dotai/rules.py +328 -0
  15. dotai/seed/roles/architect.md +26 -0
  16. dotai/seed/roles/debugger.md +26 -0
  17. dotai/seed/roles/founder.md +29 -0
  18. dotai/seed/roles/mentor.md +26 -0
  19. dotai/seed/roles/product-manager.md +26 -0
  20. dotai/seed/roles/qa.md +26 -0
  21. dotai/seed/roles/reviewer.md +26 -0
  22. dotai/seed/roles/security.md +26 -0
  23. dotai/seed/roles/ship.md +26 -0
  24. dotai/seed/roles/writer.md +26 -0
  25. dotai/seed/skills/careful.md +31 -0
  26. dotai/seed/skills/commit-helper.md +48 -0
  27. dotai/seed/skills/context-dump.md +47 -0
  28. dotai/seed/skills/investigate.md +38 -0
  29. dotai/seed/skills/learn.md +37 -0
  30. dotai/seed/skills/parallel-work.md +88 -0
  31. dotai/seed/skills/plan.md +38 -0
  32. dotai/seed/skills/review.md +43 -0
  33. dotai/seed/skills/scaffold.md +35 -0
  34. dotai/seed/skills/ship.md +29 -0
  35. dotai/seed/skills/techdebt.md +44 -0
  36. dotai/seed/skills/verify.md +35 -0
  37. dotai/skills.py +452 -0
  38. dotai/store.py +68 -0
  39. dotai/sync.py +422 -0
  40. dotai/tools/__init__.py +9 -0
  41. dotai/tools/base.py +155 -0
  42. dotai/tools/registry.py +80 -0
  43. dotai/utils.py +8 -0
  44. dotai_cli-0.1.1.dist-info/METADATA +594 -0
  45. dotai_cli-0.1.1.dist-info/RECORD +48 -0
  46. dotai_cli-0.1.1.dist-info/WHEEL +4 -0
  47. dotai_cli-0.1.1.dist-info/entry_points.txt +2 -0
  48. dotai_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
dotai/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dotai — Universal AI context for any coding agent."""
2
+
3
+ __version__ = "0.1.1"
dotai/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running dotai as: python -m dotai"""
2
+
3
+ from .cli import app
4
+
5
+ app()
dotai/cli/__init__.py ADDED
@@ -0,0 +1,148 @@
1
+ """dotai CLI — manage ~/.ai/ knowledge, roles, skills, and agent sync."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from .. import __version__
11
+
12
+
13
+ def _version_callback(value: bool):
14
+ if value:
15
+ print(f"dotai {__version__}")
16
+ raise typer.Exit()
17
+
18
+
19
+ app = typer.Typer(help="Universal AI context for any coding agent.", no_args_is_help=True)
20
+
21
+
22
+ @app.callback()
23
+ def main(
24
+ version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit"),
25
+ ):
26
+ pass
27
+ console = Console()
28
+
29
+ SEED_DIR = Path(__file__).parent.parent / "seed"
30
+
31
+
32
+ @app.command()
33
+ def init(
34
+ project: Optional[str] = typer.Argument(None, help="Project path to initialize (omit for global ~/.ai/)"),
35
+ force: bool = typer.Option(False, "--force", "-f", help="Re-seed missing/updated roles and skills (overwrites seeds, keeps custom files)"),
36
+ ):
37
+ """Initialize ~/.ai/ with seed roles and skills, or add .ai/ to a project.
38
+
39
+ Use --force to re-seed after upgrading dotai (overwrites seed files, keeps your custom files).
40
+ """
41
+ from ..store import load_config, save_config
42
+ from ..models import ProjectConfig
43
+
44
+ if project:
45
+ # Project init
46
+ project_path = Path(project).expanduser().resolve()
47
+ if not project_path.exists():
48
+ console.print(f"[red]Path does not exist: {project_path}[/red]")
49
+ raise typer.Exit(1)
50
+
51
+ ai_dir = project_path / ".ai"
52
+ ai_dir.mkdir(exist_ok=True)
53
+ (ai_dir / "rules").mkdir(exist_ok=True)
54
+ (ai_dir / "roles").mkdir(exist_ok=True)
55
+ (ai_dir / "skills").mkdir(exist_ok=True)
56
+ (ai_dir / "tools").mkdir(exist_ok=True)
57
+
58
+ # Create starter files
59
+ if not (ai_dir / "rules.md").exists():
60
+ (ai_dir / "rules.md").write_text(
61
+ "# Project Rules\n\nProject-specific conventions and guidelines.\n"
62
+ )
63
+ # Register in config
64
+ config = load_config()
65
+ config.add_project(ProjectConfig(
66
+ name=project_path.name,
67
+ path=project_path,
68
+ ))
69
+ save_config(config)
70
+
71
+ console.print(f"[green]Initialized .ai/ in {project_path}[/green]")
72
+ console.print(f" Created: rules.md, roles/, skills/, tools/")
73
+
74
+ # Auto-sync agent config files into the project
75
+ from ..sync import sync_project
76
+ written = sync_project(project_path, config, project_path.name)
77
+ for f in written:
78
+ console.print(f" [green]Synced[/green] {f}")
79
+ else:
80
+ # Global init
81
+ ai_dir = Path.home() / ".ai"
82
+ ai_dir.mkdir(exist_ok=True)
83
+
84
+ # Copy seed roles
85
+ roles_dir = ai_dir / "roles"
86
+ roles_dir.mkdir(exist_ok=True)
87
+ seed_roles = SEED_DIR / "roles"
88
+ if seed_roles.exists():
89
+ copied = 0
90
+ for role_file in seed_roles.glob("*.md"):
91
+ dest = roles_dir / role_file.name
92
+ if force or not dest.exists():
93
+ shutil.copy2(role_file, dest)
94
+ copied += 1
95
+ if copied:
96
+ verb = "Updated" if force else "Seeded"
97
+ console.print(f" {verb} {copied} roles")
98
+
99
+ # Copy seed skills (files and folders)
100
+ skills_dir = ai_dir / "skills"
101
+ skills_dir.mkdir(exist_ok=True)
102
+ seed_skills = SEED_DIR / "skills"
103
+ if seed_skills.exists():
104
+ copied = 0
105
+ # Single-file skills
106
+ for skill_file in seed_skills.glob("*.md"):
107
+ dest = skills_dir / skill_file.name
108
+ if force or not dest.exists():
109
+ shutil.copy2(skill_file, dest)
110
+ copied += 1
111
+ # Folder-based skills
112
+ for item in seed_skills.iterdir():
113
+ if item.is_dir() and (item / "main.md").exists():
114
+ dest = skills_dir / item.name
115
+ if force or not dest.exists():
116
+ if dest.exists():
117
+ shutil.rmtree(dest)
118
+ shutil.copytree(item, dest)
119
+ copied += 1
120
+ if copied:
121
+ verb = "Updated" if force else "Seeded"
122
+ console.print(f" {verb} {copied} skills")
123
+
124
+ # Create rules and tools dirs
125
+ (ai_dir / "rules").mkdir(exist_ok=True)
126
+ (ai_dir / "tools").mkdir(exist_ok=True)
127
+
128
+ # Create starter files
129
+ if not (ai_dir / "rules.md").exists():
130
+ (ai_dir / "rules.md").write_text(
131
+ "# Global Rules\n\nUniversal AI coding rules across all projects.\n\n"
132
+ "## Code Quality\n\n- Write clear, readable code\n- Handle errors explicitly\n"
133
+ "- Prefer simple solutions over clever ones\n"
134
+ )
135
+ console.print(f"[green]Initialized ~/.ai/[/green]")
136
+ console.print(f" {ai_dir}/rules.md")
137
+ console.print(f" {ai_dir}/roles/ ({len(list(roles_dir.glob('*.md')))} roles)")
138
+ console.print(f" {ai_dir}/skills/ ({len(list(skills_dir.glob('*.md')))} skills)")
139
+ console.print(f" {ai_dir}/tools/")
140
+ console.print(f"\n[dim]Next: cd into a project and run `dotai sync` to generate agent configs.[/dim]")
141
+
142
+
143
+ # Import submodules to register their commands with the app
144
+ from . import roles_cmd # noqa: E402, F401
145
+ from . import skills_cmd # noqa: E402, F401
146
+ from . import rules_cmd # noqa: E402, F401
147
+ from . import sync_cmd # noqa: E402, F401
148
+ from . import watch_cmd # noqa: E402, F401
dotai/cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running dotai as: python -m dotai.cli"""
2
+
3
+ from . import app
4
+
5
+ app()
dotai/cli/roles_cmd.py ADDED
@@ -0,0 +1,62 @@
1
+ """CLI commands for roles."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from . import app, console
9
+
10
+
11
+ @app.command()
12
+ def roles():
13
+ """List all available roles."""
14
+ from ..store import load_config
15
+ from ..roles import load_all_roles
16
+
17
+ config = load_config()
18
+ all_roles = load_all_roles(config)
19
+
20
+ if not all_roles:
21
+ console.print("[dim]No roles found. Run `dotai init` to seed default roles.[/dim]")
22
+ return
23
+
24
+ table = Table(title="Available Roles")
25
+ table.add_column("Name", style="bold")
26
+ table.add_column("Scope", style="dim")
27
+ table.add_column("Description")
28
+ table.add_column("Tags", style="dim")
29
+
30
+ for role in all_roles:
31
+ table.add_row(role.id, role.scope, role.description[:60], ", ".join(role.tags))
32
+
33
+ console.print(table)
34
+
35
+
36
+ @app.command()
37
+ def role(
38
+ name: str = typer.Argument(..., help="Role ID to output (e.g. qa, paranoid-reviewer, debugger)"),
39
+ ):
40
+ """Output a role's full prompt to stdout.
41
+
42
+ Use this to inject a persona into any agent:
43
+ dotai role qa | pbcopy
44
+ dotai role paranoid-reviewer > /tmp/role.md
45
+ """
46
+ from ..store import load_config
47
+ from ..roles import load_all_roles
48
+
49
+ config = load_config()
50
+ all_roles = load_all_roles(config)
51
+
52
+ matched = next((r for r in all_roles if r.id == name), None)
53
+
54
+ if not matched:
55
+ console.print(f"[red]Role '{name}' not found.[/red]")
56
+ console.print("Available roles:")
57
+ for r in all_roles:
58
+ console.print(f" {r.id}: {r.description[:60]}")
59
+ raise typer.Exit(1)
60
+
61
+ # print() intentional — stdout for piping, no Rich markup
62
+ print(matched.to_prompt())
dotai/cli/rules_cmd.py ADDED
@@ -0,0 +1,202 @@
1
+ """CLI commands for rules."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.table import Table
9
+
10
+ from . import app, console
11
+
12
+
13
+ @app.command()
14
+ def rules(
15
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Show rules resolved for a project"),
16
+ all: bool = typer.Option(False, "--all", "-a", help="Show all rules including disabled"),
17
+ ):
18
+ """List rules. Shows resolved active rules by default."""
19
+ from ..store import load_config
20
+ from ..rules import load_all_rules, resolve_rules_for_project
21
+
22
+ config = load_config()
23
+
24
+ if all:
25
+ rule_list = load_all_rules(config)
26
+ elif project:
27
+ rule_list = resolve_rules_for_project(config, project)
28
+ else:
29
+ rule_list = load_all_rules(config)
30
+
31
+ if not rule_list:
32
+ console.print("[dim]No rules found. Use `dotai learn` to add rules.[/dim]")
33
+ return
34
+
35
+ # Check project disabled list for display
36
+ disabled_ids: set[str] = set()
37
+ if project:
38
+ proj = config.get_project(project)
39
+ if proj:
40
+ disabled_ids = set(proj.disabled_rules)
41
+
42
+ title = f"Rules (project: {project})" if project else "Rules"
43
+ table = Table(title=title)
44
+ table.add_column("Name", style="bold")
45
+ table.add_column("Status", style="green")
46
+ table.add_column("Scope", style="dim")
47
+ table.add_column("Globs", style="cyan")
48
+ table.add_column("Tags", style="dim")
49
+ table.add_column("Description")
50
+
51
+ for rule in rule_list:
52
+ if rule.id in disabled_ids:
53
+ status = "[red]disabled (project)[/red]"
54
+ elif not rule.enabled:
55
+ status = "[red]disabled[/red]"
56
+ else:
57
+ status = "[green]on[/green]"
58
+ table.add_row(
59
+ rule.id,
60
+ status,
61
+ rule.scope,
62
+ ", ".join(rule.globs) if rule.globs else "",
63
+ ", ".join(rule.tags),
64
+ rule.description[:50],
65
+ )
66
+
67
+ console.print(table)
68
+
69
+
70
+ @app.command()
71
+ def toggle(
72
+ rule_id: str = typer.Argument(..., help="Rule ID to toggle (e.g. no-useeffect)"),
73
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Toggle for a specific project only"),
74
+ on: bool = typer.Option(False, "--on", help="Enable the rule"),
75
+ off: bool = typer.Option(False, "--off", help="Disable the rule"),
76
+ ):
77
+ """Enable or disable a rule globally or for a specific project.
78
+
79
+ Globally: dotai toggle no-useeffect --off
80
+ Per project: dotai toggle no-useeffect --off -p my-legacy-app
81
+ """
82
+ from ..store import load_config
83
+ from ..rules import toggle_rule_global, toggle_rule_for_project
84
+
85
+ if not on and not off:
86
+ console.print("[red]Specify --on or --off[/red]")
87
+ raise typer.Exit(1)
88
+
89
+ config = load_config()
90
+ enabled = on # --on → True, --off → False
91
+
92
+ if project:
93
+ # Disable/enable a global rule for this project only
94
+ ok = toggle_rule_for_project(config, project, rule_id, disabled=not enabled)
95
+ if ok:
96
+ state = "enabled" if enabled else "disabled"
97
+ console.print(f"[green]Rule '{rule_id}' {state} for project '{project}'[/green]")
98
+ else:
99
+ console.print(f"[red]Project '{project}' not found or rule already in that state[/red]")
100
+ raise typer.Exit(1)
101
+ else:
102
+ # Toggle globally in the rule's frontmatter
103
+ rules_dir = config.global_rules_path
104
+ ok = toggle_rule_global(rule_id, rules_dir, enabled)
105
+ if ok:
106
+ state = "enabled" if enabled else "disabled"
107
+ console.print(f"[green]Rule '{rule_id}' {state} globally[/green]")
108
+ else:
109
+ console.print(f"[red]Rule '{rule_id}' not found in {rules_dir}[/red]")
110
+ raise typer.Exit(1)
111
+
112
+
113
+ @app.command()
114
+ def learn(
115
+ title: str = typer.Argument(..., help="Short title for the rule or learning"),
116
+ from_file: Optional[str] = typer.Option(None, "--from-file", "-f", help="Import rule from a file"),
117
+ issue: Optional[str] = typer.Option(None, "--issue", "-i", help="What went wrong (inline mode)"),
118
+ correction: Optional[str] = typer.Option(None, "--correction", "-c", help="What to do instead (inline mode)"),
119
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="One-line description"),
120
+ globs: Optional[str] = typer.Option(None, "--globs", "-g", help="File patterns this rule applies to (e.g. '*.tsx,*.ts')"),
121
+ tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags"),
122
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name (writes to project rules/)"),
123
+ ):
124
+ """Add a rule from a file or record an inline learning.
125
+
126
+ From file (creates structured rule in ~/.ai/rules/):
127
+ dotai learn "no-useEffect" -f react-rule.md -g "*.tsx,*.ts"
128
+
129
+ Inline learning (appends to rules.md):
130
+ dotai learn "title" -i "what went wrong" -c "what to do instead"
131
+ """
132
+ from ..store import load_config
133
+
134
+ if not from_file and not (issue and correction):
135
+ console.print("[red]Provide --from-file, or --issue and --correction[/red]")
136
+ raise typer.Exit(1)
137
+
138
+ config = load_config()
139
+
140
+ if from_file:
141
+ from ..rules import create_rule_from_file
142
+
143
+ source = Path(from_file).expanduser().resolve()
144
+ if not source.exists():
145
+ console.print(f"[red]File not found: {source}[/red]")
146
+ raise typer.Exit(1)
147
+
148
+ # Determine target rules directory
149
+ if project:
150
+ proj = config.get_project(project)
151
+ if not proj:
152
+ console.print(f"[red]Project '{project}' not found[/red]")
153
+ raise typer.Exit(1)
154
+ rules_dir = proj.rules_path
155
+ else:
156
+ rules_dir = config.global_rules_path
157
+
158
+ glob_list = [g.strip() for g in globs.split(",")] if globs else None
159
+ tag_list = [t.strip() for t in tags.split(",")] if tags else None
160
+
161
+ dest = create_rule_from_file(
162
+ source_path=source,
163
+ name=title,
164
+ dest_dir=rules_dir,
165
+ description=description,
166
+ globs=glob_list,
167
+ tags=tag_list,
168
+ )
169
+
170
+ console.print(f"[green]Rule created:[/green] {title}")
171
+ console.print(f" [dim]{dest}[/dim]")
172
+
173
+ # Show what was detected
174
+ from ..rules import parse_rule_file
175
+ rule = parse_rule_file(dest)
176
+ if rule:
177
+ if rule.tags:
178
+ console.print(f" Tags: {', '.join(rule.tags)}")
179
+ if rule.globs:
180
+ console.print(f" Globs: {', '.join(rule.globs)}")
181
+ console.print(f" Description: {rule.description}")
182
+ else:
183
+ # Inline learning — append to rules.md
184
+ if project:
185
+ proj = config.get_project(project)
186
+ if not proj:
187
+ console.print(f"[red]Project '{project}' not found[/red]")
188
+ raise typer.Exit(1)
189
+ rules_path = proj.full_ai_path / "rules.md"
190
+ else:
191
+ rules_path = config.global_ai_dir / "rules.md"
192
+
193
+ rules_path.parent.mkdir(parents=True, exist_ok=True)
194
+
195
+ entry = f"\n### {datetime.now().strftime('%Y-%m-%d')}: {title}\n"
196
+ entry += f"**Issue:** {issue}\n"
197
+ entry += f"**Correction:** {correction}\n"
198
+
199
+ with open(rules_path, "a") as f:
200
+ f.write(entry)
201
+
202
+ console.print(f"[green]Recorded learning:[/green] {title}")