From a187b1d58aac33d6b93cabca0458596eb04e4cfd Mon Sep 17 00:00:00 2001 From: Dan Chagas Date: Sun, 13 Sep 2026 21:35:24 -0300 Subject: [PATCH] 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');