diff --git a/AGENTS.md b/AGENTS.md index 5b2bf3c..c3e5093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,10 +48,14 @@ Capture what matters. Decisions, context, things to remember. Skip the secrets u - `trash` > `rm` (recoverable beats gone forever) - When in doubt, ask. -### 🔑 Credentials -- **Never read credential files.** Not even to "verify" or "check" them. +### 🔑 Credentials — HARD RULES +- **NEVER read credential files.** Not with `cat`, `read`, `exec`, `node -e`, or ANY tool. Not even to "debug", "verify", "check format", or "count lines". NO EXCEPTIONS. +- **NEVER use tools that would display file contents** on any file in `.credentials/`. This includes `grep`, `head`, `tail`, `cat -A`, `wc`, or any command that could leak values in output. +- **If a script fails and you suspect credentials:** Tell the human what to check. Do NOT look yourself. +- **If you need to know what keys exist:** You wrote the placeholder file — check git history or TOOLS.md, not the live file. - When setting up a new integration, create `.credentials/service.env` with **placeholder values** and let the human fill them in. - Scripts source credentials at runtime — you don't need to see them. +- **Violation of these rules is a serious breach of trust.** No excuse is valid. - Example placeholder file: ``` SERVICE_URL=https://example.com diff --git a/TOOLS.md b/TOOLS.md index ffc3932..746276f 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -79,7 +79,7 @@ audiobooks stats # Listening stats overview audiobooks libraries # List libraries ``` -- Credentials: `.credentials/audiobookshelf.env` +- Credentials: `.credentials/services.env` - URL: `https://audiobooks.cloonar.com` - Output is tab-separated for minimal tokens - Use during wind-down to suggest continuing audiobook @@ -101,7 +101,7 @@ jellyfin stats # Watch statistics overview jellyfin libraries # List libraries ``` -- Credentials: `.credentials/jellyfin.env` (user-scoped token for `tv`) +- Credentials: `.credentials/services.env` (user-scoped token for `tv`) - URL: `https://jellyfin.cloonar.com` - Output is tab-separated for minimal tokens - Use during wind-down to suggest specific shows/movies @@ -126,7 +126,7 @@ forgejo raw # Raw API call - URL: `https://git.cloonar.com` - User: `openclawd` (read-only) -- Token stored in `.credentials/forgejo.env` +- Credentials: `.credentials/services.env` ## CalDAV Calendar Access diff --git a/bin/ainews b/bin/ainews index 7e388c0..b68f12b 100755 --- a/bin/ainews +++ b/bin/ainews @@ -2,7 +2,9 @@ # AI News RSS helper - aggregates multiple AI-focused feeds set -e -FIVEFILTERS_URL="https://fivefilters.cloonar.com" +CRED_FILE="${CRED_FILE:-$(dirname "$0")/../.credentials/services.env}" +source "$CRED_FILE" + SEEN_FILE="${AINEWS_SEEN_FILE:-$HOME/clawd/memory/ainews-seen.txt}" CURL="curl -skL" diff --git a/bin/audiobooks b/bin/audiobooks index a46c99c..1783129 100755 --- a/bin/audiobooks +++ b/bin/audiobooks @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const https = require('https'); -const CRED_PATH = path.join(__dirname, '..', '.credentials', 'audiobookshelf.env'); +const CRED_PATH = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env'); function loadCreds() { const raw = fs.readFileSync(CRED_PATH, 'utf8'); diff --git a/bin/derstandard b/bin/derstandard index a8c836d..f8dcd65 100755 --- a/bin/derstandard +++ b/bin/derstandard @@ -2,7 +2,9 @@ # Der Standard RSS helper - fetches via internal fivefilters set -e -FIVEFILTERS_URL="https://fivefilters.cloonar.com" +CRED_FILE="${CRED_FILE:-$(dirname "$0")/../.credentials/services.env}" +source "$CRED_FILE" + RSS_SOURCE="https://www.derstandard.at/rss" SEEN_FILE="${DERSTANDARD_SEEN_FILE:-$HOME/clawd/memory/derstandard-seen.txt}" diff --git a/bin/forgejo b/bin/forgejo index 31b2e7d..5516fde 100755 --- a/bin/forgejo +++ b/bin/forgejo @@ -2,10 +2,10 @@ # Forgejo CLI helper - token-efficient output set -e -FORGEJO_URL="https://git.cloonar.com" -TOKEN="03e35afac87cd3d8db7d4a186714dacf584c01f7" +CRED_FILE="${CRED_FILE:-$(dirname "$0")/../.credentials/services.env}" +source "$CRED_FILE" -CURL="curl -sk -H \"Authorization: token ${TOKEN}\"" +CURL="curl -sk -H \"Authorization: token ${FORGEJO_TOKEN}\"" usage() { cat < { const [k, ...v] = l.split('='); if (k && v.length) env[k.trim()] = v.join('=').trim(); }); +envFile.split('\n').forEach(l => { const m = l.match(/^(\w+)=(.+)$/); if (m) env[m[1]] = m[2]; }); const BASE = env.JELLYFIN_URL; -const API_KEY = env.JELLYFIN_API_KEY; -const USER_ID = env.JELLYFIN_USER_ID; +const TOKEN_CACHE = path.join(__dirname, '..', '.credentials', '.jellyfin-token.json'); -function api(endpoint) { - const sep = endpoint.includes('?') ? '&' : '?'; - const url = `${BASE}${endpoint}${sep}api_key=${API_KEY}`; +function request(method, endpoint, body) { return new Promise((resolve, reject) => { - https.get(url, res => { + const sep = endpoint.includes('?') ? '&' : '?'; + const u = new URL(`${BASE}${endpoint}`); + const opts = { + hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, + method, headers: {} + }; + if (body) { + const data = JSON.stringify(body); + opts.headers['Content-Type'] = 'application/json'; + opts.headers['Content-Length'] = Buffer.byteLength(data); + } + const req = https.request(opts, res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + try { resolve({ status: res.statusCode, data: JSON.parse(d) }); } + catch { resolve({ status: res.statusCode, data: d }); } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function authenticate() { + // Check cache first + try { + const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf8')); + if (cached.token && cached.userId) { + // Verify token still works + const test = await apiWith(cached.token, cached.userId, `/Users/${cached.userId}`); + if (test) return cached; + } + } catch {} + + // Authenticate + const res = await request('POST', '/Users/AuthenticateByName', { + Username: env.JELLYFIN_USER, + Pw: env.JELLYFIN_PASS + }); + + // Add auth header for the request + const authRes = await new Promise((resolve, reject) => { + const body = JSON.stringify({ Username: env.JELLYFIN_USER, Pw: env.JELLYFIN_PASS }); + const u = new URL(`${BASE}/Users/AuthenticateByName`); + const req = https.request({ + hostname: u.hostname, port: u.port || 443, path: u.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'X-Emby-Authorization': 'MediaBrowser Client="Hoid", Device="CLI", DeviceId="hoid-cli-1", Version="1.0"' + } + }, res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => { + try { resolve({ status: res.statusCode, data: JSON.parse(d) }); } + catch { resolve({ status: res.statusCode, data: d }); } + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); + + if (authRes.status !== 200 || !authRes.data.AccessToken) { + console.error(`Auth failed (${authRes.status}):`, typeof authRes.data === 'string' ? authRes.data : JSON.stringify(authRes.data).slice(0, 200)); + process.exit(1); + } + + const result = { token: authRes.data.AccessToken, userId: authRes.data.User.Id }; + fs.writeFileSync(TOKEN_CACHE, JSON.stringify(result)); + return result; +} + +function apiWith(token, userId, endpoint) { + const sep = endpoint.includes('?') ? '&' : '?'; + const url = `${BASE}${endpoint}`; + return new Promise((resolve, reject) => { + const u = new URL(url); + https.get({ + hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, + headers: { 'X-Emby-Token': token } + }, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => { + if (res.statusCode === 401) return resolve(null); try { resolve(JSON.parse(data)); } catch { resolve(data); } }); }).on('error', reject); }); } -function getUserId() { return USER_ID; } +let _auth; +async function api(endpoint) { + if (!_auth) _auth = await authenticate(); + const result = await apiWith(_auth.token, _auth.userId, endpoint); + if (result === null) { + // Token expired, re-auth + try { fs.unlinkSync(TOKEN_CACHE); } catch {} + _auth = await authenticate(); + return apiWith(_auth.token, _auth.userId, endpoint); + } + return result; +} + +async function getUserId() { + if (!_auth) _auth = await authenticate(); + return _auth.userId; +} function dur(ticks) { if (!ticks) return ''; @@ -48,11 +148,10 @@ const commands = { async resume(limit = 10) { const uid = await getUserId(); const r = await api(`/Users/${uid}/Items/Resume?Limit=${limit}&Fields=Overview,RunTimeTicks&MediaTypes=Video`); - if (!r.Items.length) return console.log('Nothing in progress'); + if (!r.Items || !r.Items.length) return console.log('Nothing in progress'); console.log('TITLE\tEPISODE\tPROGRESS\tDURATION'); r.Items.forEach(i => { const show = i.SeriesName || ''; - const name = show ? `${i.Name}` : i.Name; console.log([show || i.Name, show ? i.Name : '', pct(i.UserData, i.RunTimeTicks), dur(i.RunTimeTicks)].join('\t')); }); }, @@ -60,7 +159,7 @@ const commands = { async recent(limit = 10) { const uid = await getUserId(); const r = await api(`/Users/${uid}/Items/Latest?Limit=${limit}&Fields=RunTimeTicks`); - if (!r.length) return console.log('No recent items'); + if (!r || !r.length) return console.log('No recent items'); console.log('TYPE\tTITLE\tNAME\tYEAR\tDURATION'); r.forEach(i => { console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || '', dur(i.RunTimeTicks)].join('\t')); @@ -70,7 +169,7 @@ const commands = { async watched(limit = 20) { const uid = await getUserId(); const r = await api(`/Users/${uid}/Items?Limit=${limit}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsPlayed&Recursive=true&IncludeItemTypes=Movie,Episode&Fields=RunTimeTicks,DateLastMediaAdded`); - if (!r.Items.length) return console.log('No watched items'); + if (!r.Items || !r.Items.length) return console.log('No watched items'); console.log('TYPE\tTITLE\tEPISODE\tYEAR'); r.Items.forEach(i => { console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t')); @@ -80,7 +179,7 @@ const commands = { async shows() { const uid = await getUserId(); const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Series&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`); - if (!r.Items.length) return console.log('No shows'); + if (!r.Items || !r.Items.length) return console.log('No shows'); console.log('TITLE\tYEAR\tSTATUS'); r.Items.forEach(i => { console.log([i.Name, i.ProductionYear || '', pct(i.UserData, i.RunTimeTicks) || 'unwatched'].join('\t')); @@ -90,7 +189,7 @@ const commands = { async movies() { const uid = await getUserId(); const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Movie&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`); - if (!r.Items.length) return console.log('No movies'); + if (!r.Items || !r.Items.length) return console.log('No movies'); console.log('TITLE\tYEAR\tDURATION\tSTATUS'); r.Items.forEach(i => { console.log([i.Name, i.ProductionYear || '', dur(i.RunTimeTicks), pct(i.UserData, i.RunTimeTicks) || '-'].join('\t')); @@ -101,7 +200,7 @@ const commands = { if (!query) { console.error('Usage: jellyfin search '); process.exit(1); } const uid = await getUserId(); const r = await api(`/Users/${uid}/Items?SearchTerm=${encodeURIComponent(query)}&Recursive=true&IncludeItemTypes=Movie,Series,Episode&Limit=10&Fields=RunTimeTicks`); - if (!r.Items.length) return console.log('No results'); + if (!r.Items || !r.Items.length) return console.log('No results'); console.log('TYPE\tTITLE\tNAME\tYEAR'); r.Items.forEach(i => { console.log([i.Type, i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t')); @@ -121,7 +220,7 @@ const commands = { }, async libraries() { - const uid = getUserId(); + const uid = await getUserId(); const r = await api(`/Users/${uid}/Views`); console.log('NAME\tTYPE\tID'); (r.Items || []).forEach(l => {