akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
142 lines (111 loc) • 5.1 kB
Markdown
# Website Sources
`akm import <url>` and every other URL-based knowledge read path pass through
a pluggable fetcher seam before the built-in website scraper.
akm ships five built-in fetchers, probed in this order (host-specific
fetchers before the deliberately loose path-shaped RSS matcher; any fetcher
returning `null` falls through to the generic website crawler):
| Fetcher | Handles | What it extracts |
| --- | --- | --- |
| `github-repository` | `github.com/<owner>/<repo>` URLs | The repository README, rendered to markdown |
| `youtube-transcript` | YouTube watch/short URLs | The video description and transcript when captions are available |
| `bluesky-profile` | `bsky.app` profile URLs | Public profile and recent posts via the unauthenticated AT Protocol API |
| `x` | `x.com`/`twitter.com` profiles, posts, and articles | Post/article content; profile timelines need a bearer token (below) |
| `rss-feed` | Feed-shaped paths (`.rss`/`.atom`/`.xml`, `?feed=`) | Feed items as markdown sections |
## X credentials
The `x` fetcher reads public post/article pages without credentials. Profile
timelines require an API bearer token, resolved in order from the
`X_BEARER_TOKEN` environment variable, then the `secrets/x-bearer-token` akm
secret (`akm secret set secrets/x-bearer-token`). Alternatively, set `X_RSS_TEMPLATE`
to an RSS-bridge URL containing `{username}` to fetch profiles through the
`rss-feed` path instead.
## Crawl options
When no fetcher claims a URL, the generic crawler applies the `website`
source descriptor's knobs (see the config schema): `maxPages` (default 50),
`maxDepth` (default 3), `respectRobots` (default `true`; set `false` to
bypass robots.txt for that source), and `crawlTimeoutMs` per request.
## Why
Some sites need custom extraction logic that the generic HTML-to-markdown path
cannot provide well.
Examples:
- YouTube: fetch the transcript instead of the video description page chrome
- GitHub issues: fetch the issue body plus comments instead of the repository shell
- PDF-heavy sites: extract the underlying text instead of scraping an embed page
## Discovery
Drop fetcher modules into:
```text
<stashDir>/scripts/wiki-fetchers/
```
`<stashDir>` is the active bundle for the current operation. When a command has a
resolved write target (for example `akm import --target ...`), akm loads
fetchers from that target bundle before falling back to the built-in website
scraper.
Files ending in `.ts`, `.js`, or `.mjs` are loaded in alphabetical order.
The first fetcher whose `matches()` returns `true` gets a chance to handle the
URL.
If a fetcher:
- returns `null`, akm falls through to the next fetcher or the built-in website scraper
- throws, akm logs a warning and falls through to the next fetcher or the built-in website scraper
## Interface
Fetchers should export a default object with this shape:
```ts
export interface WikiSnapshotResult {
url: string;
title: string;
markdown: string;
preferredName?: string;
tags?: string[];
}
export interface FetcherContext {
stashDir: string;
timeoutMs: number;
signal?: AbortSignal;
/**
* Resolve a secret by ref (e.g. `secrets/x-bearer-token`) from akm's
* secret store, or null when absent. Injected rather than imported so
* fetchers stay leaves in the import graph; never log the returned value.
*/
resolveSecret?: (ref: string) => string | null;
}
export interface WikiSnapshotFetcher {
name: string;
matches(url: URL, context: FetcherContext): boolean;
fetch(url: URL, context: FetcherContext): Promise<WikiSnapshotResult | null>;
}
```
`markdown` should contain only the body content. akm still wraps the result in
the standard raw snapshot frontmatter (`name`, `description`, `sourceUrl`,
`title`, `updated`, a `lint_skip` entry for `stale-path`, and `tags`) so
downstream wiki tooling continues to work normally.
Custom `tags` are appended to the default snapshot tags (`website` and the
resolved hostname). They do not replace those defaults.
## Example
```ts
export default {
name: "youtube-transcript",
matches(url: URL, _context: FetcherContext) {
return (
(url.hostname === "www.youtube.com" && url.pathname === "/watch") ||
url.hostname === "youtu.be"
);
},
async fetch(url: URL, _context: FetcherContext) {
const videoId = url.hostname === "youtu.be" ? url.pathname.slice(1) : url.searchParams.get("v");
if (!videoId) return null;
const transcript = await getTranscript(videoId);
return {
url: url.toString(),
title: `Video ${videoId}`,
markdown: `## Transcript\n\n${transcript}`,
preferredName: `videos/${videoId}`,
tags: ["video", "transcript"],
};
},
};
```
## Notes
- The fetcher seam is shared by `akm import <url>` and other URL-based
knowledge reads that use `fetchWebsiteMarkdownSnapshot()`.
- The built-in website scraper remains the default path when no custom fetcher
matches.
- Bundle-local fetchers are loaded before built-ins, so you can override any
built-in fetcher's behavior for your own workflow when needed.