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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ coverage

/srcpack.config.ts
/.srcpack/
tmp/
12 changes: 12 additions & 0 deletions .vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ export default defineConfig({
text: "001 — git: source tokens",
link: "/adr/001-git-source-tokens",
},
{
text: "002 — Minimum Node version",
link: "/adr/002-minimum-node-version",
},
{
text: "003 — Linear issues as virtual files",
link: "/adr/003-linear-issues-as-virtual-files",
},
{
text: "004 — Path boundaries",
link: "/adr/004-path-boundaries",
},
],
},
],
Expand Down
49 changes: 34 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Srcpack

[![npm version](https://img.shields.io/npm/v/srcpack)](https://www.npmjs.com/package/srcpack) [![npm downloads](https://img.shields.io/npm/dm/srcpack)](https://www.npmjs.com/package/srcpack) [![CI](https://img.shields.io/github/actions/workflow/status/kriasoft/srcpack/ci.yml?branch=main&label=CI)](https://github.com/kriasoft/srcpack/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/srcpack)](./LICENSE) [![Discord](https://img.shields.io/discord/643523529131950086?label=Discord&logo=discord&logoColor=white)](https://discord.com/invite/aG83xEb6RX)

Zero-config CLI for bundling code into LLM-optimized context files.

**Requirements:** Node.js 22.18+ or Bun
Expand Down Expand Up @@ -48,14 +50,14 @@ Or add to `package.json`:

### Options

| Option | Default | Description |
| ------------- | ---------- | -------------------------------------- |
| `outDir` | `.srcpack` | Output directory for bundles |
| `emptyOutDir` | `true`\* | Empty output directory before bundling |
| `bundles` | — | Named bundles with glob patterns |
| `upload` | — | Upload destination(s) |
| Option | Default | Description |
| ------------- | ---------- | ------------------------------------- |
| `outDir` | `.srcpack` | Output directory for bundles |
| `emptyOutDir` | `true`\* | Empty output directory before writing |
| `bundles` | — | Named bundle definitions |
| `upload` | — | Upload destination(s) |

\*`emptyOutDir` defaults to `true` when `outDir` is inside project root. When `outDir` is outside root, a warning is emitted unless explicitly set. Emptying happens only on a full run, so `npx srcpack web` leaves other bundles in place.
\*Only the default `.srcpack` is emptied automatically — it's srcpack's directory by convention. Any other `outDir` needs an explicit `emptyOutDir: true`, so `outDir: "src"` can't quietly delete your sources. Emptying also happens only on a full run, so `npx srcpack web` leaves other bundles in place.

### Bundle Config

Expand All @@ -75,6 +77,7 @@ Or add to `package.json`:
// Full options
{
include: "src/**/*",
linear: { team: "ENG" }, // Linear issues as virtual files
outfile: "~/Downloads/bundle.txt", // custom output path
index: true, // include index header (default)
prompt: "./prompts/review.md" // prepend from file (or inline text)
Expand All @@ -85,6 +88,22 @@ Patterns follow glob syntax. Prefix with `!` to exclude, `+` to force-include (b

A pattern can also name a set of changed files: `git:staged`, `git:unstaged`, `git:untracked`, `git:dirty`, or `git:<rev>` (e.g. `git:main`, `git:HEAD~3`). Deleted files are skipped, and `git:<rev>` compares against the merge base so a stale branch still reports only your own changes. See [Git sources](https://kriasoft.com/srcpack/configuration#git-sources-git-prefix).

### Linear Issues

A bundle can include [Linear](https://linear.app) issues next to your code. Each issue becomes a virtual file at `linear/issues/ENG-123.md`, so it gets its own index entry and line range — letting you ask whether `[4] src/board.ts` actually implements `[2] ENG-123`.

```typescript
bundles: {
backlog: { linear: "ENG" }, // non-terminal issues, team ENG
planning: {
include: ["docs/**/*.md"],
linear: { team: "ENG", project: "Roadmap" }, // scoped to one project
},
}
```

Authentication reads `LINEAR_API_KEY` from the environment (Linear → Settings → Security & access → Personal API keys), never from the config file. `team` is required, completed/canceled/duplicate issues are excluded by default, and issues obey `!` exclusions like any other entry. See [Linear issues](https://kriasoft.com/srcpack/configuration#linear-issues).

### Google Drive Upload

To upload bundles to Google Drive, add OAuth credentials to your config:
Expand Down Expand Up @@ -115,16 +134,16 @@ export default defineConfig({

```text
# Index (3 files)
# [1] src/index.ts L1-L42 (42 lines)
# [2] src/utils.ts L43-L89 (47 lines)
# [3] src/api.ts L90-L150 (61 lines)
# [1] src/api.ts L7-L67 (61 lines)
# [2] src/index.ts L69-L110 (42 lines)
# [3] src/utils.ts L112-L158 (47 lines)

#==> [1] src/index.ts <==
import { utils } from "./utils";
#==> [1] src/api.ts <==
export async function fetchBoard() {
...

#==> [2] src/utils.ts <==
export function utils() {
#==> [2] src/index.ts <==
import { utils } from "./utils";
...
```

Expand All @@ -141,7 +160,7 @@ npx srcpack --staged # Bundle staged changes (no config needed)
npx srcpack --dirty # Bundle staged + unstaged + untracked
npx srcpack --since main # Bundle changes since main
npx srcpack --dry-run # Preview without writing files
npx srcpack --emptyOutDir # Empty output directory before bundling
npx srcpack --emptyOutDir # Empty output directory before writing
npx srcpack --no-emptyOutDir # Keep existing files in output directory
npx srcpack --no-upload # Bundle only, skip upload
npx srcpack init # Interactive config setup
Expand Down
75 changes: 18 additions & 57 deletions docs/adr/001-git-source-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,85 +4,46 @@

## Context

Bundling "what I'm currently changing" is the most common ad-hoc need: hand an
LLM your staged diff, or everything on this branch, for review. Globs can't
express it — the file set comes from git, not from the filesystem layout.
Bundling "what I'm currently changing" is the most common ad-hoc need: hand an LLM your staged diff, or everything on this branch, for review. Globs can't express it — the file set comes from git, not from the filesystem layout.

The obvious-looking API spreads a resolved list into the pattern array:

```ts
review: [...$staged, "!bun.lock"];
```

It reads well and it's wrong. A spread forces `$staged` to be concrete at
config **import** time, which means git runs when the module loads (even for
`srcpack docs`), the config stops being inspectable data, `package.json` config
becomes impossible, and import-time `cwd` may differ from the resolved `root`.
Worst of all, concrete paths land in an array that is later matched as globs,
so a staged file named `src/[id].tsx` silently matches nothing.
It reads well and it's wrong. A spread forces `$staged` to be concrete at config **import** time, which means git runs when the module loads (even for `srcpack docs`), the config stops being inspectable data, `package.json` config becomes impossible, and import-time `cwd` may differ from the resolved `root`. Worst of all, concrete paths land in an array that is later matched as globs, so a staged file named `src/[id].tsx` silently matches nothing.

## Decision

A pattern may be a `git:` source instead of a glob, resolved lazily inside
`resolvePatterns()` alongside globs:
A pattern may be a `git:` source instead of a glob, resolved lazily inside `resolvePatterns()` alongside globs:

```ts
review: ["git:staged", "!bun.lock"];
```

Sources: `git:staged`, `git:unstaged`, `git:untracked`, `git:dirty`, and
`git:<rev>` for any revision or range.
Sources: `git:staged`, `git:unstaged`, `git:untracked`, `git:dirty`, and `git:<rev>` for any revision or range.

CLI flags `--staged`, `--dirty`, and `--since <rev>` build a one-off bundle from
the same tokens, and work with no config file at all.
CLI flags `--staged`, `--dirty`, and `--since <rev>` build a one-off bundle from the same tokens, and work with no config file at all.

Supporting decisions:

- **Deleted and unmerged entries are filtered** (`--diff-filter=ACMR`), and
every candidate is stat-checked before bundling. Git lists paths; only some
of them are readable regular files (submodules, or a file deleted after git
listed it).
- **`git:<rev>` uses `git diff --merge-base`** for a single revision. `git:main`
on a branch that has fallen behind main would otherwise report other people's
commits. For an ancestor like `HEAD~3` the merge base is the revision itself,
so this is a no-op — one rule that's right in both cases. Ranges pass through
verbatim.
- **A source selects paths; content always comes from the worktree.** Reading
staged blobs would put content in the bundle that doesn't match the files on
disk — confusing when the LLM's answer cites a line.
- **`.gitignore` does not apply** to git sources. Anything git reports is either
tracked (possibly force-added past `.gitignore`, and deliberately so) or was
filtered by `--exclude-standard` already.
- **`!git:...` and `+git:...` are errors.** Exclusion has no clear meaning, and
force-include is already implied. Failing loudly beats a silent no-op.
- **Empty bundles are not written**, and a previous run's file is removed.
"Nothing staged" is routine, and a stale bundle that then gets uploaded to
Drive is worse than no file.
- **Symlinks are never followed** (`lstat`, not `stat`). Git happily tracks a
link pointing anywhere; following one would bundle a file from outside the
project under an innocuous in-repo name.
- **Ad-hoc CLI bundles are never uploaded.** The user configured upload for the
bundles they declared, and `upload.exclude` cannot name a bundle that only
exists for one run.
- **`outDir` and every configured `outfile` are excluded from every bundle.**
Ad-hoc runs don't empty `outDir`, so `git:untracked` reports the last run's
bundle and each rerun nests it one level deeper.
- **Deleted and unmerged entries are filtered** (`--diff-filter=ACMR`), and every candidate is stat-checked before bundling. Git lists paths; only some of them are readable regular files (submodules, or a file deleted after git listed it).
- **`git:<rev>` uses `git diff --merge-base`** for a single revision. `git:main` on a branch that has fallen behind main would otherwise report other people's commits. For an ancestor like `HEAD~3` the merge base is the revision itself, so this is a no-op — one rule that's right in both cases. Ranges pass through verbatim.
- **A source selects paths; content always comes from the worktree.** Reading staged blobs would put content in the bundle that doesn't match the files on disk — confusing when the LLM's answer cites a line.
- **`.gitignore` does not apply** to git sources. Anything git reports is either tracked (possibly force-added past `.gitignore`, and deliberately so) or was filtered by `--exclude-standard` already.
- **`!git:...` and `+git:...` are errors.** Exclusion has no clear meaning, and force-include is already implied. Failing loudly beats a silent no-op.
- **Empty bundles are not written**, and a previous run's file is removed. "Nothing staged" is routine, and a stale bundle that then gets uploaded to Drive is worse than no file.
- **Symlinks are never followed** (`lstat`, not `stat`). Git happily tracks a link pointing anywhere; following one would bundle a file from outside the project under an innocuous in-repo name.
- **Ad-hoc CLI bundles are never uploaded.** The user configured upload for the bundles they declared, and `upload.exclude` cannot name a bundle that only exists for one run.
- **`outDir` and every configured `outfile` are excluded from every bundle.** Ad-hoc runs don't empty `outDir`, so `git:untracked` reports the last run's bundle and each rerun nests it one level deeper.

## Alternatives

- **Typed helpers** (`[staged(), "!bun.lock"]`) — real autocomplete, but the
array becomes `(string | Source)[]`, it can't work in `package.json`, and it
adds permanent public exports.
- **A `from` field** (`{ from: "staged", include: "src/**" }`) — conceptually
cleaner (a source isn't a glob), but adds a second axis plus an `exclude`
field, giving two ways to say the same thing.
- **An async resolver** (`include: async ({ git }) => …`) — maximum power, but
the config is no longer data and `git.*` becomes an API to maintain.
- **Typed helpers** (`[staged(), "!bun.lock"]`) — real autocomplete, but the array becomes `(string | Source)[]`, it can't work in `package.json`, and it adds permanent public exports.
- **A `from` field** (`{ from: "staged", include: "src/**" }`) — conceptually cleaner (a source isn't a glob), but adds a second axis plus an `exclude` field, giving two ways to say the same thing.
- **An async resolver** (`include: async ({ git }) => …`) — maximum power, but the config is no longer data and `git.*` becomes an API to maintain.

## Consequences

The pattern array gains a second kind of entry, so `git:` is now reserved as a
scheme (a branch named `staged` needs `git:refs/heads/staged`). In exchange
there is no new config shape, no new export, and the feature composes with `!`
exclusions and globs for free. Future non-glob sources can reuse the
`scheme:` convention.
The pattern array gains a second kind of entry, so `git:` is now reserved as a scheme (a branch named `staged` needs `git:refs/heads/staged`). In exchange there is no new config shape, no new export, and the feature composes with `!` exclusions and globs for free. Future non-glob sources can reuse the `scheme:` convention.
54 changes: 14 additions & 40 deletions docs/adr/002-minimum-node-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,29 @@

## Context

`srcpack.config.ts` is the primary config format — the first entry in
`searchPlaces`, and what `srcpack init` writes. Loading TypeScript at runtime
is therefore not optional, and until now cosmiconfig carried its own
`typescript` dependency to do it.
`srcpack.config.ts` is the primary config format — the first entry in `searchPlaces`, and what `srcpack init` writes. Loading TypeScript at runtime is therefore not optional, and until now cosmiconfig carried its own `typescript` dependency to do it.

cosmiconfig 10 removes that dependency in favour of Node's built-in type
stripping, and requires `^22.18 || >=24`. Type stripping is enabled by default
from Node 22.18, so the loader works on any runtime cosmiconfig itself accepts —
but on Node 20 a `.ts` config now fails to load at all.
cosmiconfig 10 removes that dependency in favour of Node's built-in type stripping, and requires `^22.18 || >=24`. Type stripping is enabled by default from Node 22.18, so the loader works on any runtime cosmiconfig itself accepts — but on Node 20 a `.ts` config now fails to load at all.

The declared floor was `>=18.0.0`, which had already drifted from reality:
Node 18 reached end-of-life 2025-04-30 and Node 20 followed on 2026-04-30.
The declared floor was `>=18.0.0`, which had already drifted from reality: Node 18 reached end-of-life 2025-04-30 and Node 20 followed on 2026-04-30.

## Decision

`engines.node` becomes `^22.18.0 || >=24`, matching cosmiconfig's own range
rather than inventing a looser one.
`engines.node` becomes `^22.18.0 || >=24`, matching cosmiconfig's own range rather than inventing a looser one.

Pinning to the dependency's range is deliberate. A floor of `>=20` would install
cleanly and then fail at the first `srcpack.config.ts` — the failure would
surface as a confusing parse error rather than an unmet engine warning at
install time.
Pinning to the dependency's range is deliberate. A floor of `>=20` would install cleanly and then fail at the first `srcpack.config.ts` — the failure would surface as a confusing parse error rather than an unmet engine warning at install time.

## Alternatives

- **Keep `>=18` and bundle a TypeScript parser** — restores Node 20 support at
the cost of a heavyweight dependency for a runtime everyone's package manager
already warns about.
- **Drop `.ts` config support below Node 22.18, keep the floor low** — two
behaviours for one documented feature, discovered only at run time.
- **Keep `>=18` and bundle a TypeScript parser** — restores Node 20 support at the cost of a heavyweight dependency for a runtime everyone's package manager already warns about.
- **Drop `.ts` config support below Node 22.18, keep the floor low** — two behaviours for one documented feature, discovered only at run time.

## Consequences

Config files must use erasable syntax only. Type annotations, `satisfies`, and
`import type` are fine; `enum` and `namespace` are not — Node strips types, it
does not compile them. `defineConfig` objects use none of the latter, so the
`init` template and every documented example are unaffected.

Node also derives a `.ts` file's module format from the nearest package.json
`type`, so in a CommonJS project the template's `import { defineConfig }` line
is a syntax error — the bundled TypeScript compiler used to hide this. So
`srcpack.config.mts` joins `searchPlaces`, and `init` writes it whenever the
project is not `"type": "module"`. `.mts` is unconditionally ESM and loads
either way; `.ts` stays the default for ESM projects because it is the name
the docs use.

The test suite runs on Bun, which loads either extension regardless of package
type and so cannot see this class of failure. CI installs the packed tarball
into a CommonJS project and runs the CLI under Node to cover it.

Both EOL runtimes are dropped in one step, so the next floor bump can wait for
a real forcing function rather than following each dependency's minor releases.
Config files must use erasable syntax only. Type annotations, `satisfies`, and `import type` are fine; `enum` and `namespace` are not — Node strips types, it does not compile them. `defineConfig` objects use none of the latter, so the `init` template and every documented example are unaffected.

Node also derives a `.ts` file's module format from the nearest package.json `type`, so in a CommonJS project the template's `import { defineConfig }` line is a syntax error — the bundled TypeScript compiler used to hide this. So `srcpack.config.mts` joins `searchPlaces`, and `init` writes it whenever the project is not `"type": "module"`. `.mts` is unconditionally ESM and loads either way; `.ts` stays the default for ESM projects because it is the name the docs use.

The test suite runs on Bun, which loads either extension regardless of package type and so cannot see this class of failure. CI installs the packed tarball into a CommonJS project and runs the CLI under Node to cover it.

Both EOL runtimes are dropped in one step, so the next floor bump can wait for a real forcing function rather than following each dependency's minor releases.
Loading