From a187b1d58aac33d6b93cabca0458596eb04e4cfd Mon Sep 17 00:00:00 2001 From: Dan Chagas Date: Sun, 13 Sep 2026 21:35:24 -0300 Subject: [PATCH 1/3] Add oc session ls|rm, PowerShell login docs, Windows test fix --- CHANGELOG.md | 13 +++++++++ README.md | 12 ++++++++- llms.txt | 2 +- skills/web-browsing-cli/SKILL.md | 15 +++++++++++ src/cli.js | 31 ++++++++++++++++++--- src/session.js | 37 ++++++++++++++++++++++++- tests/cli-auth.test.js | 3 ++- tests/cli.test.js | 46 +++++++++++++++++++++++++++++--- tests/distill.test.js | 3 ++- 9 files changed, 151 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe09e4f..8595b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Notable changes per release. Releases before 0.4.0 are listed at ## Unreleased +### Added + +- `oc session ls` lists saved sessions (name, url, title) and `oc session rm + [name]` forgets one — saved page plus cookies, the same promise `oc logout` + makes. State lives in `~/.only-cli` (`%USERPROFILE%\.only-cli` on Windows, + `OC_HOME` overrides), so agents can now inspect and drop it without guessing + paths. +- Login docs gained a PowerShell equivalent (`$h | oc login --cookie - ...`), + since Windows has no `printf`. +- The CLI test harness resolves the binary with `fileURLToPath`, so the suite + runs on Windows checkouts (`.pathname` breaks on drive-letter paths with + spaces). + ### Fixed - A feed entry's title is now the link to the entry, so `oc do ` on a post diff --git a/README.md b/README.md index 13e3190..4d736a3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ oc fill type into a numbered input (planned) oc submit [n] submit a form (planned) oc login seed cookies for a session (--cookie, --domain) oc logout [session] forget a session: cookies and saved page +oc session ls|rm [name] list saved sessions, or forget one (page + cookies) ``` Flags: `--budget ` (default 500), `--json`, `--html` (raw as cleaned HTML), `--session `, `--verbose`/`-v` (metrics on stderr, or export `OC_VERBOSE=1`). @@ -123,6 +124,15 @@ oc open https://example.com/dashboard --session work oc logout work ``` +Windows (PowerShell — no `printf` there): + +```powershell +$h = 'session=...; auth=...' # paste from browser devtools +$h | oc login --cookie - --domain example.com --expires 2h --session work +oc open https://example.com/dashboard --session work +oc logout work +``` + Prefer `--cookie -`, which reads the header from stdin. The flag also takes the header inline (`--cookie "session=..."`), but an argument is a live credential in `ps` for as long as `oc` runs and in your shell history afterwards. Copy the `Cookie` header from your browser's devtools (Application → Cookies, or the Network tab on a request); a leading `Cookie:` is stripped for you. `--domain` is the site hostname those cookies belong to, and it has to be a real hostname: a bare TLD like `com` is refused, because the match is a suffix match and those cookies would go to every `.com` host the session ever fetched. Cookie names and values are checked at login too, so a stray control character fails there rather than deep inside the HTTP client. @@ -131,7 +141,7 @@ Seeded cookies are https-only. They almost always come from an https browser ses Cookies live in a separate sidecar file (`.cookies.json`) under `~/.only-cli/sessions/`, mode `0600`, not in the page-state JSON and never in `--json` output. The default lifetime is one hour (`--expires 1h`), and a jar holds at most 50 cookies so a page cannot bloat it. When cookies expire or the site returns a login page, `oc` says so plainly (exit 2) instead of distilling the login form as content. -`oc logout` forgets the whole session, not just its cookies: a page saved under that name can hold the distilled text of something only the login could reach, so the snapshot goes with the jar. +`oc logout` forgets the whole session, not just its cookies: a page saved under that name can hold the distilled text of something only the login could reach, so the snapshot goes with the jar. `oc session rm [name]` forgets a session the same way (page plus cookies) without switching to it first, and `oc session ls` lists what is on disk. State lives in `~/.only-cli` (`%USERPROFILE%\.only-cli` on Windows, override with `OC_HOME`), one JSON per session plus search-index caches — delete the directory to start over. `oc open` remembers the page it rendered in a JSON file per session under `~/.only-cli` (override with `OC_HOME`), so `oc do 3` follows `[3]` without the agent ever handling a URL. A result title on a search page is a link, so `oc do` on it opens the result rather than repeating the title. Pages longer than the budget say what they left out; `oc find`, `oc read `, and `oc next` read the rest without refetching the page, and a `find` with a single match prints that region instead of the number to read it with. The budget is a target rather than a hard cap: a page that would only run a little long is printed whole rather than cut, since one extra tool call costs far more than the tokens it would have saved. diff --git a/llms.txt b/llms.txt index 5446df0..376f34c 100644 --- a/llms.txt +++ b/llms.txt @@ -5,7 +5,7 @@ Key facts: - Install: `npm install -g @only-cli/oc`, or zero-install with `npx @only-cli/oc` -- Commands: `oc open ` (compact view with numbered actions), `oc do ` (follow numbered link [n], or read [n] when it is text rather than a link), `oc find ` (where a string appears on the page already open), `oc read ` (one region in full), `oc next` (the next screenful), `oc raw [url]` (whole page as markdown, `--html` for cleaned HTML), `oc --help` for the full surface +- Commands: `oc open ` (compact view with numbered actions), `oc do ` (follow numbered link [n], or read [n] when it is text rather than a link), `oc find ` (where a string appears on the page already open), `oc read ` (one region in full), `oc next` (the next screenful), `oc raw [url]` (whole page as markdown, `--html` for cleaned HTML), `oc session ls|rm [name]` (list or forget saved sessions), `oc --help` for the full surface - Default output budget is 500 tokens per page; `--budget ` adjusts it, and `find`, `read `, or `next` collect what the budget cut without refetching the page - The budget is a target rather than a hard cap: a page that would finish within about four times it is printed whole, because a second command costs the agent far more than the lines the cut would have saved - The render leads with the page's main content and puts navigation, sidebar, and footer after it, so the budget is spent on what was asked for rather than on menus diff --git a/skills/web-browsing-cli/SKILL.md b/skills/web-browsing-cli/SKILL.md index df940a7..4c94e71 100644 --- a/skills/web-browsing-cli/SKILL.md +++ b/skills/web-browsing-cli/SKILL.md @@ -17,6 +17,8 @@ npx --yes @only-cli/oc@0.5.3 read full text of region [n] npx --yes @only-cli/oc@0.5.3 raw [url] whole page as markdown (--html for cleaned HTML) npx --yes @only-cli/oc@0.5.3 login seed cookies (--cookie, --domain, --expires) npx --yes @only-cli/oc@0.5.3 logout [session] forget a session: cookies and saved page +npx --yes @only-cli/oc@0.5.3 session ls list saved sessions (name, url, title) +npx --yes @only-cli/oc@0.5.3 session rm [name] forget a saved session: page and cookies ``` None of these except `open`/`do`/`raw ` fetch anything; they replay the page `open` already saved. @@ -86,10 +88,23 @@ oc open https://example.com/dashboard --session work oc logout work ``` +Windows (PowerShell — no `printf`): + +```powershell +$h = 'session=...; auth=...' +$h | oc login --cookie - --domain example.com --expires 2h --session work +oc open https://example.com/dashboard --session work +oc logout work +``` + Pass `--cookie -` and pipe the header in, as above: an inline `--cookie "session=..."` puts a live credential in `ps` and in shell history. Copy the header from browser devtools. `--domain` must be a real hostname; a bare TLD like `com` is refused, since the cookies would then go to every `.com` host the session fetched. Default lifetime is 1h. Seeded cookies are https-only: they are never sent over plain `http`, including on a redirect that downgrades, unless you seeded them with `--allow-http`. When cookies expire or the site returns a login page, `oc` says so (exit 2) instead of rendering the login form as content. Cookies live in a separate file from page state and are never included in `--json` output. `oc logout` drops that session's saved page along with its cookies. +## Saved sessions live on disk + +State is one JSON per session under `~/.only-cli/sessions/` (`%USERPROFILE%\.only-cli\sessions\` on Windows, `OC_HOME` overrides). `session ls` shows what accumulated; `session rm [name]` drops a session's page and cookies (same promise as `logout`). Deleting the directory starts over. + ## When not to use it Pages needing heavy client-side JS aren't supported yet. A page with no readable text (JavaScript-only, a consent wall, a bot challenge) prints one line on stderr and exits 2, which is distinct from the exit 1 every other failure uses, so exit 2 means "oc cannot read this one" rather than "this page is empty". Take it at its word: say so and fall back to another tool rather than retrying the same URL. diff --git a/src/cli.js b/src/cli.js index 73b4376..f7436f1 100755 --- a/src/cli.js +++ b/src/cli.js @@ -10,7 +10,7 @@ import { nodeSearch } from './nodedocs.js'; import { rdocSearch } from './rdoc.js'; import { apiSearch } from './apisearch.js'; import * as act from './act.js'; -import { DEFAULT_SESSION, assertSafeName, clearSession, loadSession, saveSession, sessionFromPage } from './session.js'; +import { DEFAULT_SESSION, assertSafeName, clearSession, listSessions, loadSession, saveSession, sessionFromPage } from './session.js'; import { authFailure, sessionExpiredMessage } from './auth.js'; import { loadCookieJar, @@ -42,7 +42,7 @@ usage: oc [args] [flags] back return to the previous page (planned) login seed cookies for a session (--cookie, --domain) logout [session] forget a session: its cookies and its saved page - session ls|rm manage saved sessions (planned) + session ls|rm [name] list saved sessions, or forget one (page + cookies) flags: --budget tighten or loosen the render budget (default 500, @@ -365,7 +365,32 @@ async function main() { case 'submit': return act.submit(args[0] ? Number(args[0]) : undefined); case 'back': return act.back(); case 'sites': return console.log(listSites()); - case 'session': throw new act.NotImplemented('session'); + case 'session': { + // Saved sessions accumulate on disk (one JSON per page kept for + // do/read/next), so agents can inspect and drop them without guessing + // paths under ~/.only-cli. With no name, rm targets --session. + const [sub, target] = args; + if (sub === 'ls') { + const list = listSessions(); + if (values.json) return console.log(JSON.stringify(list)); + if (!list.length) return console.log('no saved sessions'); + for (const s of list) { + console.log(`${s.name}${s.url ? ` ${s.url}` : ''}${s.title ? ` (${s.title})` : ''}`); + } + return; + } + if (sub === 'rm') { + const name = target ? assertSafeName(target) : sessionName; + // The saved page can hold text only cookies could reach, so rm drops + // the cookies with it: after rm nothing of that login remains, the + // same promise 'oc logout' makes. + clearSession(name); + clearCookieJar(name); + if (values.json) return console.log(JSON.stringify({ forgotten: name })); + return console.log(`forgot session '${name}'`); + } + throw new Error(`usage: oc session ls|rm [name]`); + } default: throw new Error(`unknown command '${command}', run oc --help`); } diff --git a/src/session.js b/src/session.js index a85449c..acc767c 100644 --- a/src/session.js +++ b/src/session.js @@ -12,7 +12,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync, readdirSync, statSync } from 'node:fs'; export const DEFAULT_SESSION = 'default'; @@ -191,3 +191,38 @@ export function loadSession(name) { return null; } } + +/** + * Every saved page on disk, for `oc session ls`. Cookie sidecars + * (`.cookies.json`) are not sessions and are skipped. An unreadable + * file is still listed by name: `oc session rm` can drop it. + * @returns {{name: string, url: string|null, title: string|null, savedAt: string|null, bytes: number|null}[]} + */ +export function listSessions() { + let files; + try { + files = readdirSync(sessionDir()); + } catch { + return []; + } + const out = []; + for (const file of files) { + if (!file.endsWith('.json') || file.endsWith('.cookies.json')) continue; + const name = file.slice(0, -'.json'.length); + if (!SAFE_NAME.test(name)) continue; + const path = join(sessionDir(), file); + const info = { name, url: null, title: null, savedAt: null, bytes: null }; + try { + info.bytes = statSync(path).size; + const state = JSON.parse(readFileSync(path, 'utf8')); + info.url = state?.url ?? null; + info.title = state?.title ?? null; + info.savedAt = state?.savedAt ?? null; + } catch { + // listed by name anyway + } + out.push(info); + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} diff --git a/tests/cli-auth.test.js b/tests/cli-auth.test.js index e0600ed..e4aa423 100644 --- a/tests/cli-auth.test.js +++ b/tests/cli-auth.test.js @@ -5,11 +5,12 @@ import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync } from import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const OC_HOME = mkdtempSync(join(tmpdir(), 'oc-cli-auth-')); process.env.OC_HOME = OC_HOME; -const bin = new URL('../src/cli.js', import.meta.url).pathname; +const bin = fileURLToPath(new URL('../src/cli.js', import.meta.url)); const loginHtml = readFileSync(new URL('./pages/login.html', import.meta.url), 'utf8'); const dashHtml = `Dashboard

