nibs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # yaml-language-server: $schema=https://www.coderabbit.ai/integrations/schema.v2.json
2
+ # Settings not listed here keep CodeRabbit's defaults (reviews.profile: chill).
3
+ reviews:
4
+ auto_review:
5
+ # The release pull request that release.yml opens changes only CHANGELOG.md
6
+ # and the release manifest, both generated.
7
+ ignore_title_keywords:
8
+ - 'chore(main): release'
@@ -0,0 +1,18 @@
1
+ version: 2
2
+ updates:
3
+ # nibs has no dependencies to install; only the actions in the workflows.
4
+ - package-ecosystem: github-actions
5
+ directory: /
6
+ schedule:
7
+ interval: weekly
8
+ # Wait three days after a release: compromised releases are usually found
9
+ # and removed within hours or days. Three days is also Dependabot's default.
10
+ cooldown:
11
+ default-days: 3
12
+ # Majors come one by one: they can break the workflow.
13
+ groups:
14
+ actions:
15
+ patterns: ['*']
16
+ update-types: [minor, patch]
17
+ commit-message:
18
+ prefix: ci
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "python",
6
+ "include-component-in-tag": false,
7
+ "bump-minor-pre-major": true,
8
+ "initial-version": "0.1.0"
9
+ }
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ {".":"0.1.0"}
@@ -0,0 +1,148 @@
1
+ name: Release
2
+
3
+ # Merging a pull request that bumps the version (feat:, fix:, a breaking change;
4
+ # see CONTRIBUTING.md) makes release-please open or update a release pull
5
+ # request, which changes CHANGELOG.md and .github/release-please-manifest.json.
6
+ # Merging that tags v<version> and creates the GitHub release; the jobs below
7
+ # then build, test and publish it to PyPI. The version of the package comes
8
+ # from the tag. Nothing is published from a laptop.
9
+ #
10
+ # Run by hand (Run workflow on main, with the tag) to publish a tag again after
11
+ # a failure outside the tagged code, such as the PyPI settings. If the tagged
12
+ # code is at fault, release a new version instead.
13
+ on:
14
+ push:
15
+ branches: [main]
16
+ workflow_dispatch:
17
+ inputs:
18
+ tag:
19
+ description: Release tag to publish, e.g. v1.2.3
20
+ required: true
21
+
22
+ permissions: {}
23
+
24
+ # One release at a time, and never cancel one halfway.
25
+ concurrency:
26
+ group: release
27
+ cancel-in-progress: false
28
+
29
+ jobs:
30
+ release-please:
31
+ if: github.event_name == 'push'
32
+ runs-on: ubuntu-latest
33
+ timeout-minutes: 10
34
+ permissions:
35
+ contents: write
36
+ pull-requests: write
37
+ issues: write
38
+ outputs:
39
+ release_created: ${{ steps.release.outputs.release_created }}
40
+ tag_name: ${{ steps.release.outputs.tag_name }}
41
+ steps:
42
+ - id: release
43
+ uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
44
+ with:
45
+ config-file: .github/release-please-config.json
46
+ manifest-file: .github/release-please-manifest.json
47
+
48
+ # The build tool runs only here, and cannot publish. publish installs and runs
49
+ # nothing from the repository, and publishes only the files build made: their
50
+ # SHA-256 sums are build's outputs, which later jobs cannot change.
51
+ build:
52
+ needs: release-please
53
+ if: >-
54
+ !cancelled() && needs.release-please.result != 'failure' &&
55
+ (github.event_name == 'workflow_dispatch' || needs.release-please.outputs.release_created == 'true')
56
+ runs-on: ubuntu-latest
57
+ timeout-minutes: 10
58
+ permissions:
59
+ contents: read
60
+ env:
61
+ TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}
62
+ outputs:
63
+ sha: ${{ steps.commit.outputs.sha }}
64
+ sums: ${{ steps.build.outputs.sums }}
65
+ steps:
66
+ - name: Check the tag
67
+ run: '[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "not a release tag: $TAG"; exit 1; }'
68
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69
+ with:
70
+ ref: refs/tags/${{ env.TAG }}
71
+ # The version is read from the tag, which needs the history.
72
+ fetch-depth: 0
73
+ persist-credentials: false
74
+ - id: commit
75
+ name: Check the commit
76
+ run: |
77
+ # By hand, only from main's own workflow, and only for a commit on main.
78
+ if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
79
+ [[ "$GITHUB_REF" == refs/heads/main ]] || { echo "run it on main, not $GITHUB_REF"; exit 1; }
80
+ git merge-base --is-ancestor HEAD origin/main || { echo "$TAG is not on main"; exit 1; }
81
+ fi
82
+ echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
83
+ - name: Check the version against PyPI
84
+ run: |
85
+ status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/nibs/${TAG#v}/json")
86
+ [[ "$status" == 404 ]] || { echo "${TAG#v} is already on PyPI (HTTP $status)"; exit 1; }
87
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
88
+ with:
89
+ python-version: "3.15"
90
+ - id: build
91
+ run: |
92
+ pip install build
93
+ python -m build --outdir "$RUNNER_TEMP/dist"
94
+ cd "$RUNNER_TEMP/dist"
95
+ version=${TAG#v}
96
+ [[ -f "nibs-$version-py3-none-any.whl" && -f "nibs-$version.tar.gz" ]] || { ls; echo "the files are not version $version"; exit 1; }
97
+ { echo 'sums<<EOF'; sha256sum ./*; echo EOF; } >> "$GITHUB_OUTPUT"
98
+ # The wheel as it will be published installs, and reports the version.
99
+ - run: |
100
+ python -m venv "$RUNNER_TEMP/venv"
101
+ "$RUNNER_TEMP/venv/bin/pip" install "$RUNNER_TEMP"/dist/*.whl
102
+ [[ "$("$RUNNER_TEMP/venv/bin/nibs" --version)" == "nibs ${TAG#v}" ]]
103
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
104
+ with:
105
+ name: dist
106
+ path: ${{ runner.temp }}/dist
107
+ if-no-files-found: error
108
+ retention-days: 7
109
+
110
+ # Separate from build, so that the test tools never touch what is published.
111
+ test:
112
+ needs: build
113
+ if: ${{ !cancelled() && needs.build.result == 'success' }}
114
+ runs-on: ubuntu-latest
115
+ timeout-minutes: 10
116
+ permissions:
117
+ contents: read
118
+ steps:
119
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
120
+ with:
121
+ ref: ${{ needs.build.outputs.sha }}
122
+ persist-credentials: false
123
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
124
+ with:
125
+ python-version: "3.15"
126
+ - run: pip install -e . pytest
127
+ - run: pytest -q
128
+
129
+ publish:
130
+ needs: [build, test]
131
+ if: ${{ !cancelled() && needs.build.result == 'success' && needs.test.result == 'success' }}
132
+ runs-on: ubuntu-latest
133
+ timeout-minutes: 10
134
+ # PyPI's trusted publisher accepts this environment only.
135
+ environment: pypi
136
+ permissions:
137
+ contents: read
138
+ id-token: write # PyPI trusted publishing
139
+ steps:
140
+ # No checkout: only the files build made are used.
141
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
142
+ with:
143
+ name: dist
144
+ path: dist
145
+ - run: cd dist && sha256sum --check --strict <<< "$SUMS"
146
+ env:
147
+ SUMS: ${{ needs.build.outputs.sums }}
148
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -0,0 +1,54 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
14
+
15
+ jobs:
16
+ test:
17
+ name: test (${{ matrix.os }}, Python ${{ matrix.python }})
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ os: [ubuntu-latest]
22
+ python: ["3.11", "3.12", "3.13", "3.14", "3.15"]
23
+ # Every supported Python on one OS; the others only at the oldest and
24
+ # newest, since what differs by OS (file paths, replacing the cache
25
+ # file) does not depend on the Python version.
26
+ include:
27
+ - os: macos-latest
28
+ python: "3.11"
29
+ - os: macos-latest
30
+ python: "3.15"
31
+ - os: windows-latest
32
+ python: "3.11"
33
+ - os: windows-latest
34
+ python: "3.15"
35
+ runs-on: ${{ matrix.os }}
36
+ timeout-minutes: 10
37
+ steps:
38
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
39
+ with:
40
+ persist-credentials: false
41
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
42
+ with:
43
+ python-version: ${{ matrix.python }}
44
+ - run: pip install -e . pytest
45
+ - run: pytest -q
46
+ # The wheel as it would be published installs, and its command starts.
47
+ - name: Install the wheel in a new environment
48
+ shell: bash
49
+ run: |
50
+ pip wheel --no-deps -w "$RUNNER_TEMP/wheel" .
51
+ python -m venv "$RUNNER_TEMP/venv"
52
+ if [[ -d "$RUNNER_TEMP/venv/Scripts" ]]; then bin="$RUNNER_TEMP/venv/Scripts"; else bin="$RUNNER_TEMP/venv/bin"; fi
53
+ "$bin/python" -m pip install "$RUNNER_TEMP"/wheel/nibs-*.whl
54
+ "$bin/nibs" --help
nibs-0.1.0/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .DS_Store
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * read a web page by its headings ([6e42fc1](https://github.com/nhinata/nibs/commit/6e42fc1571dc3a17e93febf0caed0db9d289857d))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * close a code fence only with one at least as long ([#10](https://github.com/nhinata/nibs/issues/10)) ([e4c363d](https://github.com/nhinata/nibs/commit/e4c363d66e23bc12209a5d2cf74925f532b180b4))
14
+ * keep article headers, forms and code blocks when reading HTML ([#4](https://github.com/nhinata/nibs/issues/4)) ([7ff6281](https://github.com/nhinata/nibs/commit/7ff62811e90ba1530804212c997fb0eea10782ad))
15
+ * read only http and https URLs ([#2](https://github.com/nhinata/nibs/issues/2)) ([11dd070](https://github.com/nhinata/nibs/commit/11dd070561fb2a1fb682ecadaeab9d1224b24aed))
16
+ * read the charset a page declares in a meta tag ([#5](https://github.com/nhinata/nibs/issues/5)) ([478c52e](https://github.com/nhinata/nibs/commit/478c52ead8fb284a0b1d8cdfb5c4bf145122b28c))
17
+ * show a message instead of a traceback on broken input ([#6](https://github.com/nhinata/nibs/issues/6)) ([6d603c2](https://github.com/nhinata/nibs/commit/6d603c29036f55f68e8328f5e82c5cb74ea12495))
@@ -0,0 +1,29 @@
1
+ # Contributing
2
+
3
+ ## Development
4
+
5
+ ```sh
6
+ python -m venv .venv
7
+ .venv/bin/pip install -e . pytest
8
+ .venv/bin/pytest -q
9
+ ```
10
+
11
+ The tests do not reach the internet: they serve pages from a fake site (`tests/conftest.py`) or from a web server on localhost (`tests/test_fetch.py`).
12
+
13
+ ## Pull requests
14
+
15
+ - **One topic per pull request.** Name the branch `<type>/<topic>`, such as `fix/sphinx-headings`.
16
+ - **Titles follow [Conventional Commits](https://www.conventionalcommits.org/).** Pull requests are squash-merged, so the title becomes the commit on `main`, and the release tooling reads it to pick the next version:
17
+
18
+ | Prefix | Use for | Version bump |
19
+ | --- | --- | --- |
20
+ | `feat:` | a new command, option or output | minor |
21
+ | `fix:` | wrong output or a crash | patch |
22
+ | `feat!:` / `fix!:` | a change to the output or to a command that breaks instructions written for the old one | major (minor while 0.x) |
23
+ | `docs:`, `test:`, `refactor:`, `ci:`, `chore:` | everything else | none |
24
+
25
+ - `main` is protected: every change goes through a pull request, and the tests must pass.
26
+
27
+ ## Releases
28
+
29
+ Merging a pull request that bumps the version makes release-please open a release pull request. Merging that publishes the version to PyPI ([release.yml](.github/workflows/release.yml)).
nibs-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nhinata
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
nibs-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: nibs
3
+ Version: 0.1.0
4
+ Summary: Read a web page by its headings: list them, then print only the section you need
5
+ Project-URL: Repository, https://github.com/nhinata/nibs
6
+ Author: nhinata
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agent,cli,llm,markdown,tokens,web
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+
18
+ # nibs
19
+
20
+ Read a web page by its headings: list them, then print only the section you need.
21
+
22
+ An AI agent that reads a whole documentation page to answer one question puts tens of thousands of characters into its context, most of which it never uses. nibs lets it look at the page's headings first and then take just one section, in the page's own words.
23
+
24
+ ```console
25
+ $ nibs toc https://docs.python.org/3/library/argparse.html
26
+ 82,019 chars, 57 headings
27
+ 1. Navigation (254)
28
+ 2. argparse — Parser for command-line options, arguments and subcommands¶ (81,665)
29
+ 3. ArgumentParser objects¶ (19,217)
30
+ 4. prog¶ (1,483)
31
+ 5. usage¶ (893)
32
+ ...
33
+ $ nibs find https://docs.python.org/3/library/argparse.html allow_abbrev --limit 2
34
+ ## ArgumentParser objects¶
35
+ class argparse.ArgumentParser(prog=None, usage=None, description=None, epilog=None, ...
36
+ allow_abbrev - Allows long options to be abbreviated if the abbreviation is unambiguous (default: True)
37
+ [nibs: stopped at 2 matches; pass --limit]
38
+ $ nibs section https://docs.python.org/3/library/argparse.html 4
39
+ ```
40
+
41
+ ## Install
42
+
43
+ ```sh
44
+ pipx install nibs # or: uv tool install nibs
45
+ ```
46
+
47
+ Python 3.11 or later. No other dependencies.
48
+
49
+ ## Commands
50
+
51
+ ```sh
52
+ nibs toc <url> # headings, numbered, with each section's size in characters
53
+ nibs section <url> <n|heading> # one section, by its number in toc or by heading text
54
+ nibs find <url> <regex> # matching lines, each under the heading it belongs to
55
+ nibs stats # characters printed so far, against reading each page whole
56
+ ```
57
+
58
+ - `section` prints at most 8,000 characters; `--max` changes that. A heading given as text matches exactly first, then as a case-insensitive substring.
59
+ - `find` prints at most 30 lines; `--limit` changes that.
60
+
61
+ ## How a page is read
62
+
63
+ - Only `http` and `https` URLs are read, also when a page redirects.
64
+ - If the site serves Markdown, either at the URL itself or at the URL with `.md` added, nibs uses it. Many documentation sites do.
65
+ - Otherwise nibs turns the HTML into headings, text and code blocks (from `<pre>`). It leaves out scripts, styles, navigation, sidebars, and the page's own header and footer; a header or footer inside an article is kept. Only heading tags become headings, and only `<pre>` becomes a code block: a line of text that would read as either (see below) gets a backslash in front.
66
+ - Control characters in the page are removed before printing.
67
+ - nibs does not run JavaScript. A page that builds its content in the browser comes back with little or no text; when no headings are found, nibs says so.
68
+ - A fetched page is kept for 15 minutes in `~/.cache/nibs` (or `$XDG_CACHE_HOME/nibs`), so `toc`, `section` and `find` on the same page fetch it once.
69
+
70
+ ### Headings and code blocks
71
+
72
+ nibs splits a page by its Markdown headings. It reads only what it needs for that, by this rule rather than all of CommonMark:
73
+
74
+ - A heading is a line that starts with 1 to 6 `#` and a space.
75
+ - A line that starts with three or more `` ` `` or `~` opens a code block, after any spaces. The block ends at a line of the same character, at least as long, with nothing after it. Headings inside a code block are not headings.
76
+ - Nothing else is read as structure: not headings underlined with `===` or `---`, not code indented by four spaces.
77
+
78
+ ## Using it from an agent
79
+
80
+ Tell the agent in its instructions, for example in `CLAUDE.md` or `AGENTS.md`:
81
+
82
+ ```markdown
83
+ To read documentation on the web, run `nibs toc <url>` to see the headings,
84
+ then `nibs section <url> <n>` for the part you need, or `nibs find <url> <regex>`.
85
+ ```
86
+
87
+ ## The usage record
88
+
89
+ Each run adds one line to `~/.local/state/nibs/usage.jsonl` (or `$XDG_STATE_HOME/nibs/usage.jsonl`): the time, the command, the URL, the page's size and how many characters were printed. `nibs stats` reads it. Set `NIBS_LOG` to another path to keep the record there. Nothing is sent anywhere.
90
+
91
+ ## License
92
+
93
+ MIT
nibs-0.1.0/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # nibs
2
+
3
+ Read a web page by its headings: list them, then print only the section you need.
4
+
5
+ An AI agent that reads a whole documentation page to answer one question puts tens of thousands of characters into its context, most of which it never uses. nibs lets it look at the page's headings first and then take just one section, in the page's own words.
6
+
7
+ ```console
8
+ $ nibs toc https://docs.python.org/3/library/argparse.html
9
+ 82,019 chars, 57 headings
10
+ 1. Navigation (254)
11
+ 2. argparse — Parser for command-line options, arguments and subcommands¶ (81,665)
12
+ 3. ArgumentParser objects¶ (19,217)
13
+ 4. prog¶ (1,483)
14
+ 5. usage¶ (893)
15
+ ...
16
+ $ nibs find https://docs.python.org/3/library/argparse.html allow_abbrev --limit 2
17
+ ## ArgumentParser objects¶
18
+ class argparse.ArgumentParser(prog=None, usage=None, description=None, epilog=None, ...
19
+ allow_abbrev - Allows long options to be abbreviated if the abbreviation is unambiguous (default: True)
20
+ [nibs: stopped at 2 matches; pass --limit]
21
+ $ nibs section https://docs.python.org/3/library/argparse.html 4
22
+ ```
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pipx install nibs # or: uv tool install nibs
28
+ ```
29
+
30
+ Python 3.11 or later. No other dependencies.
31
+
32
+ ## Commands
33
+
34
+ ```sh
35
+ nibs toc <url> # headings, numbered, with each section's size in characters
36
+ nibs section <url> <n|heading> # one section, by its number in toc or by heading text
37
+ nibs find <url> <regex> # matching lines, each under the heading it belongs to
38
+ nibs stats # characters printed so far, against reading each page whole
39
+ ```
40
+
41
+ - `section` prints at most 8,000 characters; `--max` changes that. A heading given as text matches exactly first, then as a case-insensitive substring.
42
+ - `find` prints at most 30 lines; `--limit` changes that.
43
+
44
+ ## How a page is read
45
+
46
+ - Only `http` and `https` URLs are read, also when a page redirects.
47
+ - If the site serves Markdown, either at the URL itself or at the URL with `.md` added, nibs uses it. Many documentation sites do.
48
+ - Otherwise nibs turns the HTML into headings, text and code blocks (from `<pre>`). It leaves out scripts, styles, navigation, sidebars, and the page's own header and footer; a header or footer inside an article is kept. Only heading tags become headings, and only `<pre>` becomes a code block: a line of text that would read as either (see below) gets a backslash in front.
49
+ - Control characters in the page are removed before printing.
50
+ - nibs does not run JavaScript. A page that builds its content in the browser comes back with little or no text; when no headings are found, nibs says so.
51
+ - A fetched page is kept for 15 minutes in `~/.cache/nibs` (or `$XDG_CACHE_HOME/nibs`), so `toc`, `section` and `find` on the same page fetch it once.
52
+
53
+ ### Headings and code blocks
54
+
55
+ nibs splits a page by its Markdown headings. It reads only what it needs for that, by this rule rather than all of CommonMark:
56
+
57
+ - A heading is a line that starts with 1 to 6 `#` and a space.
58
+ - A line that starts with three or more `` ` `` or `~` opens a code block, after any spaces. The block ends at a line of the same character, at least as long, with nothing after it. Headings inside a code block are not headings.
59
+ - Nothing else is read as structure: not headings underlined with `===` or `---`, not code indented by four spaces.
60
+
61
+ ## Using it from an agent
62
+
63
+ Tell the agent in its instructions, for example in `CLAUDE.md` or `AGENTS.md`:
64
+
65
+ ```markdown
66
+ To read documentation on the web, run `nibs toc <url>` to see the headings,
67
+ then `nibs section <url> <n>` for the part you need, or `nibs find <url> <regex>`.
68
+ ```
69
+
70
+ ## The usage record
71
+
72
+ Each run adds one line to `~/.local/state/nibs/usage.jsonl` (or `$XDG_STATE_HOME/nibs/usage.jsonl`): the time, the command, the URL, the page's size and how many characters were printed. `nibs stats` reads it. Set `NIBS_LOG` to another path to keep the record there. Nothing is sent anywhere.
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nibs"
7
+ dynamic = ["version"]
8
+ description = "Read a web page by its headings: list them, then print only the section you need"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "nhinata" }]
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.11"
14
+ dependencies = []
15
+ keywords = ["llm", "agent", "tokens", "web", "markdown", "cli"]
16
+ classifiers = [
17
+ "Environment :: Console",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Internet :: WWW/HTTP",
21
+ "Topic :: Utilities",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/nhinata/nibs"
26
+
27
+ [project.scripts]
28
+ nibs = "nibs.cli:main"
29
+
30
+ [tool.hatch.version]
31
+ source = "vcs"
32
+ fallback-version = "0.0.0"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/nibs"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("nibs")
5
+ except PackageNotFoundError: # running from a checkout that was never installed
6
+ __version__ = "unknown"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,48 @@
1
+ """Pages already read, so that toc, section and find on one page fetch it once."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import time
7
+
8
+ from .paths import cache_dir
9
+
10
+ # How long a fetched page is reused; the same default as Claude Code's WebFetch.
11
+ TTL_SECONDS = 15 * 60
12
+
13
+
14
+ def _path(url):
15
+ return cache_dir() / (hashlib.sha256(url.encode()).hexdigest() + ".json")
16
+
17
+
18
+ def get(url):
19
+ try:
20
+ with open(_path(url), encoding="utf-8") as f:
21
+ entry = json.load(f)
22
+ except (OSError, ValueError):
23
+ return None
24
+ if entry.get("url") != url or time.time() - entry.get("time", 0) > TTL_SECONDS:
25
+ return None
26
+ return entry.get("text")
27
+
28
+
29
+ def put(url, text):
30
+ try:
31
+ cache_dir().mkdir(parents=True, exist_ok=True)
32
+ _prune()
33
+ tmp = _path(url).with_suffix(".tmp")
34
+ with open(tmp, "w", encoding="utf-8") as f:
35
+ json.dump({"url": url, "time": time.time(), "text": text}, f)
36
+ os.replace(tmp, _path(url))
37
+ except OSError:
38
+ pass # A page that cannot be cached is fetched again next time.
39
+
40
+
41
+ def _prune():
42
+ now = time.time()
43
+ for p in cache_dir().glob("*.json"):
44
+ try:
45
+ if now - p.stat().st_mtime > TTL_SECONDS:
46
+ p.unlink()
47
+ except OSError:
48
+ pass