fgeo 0.1.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.
fgeo/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """fgeo — Generative Engine Optimization CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from fgeo._version import version as __version__
7
+ except Exception: # pragma: no cover - source tree fallback before build metadata exists
8
+ __version__ = "0.0.0"
fgeo/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running fgeo as: python -m fgeo"""
2
+
3
+ from fgeo.cli import app
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ app()
fgeo/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
fgeo/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """fgeo CLI — Generative Engine Optimization CLI
2
+
3
+ AI-powered content asset management and multi-platform distribution.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from fgeo import __version__
12
+ from fgeo.commands.brand import brand_app
13
+ from fgeo.commands.content import content_app
14
+ from fgeo.commands.project import project_app
15
+ from fgeo.commands.goal import goal_app
16
+ from fgeo.commands.platform import platform_app
17
+ from fgeo.commands.plan import plan_app
18
+ from fgeo.commands.publish import publish_app
19
+ from fgeo.commands.status import status_command
20
+ from fgeo.commands.style import style_app
21
+ from fgeo.commands.enable import SUPPORTED_AGENTS
22
+
23
+ console = Console()
24
+
25
+ app = typer.Typer(
26
+ name="fgeo",
27
+ help="fgeo — Generative Engine Optimization CLI.\n\nAI-powered content asset management and multi-platform distribution.",
28
+ no_args_is_help=True,
29
+ rich_markup_mode="rich",
30
+ )
31
+
32
+ # Sub-command groups
33
+ app.add_typer(brand_app, name="brand")
34
+ app.add_typer(project_app, name="project")
35
+ app.add_typer(goal_app, name="goal")
36
+ app.add_typer(platform_app, name="platform")
37
+ app.add_typer(plan_app, name="plan")
38
+ app.add_typer(content_app, name="content")
39
+ app.add_typer(publish_app, name="publish")
40
+ app.add_typer(style_app, name="style")
41
+
42
+ # Top-level commands
43
+ app.command("status")(status_command)
44
+
45
+
46
+ @app.command()
47
+ def init() -> None:
48
+ """Initialize fgeo — create ~/.fgeo global directory with config and database."""
49
+ from fgeo.commands.init import init as _init
50
+
51
+ _init()
52
+
53
+
54
+ @app.command()
55
+ def enable(
56
+ agent: str = typer.Argument(help=f"Agent to enable: {', '.join(SUPPORTED_AGENTS)} (or 'list')"),
57
+ ) -> None:
58
+ """Enable AI agent integration — sets up fcontext + fgeo skill instructions."""
59
+ from fgeo.commands.enable import enable as _enable
60
+
61
+ _enable(agent)
62
+
63
+
64
+ @app.callback(invoke_without_command=True)
65
+ def main(
66
+ version: bool = typer.Option(False, "--version", "-v", help="Show version"),
67
+ ) -> None:
68
+ if version:
69
+ console.print(f"fgeo {__version__}")
70
+ raise typer.Exit()
File without changes
fgeo/commands/brand.py ADDED
@@ -0,0 +1,100 @@
1
+ """fgeo brand — Global author brand profile management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+
10
+ from fgeo.database import get_db
11
+
12
+ console = Console()
13
+ brand_app = typer.Typer(help="Manage global author brand profile (user-level, applies across all projects).")
14
+
15
+ BRAND_FIELDS = ["name", "positioning", "voice", "core_values", "topics"]
16
+ FIELD_LABELS = {
17
+ "name": "Author Name",
18
+ "positioning": "Positioning",
19
+ "voice": "Voice & Tone",
20
+ "core_values": "Core Values",
21
+ "topics": "Topic Domains",
22
+ }
23
+ FIELD_HINTS = {
24
+ "name": "e.g. Marvin Ma · MarvinTalk",
25
+ "positioning": "e.g. 产品技术人 × AI工具链布道者",
26
+ "voice": "e.g. 直接、有观点、技术感强、偶尔幽默",
27
+ "core_values": "e.g. 工具驱动生产力、AI Native、持续输出",
28
+ "topics": "e.g. AI工具、GEO/SEO、系统设计、Developer Tools",
29
+ }
30
+
31
+
32
+ @brand_app.command("show")
33
+ def show() -> None:
34
+ """Show current brand profile."""
35
+ db = get_db()
36
+ brand = db.get_brand()
37
+ db.close()
38
+
39
+ is_empty = all(not brand.get(f) for f in BRAND_FIELDS)
40
+
41
+ if is_empty:
42
+ console.print(Panel(
43
+ "[yellow]Brand profile not set up yet.[/yellow]\n\n"
44
+ "Run [cyan]fgeo brand set <field> <value>[/cyan] to fill in your profile.\n"
45
+ "Fields: " + ", ".join(BRAND_FIELDS),
46
+ title="🎨 Brand Profile",
47
+ border_style="yellow",
48
+ ))
49
+ return
50
+
51
+ table = Table(title="🎨 Brand Profile", show_header=False, box=None, padding=(0, 2))
52
+ table.add_column("Field", style="dim", min_width=14)
53
+ table.add_column("Value", style="bold")
54
+
55
+ for field in BRAND_FIELDS:
56
+ val = brand.get(field, "")
57
+ label = FIELD_LABELS[field]
58
+ table.add_row(label, val if val else "[dim](not set)[/dim]")
59
+
60
+ console.print(table)
61
+ if brand.get("updated_at"):
62
+ console.print(f" [dim]Last updated: {brand['updated_at'][:19]}[/dim]")
63
+
64
+
65
+ @brand_app.command("set")
66
+ def set_field(
67
+ field: str = typer.Argument(help=f"Field to set: {', '.join(BRAND_FIELDS)}"),
68
+ value: str = typer.Argument(help="New value"),
69
+ ) -> None:
70
+ """Set a brand profile field."""
71
+ if field not in BRAND_FIELDS:
72
+ console.print(f"[red]Unknown field '{field}'. Choose from: {', '.join(BRAND_FIELDS)}[/red]")
73
+ raise typer.Exit(1)
74
+
75
+ db = get_db()
76
+ result = db.set_brand(field, value)
77
+ db.close()
78
+
79
+ if not result:
80
+ console.print(f"[red]Failed to set brand field '{field}'.[/red]")
81
+ raise typer.Exit(1)
82
+
83
+ console.print(f"[green]✓[/green] brand.[cyan]{field}[/cyan] → {value}")
84
+
85
+
86
+ @brand_app.command("init")
87
+ def init() -> None:
88
+ """Print brand setup guide for AI-assisted onboarding."""
89
+ console.print(Panel(
90
+ "[bold]Brand onboarding guide[/bold]\n\n"
91
+ "This command is intended to be called by AI agents during onboarding.\n"
92
+ "The AI should interview the user and then call [cyan]fgeo brand set[/cyan] for each field.\n\n"
93
+ "[bold]Fields to fill:[/bold]\n"
94
+ + "\n".join(
95
+ f" [cyan]fgeo brand set {f} \"...\"[/cyan] — {FIELD_HINTS[f]}"
96
+ for f in BRAND_FIELDS
97
+ ),
98
+ title="🎨 Brand Init",
99
+ border_style="cyan",
100
+ ))
@@ -0,0 +1,301 @@
1
+ """fgeo content — Register, list, and manage content assets (v0.2 SQLite)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from fgeo.database import get_db
13
+
14
+ console = Console()
15
+ content_app = typer.Typer(help="Manage content assets — atomic pieces of platform-native content.")
16
+
17
+
18
+ def _extract_frontmatter(path: Path) -> dict:
19
+ """Extract frontmatter fields from a markdown file."""
20
+ result: dict = {}
21
+ try:
22
+ text = path.read_text(encoding="utf-8")
23
+ lines = text.split("\n")
24
+ if lines and lines[0].strip() == "---":
25
+ for line in lines[1:]:
26
+ if line.strip() == "---":
27
+ break
28
+ if ":" in line:
29
+ key, _, value = line.partition(":")
30
+ key = key.strip().lower()
31
+ value = value.strip().strip("\"'")
32
+ if key == "tags":
33
+ value = value.strip("[]")
34
+ result[key] = [t.strip().strip("\"'") for t in value.split(",") if t.strip()]
35
+ else:
36
+ result[key] = value
37
+ if "title" not in result:
38
+ for line in lines:
39
+ if line.startswith("# "):
40
+ result["title"] = line[2:].strip()
41
+ break
42
+ except Exception:
43
+ pass
44
+ return result
45
+
46
+
47
+ @content_app.command("register")
48
+ def register(
49
+ path: Path = typer.Argument(help="Path to the content file"),
50
+ title: str = typer.Option("", "--title", "-t", help="Override title"),
51
+ project: str = typer.Option("", "--project", "-P", help="Project name"),
52
+ platform: str = typer.Option("", "--platform", help="Platform name"),
53
+ plan: str = typer.Option("", "--plan", help="Plan name"),
54
+ direction: str = typer.Option("", "--direction", "-d", help="Content direction"),
55
+ tags: str = typer.Option("", "--tags", help="Comma-separated tags"),
56
+ description: str = typer.Option("", "--desc", help="Content description"),
57
+ content_type: str = typer.Option("", "--type", help="Content type: article, video, slide, thread, short"),
58
+ status: str = typer.Option("draft", "--status", "-s", help="Initial status: planned, draft, review, published"),
59
+ ) -> None:
60
+ """Register a content file into the fgeo database."""
61
+ if not path.exists():
62
+ console.print(f"[red]File not found: {path}[/red]")
63
+ raise typer.Exit(1)
64
+
65
+ path = path.resolve()
66
+
67
+ # Extract from frontmatter for markdown files
68
+ if path.suffix.lower() in (".md", ".markdown", ".mdx"):
69
+ fm = _extract_frontmatter(path)
70
+ if not title:
71
+ title = fm.get("title", "")
72
+ if not description:
73
+ description = fm.get("description", "")
74
+ if not tags:
75
+ fm_tags = fm.get("tags", [])
76
+ if isinstance(fm_tags, list):
77
+ tags = ",".join(fm_tags)
78
+
79
+ # Auto-detect content type from extension
80
+ if not content_type:
81
+ suffix = path.suffix.lower()
82
+ if suffix in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
83
+ content_type = "video"
84
+ elif suffix in (".pptx", ".key", ".pdf"):
85
+ content_type = "slide"
86
+ else:
87
+ content_type = "article"
88
+
89
+ if not title:
90
+ title = path.stem.replace("-", " ").replace("_", " ").strip()
91
+
92
+ db = get_db()
93
+ entry = db.register_content(
94
+ source_path=str(path),
95
+ title=title,
96
+ project_name=project,
97
+ platform_name=platform,
98
+ plan_name=plan,
99
+ direction=direction,
100
+ description=description,
101
+ content_type=content_type,
102
+ tags=tags,
103
+ status=status,
104
+ )
105
+ db.close()
106
+
107
+ console.print(
108
+ Panel.fit(
109
+ f"[bold green]Content registered![/bold green]\n\n"
110
+ f" ID: [cyan]{entry['id']}[/cyan]\n"
111
+ f" Title: {entry['title'] or '[dim](untitled)[/dim]'}\n"
112
+ f" Source: {entry['source_path']}\n"
113
+ f" Type: {entry['content_type']}\n"
114
+ f" Project: {project or '[dim](none)[/dim]'}\n"
115
+ f" Platform: {platform or '[dim](none)[/dim]'}\n"
116
+ f" Direction: {direction or '[dim](none)[/dim]'}\n"
117
+ f" Status: {entry['status']}",
118
+ title="📄 fgeo content",
119
+ border_style="green",
120
+ )
121
+ )
122
+
123
+
124
+ @content_app.command("list")
125
+ def list_content(
126
+ project: str = typer.Option("", "--project", "-P", help="Filter by project"),
127
+ platform: str = typer.Option("", "--platform", help="Filter by platform (requires --project)"),
128
+ status: str = typer.Option("", "--status", "-s", help="Filter by status: planned, draft, review, published"),
129
+ direction: str = typer.Option("", "--direction", "-d", help="Filter by direction"),
130
+ no_plan: bool = typer.Option(False, "--no-plan", help="Show only content not assigned to any plan"),
131
+ ) -> None:
132
+ """List content assets."""
133
+ db = get_db()
134
+ entries = db.list_contents(
135
+ project_name=project,
136
+ platform_name=platform,
137
+ status=status,
138
+ direction=direction,
139
+ no_plan=no_plan,
140
+ )
141
+ db.close()
142
+
143
+ if not entries:
144
+ console.print("[yellow]No content found matching filters.[/yellow]")
145
+ console.print(" Run: [cyan]fgeo content register <file>[/cyan]")
146
+ return
147
+
148
+ table = Table(title=f"📚 Content ({len(entries)} items)")
149
+ table.add_column("ID", style="cyan", no_wrap=True)
150
+ table.add_column("Title", style="bold")
151
+ table.add_column("Type", style="magenta")
152
+ table.add_column("Project", style="blue")
153
+ table.add_column("Platform", style="green")
154
+ table.add_column("Direction")
155
+ table.add_column("Status", style="yellow")
156
+ table.add_column("Created", style="dim")
157
+
158
+ for e in entries:
159
+ table.add_row(
160
+ e["id"],
161
+ (e["title"][:30] + "...") if len(e["title"] or "") > 30 else (e["title"] or ""),
162
+ e["content_type"],
163
+ e.get("project_name") or "-",
164
+ e.get("platform_name") or "-",
165
+ e["direction"] or "-",
166
+ e["status"],
167
+ e["created_at"][:10],
168
+ )
169
+
170
+ console.print(table)
171
+
172
+
173
+ @content_app.command("show")
174
+ def show(
175
+ content_id: str = typer.Argument(help="Content ID"),
176
+ ) -> None:
177
+ """Show content details."""
178
+ db = get_db()
179
+ entry = db.get_content(content_id)
180
+ db.close()
181
+
182
+ if not entry:
183
+ console.print(f"[red]Content not found: {content_id}[/red]")
184
+ raise typer.Exit(1)
185
+
186
+ source_exists = Path(entry["source_path"]).exists() if entry["source_path"] else False
187
+
188
+ console.print(
189
+ Panel.fit(
190
+ f" ID: [cyan]{entry['id']}[/cyan]\n"
191
+ f" Title: [bold]{entry['title'] or '(untitled)'}[/bold]\n"
192
+ f" Description: {entry['description'] or '(none)'}\n"
193
+ f" Source: {entry['source_path'] or '(none)'} {'✓' if source_exists else '✗'}\n"
194
+ f" Type: {entry['content_type']}\n"
195
+ f" Direction: {entry['direction'] or '(none)'}\n"
196
+ f" Tags: {entry['tags'] or '(none)'}\n"
197
+ f" Status: {entry['status']}\n"
198
+ f" URL: {entry['published_url'] or '(none)'}\n"
199
+ f" Published: {entry['published_at'] or '-'}\n"
200
+ f" Created: {entry['created_at']}\n"
201
+ f" Updated: {entry['updated_at']}",
202
+ title="📄 Content Detail",
203
+ border_style="blue",
204
+ )
205
+ )
206
+
207
+
208
+ @content_app.command("set")
209
+ def set_field(
210
+ content_id: str = typer.Argument(help="Content ID"),
211
+ field: str = typer.Argument(help="Field to update: title, description, direction, tags, status, published_url, etc."),
212
+ value: str = typer.Argument(help="New value"),
213
+ ) -> None:
214
+ """Update a content field."""
215
+ db = get_db()
216
+ result = db.update_content(content_id, field, value)
217
+ db.close()
218
+
219
+ if not result:
220
+ console.print(f"[red]Failed to update. Check content ID and field name ({field}).[/red]")
221
+ raise typer.Exit(1)
222
+
223
+ console.print(f"[green]✓[/green] Content {content_id}: {field} → {value}")
224
+
225
+
226
+ @content_app.command("assign-plan")
227
+ def assign_plan(
228
+ project: str = typer.Argument(help="Project name"),
229
+ plan: str = typer.Argument(help="Plan name"),
230
+ content_id: str = typer.Option("", "--id", "-i", help="Assign a single content by ID (ignores --platform and --status)."),
231
+ platform: list[str] = typer.Option([], "--platform", "-p", help="Filter by platform name (repeatable). Omit to target all platforms."),
232
+ status: str = typer.Option("", "--status", "-s", help="Filter by content status (draft, published, planned, …). Omit to target all."),
233
+ ) -> None:
234
+ """Batch-assign a plan to all matching content in a project.
235
+
236
+ To assign a single content item, use --id:
237
+ fgeo content assign-plan myproj gtm-v1 --id cont-abc123
238
+
239
+ Examples:
240
+ fgeo content assign-plan myproj gtm-v1
241
+ fgeo content assign-plan myproj gtm-v1 --platform devto --platform medium
242
+ fgeo content assign-plan myproj gtm-v1 --platform twitter --status published
243
+ """
244
+ db = get_db()
245
+ try:
246
+ if content_id:
247
+ entry = db.get_content(content_id)
248
+ if not entry:
249
+ console.print(f"[red]Content not found: {content_id}[/red]")
250
+ raise typer.Exit(1)
251
+ plan_obj = db.get_plan(project, plan)
252
+ if not plan_obj:
253
+ console.print(f"[red]Plan not found: {plan} in project {project}[/red]")
254
+ raise typer.Exit(1)
255
+ db.update_content(content_id, "plan_id", plan_obj["id"])
256
+ console.print(f"[green]✓[/green] Assigned plan [bold]{plan}[/bold] to content {content_id}")
257
+ return
258
+ count = db.assign_plan_to_contents(
259
+ project_name=project,
260
+ plan_name=plan,
261
+ platform_names=list(platform) or None,
262
+ status=status,
263
+ )
264
+ except ValueError as exc:
265
+ console.print(f"[red]{exc}[/red]")
266
+ raise typer.Exit(1)
267
+ finally:
268
+ db.close()
269
+
270
+ filters: list[str] = []
271
+ if platform:
272
+ filters.append(f"platform: {', '.join(platform)}")
273
+ if status:
274
+ filters.append(f"status: {status}")
275
+ filter_str = f" [dim]({', '.join(filters)})[/dim]" if filters else ""
276
+ console.print(f"[green]✓[/green] Assigned plan [bold]{plan}[/bold] to {count} content(s).{filter_str}")
277
+
278
+
279
+ @content_app.command("remove")
280
+ def remove(
281
+ content_id: str = typer.Argument(help="Content ID to remove"),
282
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
283
+ ) -> None:
284
+ """Remove a content entry from the registry."""
285
+ db = get_db()
286
+ entry = db.get_content(content_id)
287
+
288
+ if not entry:
289
+ console.print(f"[red]Content not found: {content_id}[/red]")
290
+ db.close()
291
+ raise typer.Exit(1)
292
+
293
+ if not force:
294
+ console.print(f" Removing: [bold]{entry['title'] or content_id}[/bold]")
295
+ if not typer.confirm("Are you sure?", default=False):
296
+ db.close()
297
+ raise typer.Abort()
298
+
299
+ db.remove_content(content_id)
300
+ db.close()
301
+ console.print(f"[green]✓[/green] Removed content: {content_id}")