Welcome back

diff --git a/tests/cli.test.js b/tests/cli.test.js index a2aba43..4bdb264 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -1,10 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; -import { mkdtempSync, readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; // Dispatch tests: the first word of argv reaches the right handler with the // right arguments, and every wrong first word fails in one line that names the @@ -19,7 +20,7 @@ const { distill } = await import('../src/distill.js'); const { render } = await import('../src/render.js'); const { saveSession, sessionFromPage } = await import('../src/session.js'); -const bin = new URL('../src/cli.js', import.meta.url).pathname; +const bin = fileURLToPath(new URL('../src/cli.js', import.meta.url)); const newsHtml = readFileSync(new URL('./pages/news.html', import.meta.url), 'utf8'); const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']; @@ -198,7 +199,7 @@ test('do without a number, or with one the page does not have, fails in one line test('the planned commands fail with the same one-line message, naming themselves', () => { seed('stubs'); - for (const args of [['fill', '1', 'hello'], ['submit'], ['submit', '1'], ['back'], ['session', 'ls']]) { + for (const args of [['fill', '1', 'hello'], ['submit'], ['submit', '1'], ['back']]) { const r = oc([...args, '--session', 'stubs']); assert.equal(r.status, 1, args.join(' ')); assert.equal(r.stdout, '', `${args[0]} printed to stdout`); @@ -220,3 +221,42 @@ test('flags are accepted anywhere in argv, before or after the command', () => { assert.equal(before.status, 0, before.stderr); assert.equal(before.stdout, after.stdout); }); + +test('session ls reports nothing saved yet, then names what open saved', () => { + const emptyHome = mkdtempSync(join(tmpdir(), 'oc-cli-empty-')); + let r = oc(['session', 'ls'], { OC_HOME: emptyHome }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), 'no saved sessions'); + seed('first'); + seed('second'); + r = oc(['session', 'ls']); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^first https:\/\/example\.test\/news/m); + assert.match(r.stdout, /^second https:\/\/example\.test\/news/m); +}); + +test('session rm forgets the saved page and its cookies, by name or --session', () => { + seed('droppable'); + let r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', 'droppable']); + assert.equal(r.status, 0, r.stderr); + const pagePath = join(OC_HOME, 'sessions', 'droppable.json'); + const jarPath = join(OC_HOME, 'sessions', 'droppable.cookies.json'); + r = oc(['session', 'rm', 'droppable']); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), "forgot session 'droppable'"); + assert.ok(!existsSync(pagePath), 'saved page is gone'); + assert.ok(!existsSync(jarPath), 'cookie sidecar is gone'); + seed('viaflag'); + r = oc(['session', 'rm', '--session', 'viaflag']); + assert.equal(r.status, 0, r.stderr); + assert.ok(!existsSync(join(OC_HOME, 'sessions', 'viaflag.json'))); +}); + +test('session rm refuses a name that is a path, session bogus names its usage', () => { + const r = oc(['session', 'rm', '../escape']); + assert.equal(r.status, 1); + assert.match(r.stderr, /^oc: invalid session name/); + const r2 = oc(['session', 'bogus']); + assert.equal(r2.status, 1); + assert.match(r2.stderr, /^oc: usage: oc session ls\|rm \[name\]/); +}); diff --git a/tests/distill.test.js b/tests/distill.test.js index fa17529..bf3c9bc 100644 --- a/tests/distill.test.js +++ b/tests/distill.test.js @@ -1,10 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { distill, toMarkdown, toHTML, feedToHTML, jsonToHTML, youtubeToHTML, transcriptToHTML, TEXT_CAP } from '../src/distill.js'; import { render, estimateTokens, contentTokens, contentFailure } from '../src/render.js'; -const PAGES = new URL('./pages/', import.meta.url).pathname; +const PAGES = fileURLToPath(new URL('./pages/', import.meta.url)); const html = readFileSync(new URL('./pages/news.html', import.meta.url), 'utf8'); const page = () => distill(html, 'https://example.test/news'); const feed = readFileSync(new URL('./pages/feed.xml', import.meta.url), 'utf8'); From b990662a2daa2c49b67345c7a62ad493d5aba7ea Mon Sep 17 00:00:00 2001 From: only-cli Date: Tue, 15 Sep 2026 23:12:39 -0400 Subject: [PATCH 2/3] session ls sees cookie-only logins, session rm fails when nothing was removed `oc login --session work` writes a jar and no page, and `session ls` only enumerated pages, so an agent auditing leftover logins was told there were none while a live credential sat on disk. ls now lists a name when it has a page, a jar, or both, and marks jars with `[cookies]` (a `cookies` field in `--json`), since without that an agent told to drop logged-in sessions cannot tell which to rm. clearSession and clearCookieJar swallowed every unlink error, so `session rm` printed "forgot session" and exited 0 on a read-only directory, a directory named like a page, or a typo. Both now ignore only ENOENT and report whether they removed anything; rm fails with "no such session" when neither did. logout keeps its silent exit 0. Both commands share one forgetSession helper, so the next per-session artifact cannot be forgotten by one and not the other. A name ending in `.cookies` saved its page at the path of the shorter name's jar, so `rm x` deleted session `x.cookies` and ls hid it: assertSafeName now rejects the suffix. ls also uses the same name check as rm (so `..json` and directories no longer list as sessions rm cannot remove), and the sidecar suffix is one exported constant shared with cookies.js. Copy edits to the new README, SKILL, and CHANGELOG prose; the CHANGELOG now says only the CLI tests run on Windows, since the cookie file-mode test still fails there. --- CHANGELOG.md | 18 ++++---- README.md | 4 +- skills/web-browsing-cli/SKILL.md | 2 +- src/cli.js | 32 +++++++++----- src/cookies.js | 20 ++++++--- src/session.js | 76 ++++++++++++++++++++------------ tests/cli.test.js | 43 ++++++++++++++++-- 7 files changed, 135 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8595b2f..40f817f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,18 @@ Notable changes per release. Releases before 0.4.0 are listed at ### Added -- `oc session ls` lists saved sessions (name, url, title) and `oc session rm - [name]` forgets one — saved page plus cookies, the same promise `oc logout` - makes. State lives in `~/.only-cli` (`%USERPROFILE%\.only-cli` on Windows, - `OC_HOME` overrides), so agents can now inspect and drop it without guessing - paths. +- `oc session ls` lists saved sessions (name, url, title, `[cookies]` when a + jar is held) and `oc session rm [name]` forgets one: saved page plus + cookies, the same promise `oc logout` makes. `rm` fails on an unknown name + rather than reporting success. A session name can no longer end in + `.cookies`, which collided with the shorter name's cookie sidecar. State + lives in `~/.only-cli` (`%USERPROFILE%\.only-cli` on Windows, `OC_HOME` + overrides), so agents can now inspect and drop it without guessing paths. - Login docs gained a PowerShell equivalent (`$h | oc login --cookie - ...`), since Windows has no `printf`. -- The CLI test harness resolves the binary with `fileURLToPath`, so the suite - runs on Windows checkouts (`.pathname` breaks on drive-letter paths with - spaces). +- The CLI test harness resolves the binary with `fileURLToPath`, so the CLI + tests can execute on Windows checkouts (`.pathname` breaks on drive-letter + paths with spaces). The cookie file-mode test still fails there. ### Fixed diff --git a/README.md b/README.md index 4d736a3..4a627b1 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ oc open https://example.com/dashboard --session work oc logout work ``` -Windows (PowerShell — no `printf` there): +Windows (PowerShell, no `printf` there): ```powershell $h = 'session=...; auth=...' # paste from browser devtools @@ -141,7 +141,7 @@ Seeded cookies are https-only. They almost always come from an https browser ses Cookies live in a separate sidecar file (`.cookies.json`) under `~/.only-cli/sessions/`, mode `0600`, not in the page-state JSON and never in `--json` output. The default lifetime is one hour (`--expires 1h`), and a jar holds at most 50 cookies so a page cannot bloat it. When cookies expire or the site returns a login page, `oc` says so plainly (exit 2) instead of distilling the login form as content. -`oc logout` forgets the whole session, not just its cookies: a page saved under that name can hold the distilled text of something only the login could reach, so the snapshot goes with the jar. `oc session rm [name]` forgets a session the same way (page plus cookies) without switching to it first, and `oc session ls` lists what is on disk. State lives in `~/.only-cli` (`%USERPROFILE%\.only-cli` on Windows, override with `OC_HOME`), one JSON per session plus search-index caches — delete the directory to start over. +`oc logout` forgets the whole session, not just its cookies: a page saved under that name can hold the distilled text of something only the login could reach, so the snapshot goes with the jar. `oc session rm [name]` forgets a session the same way (page plus cookies) without switching to it first, and `oc session ls` lists what is on disk (`[cookies]` marks a live jar). State lives in `~/.only-cli` (`%USERPROFILE%\.only-cli` on Windows, override with `OC_HOME`), one JSON per session plus search-index caches. Delete the directory to start over. `oc open` remembers the page it rendered in a JSON file per session under `~/.only-cli` (override with `OC_HOME`), so `oc do 3` follows `[3]` without the agent ever handling a URL. A result title on a search page is a link, so `oc do` on it opens the result rather than repeating the title. Pages longer than the budget say what they left out; `oc find`, `oc read `, and `oc next` read the rest without refetching the page, and a `find` with a single match prints that region instead of the number to read it with. The budget is a target rather than a hard cap: a page that would only run a little long is printed whole rather than cut, since one extra tool call costs far more than the tokens it would have saved. diff --git a/skills/web-browsing-cli/SKILL.md b/skills/web-browsing-cli/SKILL.md index 4c94e71..4750da3 100644 --- a/skills/web-browsing-cli/SKILL.md +++ b/skills/web-browsing-cli/SKILL.md @@ -88,7 +88,7 @@ oc open https://example.com/dashboard --session work oc logout work ``` -Windows (PowerShell — no `printf`): +Windows (PowerShell, no `printf`): ```powershell $h = 'session=...; auth=...' diff --git a/src/cli.js b/src/cli.js index f7436f1..52e05ee 100755 --- a/src/cli.js +++ b/src/cli.js @@ -111,6 +111,21 @@ const noContent = (url, detail, hint = "; 'oc raw' has the page's markdown if th process.exitCode = NO_CONTENT_EXIT; }; +/** + * Forget everything saved under a name, for `oc logout` and `oc session rm`. + * The page saved under a name can be the distilled text of a page only the + * cookies could reach, so the two go together: after either command nothing + * of that login remains. Cookies go first so a failure on the page file never + * leaves the credential behind. + * @param {string} name + * @returns {boolean} whether anything was on disk to forget + */ +function forgetSession(name) { + const hadJar = clearCookieJar(name); + const hadPage = clearSession(name); + return hadJar || hadPage; +} + const LOGIN_USAGE = 'usage: printf %s "session=..." | oc login --cookie - --domain example.com' + ' [--expires 1h] [--session name] [--allow-http]'; @@ -208,11 +223,7 @@ async function main() { } if (command === 'logout') { - const name = args[0] ? assertSafeName(args[0]) : sessionName; - clearCookieJar(name); - // The page saved under this name can be the distilled text of a page only - // the cookies could reach, so logout drops it too. - clearSession(name); + forgetSession(args[0] ? assertSafeName(args[0]) : sessionName); return; } @@ -375,17 +386,16 @@ async function main() { if (values.json) return console.log(JSON.stringify(list)); if (!list.length) return console.log('no saved sessions'); for (const s of list) { - console.log(`${s.name}${s.url ? ` ${s.url}` : ''}${s.title ? ` (${s.title})` : ''}`); + console.log(`${s.name}${s.url ? ` ${s.url}` : ''}${s.title ? ` (${s.title})` : ''}${s.cookies ? ' [cookies]' : ''}`); } return; } if (sub === 'rm') { const name = target ? assertSafeName(target) : sessionName; - // The saved page can hold text only cookies could reach, so rm drops - // the cookies with it: after rm nothing of that login remains, the - // same promise 'oc logout' makes. - clearSession(name); - clearCookieJar(name); + // A name nothing was saved under is most likely a typo, and an agent + // that reads "forgot session" would move on believing the login is + // gone, so rm fails loud instead of succeeding at nothing. + if (!forgetSession(name)) throw new Error(`no such session '${name}', run oc session ls`); if (values.json) return console.log(JSON.stringify({ forgotten: name })); return console.log(`forgot session '${name}'`); } diff --git a/src/cookies.js b/src/cookies.js index 52d7882..1157ae7 100644 --- a/src/cookies.js +++ b/src/cookies.js @@ -7,7 +7,7 @@ import net from 'node:net'; import { join } from 'node:path'; import { mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, chmodSync } from 'node:fs'; -import { sessionDir, assertSafeName } from './session.js'; +import { sessionDir, assertSafeName, COOKIE_JAR_SUFFIX } from './session.js'; const DEFAULT_EXPIRES_MS = 60 * 60 * 1000; // 1h export { DEFAULT_EXPIRES_MS }; @@ -53,7 +53,7 @@ function clip(value) { * @returns {string} */ export function cookieJarPath(name) { - return join(sessionDir(), `${assertSafeName(name)}.cookies.json`); + return join(sessionDir(), `${assertSafeName(name)}${COOKIE_JAR_SUFFIX}`); } /** @@ -265,13 +265,18 @@ export function saveCookieJar(name, jar) { } /** + * Only a missing jar is fine to ignore: any other failure leaves a live + * credential on disk that the caller may be about to report as gone. * @param {string} name + * @returns {boolean} whether a jar was removed */ export function clearCookieJar(name) { try { unlinkSync(cookieJarPath(name)); - } catch { - // missing file is fine + return true; + } catch (err) { + if (err?.code === 'ENOENT') return false; + throw err; } } @@ -287,12 +292,13 @@ export function purgeExpiredJars() { return; } for (const file of readdirSync(dir)) { - if (!file.endsWith('.cookies.json')) continue; - const name = file.slice(0, -'.cookies.json'.length); + if (!file.endsWith(COOKIE_JAR_SUFFIX)) continue; try { const jar = /** @type {CookieJar} */ (JSON.parse(readFileSync(join(dir, file), 'utf8'))); - if (isSessionExpired(jar)) clearCookieJar(name); + if (isSessionExpired(jar)) unlinkSync(join(dir, file)); } catch { + // A jar that will not parse is as useless as an expired one, and a sweep + // that cannot delete a file has nothing to report: the next one retries. try { unlinkSync(join(dir, file)); } catch {} } } diff --git a/src/session.js b/src/session.js index acc767c..fdf5914 100644 --- a/src/session.js +++ b/src/session.js @@ -12,10 +12,16 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync, readdirSync, statSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync, readdirSync } from 'node:fs'; export const DEFAULT_SESSION = 'default'; +// The cookie sidecar sits next to the page file as `.cookies.json` +// (see cookies.js). Both modules split filenames on this suffix, so it lives +// in one place: a rename that reached only one of them would make `session +// ls` list jars as pages, and `session rm` unlink a jar as a page. +export const COOKIE_JAR_SUFFIX = '.cookies.json'; + // OC_HOME relocates the whole state directory, for sandboxes, CI, and tests. export const sessionDir = () => join(process.env.OC_HOME ?? join(homedir(), '.only-cli'), 'sessions'); @@ -25,13 +31,19 @@ export const sessionDir = () => join(process.env.OC_HOME ?? join(homedir(), '.on // the store. Names are user-facing labels, so this charset loses nothing real. const SAFE_NAME = /^[A-Za-z0-9._-]+$/; +// A name ending in '.cookies' would save its page at `.cookies.json`, the +// path of session ``'s cookie jar, so `oc logout x` would delete it and +// `oc session ls` would hide it. +const isSafeName = (name) => typeof name === 'string' && name !== '.' && name !== '..' + && !name.endsWith('.cookies') && SAFE_NAME.test(name); + /** * @param {string} name * @returns {string} the same name, once it is known to be a safe filename */ export function assertSafeName(name) { - if (typeof name !== 'string' || name === '.' || name === '..' || !SAFE_NAME.test(name)) { - throw new Error(`invalid session name '${name}', use letters, numbers, '.', '-', or '_'`); + if (!isSafeName(name)) { + throw new Error(`invalid session name '${name}', use letters, numbers, '.', '-', or '_' (not ending in '.cookies')`); } return name; } @@ -167,14 +179,19 @@ export function saveSession(name, state) { * Drop a saved page. `oc logout` calls this alongside clearing the cookie jar: * a snapshot taken under a login holds that page's text, so leaving it behind * would make logout mean "the cookies are gone" rather than "nothing of this - * login remains". + * login remains". Only a missing file is fine to ignore: a permission error + * or a name that is a directory leaves the page on disk, and the caller is + * about to tell the user it is gone. * @param {string} name + * @returns {boolean} whether a saved page was removed */ export function clearSession(name) { try { unlinkSync(sessionPath(name)); - } catch { - // nothing saved under that name is fine + return true; + } catch (err) { + if (err?.code === 'ENOENT') return false; + throw err; } } @@ -193,36 +210,41 @@ export function loadSession(name) { } /** - * Every saved page on disk, for `oc session ls`. Cookie sidecars - * (`.cookies.json`) are not sessions and are skipped. An unreadable - * file is still listed by name: `oc session rm` can drop it. - * @returns {{name: string, url: string|null, title: string|null, savedAt: string|null, bytes: number|null}[]} + * Every session on disk, for `oc session ls`: a name is a session when it has + * a saved page, a cookie jar, or both, since `oc login` creates a jar without + * a page and that credential is exactly what an agent auditing leftover logins + * needs to see. An unreadable page is still listed by name: `oc session rm` + * can drop it. Only names oc itself could have written are listed, so a stray + * `..json` or a directory named like a page never shows up as something rm + * then cannot remove. + * @returns {{name: string, url: string|null, title: string|null, savedAt: string|null, cookies: boolean}[]} */ export function listSessions() { - let files; + let entries; try { - files = readdirSync(sessionDir()); + entries = readdirSync(sessionDir(), { withFileTypes: true }); } catch { return []; } - const out = []; - for (const file of files) { - if (!file.endsWith('.json') || file.endsWith('.cookies.json')) continue; - const name = file.slice(0, -'.json'.length); - if (!SAFE_NAME.test(name)) continue; - const path = join(sessionDir(), file); - const info = { name, url: null, title: null, savedAt: null, bytes: null }; - try { - info.bytes = statSync(path).size; - const state = JSON.parse(readFileSync(path, 'utf8')); + /** @type {Map} */ + const byName = new Map(); + const entry = (name) => { + if (!byName.has(name)) byName.set(name, { name, url: null, title: null, savedAt: null, cookies: false }); + return byName.get(name); + }; + for (const file of entries) { + if (!file.isFile()) continue; + const jarName = file.name.endsWith(COOKIE_JAR_SUFFIX) ? file.name.slice(0, -COOKIE_JAR_SUFFIX.length) : null; + const pageName = jarName == null && file.name.endsWith('.json') ? file.name.slice(0, -'.json'.length) : null; + if (jarName != null && isSafeName(jarName)) { + entry(jarName).cookies = true; + } else if (pageName != null && isSafeName(pageName)) { + const info = entry(pageName); + const state = loadSession(pageName); info.url = state?.url ?? null; info.title = state?.title ?? null; info.savedAt = state?.savedAt ?? null; - } catch { - // listed by name anyway } - out.push(info); } - out.sort((a, b) => a.name.localeCompare(b.name)); - return out; + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/tests/cli.test.js b/tests/cli.test.js index 4bdb264..60659a3 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; -import { mkdtempSync, readFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; @@ -235,6 +235,32 @@ test('session ls reports nothing saved yet, then names what open saved', () => { assert.match(r.stdout, /^second https:\/\/example\.test\/news/m); }); +test('session ls lists a login that never opened a page, and marks which sessions hold cookies', () => { + // 'oc login' writes a jar and no page. That jar is a live credential, so a + // listing that only knew about pages would tell an agent auditing leftover + // logins that there are none. + const home = mkdtempSync(join(tmpdir(), 'oc-cli-jar-')); + let r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', 'jaronly'], { OC_HOME: home }); + assert.equal(r.status, 0, r.stderr); + r = oc(['session', 'ls'], { OC_HOME: home }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), 'jaronly [cookies]'); + r = oc(['session', 'ls', '--json'], { OC_HOME: home }); + assert.deepEqual(JSON.parse(r.stdout).map((s) => [s.name, s.cookies]), [['jaronly', true]]); +}); + +test('session ls skips files oc could not have written, so it never lists what rm cannot remove', () => { + const home = mkdtempSync(join(tmpdir(), 'oc-cli-stray-')); + const dir = join(home, 'sessions'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, '..json'), '{}'); + writeFileSync(join(dir, 'notes.txt'), ''); + mkdirSync(join(dir, 'folder.json')); + const r = oc(['session', 'ls'], { OC_HOME: home }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), 'no saved sessions'); +}); + test('session rm forgets the saved page and its cookies, by name or --session', () => { seed('droppable'); let r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', 'droppable']); @@ -252,10 +278,19 @@ test('session rm forgets the saved page and its cookies, by name or --session', assert.ok(!existsSync(join(OC_HOME, 'sessions', 'viaflag.json'))); }); -test('session rm refuses a name that is a path, session bogus names its usage', () => { - const r = oc(['session', 'rm', '../escape']); +test('session rm of a name nothing is saved under fails instead of claiming success', () => { + const r = oc(['session', 'rm', 'wrok']); assert.equal(r.status, 1); - assert.match(r.stderr, /^oc: invalid session name/); + assert.equal(r.stdout, ''); + assert.equal(r.stderr.trim(), "oc: no such session 'wrok', run oc session ls"); +}); + +test('session rm refuses a name that is a path or a cookie sidecar, session bogus names its usage', () => { + for (const name of ['../escape', 'work.cookies']) { + const r = oc(['session', 'rm', name]); + assert.equal(r.status, 1, name); + assert.match(r.stderr, /^oc: invalid session name/); + } const r2 = oc(['session', 'bogus']); assert.equal(r2.status, 1); assert.match(r2.stderr, /^oc: usage: oc session ls\|rm \[name\]/); From bd881372bc9c04cfc8087a2f3a903a35e9596eee Mon Sep 17 00:00:00 2001 From: only-cli Date: Tue, 15 Sep 2026 23:15:42 -0400 Subject: [PATCH 3/3] release: 0.5.4 oc session ls|rm, and reddit.com read through its feeds; see CHANGELOG. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- skills/web-browsing-cli/SKILL.md | 20 ++++++++++---------- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2ccb9d0..adca651 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ "name": "only-cli", "source": { "source": "github", "repo": "only-cli/oc" }, "description": "Browse websites from the terminal in a few hundred tokens", - "version": "0.5.3", + "version": "0.5.4", "homepage": "https://github.com/only-cli/oc", "license": "MIT" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6ed2360..1ecb830 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "only-cli", "description": "Browse websites from the terminal in a few hundred tokens", - "version": "0.5.3" + "version": "0.5.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f817f..48b3cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ Notable changes per release. Releases before 0.4.0 are listed at [github.com/only-cli/oc/releases](https://github.com/only-cli/oc/releases). -## Unreleased +## 0.5.4 ### Added diff --git a/package-lock.json b/package-lock.json index 3f150d6..245fd8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@only-cli/oc", - "version": "0.5.3", + "version": "0.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@only-cli/oc", - "version": "0.5.3", + "version": "0.5.4", "license": "MIT", "dependencies": { "impers": "0.1.1", diff --git a/package.json b/package.json index 3db3eef..304a007 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@only-cli/oc", - "version": "0.5.3", + "version": "0.5.4", "description": "Turn websites into a compact CLI so AI agents can browse without burning tokens.", "type": "module", "bin": { diff --git a/skills/web-browsing-cli/SKILL.md b/skills/web-browsing-cli/SKILL.md index 4750da3..ded2b8e 100644 --- a/skills/web-browsing-cli/SKILL.md +++ b/skills/web-browsing-cli/SKILL.md @@ -8,17 +8,17 @@ description: Token-efficient web browsing and web content extraction for AI agen Renders a web page as a compact, numbered terminal view instead of raw HTML. A typical page is under 500 tokens. ``` -npx --yes @only-cli/oc@0.5.3 open compact view, numbered elements -npx --yes @only-cli/oc@0.5.3 do follow link [n], or read it if [n] is text -npx --yes @only-cli/oc@0.5.3 find where a string appears, or that place itself +npx --yes @only-cli/oc@0.5.4 open compact view, numbered elements +npx --yes @only-cli/oc@0.5.4 do follow link [n], or read it if [n] is text +npx --yes @only-cli/oc@0.5.4 find where a string appears, or that place itself when only one matches -npx --yes @only-cli/oc@0.5.3 next next ~500 tokens of the page already open -npx --yes @only-cli/oc@0.5.3 read full text of region [n] -npx --yes @only-cli/oc@0.5.3 raw [url] whole page as markdown (--html for cleaned HTML) -npx --yes @only-cli/oc@0.5.3 login seed cookies (--cookie, --domain, --expires) -npx --yes @only-cli/oc@0.5.3 logout [session] forget a session: cookies and saved page -npx --yes @only-cli/oc@0.5.3 session ls list saved sessions (name, url, title) -npx --yes @only-cli/oc@0.5.3 session rm [name] forget a saved session: page and cookies +npx --yes @only-cli/oc@0.5.4 next next ~500 tokens of the page already open +npx --yes @only-cli/oc@0.5.4 read full text of region [n] +npx --yes @only-cli/oc@0.5.4 raw [url] whole page as markdown (--html for cleaned HTML) +npx --yes @only-cli/oc@0.5.4 login seed cookies (--cookie, --domain, --expires) +npx --yes @only-cli/oc@0.5.4 logout [session] forget a session: cookies and saved page +npx --yes @only-cli/oc@0.5.4 session ls list saved sessions (name, url, title) +npx --yes @only-cli/oc@0.5.4 session rm [name] forget a saved session: page and cookies ``` None of these except `open`/`do`/`raw ` fetch anything; they replay the page `open` already saved.