sedona-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,257 @@
1
+ # Node / frontend
2
+ node_modules/
3
+ dist/
4
+ .next/
5
+ .nuxt/
6
+ .output/
7
+ .cache/
8
+ *.tsbuildinfo
9
+ .turbo/
10
+ .parcel-cache/
11
+
12
+ # macOS
13
+ .DS_Store
14
+
15
+ # AWS / Textract output
16
+ output_textract/
17
+
18
+ # Byte-compiled / optimized / DLL files
19
+ __pycache__/
20
+ *.py[codz]
21
+ *$py.class
22
+
23
+ # C extensions
24
+ *.so
25
+
26
+ # Distribution / packaging
27
+ .Python
28
+ build/
29
+ develop-eggs/
30
+ dist/
31
+ downloads/
32
+ eggs/
33
+ .eggs/
34
+ /lib/
35
+ /lib64/
36
+ parts/
37
+ sdist/
38
+ var/
39
+ wheels/
40
+ share/python-wheels/
41
+ *.egg-info/
42
+ .installed.cfg
43
+ *.egg
44
+ MANIFEST
45
+
46
+ # PyInstaller
47
+ # Usually these files are written by a python script from a template
48
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
49
+ *.manifest
50
+ *.spec
51
+
52
+ # Installer logs
53
+ pip-log.txt
54
+ pip-delete-this-directory.txt
55
+
56
+ # Unit test / coverage reports
57
+ htmlcov/
58
+ .tox/
59
+ .nox/
60
+ .coverage
61
+ .coverage.*
62
+ .cache
63
+ nosetests.xml
64
+ coverage.xml
65
+ *.cover
66
+ *.py.cover
67
+ .hypothesis/
68
+ .pytest_cache/
69
+ cover/
70
+
71
+ # Translations
72
+ *.mo
73
+ *.pot
74
+
75
+ # Django stuff:
76
+ *.log
77
+ local_settings.py
78
+ db.sqlite3
79
+ db.sqlite3-journal
80
+
81
+ # Flask stuff:
82
+ instance/
83
+ .webassets-cache
84
+
85
+ # Scrapy stuff:
86
+ .scrapy
87
+
88
+ # Sphinx documentation
89
+ docs/_build/
90
+
91
+ # PyBuilder
92
+ .pybuilder/
93
+ target/
94
+
95
+ # Jupyter Notebook
96
+ .ipynb_checkpoints
97
+
98
+ # IPython
99
+ profile_default/
100
+ ipython_config.py
101
+
102
+ # pyenv
103
+ # For a library or package, you might want to ignore these files since the code is
104
+ # intended to run in multiple environments; otherwise, check them in:
105
+ # .python-version
106
+
107
+ # pipenv
108
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
109
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
110
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
111
+ # install all needed dependencies.
112
+ # Pipfile.lock
113
+
114
+ # UV
115
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
116
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
117
+ # commonly ignored for libraries.
118
+ # uv.lock
119
+
120
+ # poetry
121
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
122
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
123
+ # commonly ignored for libraries.
124
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
125
+ # poetry.lock
126
+ # poetry.toml
127
+
128
+ # pdm
129
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
130
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
131
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
132
+ # pdm.lock
133
+ # pdm.toml
134
+ .pdm-python
135
+ .pdm-build/
136
+
137
+ # pixi
138
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
139
+ # pixi.lock
140
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
141
+ # in the .venv directory. It is recommended not to include this directory in version control.
142
+ .pixi
143
+
144
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
145
+ __pypackages__/
146
+
147
+ # Celery stuff
148
+ celerybeat-schedule
149
+ celerybeat.pid
150
+
151
+ # Redis
152
+ *.rdb
153
+ *.aof
154
+ *.pid
155
+
156
+ # RabbitMQ
157
+ mnesia/
158
+ rabbitmq/
159
+ rabbitmq-data/
160
+
161
+ # ActiveMQ
162
+ activemq-data/
163
+
164
+ # SageMath parsed files
165
+ *.sage.py
166
+
167
+ # Environments
168
+ .env
169
+ .env.local
170
+ .env.share
171
+ .envrc
172
+ .venv
173
+ env/
174
+ venv/
175
+ ENV/
176
+ env.bak/
177
+ venv.bak/
178
+
179
+ # Spyder project settings
180
+ .spyderproject
181
+ .spyproject
182
+
183
+ # Rope project settings
184
+ .ropeproject
185
+
186
+ # mkdocs documentation
187
+ /site
188
+
189
+ # mypy
190
+ .mypy_cache/
191
+ .dmypy.json
192
+ dmypy.json
193
+
194
+ # Pyre type checker
195
+ .pyre/
196
+
197
+ # pytype static type analyzer
198
+ .pytype/
199
+
200
+ # Cython debug symbols
201
+ cython_debug/
202
+
203
+ # PyCharm
204
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
205
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
206
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
207
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
208
+ # .idea/
209
+
210
+ # Abstra
211
+ # Abstra is an AI-powered process automation framework.
212
+ # Ignore directories containing user credentials, local state, and settings.
213
+ # Learn more at https://abstra.io/docs
214
+ .abstra/
215
+
216
+ # Visual Studio Code
217
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
218
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
219
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
220
+ # you could uncomment the following to ignore the entire vscode folder
221
+ # .vscode/
222
+ # Temporary file for partial code execution
223
+ tempCodeRunnerFile.py
224
+
225
+ # Ruff stuff:
226
+ .ruff_cache/
227
+
228
+ # PyPI configuration file
229
+ .pypirc
230
+
231
+ # Marimo
232
+ marimo/_static/
233
+ marimo/_lsp/
234
+ __marimo__/
235
+
236
+ # Streamlit
237
+ .streamlit/secrets.toml
238
+
239
+ # Google OAuth client secrets
240
+ *_secret.json
241
+ token*.json
242
+ # OAuth token caches (leading-dot names not caught by token*.json)
243
+ .x_tokens.json
244
+ *.x_tokens.json
245
+ src/services/interfaces/twitter/scripts/.x_tokens.json
246
+ .vercel
247
+ .env*.local
248
+
249
+ # Notion exports (may contain secrets)
250
+ notion_export/
251
+
252
+ # Env files (all variants — secrets never enter git)
253
+ .env
254
+ .env.*
255
+ !.env.example
256
+ email_relevance_labels.json
257
+ email_relevance_eval.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sedona Health
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: sedona-cli
3
+ Version: 0.1.0
4
+ Summary: Export your AI chats — Claude Code, Claude.ai/Cowork, ChatGPT — and terminal sessions into your company's context.
5
+ Project-URL: Homepage, https://github.com/Sedona-Health/sedona-internal
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: chatgpt,claude,claude-code,context,export,knowledge-base,transcripts
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27
20
+ Description-Content-Type: text/markdown
21
+
22
+ # sedona-cli
23
+
24
+ Your AI chats hold a surprising amount of company context — decisions, debugging
25
+ trails, design discussions, institutional knowledge that never makes it into a
26
+ doc. `sedona` ships them into Sedona's company knowledge graph, where they
27
+ become searchable context for everyone (with secrets scrubbed and sensitive
28
+ sessions automatically restricted).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ uv tool install sedona-cli # or: pipx install sedona-cli / pip install sedona-cli
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ```bash
39
+ sedona auth # email OTP — requires a company employee email
40
+ sedona list # recent Claude Code sessions + chat exports it found
41
+ sedona send --recent 3 # scrub + upload your 3 most recent sessions
42
+ sedona send ~/Downloads/conversations.json # a Claude.ai / ChatGPT export
43
+ history | sedona send --stdin # raw terminal scrollback
44
+ sedona init-skill # let your local Claude Code do this on request
45
+ ```
46
+
47
+ After `sedona init-skill`, you can just tell Claude Code *"share this session
48
+ with Sedona"* and it handles the upload.
49
+
50
+ ## What it can export
51
+
52
+ | Source | How |
53
+ |---|---|
54
+ | **Claude Code** sessions | Read directly from `~/.claude/projects/` — `sedona send --recent N` |
55
+ | **Claude.ai / Cowork** chats | Request a data export in claude.ai settings, then `sedona send conversations.json` |
56
+ | **ChatGPT** chats | Request a data export in ChatGPT settings, then `sedona send conversations.json` |
57
+ | **Terminal** sessions | Pipe anything: `tmux capture-pane -p \| sedona send --stdin` |
58
+
59
+ ## Privacy
60
+
61
+ - **Secrets never leave your machine**: API keys, tokens, JWTs, and private
62
+ keys are redacted locally before upload (and the server scrubs again as a
63
+ backstop).
64
+ - The server classifies each conversation's visibility **fail-closed** —
65
+ sensitive content is restricted to you or admins, not shared company-wide.
66
+ - Tool output in coding sessions is truncated; assistant thinking blocks are
67
+ dropped entirely.
68
+ - Authentication requires a verified company employee email; the tool does
69
+ nothing useful outside the company.
70
+
71
+ ## License
72
+
73
+ MIT (this CLI only).
@@ -0,0 +1,52 @@
1
+ # sedona-cli
2
+
3
+ Your AI chats hold a surprising amount of company context — decisions, debugging
4
+ trails, design discussions, institutional knowledge that never makes it into a
5
+ doc. `sedona` ships them into Sedona's company knowledge graph, where they
6
+ become searchable context for everyone (with secrets scrubbed and sensitive
7
+ sessions automatically restricted).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ uv tool install sedona-cli # or: pipx install sedona-cli / pip install sedona-cli
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```bash
18
+ sedona auth # email OTP — requires a company employee email
19
+ sedona list # recent Claude Code sessions + chat exports it found
20
+ sedona send --recent 3 # scrub + upload your 3 most recent sessions
21
+ sedona send ~/Downloads/conversations.json # a Claude.ai / ChatGPT export
22
+ history | sedona send --stdin # raw terminal scrollback
23
+ sedona init-skill # let your local Claude Code do this on request
24
+ ```
25
+
26
+ After `sedona init-skill`, you can just tell Claude Code *"share this session
27
+ with Sedona"* and it handles the upload.
28
+
29
+ ## What it can export
30
+
31
+ | Source | How |
32
+ |---|---|
33
+ | **Claude Code** sessions | Read directly from `~/.claude/projects/` — `sedona send --recent N` |
34
+ | **Claude.ai / Cowork** chats | Request a data export in claude.ai settings, then `sedona send conversations.json` |
35
+ | **ChatGPT** chats | Request a data export in ChatGPT settings, then `sedona send conversations.json` |
36
+ | **Terminal** sessions | Pipe anything: `tmux capture-pane -p \| sedona send --stdin` |
37
+
38
+ ## Privacy
39
+
40
+ - **Secrets never leave your machine**: API keys, tokens, JWTs, and private
41
+ keys are redacted locally before upload (and the server scrubs again as a
42
+ backstop).
43
+ - The server classifies each conversation's visibility **fail-closed** —
44
+ sensitive content is restricted to you or admins, not shared company-wide.
45
+ - Tool output in coding sessions is truncated; assistant thinking blocks are
46
+ dropped entirely.
47
+ - Authentication requires a verified company employee email; the tool does
48
+ nothing useful outside the company.
49
+
50
+ ## License
51
+
52
+ MIT (this CLI only).
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "sedona-cli"
3
+ version = "0.1.0"
4
+ description = "Export your AI chats — Claude Code, Claude.ai/Cowork, ChatGPT — and terminal sessions into your company's context."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ keywords = ["claude", "claude-code", "chatgpt", "transcripts", "context", "knowledge-base", "export"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Utilities",
20
+ ]
21
+ dependencies = [
22
+ "httpx>=0.27",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/Sedona-Health/sedona-internal"
27
+
28
+ [project.scripts]
29
+ sedona = "sedona_cli.main:main"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
@@ -0,0 +1,12 @@
1
+ """Sedona CLI — export and upload AI-chat / terminal transcripts to company context.
2
+
3
+ Lives in the sedona-internal repo as a uv workspace member so the backend and
4
+ the CLI share one secret scrubber (``sedona_cli.redact``); published to PyPI as
5
+ ``sedona-cli``. Install on a laptop:
6
+
7
+ uv tool install sedona-cli
8
+
9
+ Then: ``sedona auth`` → ``sedona list`` → ``sedona send --recent 3``.
10
+ """
11
+
12
+ __version__ = "0.1.0"
@@ -0,0 +1,41 @@
1
+ """``sedona auth`` — email OTP → long-lived upload token.
2
+
3
+ The OTP exchange is proxied by the backend (``/transcripts/auth/start`` +
4
+ ``/verify``) so the CLI needs no Supabase configuration; proof of mailbox
5
+ control yields the same token the portal mint endpoint issues.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+
12
+ from sedona_cli import config
13
+
14
+
15
+ def run_auth(url: str | None = None) -> int:
16
+ base = (url or config.base_url()).rstrip("/")
17
+ email = input("Work email: ").strip().lower()
18
+ if not email or "@" not in email:
19
+ print("That doesn't look like an email address.")
20
+ return 1
21
+
22
+ with httpx.Client(timeout=30) as client:
23
+ resp = client.post(f"{base}/transcripts/auth/start", json={"email": email})
24
+ if resp.status_code != 200:
25
+ print(f"Could not send code: {resp.json().get('detail', resp.text)}")
26
+ return 1
27
+ print(f"Code sent to {email}.")
28
+
29
+ code = input("6-digit code: ").strip()
30
+ resp = client.post(
31
+ f"{base}/transcripts/auth/verify", json={"email": email, "code": code}
32
+ )
33
+ if resp.status_code != 200:
34
+ print(f"Verification failed: {resp.json().get('detail', resp.text)}")
35
+ return 1
36
+ token = resp.json()["token"]
37
+
38
+ config.save(url=base, token=token)
39
+ print("Authenticated — token saved to ~/.config/sedona/config.json.")
40
+ print("Try: sedona list then sedona send --recent 1")
41
+ return 0
@@ -0,0 +1,34 @@
1
+ """CLI config: Sedona base URL + upload token in ~/.config/sedona/config.json."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ DEFAULT_URL = "https://sedona-internal-production.up.railway.app"
10
+
11
+ _CONFIG_DIR = Path(os.environ.get("SEDONA_CONFIG_DIR", "~/.config/sedona")).expanduser()
12
+ _CONFIG_PATH = _CONFIG_DIR / "config.json"
13
+
14
+
15
+ def load() -> dict:
16
+ try:
17
+ return json.loads(_CONFIG_PATH.read_text())
18
+ except (OSError, json.JSONDecodeError):
19
+ return {}
20
+
21
+
22
+ def save(**updates) -> None:
23
+ cfg = {**load(), **updates}
24
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
25
+ _CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + "\n")
26
+ _CONFIG_PATH.chmod(0o600)
27
+
28
+
29
+ def base_url() -> str:
30
+ return (os.environ.get("SEDONA_URL") or load().get("url") or DEFAULT_URL).rstrip("/")
31
+
32
+
33
+ def token() -> str | None:
34
+ return os.environ.get("SEDONA_TRANSCRIPT_TOKEN") or load().get("token")
@@ -0,0 +1,91 @@
1
+ """Find local AI-chat context worth uploading.
2
+
3
+ Claude Code sessions live at ``~/.claude/projects/<project-slug>/<uuid>.jsonl``;
4
+ the session title is on an ``ai-title`` (newer) or ``summary`` (older) line.
5
+ Claude.ai / ChatGPT data exports land in ``~/Downloads`` as
6
+ ``conversations.json`` (possibly inside the export zip the user expanded).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass
18
+ class SessionInfo:
19
+ path: Path
20
+ project: str
21
+ title: str
22
+ mtime: float
23
+ size: int
24
+
25
+
26
+ def _claude_dir() -> Path:
27
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude")).expanduser()
28
+
29
+
30
+ def session_title(path: Path) -> str:
31
+ """Best-effort title: last ai-title/summary line, else first user text."""
32
+ title = ""
33
+ first_user = ""
34
+ try:
35
+ with open(path, encoding="utf-8", errors="replace") as f:
36
+ for line in f:
37
+ if '"ai-title"' not in line and '"summary"' not in line and (
38
+ first_user or '"user"' not in line
39
+ ):
40
+ continue
41
+ try:
42
+ obj = json.loads(line)
43
+ except json.JSONDecodeError:
44
+ continue
45
+ if obj.get("type") == "ai-title" and obj.get("aiTitle"):
46
+ title = obj["aiTitle"]
47
+ elif obj.get("type") == "summary" and obj.get("summary"):
48
+ title = obj["summary"]
49
+ elif not first_user and obj.get("type") == "user":
50
+ content = (obj.get("message") or {}).get("content")
51
+ if isinstance(content, str) and content.strip():
52
+ first_user = content.strip().splitlines()[0][:80]
53
+ except OSError:
54
+ pass
55
+ return title or first_user or path.stem
56
+
57
+
58
+ def find_sessions(project: str | None = None, limit: int = 20) -> list[SessionInfo]:
59
+ """Claude Code sessions, newest first."""
60
+ root = _claude_dir() / "projects"
61
+ if not root.is_dir():
62
+ return []
63
+ paths = [
64
+ p
65
+ for p in root.glob("*/*.jsonl")
66
+ if not project or project in p.parent.name
67
+ ]
68
+ paths.sort(key=lambda p: p.stat().st_mtime, reverse=True)
69
+ out = []
70
+ for p in paths[:limit]:
71
+ st = p.stat()
72
+ out.append(
73
+ SessionInfo(
74
+ path=p,
75
+ project=p.parent.name.lstrip("-").replace("-", "/"),
76
+ title=session_title(p),
77
+ mtime=st.st_mtime,
78
+ size=st.st_size,
79
+ )
80
+ )
81
+ return out
82
+
83
+
84
+ def find_chat_exports() -> list[Path]:
85
+ """conversations.json files (Claude.ai / ChatGPT data exports) in ~/Downloads."""
86
+ downloads = Path("~/Downloads").expanduser()
87
+ if not downloads.is_dir():
88
+ return []
89
+ hits = list(downloads.glob("conversations.json"))
90
+ hits += [p for p in downloads.glob("*/conversations.json")]
91
+ return sorted(hits, key=lambda p: p.stat().st_mtime, reverse=True)
@@ -0,0 +1,169 @@
1
+ """``sedona`` — upload AI-chat / terminal transcripts into company context.
2
+
3
+ Commands:
4
+ sedona auth authenticate via email OTP
5
+ sedona list show recent Claude Code sessions + exports
6
+ sedona send --recent N upload the N most recent sessions
7
+ sedona send <path> [<path>...] upload specific files
8
+ cmd | sedona send --stdin upload raw terminal output
9
+ sedona init-skill install the local Claude Code skill
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import httpx
20
+
21
+ from sedona_cli import config
22
+ from sedona_cli.auth import run_auth
23
+ from sedona_cli.discover import find_chat_exports, find_sessions
24
+ from sedona_cli.redact import redact_secrets
25
+
26
+ _MAX_UPLOAD_BYTES = 25 * 1024 * 1024
27
+
28
+
29
+ def _age(mtime: float) -> str:
30
+ mins = max(0, int((time.time() - mtime) / 60))
31
+ if mins < 60:
32
+ return f"{mins}m ago"
33
+ if mins < 60 * 24:
34
+ return f"{mins // 60}h ago"
35
+ return f"{mins // (60 * 24)}d ago"
36
+
37
+
38
+ def cmd_list(args) -> int:
39
+ sessions = find_sessions(project=args.project)
40
+ if sessions:
41
+ print("Recent Claude Code sessions:")
42
+ for s in sessions:
43
+ print(f" {_age(s.mtime):>8} {s.size // 1024:>6} KB [{s.project}] {s.title}")
44
+ else:
45
+ print("No Claude Code sessions found under ~/.claude/projects.")
46
+ exports = find_chat_exports()
47
+ if exports:
48
+ print("\nChat exports in ~/Downloads:")
49
+ for p in exports:
50
+ print(f" {p}")
51
+ print("\nUpload with: sedona send --recent 1 or sedona send <path>")
52
+ return 0
53
+
54
+
55
+ def _upload(client: httpx.Client, base: str, token: str, path: Path | None, content: str) -> bool:
56
+ name = path.name if path else "terminal-stdin.txt"
57
+ clean, redactions = redact_secrets(content)
58
+ if len(clean.encode()) > _MAX_UPLOAD_BYTES:
59
+ print(f" ✗ {name}: exceeds 25 MB, skipping")
60
+ return False
61
+ resp = client.post(
62
+ f"{base}/transcripts/upload",
63
+ json={"filename": name, "content": clean},
64
+ headers={"X-Transcript-Token": token},
65
+ timeout=300,
66
+ )
67
+ if resp.status_code != 200:
68
+ try:
69
+ detail = resp.json().get("detail", resp.text)
70
+ except ValueError:
71
+ detail = resp.text
72
+ print(f" ✗ {name}: {resp.status_code} {detail}")
73
+ return False
74
+ body = resp.json()
75
+ if body.get("status") == "queued":
76
+ print(
77
+ f" ⧖ {name}: {body['conversations']} conversations queued "
78
+ f"(ingesting {body['ingesting']}, skipped {body['skipped']}), redactions={redactions}"
79
+ )
80
+ return True
81
+ for doc in body.get("documents", []):
82
+ print(
83
+ f" ✓ {doc['title']} [{doc['format']}, visibility={doc['visibility']}, "
84
+ f"chunks={doc['chunks']}, v{doc['version']}]"
85
+ )
86
+ if redactions or body.get("redactions"):
87
+ print(f" redacted locally={redactions}, server-side={body.get('redactions', 0)}")
88
+ return True
89
+
90
+
91
+ def cmd_send(args) -> int:
92
+ token = config.token()
93
+ if not token:
94
+ print("Not authenticated — run `sedona auth` first.")
95
+ return 1
96
+ base = config.base_url()
97
+
98
+ targets: list[Path] = [Path(p).expanduser() for p in args.paths]
99
+ if args.recent:
100
+ sessions = find_sessions(project=args.project, limit=args.recent)
101
+ if not sessions:
102
+ print("No sessions found to send.")
103
+ return 1
104
+ targets += [s.path for s in sessions]
105
+
106
+ ok = True
107
+ with httpx.Client(timeout=300) as client:
108
+ if args.stdin:
109
+ content = sys.stdin.read()
110
+ if content.strip():
111
+ ok &= _upload(client, base, token, None, content)
112
+ else:
113
+ print("Nothing on stdin.")
114
+ ok = False
115
+ for path in targets:
116
+ if not path.is_file():
117
+ print(f" ✗ {path}: not a file")
118
+ ok = False
119
+ continue
120
+ ok &= _upload(client, base, token, path, path.read_text(errors="replace"))
121
+
122
+ if not targets and not args.stdin:
123
+ print("Nothing to send — pass paths, --recent N, or --stdin.")
124
+ return 1
125
+ return 0 if ok else 1
126
+
127
+
128
+ def cmd_init_skill(args) -> int:
129
+ template = Path(__file__).parent / "skill_template.md"
130
+ dest = Path("~/.claude/skills/sedona-upload/SKILL.md").expanduser()
131
+ dest.parent.mkdir(parents=True, exist_ok=True)
132
+ dest.write_text(template.read_text())
133
+ print(f"Installed skill → {dest}")
134
+ print('Your local Claude Code can now act on "share this session with Sedona".')
135
+ return 0
136
+
137
+
138
+ def main(argv: list[str] | None = None) -> int:
139
+ parser = argparse.ArgumentParser(prog="sedona", description=__doc__.split("\n")[0])
140
+ sub = parser.add_subparsers(dest="command", required=True)
141
+
142
+ p_auth = sub.add_parser("auth", help="authenticate via email OTP")
143
+ p_auth.add_argument("--url", help="Sedona base URL (default: saved or SEDONA_URL)")
144
+
145
+ p_list = sub.add_parser("list", help="show recent sessions and chat exports")
146
+ p_list.add_argument("--project", help="filter sessions by project slug substring")
147
+
148
+ p_send = sub.add_parser("send", help="scrub and upload transcripts")
149
+ p_send.add_argument("paths", nargs="*", help="transcript files to upload")
150
+ p_send.add_argument("--recent", type=int, metavar="N", help="send the N most recent sessions")
151
+ p_send.add_argument("--project", help="with --recent: filter by project slug substring")
152
+ p_send.add_argument("--stdin", action="store_true", help="read a terminal capture from stdin")
153
+
154
+ sub.add_parser("init-skill", help="install the Claude Code skill into ~/.claude/skills")
155
+
156
+ args = parser.parse_args(argv)
157
+ if args.command == "auth":
158
+ return run_auth(args.url)
159
+ if args.command == "list":
160
+ return cmd_list(args)
161
+ if args.command == "send":
162
+ return cmd_send(args)
163
+ if args.command == "init-skill":
164
+ return cmd_init_skill(args)
165
+ return 2
166
+
167
+
168
+ if __name__ == "__main__":
169
+ raise SystemExit(main())
@@ -0,0 +1,92 @@
1
+ """Secret scrubbing for transcripts — the single source of truth.
2
+
3
+ Used client-side by ``sedona send`` (secrets never leave the laptop) and
4
+ server-side by the transcript ingest path as a backstop (re-exported as
5
+ ``src.lib.redact``). Stdlib-only by design so the CLI stays dependency-light.
6
+
7
+ Redaction is fail-closed: a false positive loses one token from a transcript,
8
+ a false negative puts a credential in the company graph. The entropy guard
9
+ exists only to keep *hex* artifacts (git SHAs, UUIDs, document hashes) alive —
10
+ hex tops out at 4 bits/char, random base64 sits well above it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import re
17
+
18
+ __all__ = ["redact_secrets"]
19
+
20
+ _MASK = "[REDACTED:{kind}]"
21
+
22
+ # Ordered: specific vendor formats before generic patterns, so the mask kind
23
+ # stays informative and the generic passes never see already-masked text.
24
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
25
+ (
26
+ "pem",
27
+ re.compile(
28
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
29
+ re.DOTALL,
30
+ ),
31
+ ),
32
+ ("aws-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
33
+ ("anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}")),
34
+ ("api-key", re.compile(r"\bsk-[A-Za-z0-9_-]{32,}")),
35
+ ("github", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})")),
36
+ ("slack", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}")),
37
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}")),
38
+ ("bearer", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{20,}")),
39
+ ]
40
+
41
+ # `KEY=value` / `key: value` assignments — keep the key name, mask the value.
42
+ _ASSIGNMENT = re.compile(
43
+ r"(?i)\b([A-Za-z0-9_-]*(?:api[_-]?key|secret|token|password|passwd|credential)s?)"
44
+ r"(\s*[=:]\s*)(['\"]?)([^\s'\"\[\]]{16,})\3"
45
+ )
46
+
47
+ # Bare high-entropy tokens (base64-ish, 32+ chars). Requires at least one
48
+ # non-hex character so hex artifacts can never match regardless of entropy.
49
+ _TOKEN = re.compile(r"\b(?=[A-Za-z0-9+/_-]*[G-Zg-z+/_-])[A-Za-z0-9+/_-]{32,}\b")
50
+
51
+ _ENTROPY_THRESHOLD = 4.0
52
+
53
+
54
+ def _shannon_entropy(s: str) -> float:
55
+ counts: dict[str, int] = {}
56
+ for ch in s:
57
+ counts[ch] = counts.get(ch, 0) + 1
58
+ n = len(s)
59
+ return -sum(c / n * math.log2(c / n) for c in counts.values())
60
+
61
+
62
+ def redact_secrets(text: str) -> tuple[str, int]:
63
+ """Mask credentials in ``text``. Returns ``(clean_text, num_redactions)``."""
64
+ count = 0
65
+
66
+ def _sub(pattern: re.Pattern[str], repl, s: str) -> str:
67
+ nonlocal count
68
+ out, n = pattern.subn(repl, s)
69
+ count += n
70
+ return out
71
+
72
+ for kind, pattern in _PATTERNS:
73
+ text = _sub(pattern, _MASK.format(kind=kind), text)
74
+
75
+ text = _sub(
76
+ _ASSIGNMENT,
77
+ lambda m: f"{m.group(1)}{m.group(2)}{_MASK.format(kind='value')}",
78
+ text,
79
+ )
80
+
81
+ def _entropy_repl(m: re.Match[str]) -> str:
82
+ token = m.group(0)
83
+ if re.fullmatch(r"[0-9a-fA-F-]+", token): # UUIDs, SHAs, doc hashes
84
+ return token
85
+ if "REDACTED" in token or _shannon_entropy(token) <= _ENTROPY_THRESHOLD:
86
+ return token
87
+ nonlocal count
88
+ count += 1
89
+ return _MASK.format(kind="token")
90
+
91
+ out, _ = _TOKEN.subn(_entropy_repl, text)
92
+ return out, count
@@ -0,0 +1,29 @@
1
+ # Share this session with Sedona
2
+
3
+ Use this skill when the user asks to "share this session with Sedona", "upload
4
+ this conversation to company context", "send this thread to Sedona", or similar.
5
+
6
+ Sedona is the company's knowledge agent. Uploading a session makes its content
7
+ searchable company context (secrets are scrubbed locally before upload, and the
8
+ server classifies visibility — sensitive sessions are restricted automatically).
9
+
10
+ ## Steps
11
+
12
+ 1. Check the CLI is set up: `sedona list` should print recent sessions. If the
13
+ command is missing, install it: `uv tool install sedona-cli`. If it fails
14
+ with a missing-token error, run `sedona auth` first (interactive — ask the
15
+ user to run it themselves in a terminal).
16
+ 2. To upload the most recent session(s) of this project:
17
+ `sedona send --recent 1` (or `--recent N` for the last N).
18
+ To upload a specific session file: `sedona send <path>`.
19
+ For raw terminal scrollback: pipe it — `history | sedona send --stdin`.
20
+ 3. Report the result to the user: each uploaded document's title, visibility
21
+ tier, and redaction count are printed by the CLI.
22
+
23
+ ## Notes
24
+
25
+ - The currently active session's file is still being written; prefer uploading
26
+ after the work wraps up, or warn the user the upload is a snapshot.
27
+ - Claude.ai / ChatGPT exports (`conversations.json`) can also be sent:
28
+ `sedona send ~/Downloads/conversations.json`. Large exports are capped
29
+ server-side (~25 conversations per file); suggest the user trim if needed.