Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2777,6 +2777,36 @@ def fail(e):
return


def cmd_env(args):
"""Manage secrets/settings in the global .env."""
from services.env_settings import run_env_action

accent = "\033[38;2;212;135;74m"
green = "\033[38;2;74;222;128m"
gray = "\033[38;5;245m"
reset = "\033[0m"
action = getattr(args, "env_action", None) or "list"
try:
if action == "set":
run_env_action("set", args.key, args.value)
print(f" {green}✓{reset} {args.key} set")
elif action == "unset":
run_env_action("unset", args.key)
print(f" {green}✓{reset} {args.key} removed")
else:
data = run_env_action("list")
print(f"\n {gray}Settings ({data['path']}){reset}\n")
for s in data["settings"]:
mark = f"{green}set{reset}" if s["set"] else f"{gray}not set{reset}"
val = f" {gray}{s['preview']}{reset}" if s["set"] else ""
print(f" {accent}{s['key']}{reset} — {s['label']} [{mark}]{val}")
print(f" {gray}{s['help']}{reset}")
print(f" {gray}{s['url']}{reset}\n")
except ValueError as e:
print(f" ✗ {e}", file=sys.stderr)
sys.exit(1)


def cmd_config(args):
"""Export, import, and activate config profiles."""
from config_bundle import run_config_action
Expand Down Expand Up @@ -3334,6 +3364,16 @@ def main():
cfg_use = cfg_sub.add_parser("use", help="Activate a config root for future runs")
cfg_use.add_argument("home", help="Path to the config root to activate")

# ── env (secrets / settings) ──
env_p = sub.add_parser("env", help="Manage secrets/settings stored in .env (e.g. HF_TOKEN)")
env_sub = env_p.add_subparsers(dest="env_action")
env_sub.add_parser("list", help="Show known settings and whether they're set")
env_set = env_sub.add_parser("set", help="Set a setting")
env_set.add_argument("key", help="Setting key, e.g. HF_TOKEN")
env_set.add_argument("value", help="Value")
env_unset = env_sub.add_parser("unset", help="Remove a setting")
env_unset.add_argument("key", help="Setting key, e.g. HF_TOKEN")

# ── cache ──
cache_p = sub.add_parser("cache", help="Manage transcription cache")
cache_sub = cache_p.add_subparsers(dest="cache_action")
Expand Down Expand Up @@ -3387,6 +3427,8 @@ def main():
cmd_youtube(args)
elif args.command == "config":
cmd_config(args)
elif args.command == "env":
cmd_env(args)
elif args.command == "cache":
cmd_cache(args)
elif args.command == "info":
Expand Down
13 changes: 13 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,18 @@ def handle_suggest_clips(task_id: str, params: dict):
emit_result(task_id, "success", data={"clips": clips})


def handle_manage_env(task_id: str, params: dict):
"""List/set/unset user secrets in the global .env (e.g. HF_TOKEN)."""
from services.env_settings import run_env_action

try:
data = run_env_action(params.get("action", "list"), params.get("key"), params.get("value"))
except ValueError as e:
emit_result(task_id, "error", error=str(e))
return
emit_result(task_id, "success", data=data)


