mirror of
https://github.com/openai/skills.git
synced 2026-09-18 15:06:29 +03:00
remove atlas for now (#108)
This commit is contained in:
@@ -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.
|
||||
@@ -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 <window_id> <tab_index>
|
||||
```
|
||||
|
||||
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 <window_id> <tab_index>
|
||||
uv run --python 3.12 python "$ATLAS_CLI" close-tab <window_id> <tab_index>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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."
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M11.807 7.413a.575.575 0 0 1 .387-.002.588.588 0 0 1 .347.753l-1.806 4.948a.585.585 0 0 1-.736.357.577.577 0 0 1-.366-.396l-.496-1.85a.575.575 0 0 0-.407-.406l-1.849-.5a.577.577 0 0 1-.395-.369.584.584 0 0 1 .359-.734l4.962-1.801Z"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M9.002 1.75a4.51 4.51 0 0 1 2.922 1.072 4.523 4.523 0 0 1 5.253 5.253 4.523 4.523 0 0 1-1.922 7.18 4.523 4.523 0 0 1-7.18 1.922 4.522 4.522 0 0 1-5.253-5.253 4.506 4.506 0 0 1-1.065-2.69l-.007-.233a4.525 4.525 0 0 1 2.994-4.257A4.525 4.525 0 0 1 9.002 1.75Zm0 1.328a3.197 3.197 0 0 0-3.087 2.367.665.665 0 0 1-.47.47 3.196 3.196 0 0 0-1.43 5.346.665.665 0 0 1 .17.64 3.195 3.195 0 0 0 3.913 3.914l.088-.018a.664.664 0 0 1 .554.19 3.197 3.197 0 0 0 5.345-1.432l.028-.084a.664.664 0 0 1 .442-.386 3.197 3.197 0 0 0 1.432-5.346.664.664 0 0 1-.172-.64A3.196 3.196 0 0 0 11.9 4.185a.665.665 0 0 1-.64-.172 3.184 3.184 0 0 0-2.093-.931l-.165-.004Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -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/<profile>`
|
||||
|
||||
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 <n>`
|
||||
- `reload tab <n>`
|
||||
|
||||
Treat Atlas as a single app with one AppleScript target.
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user