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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,22 @@
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

- `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 CLI
tests can execute on Windows checkouts (`.pathname` breaks on drive-letter
paths with spaces). The cookie file-mode test still fails there.

### Fixed

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ oc fill <n> <text> 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 <tokens>` (default 500), `--json`, `--html` (raw as cleaned HTML), `--session <name>`, `--verbose`/`-v` (metrics on stderr, or export `OC_VERBOSE=1`).
Expand Down Expand Up @@ -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.
Expand All @@ -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 (`<session>.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 (`[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 <n>`, 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.

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Key facts:

- Install: `npm install -g @only-cli/oc`, or zero-install with `npx @only-cli/oc`
- Commands: `oc open <url>` (compact view with numbered actions), `oc do <n>` (follow numbered link [n], or read [n] when it is text rather than a link), `oc find <query>` (where a string appears on the page already open), `oc read <n>` (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 <url>` (compact view with numbered actions), `oc do <n>` (follow numbered link [n], or read [n] when it is text rather than a link), `oc find <query>` (where a string appears on the page already open), `oc read <n>` (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 <n>` adjusts it, and `find`, `read <n>`, 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
31 changes: 23 additions & 8 deletions skills/web-browsing-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +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 <url> compact view, numbered elements
npx --yes @only-cli/oc@0.5.3 do <n> follow link [n], or read it if [n] is text
npx --yes @only-cli/oc@0.5.3 find <query> where a string appears, or that place itself
npx --yes @only-cli/oc@0.5.4 open <url> compact view, numbered elements
npx --yes @only-cli/oc@0.5.4 do <n> follow link [n], or read it if [n] is text
npx --yes @only-cli/oc@0.5.4 find <query> 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 <n> 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.4 next next ~500 tokens of the page already open
npx --yes @only-cli/oc@0.5.4 read <n> 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 <url>` fetch anything; they replay the page `open` already saved.
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 43 additions & 8 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,7 +42,7 @@ usage: oc <command> [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 <tokens> tighten or loosen the render budget (default 500,
Expand Down Expand Up @@ -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]';

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -365,7 +376,31 @@ 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})` : ''}${s.cookies ? ' [cookies]' : ''}`);
}
return;
}
if (sub === 'rm') {
const name = target ? assertSafeName(target) : sessionName;
// 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}'`);
}
throw new Error(`usage: oc session ls|rm [name]`);
}
default:
throw new Error(`unknown command '${command}', run oc --help`);
}
Expand Down
20 changes: 13 additions & 7 deletions src/cookies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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}`);
}

/**
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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 {}
}
}
Expand Down
Loading