def handle_find_moment(task_id: str, params: dict):
"""Locate user-pasted/described moments in the transcript via the AI CLI."""
from services.claude_suggest import find_moments_from_text
Expand Down Expand Up @@ -512,6 +524,7 @@ def handle_run_integration_tool(task_id: str, params: dict):
"corrections": handle_corrections,
"suggest_clips": handle_suggest_clips,
"find_moment": handle_find_moment,
"manage_env": handle_manage_env,
"generate_content": handle_generate_content,
"manage_integrations": handle_manage_integrations,
"run_integration_tool": handle_run_integration_tool,
Expand Down
136 changes: 136 additions & 0 deletions backend/services/env_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Manage user secrets/settings stored in the global .env (PODCLI_ENV_FILE),
the file dotenv loads at startup. Currently just HF_TOKEN; the registry keeps it
easy to add more."""

from __future__ import annotations

import os
from typing import Any, Optional

SETTINGS = [
{
"key": "HF_TOKEN",
"label": "HuggingFace token",
"help": "Enables speaker detection (pyannote), which makes face tracking "
"speaker-aware. Create a token and accept the model terms for "
"pyannote/speaker-diarization-3.1, segmentation-3.0, and "
"speaker-diarization-community-1.",
"url": "https://huggingface.co/settings/tokens",
"secret": True,
},
]

_KEYS = {s["key"] for s in SETTINGS}


def _env_path() -> str:
return os.environ.get("PODCLI_ENV_FILE") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "..", ".env"
)


def _read_pairs() -> dict[str, str]:
path = _env_path()
pairs: dict[str, str] = {}
if not os.path.exists(path):
return pairs
with open(path, encoding="utf-8") as f:
for line in f:
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
k, v = s.split("=", 1)
pairs[k.strip()] = v.strip()
return pairs


def _mask(value: str) -> str:
if not value:
return ""
return value[:3] + "…" + value[-4:] if len(value) > 10 else "•" * len(value)


def list_settings() -> list[dict[str, Any]]:
pairs = _read_pairs()
out = []
for s in SETTINGS:
raw = pairs.get(s["key"], "")
out.append({
"key": s["key"],
"label": s["label"],
"help": s["help"],
"url": s["url"],
"secret": s["secret"],
"set": bool(raw),
"preview": _mask(raw) if s["secret"] else raw,
})
return out


def _write_pairs(pairs: dict[str, str]) -> None:
"""Rewrite the .env preserving comments/unknown lines; upsert known keys in
place, append new ones. Atomic via temp+rename; mode 0600 (holds secrets)."""
path = os.path.abspath(_env_path())
os.makedirs(os.path.dirname(path), exist_ok=True)
existing_lines = []
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
existing_lines = f.read().splitlines()

seen = set()
out_lines = []
for line in existing_lines:
s = line.strip()
if s and not s.startswith("#") and "=" in s:
k = s.split("=", 1)[0].strip()
if k in pairs:
seen.add(k)
if pairs[k] is None:
continue # unset: drop the line
out_lines.append(f"{k}={pairs[k]}")
continue
out_lines.append(line)
for k, v in pairs.items():
if k not in seen and v is not None:
out_lines.append(f"{k}={v}")

tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write("\n".join(out_lines).rstrip("\n") + "\n")
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass


def set_setting(key: str, value: str) -> None:
if key not in _KEYS:
raise ValueError(f"unknown setting {key!r} (known: {', '.join(sorted(_KEYS))})")
value = (value or "").strip()
if not value:
raise ValueError("value is empty")
_write_pairs({key: value})


def unset_setting(key: str) -> None:
if key not in _KEYS:
raise ValueError(f"unknown setting {key!r} (known: {', '.join(sorted(_KEYS))})")
_write_pairs({key: None})


def run_env_action(action: str, key: Optional[str] = None, value: Optional[str] = None) -> dict[str, Any]:
act = (action or "list").strip().lower()
if act == "list":
return {"settings": list_settings(), "path": os.path.abspath(_env_path())}
if act == "set":
if not key:
raise ValueError("key is required")
set_setting(key, value or "")
return {"ok": True, "key": key}
if act == "unset":
if not key:
raise ValueError("key is required")
unset_setting(key)
return {"ok": True, "key": key}
raise ValueError(f"unknown env action: {action}")
3 changes: 2 additions & 1 deletion backend/services/speaker_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ def run_diarization(
" → https://huggingface.co/pyannote/speaker-diarization-3.1\n"
" → https://huggingface.co/pyannote/segmentation-3.0\n"
" → https://huggingface.co/pyannote/speaker-diarization-community-1\n"
" 3. Add to your .env file: HF_TOKEN=hf_your_token_here"
" 3. Set it: podcli env set HF_TOKEN hf_your_token_here\n"
" (or in the Web UI: Config → Secrets)"
)
if progress_callback:
progress_callback(0, msg)
Expand Down
2 changes: 1 addition & 1 deletion src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

export interface TaskRequest {
task_id: string;
task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config";
task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env";
params: Record<string, unknown>;
}

Expand Down
63 changes: 63 additions & 0 deletions src/ui/client/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,43 @@ export default function ConfigPage() {
const [activate, setActivate] = useState(false);
const [importing, setImporting] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [settings, setSettings] = useState<any[]>([]);
const [secretInputs, setSecretInputs] = useState<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);

async function loadStatus() {
setStatus(await api("/config/status"));
setStatusError(null);
}

async function loadSettings() {
try {
const data = await api<any>("/settings");
setSettings(data.settings || []);
} catch { /* settings are optional */ }
}

useEffect(() => {
loadStatus().catch((e) => setStatusError(e.message));
loadSettings();
}, []);

async function saveSetting(key: string) {
setSavingKey(key);
setMsg(null);
try {
await api("/settings", { method: "POST", body: JSON.stringify({ key, value: secretInputs[key] ?? "" }) });
setSecretInputs((p) => ({ ...p, [key]: "" }));
setMsg({ text: `${key} saved.`, ok: true });
await loadSettings();
} catch (e: any) {
setMsg({ text: e.message, ok: false });
} finally {
setSavingKey(null);
}
}

async function onMigrate() {
setMsg({ text: "Migrating…", ok: true });
try {
Expand Down Expand Up @@ -83,6 +109,43 @@ export default function ConfigPage() {
)}
</div>

{settings.length > 0 && (
<div className="section">
<div className="section-label">Secrets</div>
{settings.map((s) => (
<div key={s.key} style={{ marginBottom: 16 }}>
<label className="field-label">
{s.label}{" "}
<span style={{ color: s.set ? "var(--green)" : "var(--text3)", fontSize: 11 }}>
{s.set ? `set · ${s.preview}` : "not set"}
</span>
</label>
<div className="set-file">
<input
type="password"
placeholder={s.set ? "Enter a new value to replace" : "e.g. hf_…"}
value={secretInputs[s.key] ?? ""}
onChange={(e) => setSecretInputs((p) => ({ ...p, [s.key]: e.target.value }))}
style={{ fontSize: 13, flex: 1 }}
/>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => saveSetting(s.key)}
disabled={savingKey === s.key || !(secretInputs[s.key] ?? "").trim()}
>
{savingKey === s.key ? "Saving…" : "Save"}
</button>
</div>
<div style={{ fontSize: 12, color: "var(--text2)", marginTop: 6 }}>
{s.help}{" "}
<a href={s.url} target="_blank" rel="noopener" style={{ color: "var(--accent)" }}>Get token →</a>
</div>
</div>
))}
</div>
)}

<div className="section">
<div className="section-label">Actions</div>
<div className="set-actions">
Expand Down
27 changes: 27 additions & 0 deletions src/ui/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,33 @@ app.post("/api/clips/:id/thumbnail/select", async (req, res) => {
res.json({ ok: true, preview_path: pick });
});

// --- Secrets/settings stored in the global .env (e.g. HF_TOKEN) ---

app.get("/api/settings", async (_req, res) => {
try {
const result = await executor.execute<{ settings?: unknown[] }>("manage_env", { action: "list" });
res.json(result.data ?? { settings: [] });
} catch (err: unknown) {
res.status(500).json({ error: errMsg(err) });
}
});

app.post("/api/settings", async (req, res) => {
const key = typeof req.body?.key === "string" ? req.body.key : "";
const value = typeof req.body?.value === "string" ? req.body.value : "";
if (!key) {
res.status(400).json({ error: "key is required" });
return;
}
try {
const action = value.trim() ? "set" : "unset";
const result = await executor.execute("manage_env", { action, key, value });
res.json(result.data ?? { ok: true });
} catch (err: unknown) {
res.status(500).json({ error: errMsg(err) });
}
});

app.get("/api/youtube/config", (_req, res) => {
try {
const all = JSON.parse(readFileSync(paths.integrations, "utf-8"));
Expand Down
Loading
Loading