diff --git a/skills/.curated/atlas/LICENSE.txt b/skills/.curated/atlas/LICENSE.txt deleted file mode 100644 index 13e25df..0000000 --- a/skills/.curated/atlas/LICENSE.txt +++ /dev/null @@ -1,201 +0,0 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf of - any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don\'t include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/skills/.curated/atlas/SKILL.md b/skills/.curated/atlas/SKILL.md deleted file mode 100644 index c4c600c..0000000 --- a/skills/.curated/atlas/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: "atlas" -description: "macOS-only AppleScript control for the ChatGPT Atlas desktop app. Use only when the user explicitly asks to control Atlas tabs/bookmarks/history on macOS and the \"ChatGPT Atlas\" app is installed; do not trigger for general browser tasks or non-macOS environments." ---- - - -# Atlas Control (macOS) - -Use the bundled CLI to control Atlas and inspect local browser data. - -## Quick Start - -Set a stable path to the CLI: - -```bash -export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" -export ATLAS_CLI="$CODEX_HOME/skills/atlas/scripts/atlas_cli.py" -``` - -User-scoped skills install under `$CODEX_HOME/skills` (default: `~/.codex/skills`). - -Then run: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" app-name -uv run --python 3.12 python "$ATLAS_CLI" tabs --json -``` - -The CLI requires the Atlas app bundle in `/Applications` or `~/Applications`: - -- `ChatGPT Atlas` - -If AppleScript fails with a permissions error, grant Automation permission in System Settings > Privacy & Security > Automation, allowing your terminal to control ChatGPT Atlas. - -## Tabs Workflow - -1. List tabs to get `window_id` and `tab_index`: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" tabs -``` - -2. Focus a tab using the `window_id` and `tab_index` from the listing: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" focus-tab -``` - -3. Open a new tab: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" open-tab "https://chatgpt.com/" -``` - -Optional maintenance commands: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" reload-tab -uv run --python 3.12 python "$ATLAS_CLI" close-tab -``` - -## Bookmarks and History - -Atlas stores Chromium-style profile data under `~/Library/Application Support/com.openai.atlas/browser-data/host/`. - -List bookmarks: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" bookmarks --limit 100 -``` - -Search bookmarks: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" bookmarks --search "docs" -``` - -Search history: - -```bash -uv run --python 3.12 python "$ATLAS_CLI" history --search "openai docs" --limit 50 -``` - -History for today (local time): - -```bash -uv run --python 3.12 python "$ATLAS_CLI" history --today --limit 200 --json -``` - -The history command copies the SQLite database to a temporary location to avoid lock errors. - -If history looks stale or empty, ask the user which Atlas install they are using, then check both Atlas data roots and inspect the one with the most recent `History` file: - -- `~/Library/Application Support/com.openai.atlas/browser-data/host/` -- `~/Library/Application Support/com.openai.atlas.beta/browser-data/host/` - -## References - -Read `references/atlas-data.md` in the skill folder (for example, `$CODEX_HOME/skills/atlas/references/atlas-data.md`) when adjusting data paths or timestamps. diff --git a/skills/.curated/atlas/agents/openai.yaml b/skills/.curated/atlas/agents/openai.yaml deleted file mode 100644 index 6612200..0000000 --- a/skills/.curated/atlas/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Atlas" - short_description: "Use to manage tabs in ChatGPT Atlas and access your browser history." - icon_small: "./assets/atlas-small.svg" - icon_large: "./assets/atlas.png" - default_prompt: "List all of my currently open browser tabs and call out duplicates." diff --git a/skills/.curated/atlas/assets/atlas-small.svg b/skills/.curated/atlas/assets/atlas-small.svg deleted file mode 100644 index e631bc0..0000000 --- a/skills/.curated/atlas/assets/atlas-small.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/skills/.curated/atlas/assets/atlas.png b/skills/.curated/atlas/assets/atlas.png deleted file mode 100644 index 854bb73..0000000 Binary files a/skills/.curated/atlas/assets/atlas.png and /dev/null differ diff --git a/skills/.curated/atlas/references/atlas-data.md b/skills/.curated/atlas/references/atlas-data.md deleted file mode 100644 index 701ed8d..0000000 --- a/skills/.curated/atlas/references/atlas-data.md +++ /dev/null @@ -1,26 +0,0 @@ -# Atlas Data Paths (macOS) - -Atlas stores Chromium-style profile data under: - -- `~/Library/Application Support/com.openai.atlas/browser-data/host/Local State` -- Profiles under: `~/Library/Application Support/com.openai.atlas/browser-data/host/` - -Key files in the active profile: - -- `History` (SQLite DB, table `urls`) -- `Bookmarks` (JSON) - -The active profile is derived from `profile.last_used` in `Local State`, with a fallback to `Default`. - -## AppleScript Assumptions - -Atlas appears to expose a Safari-style AppleScript dictionary with: - -- `every window` -- `every tab of w` -- `active tab index` -- `make new tab with properties {URL:"..."}` -- `close tab ` -- `reload tab ` - -Treat Atlas as a single app with one AppleScript target. diff --git a/skills/.curated/atlas/scripts/atlas_cli.py b/skills/.curated/atlas/scripts/atlas_cli.py deleted file mode 100644 index 96207ef..0000000 --- a/skills/.curated/atlas/scripts/atlas_cli.py +++ /dev/null @@ -1,473 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import sqlite3 -import sys -from dataclasses import asdict, dataclass -from datetime import datetime, timedelta -from pathlib import Path -from typing import Iterable, Sequence - -from atlas_common import ( - AtlasError, - copy_sqlite_db, - detect_atlas_app_name, - detect_tab_capable_app_name, - get_bookmarks_path, - get_history_path, - tell_atlas, -) - -ROW_SEP = "|||" -CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 -DEFAULT_HISTORY_LIMIT = 200 -DEFAULT_BOOKMARK_LIMIT = 200 - - -@dataclass(slots=True) -class Tab: - title: str - url: str - window_id: int - tab_index: int - is_active: bool - - -@dataclass(slots=True) -class HistoryRow: - id: int - url: str - title: str - last_visited_at: str - - -@dataclass(slots=True) -class Bookmark: - id: str - name: str - url: str - date_added: str - folder: str | None = None - - -def _escape_applescript_string(value: str) -> str: - return value.replace("\\", "\\\\").replace('"', '\\"') - - -def _chrome_microseconds_to_iso_date(value: str | None) -> str: - if not value: - return "unknown" - - try: - timestamp = int(value) - except ValueError: - return "unknown" - - unix_seconds = timestamp / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS - try: - dt = datetime.fromtimestamp(unix_seconds) - except (OverflowError, OSError, ValueError): - return "unknown" - return dt.date().isoformat() - - -def _chrome_time_from_unix_seconds(unix_seconds: float) -> int: - return int((unix_seconds + CHROME_EPOCH_OFFSET_SECONDS) * 1_000_000) - - -def _chrome_time_bounds_today() -> tuple[int, int]: - now = datetime.now().astimezone() - start = now.replace(hour=0, minute=0, second=0, microsecond=0) - end = start + timedelta(days=1) - return ( - _chrome_time_from_unix_seconds(start.timestamp()), - _chrome_time_from_unix_seconds(end.timestamp()), - ) - - -def get_tabs() -> list[Tab]: - tab_app = detect_tab_capable_app_name() - script_body = f""" -set tabList to {{}} - -repeat with w in every window - set windowId to id of w - set tabIndex to 0 - set activeIndex to active tab index of w - try - set winTabs to every tab of w - repeat with t in winTabs - set tabIndex to tabIndex + 1 - set tabTitle to title of t - set tabURL to URL of t - set isActive to (tabIndex = activeIndex) - set end of tabList to {{tabTitle, tabURL, windowId, tabIndex, isActive}} - end repeat - end try -end repeat - -set output to "" -repeat with tabInfo in tabList - set output to output & item 1 of tabInfo & "{ROW_SEP}" & item 2 of tabInfo & "{ROW_SEP}" & item 3 of tabInfo & "{ROW_SEP}" & item 4 of tabInfo & "{ROW_SEP}" & item 5 of tabInfo & "\\n" -end repeat - -return output -""".strip() - - raw = tell_atlas(script_body, app_name=tab_app) - if not raw: - return [] - - tabs: list[Tab] = [] - for line in raw.splitlines(): - parts = line.split(ROW_SEP) - if len(parts) != 5: - continue - title, url, window_id, tab_index, is_active = parts - try: - tabs.append( - Tab( - title=title, - url=url, - window_id=int(window_id), - tab_index=int(tab_index), - is_active=is_active == "true", - ) - ) - except ValueError: - continue - - return tabs - - -def open_new_tab(url: str) -> None: - escaped_url = _escape_applescript_string(url) - script_body = f""" -activate -delay 0.1 -open location "{escaped_url}" - -activate -""".strip() - tell_atlas(script_body) - - -def focus_tab(window_id: int, tab_index: int) -> None: - tab_app = detect_tab_capable_app_name() - script_body = f""" -activate -set _wnd to first window whose id is {window_id} -set index of _wnd to 1 -set active tab index of _wnd to {tab_index} -return true -""".strip() - tell_atlas(script_body, app_name=tab_app) - - -def close_tab(window_id: int, tab_index: int) -> None: - tab_app = detect_tab_capable_app_name() - script_body = f""" -set _wnd to first window whose id is {window_id} -tell _wnd - close tab {tab_index} -end tell -""".strip() - tell_atlas(script_body, app_name=tab_app) - - -def reload_tab(window_id: int, tab_index: int) -> None: - tab_app = detect_tab_capable_app_name() - script_body = f""" -set _wnd to first window whose id is {window_id} -tell _wnd - reload tab {tab_index} -end tell -""".strip() - tell_atlas(script_body, app_name=tab_app) - - -def _history_query( - terms: Sequence[str], - limit: int, - *, - chrome_start: int | None = None, - chrome_end: int | None = None, -) -> tuple[str, list[str | int]]: - params: list[str | int] = [] - conditions: list[str] = [] - - if terms: - for term in terms: - conditions.append("(url LIKE ? OR title LIKE ?)") - like = f"%{term}%" - params.extend([like, like]) - - if chrome_start is not None: - conditions.append("last_visit_time >= ?") - params.append(chrome_start) - - if chrome_end is not None: - conditions.append("last_visit_time < ?") - params.append(chrome_end) - - where_clause = "" - if conditions: - where_clause = "\nWHERE " + " AND ".join(conditions) - - base = f""" -WITH filtered AS ( - SELECT id, url, title, last_visit_time - FROM urls{where_clause} -), -ranked AS ( - SELECT - id, - url, - title, - last_visit_time, - ROW_NUMBER() OVER ( - PARTITION BY url - ORDER BY last_visit_time DESC, id DESC - ) AS rn - FROM filtered -) -SELECT - id, - url, - title, - datetime(last_visit_time / 1000000 + (strftime('%s', '1601-01-01')), 'unixepoch', 'localtime') AS lastVisitedAt -FROM ranked -WHERE rn = 1 -ORDER BY last_visit_time DESC -LIMIT ? -""".strip() - - params.append(limit) - return base, params - - -def search_history(search_text: str | None, limit: int, *, today: bool = False) -> list[HistoryRow]: - history_path = get_history_path() - db_copy = copy_sqlite_db(history_path) - - terms = [t for t in (search_text or "").split() if t] - chrome_start: int | None = None - chrome_end: int | None = None - if today: - chrome_start, chrome_end = _chrome_time_bounds_today() - - query, params = _history_query(terms, limit, chrome_start=chrome_start, chrome_end=chrome_end) - - conn = sqlite3.connect(db_copy) - conn.row_factory = sqlite3.Row - try: - rows = conn.execute(query, params).fetchall() - finally: - conn.close() - - history: list[HistoryRow] = [] - for row in rows: - history.append( - HistoryRow( - id=int(row["id"]), - url=str(row["url"] or ""), - title=str(row["title"] or ""), - last_visited_at=str(row["lastVisitedAt"] or ""), - ) - ) - return history - - -def _iter_bookmark_nodes(node: dict, folder: str) -> Iterable[Bookmark]: - node_type = str(node.get("type") or "") - if node_type == "url" and node.get("url"): - yield Bookmark( - id=str(node.get("id") or ""), - name=str(node.get("name") or "Untitled"), - url=str(node.get("url") or ""), - date_added=_chrome_microseconds_to_iso_date(node.get("date_added")), - folder=folder or None, - ) - - children = node.get("children") or [] - if not isinstance(children, list): - return - - current_folder = str(node.get("name") or folder) - for child in children: - if isinstance(child, dict): - yield from _iter_bookmark_nodes(child, current_folder) - - -def get_bookmarks(limit: int, search_text: str | None) -> list[Bookmark]: - bookmarks_path = get_bookmarks_path() - data = json.loads(bookmarks_path.read_text(encoding="utf-8")) - - roots = data.get("roots") or {} - ordered_roots = [ - ("Bookmarks Bar", roots.get("bookmark_bar") or {}), - ("Other Bookmarks", roots.get("other") or {}), - ("Synced Bookmarks", roots.get("synced") or {}), - ] - - bookmarks: list[Bookmark] = [] - for folder_name, root in ordered_roots: - if isinstance(root, dict): - bookmarks.extend(_iter_bookmark_nodes(root, folder_name)) - - if search_text: - lowered = search_text.lower() - bookmarks = [ - b - for b in bookmarks - if lowered in b.name.lower() or lowered in b.url.lower() or lowered in (b.folder or "").lower() - ] - - return bookmarks[:limit] - - -def _print_table(rows: Iterable[Sequence[object]], headers: Sequence[str]) -> None: - widths = [len(h) for h in headers] - materialized = [tuple(str(cell) for cell in row) for row in rows] - for row in materialized: - for i, cell in enumerate(row): - widths[i] = max(widths[i], len(cell)) - - def fmt_row(row: Sequence[str]) -> str: - return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)) - - print(fmt_row(headers)) - print(fmt_row(["-" * w for w in widths])) - for row in materialized: - print(fmt_row(row)) - - -def _render_tabs(tabs: list[Tab], as_json: bool) -> None: - if as_json: - print(json.dumps([asdict(t) for t in tabs], indent=2)) - return - - rows = [ - ( - "*" if t.is_active else " ", - t.window_id, - t.tab_index, - t.title, - t.url, - ) - for t in tabs - ] - _print_table(rows, headers=["A", "window_id", "tab", "title", "url"]) - - -def _render_history(rows: list[HistoryRow], as_json: bool) -> None: - if as_json: - print(json.dumps([asdict(r) for r in rows], indent=2)) - return - - table = [(r.last_visited_at, r.title, r.url) for r in rows] - _print_table(table, headers=["last_visited_at", "title", "url"]) - - -def _render_bookmarks(rows: list[Bookmark], as_json: bool) -> None: - if as_json: - print(json.dumps([asdict(r) for r in rows], indent=2)) - return - - table = [(r.folder or "", r.name, r.url, r.date_added) for r in rows] - _print_table(table, headers=["folder", "name", "url", "date_added"]) - - -def _parse_args(argv: Sequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Control ChatGPT Atlas from the terminal.") - sub = parser.add_subparsers(dest="command", required=True) - - sub.add_parser("app-name", help="Print the detected Atlas application name") - - tabs_parser = sub.add_parser("tabs", help="List open Atlas tabs") - tabs_parser.add_argument("--json", action="store_true", help="Emit JSON instead of a table") - - open_parser = sub.add_parser("open-tab", help="Open a new Atlas tab") - open_parser.add_argument("url", help="URL to open") - - focus_parser = sub.add_parser("focus-tab", help="Focus a tab by window id and tab index") - focus_parser.add_argument("window_id", type=int, help="Atlas window id (from tabs output)") - focus_parser.add_argument("tab_index", type=int, help="Tab index within the window (1-based)") - - close_parser = sub.add_parser("close-tab", help="Close a tab by window id and tab index") - close_parser.add_argument("window_id", type=int, help="Atlas window id (from tabs output)") - close_parser.add_argument("tab_index", type=int, help="Tab index within the window (1-based)") - - reload_parser = sub.add_parser("reload-tab", help="Reload a tab by window id and tab index") - reload_parser.add_argument("window_id", type=int, help="Atlas window id (from tabs output)") - reload_parser.add_argument("tab_index", type=int, help="Tab index within the window (1-based)") - - history_parser = sub.add_parser("history", help="Search Atlas history") - history_parser.add_argument("--search", default=None, help="Space-separated search terms") - history_parser.add_argument("--limit", type=int, default=DEFAULT_HISTORY_LIMIT, help="Max rows to return") - history_parser.add_argument( - "--today", - action="store_true", - help="Limit results to pages visited today (local time)", - ) - history_parser.add_argument("--json", action="store_true", help="Emit JSON instead of a table") - - bookmarks_parser = sub.add_parser("bookmarks", help="List Atlas bookmarks") - bookmarks_parser.add_argument("--search", default=None, help="Filter bookmarks by text") - bookmarks_parser.add_argument("--limit", type=int, default=DEFAULT_BOOKMARK_LIMIT, help="Max rows to return") - bookmarks_parser.add_argument("--json", action="store_true", help="Emit JSON instead of a table") - - return parser.parse_args(argv) - - -def main(argv: Sequence[str] | None = None) -> int: - args = _parse_args(argv or sys.argv[1:]) - - try: - if args.command == "app-name": - print(detect_atlas_app_name()) - return 0 - - if args.command == "tabs": - _render_tabs(get_tabs(), as_json=args.json) - return 0 - - if args.command == "open-tab": - open_new_tab(args.url) - print(f"Opened tab: {args.url}") - return 0 - - if args.command == "focus-tab": - focus_tab(args.window_id, args.tab_index) - print(f"Focused window id {args.window_id}, tab {args.tab_index}") - return 0 - - if args.command == "close-tab": - close_tab(args.window_id, args.tab_index) - print(f"Closed window id {args.window_id}, tab {args.tab_index}") - return 0 - - if args.command == "reload-tab": - reload_tab(args.window_id, args.tab_index) - print(f"Reloaded window id {args.window_id}, tab {args.tab_index}") - return 0 - - if args.command == "history": - rows = search_history(args.search, max(1, args.limit), today=args.today) - _render_history(rows, as_json=args.json) - return 0 - - if args.command == "bookmarks": - rows = get_bookmarks(max(1, args.limit), args.search) - _render_bookmarks(rows, as_json=args.json) - return 0 - - raise AtlasError(f"Unknown command: {args.command}") - except AtlasError as exc: - print(f"atlas error: {exc}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/.curated/atlas/scripts/atlas_common.py b/skills/.curated/atlas/scripts/atlas_common.py deleted file mode 100644 index 9c91bc9..0000000 --- a/skills/.curated/atlas/scripts/atlas_common.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -import json -import shutil -import subprocess -import tempfile -from functools import lru_cache -from pathlib import Path -from typing import Any - -ATLAS_APP_NAME = "ChatGPT Atlas" - -LOCAL_STATE_PATH = ( - Path.home() - / "Library" - / "Application Support" - / "com.openai.atlas" - / "browser-data" - / "host" - / "Local State" -) - - -class AtlasError(RuntimeError): - """Raised when Atlas-specific operations fail.""" - - -def _app_bundle_paths(app_name: str) -> list[Path]: - return [ - Path("/Applications") / f"{app_name}.app", - Path.home() / "Applications" / f"{app_name}.app", - ] - - -def is_app_installed(app_name: str) -> bool: - return any(path.exists() for path in _app_bundle_paths(app_name)) - - -@lru_cache(maxsize=1) -def detect_atlas_app_name() -> str: - """Return the Atlas app name when it is installed.""" - if is_app_installed(ATLAS_APP_NAME): - return ATLAS_APP_NAME - - raise AtlasError("Could not find ChatGPT Atlas. Install the ChatGPT Atlas app.") - - -def _ensure_local_state_path(path: Path = LOCAL_STATE_PATH) -> Path: - if not path.exists(): - raise AtlasError(f"Local State file not found at: {path}") - return path - - -def read_local_state(path: Path = LOCAL_STATE_PATH) -> dict[str, Any]: - local_state_path = _ensure_local_state_path(path) - try: - return json.loads(local_state_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise AtlasError(f"Failed to parse Local State JSON at: {local_state_path}") from exc - - -def get_active_profile_path() -> Path: - """Resolve the active Atlas profile directory, falling back to Default.""" - local_state = read_local_state() - host_root = LOCAL_STATE_PATH.parent - - last_used = local_state.get("profile", {}).get("last_used") - if isinstance(last_used, str) and last_used: - candidate = host_root / last_used - if candidate.exists(): - return candidate - - default_profile = host_root / "Default" - if default_profile.exists(): - return default_profile - - raise AtlasError( - "Unable to resolve an Atlas profile directory. " - f"Checked last_used={last_used!r} and Default under: {host_root}" - ) - - -def get_history_path() -> Path: - return get_active_profile_path() / "History" - - -def get_bookmarks_path() -> Path: - return get_active_profile_path() / "Bookmarks" - - -def copy_sqlite_db(path: Path) -> Path: - """Copy a SQLite DB to a temp location to avoid lock errors.""" - if not path.exists(): - raise AtlasError(f"SQLite database not found at: {path}") - - tmp_dir = Path(tempfile.mkdtemp(prefix="atlas-db-")) - dest = tmp_dir / path.name - shutil.copy2(path, dest) - return dest - - -def _run_applescript_raw(script: str) -> subprocess.CompletedProcess[str]: - if shutil.which("osascript") is None: - raise AtlasError("osascript is not available on PATH; Atlas control requires macOS") - - return subprocess.run( - ["osascript", "-e", script], - check=False, - capture_output=True, - text=True, - ) - - -def _applescript_hint(stderr: str) -> str | None: - lower = stderr.lower() - if "-1743" in stderr or "not authorized" in lower or "not permitted" in lower: - return ( - "Grant Automation permission in System Settings > Privacy & Security > Automation, " - "and allow your terminal to control ChatGPT Atlas." - ) - return None - - -def run_applescript(script: str) -> str: - """Execute AppleScript via osascript and return stdout.""" - completed = _run_applescript_raw(script) - if completed.returncode != 0: - stderr = (completed.stderr or "").strip() - hint = _applescript_hint(stderr) - if hint: - stderr = f"{stderr} (Hint: {hint})" - raise AtlasError(f"AppleScript failed: {stderr}") - return (completed.stdout or "").strip() - - -@lru_cache(maxsize=4) -def is_tab_capable(app_name: str) -> bool: - """Return True if the app supports basic window/tab AppleScript queries.""" - if not is_app_installed(app_name): - return False - - probe = f'tell application "{app_name}" to get count of windows' - completed = _run_applescript_raw(probe) - if completed.returncode != 0: - stderr = (completed.stderr or "").strip() - hint = _applescript_hint(stderr) - if hint: - raise AtlasError(f"AppleScript probe failed: {stderr} (Hint: {hint})") - return False - return True - - -@lru_cache(maxsize=1) -def detect_tab_capable_app_name() -> str: - """Return the Atlas app name when it supports tab scripting.""" - app_name = detect_atlas_app_name() - if not is_tab_capable(app_name): - raise AtlasError( - "ChatGPT Atlas is installed but does not appear to expose window/tab scripting." - ) - return app_name - - -def tell_atlas(script_body: str, app_name: str | None = None) -> str: - """Wrap a script body in a tell application block for the detected Atlas app.""" - target_app = app_name or detect_atlas_app_name() - script = f'tell application "{target_app}"\n{script_body}\nend tell' - return run_applescript(script)