sedona-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sedona_cli/__init__.py +12 -0
- sedona_cli/auth.py +41 -0
- sedona_cli/config.py +34 -0
- sedona_cli/discover.py +91 -0
- sedona_cli/main.py +169 -0
- sedona_cli/redact.py +92 -0
- sedona_cli/skill_template.md +29 -0
- sedona_cli-0.1.0.dist-info/METADATA +73 -0
- sedona_cli-0.1.0.dist-info/RECORD +12 -0
- sedona_cli-0.1.0.dist-info/WHEEL +4 -0
- sedona_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sedona_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
sedona_cli/__init__.py
ADDED
|
@@ -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"
|
sedona_cli/auth.py
ADDED
|
@@ -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
|
sedona_cli/config.py
ADDED
|
@@ -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")
|
sedona_cli/discover.py
ADDED
|
@@ -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)
|
sedona_cli/main.py
ADDED
|
@@ -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())
|
sedona_cli/redact.py
ADDED
|
@@ -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.
|
|
@@ -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,12 @@
|
|
|
1
|
+
sedona_cli/__init__.py,sha256=7xbqYJS0CEjCJAA0KBg2yGI8MC5F6z17yaOR4u3v5bU,417
|
|
2
|
+
sedona_cli/auth.py,sha256=WKsMgRRqQwNdwSYPZrpSMrE0KKBOIGV53cThGhixNjk,1464
|
|
3
|
+
sedona_cli/config.py,sha256=hx2S2GnEthJQeQxK_I2lV-1rEIbbupuN3ttjqAyqtpM,937
|
|
4
|
+
sedona_cli/discover.py,sha256=o2L2bS4UcPcuEWo70NUU-yQcSk9fLoe609LmEur4gEg,3008
|
|
5
|
+
sedona_cli/main.py,sha256=mJd7xQSl-9sRMBuA_BXL6w7G1h5N9g_02spridFjfxU,6100
|
|
6
|
+
sedona_cli/redact.py,sha256=YT6JvRk7sV8YqTwGaSf143CLpEhSQXxdAAhQblvH7rU,3272
|
|
7
|
+
sedona_cli/skill_template.md,sha256=fM1e_D5lNfIlVThWIVCbIjSJCUkvU9Tq9P6H4V1xcgM,1496
|
|
8
|
+
sedona_cli-0.1.0.dist-info/METADATA,sha256=Q5LYXBgnuXixVmpE1SzMGfXG-bKZ1xvtXOKHTH3vBAY,2993
|
|
9
|
+
sedona_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
sedona_cli-0.1.0.dist-info/entry_points.txt,sha256=-anWIsV_zWUpR59OrOtV1SMNARE5jRzQxv9N1t9NvMs,48
|
|
11
|
+
sedona_cli-0.1.0.dist-info/licenses/LICENSE,sha256=eY6CBcKZC67XVZXae1ZIJPhVJdI7gFlpkS8twRCDN1Q,1070
|
|
12
|
+
sedona_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|