Skip to content

Commit 8250f00

Browse files
Itay Inbarclaude
andcommitted
v1.9.3: LITTLE_CODER_EXTRA_EXTENSIONS env var for third-party pi extensions (#46)
The launcher passes --no-extensions to pi, so a globally-installed pi package (pi-ponytail, etc) doesn't reach the running little-coder. Until now the supported workaround was `little-coder -e <abs path>` per launch, or dropping a wrapper into <pkgRoot>/.pi/extensions/ — the latter gets wiped on upgrade. New env var LITTLE_CODER_EXTRA_EXTENSIONS: path-delimited list of extra extensions to layer on top of the bundled set. Each entry can be a direct file or a directory containing index.ts / index.js; ~/ is expanded; missing paths emit a one-line warning and are skipped so a typo doesn't kill the session. Parsing rules live in bin/extras.mjs so they're unit-testable in isolation (9 cases). Launcher integration verified end-to-end — warning prints for a bad path; valid paths pass through silently as --extension <entry> flags to pi. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 818f0e8 commit 8250f00

6 files changed

Lines changed: 203 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to little-coder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and little-coder's public interface (CLI, providers, tools, skills) follows semver starting at `v0.0.1` post-rename.
44

5+
## [v1.9.3] — 2026-06-18
6+
7+
### Added
8+
- **`LITTLE_CODER_EXTRA_EXTENSIONS` env var: layer third-party pi extensions onto the bundled set without forking the installed package** ([#46](https://github.com/itayinbarr/little-coder/issues/46)). Path-delimited list (`:` on POSIX, `;` on Windows — `node:path.delimiter`) of extension paths. Each entry can be a direct file (e.g. a `pi-ponytail`-style `extensions/ponytail.js`) or a directory containing `index.ts` / `index.js` (the launcher prefers `.ts`). A leading `~/` is expanded; missing paths log a one-line warning to stderr and are skipped (a typo in the env var doesn't kill the session). Survives upgrades — drop the env var into your shell rc once and every `little-coder` run picks up the extras. Example: `LITTLE_CODER_EXTRA_EXTENSIONS=~/.local/lib/node_modules/pi-ponytail/extensions/ponytail.js little-coder`. Parsing rules live in `bin/extras.mjs` so they're unit-testable in isolation (9 cases covering direct-file / dir-index-resolution / `index.ts`-preference / missing-path warning / `~/` expansion / multiple entries / whitespace trimming). The launcher-level integration is exercised end-to-end (warning prints for a bad path; valid paths pass through silently to pi as `--extension <entry>` flags). Closest siblings — third-party skill bundles — are not yet covered; `skill-inject` still discovers only `<pkgRoot>/skills/tools/*.md`, and a follow-up will add the same kind of override.
9+
10+
### Notes for upgraders
11+
- No CLI-flag or public-API changes. The new env var is opt-in: unset = identical behavior to v1.9.2. If you were carrying a custom wrapper extension inside the installed npm package (which gets wiped on upgrade), you can drop it and use the env var instead.
12+
13+
---
14+
515
## [v1.9.2] — 2026-06-18
616

717
### Fixed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ The agent uses the directory you launched it from as its working directory — `
6767
- **Sub-coders (`dispatch`)** — little-coder can spawn isolated child sessions to research a question (read the repo + browse online, read-only) and report back concisely, without cluttering the main conversation. A live panel above the input tracks them. Tune parallelism with `LITTLE_CODER_SUBCODER_CONCURRENCY` (default 2).
6868
- **Sessions** — each session is auto-named from your first prompt (rename with `/name`) and shown in the terminal tab title. Use `/resume` to list and reopen past sessions for the current directory.
6969
- **Read-before-edit** — editing a file requires reading it first, so edits match the file's exact current text.
70+
- **Third-party extensions (`LITTLE_CODER_EXTRA_EXTENSIONS`)** — path-delimited list (`:` on POSIX, `;` on Windows) of extension paths to layer on top of the bundled set. Each entry can be a direct file (e.g. a `pi-ponytail`-style `extensions/ponytail.js`) or a directory containing `index.ts` / `index.js`. `~/` is expanded; missing paths log a warning and are skipped. Survives upgrades, no patching the installed package. Example: `LITTLE_CODER_EXTRA_EXTENSIONS=~/.local/lib/node_modules/pi-ponytail/extensions/ponytail.js little-coder`. (Single-file extensions can still use `little-coder -e <path>` for one-off loads.)
7071

7172
For local providers (llama.cpp, Ollama, LM Studio) pi expects *some* value in the API-key env even though local servers ignore it:
7273

bin/extras.mjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Helpers for layering third-party pi extensions onto little-coder's bundled
2+
// set. Extracted from the launcher so the parsing rules — path-delimited list,
3+
// `~/` expansion, directory-with-index resolution, missing-path warning — are
4+
// directly unit-testable without spawning the whole CLI.
5+
6+
import { existsSync, statSync } from "node:fs";
7+
import { homedir } from "node:os";
8+
import { delimiter, join } from "node:path";
9+
10+
// Given the value of LITTLE_CODER_EXTRA_EXTENSIONS (path-delimited list of
11+
// extension paths), return the resolved entry files that should be passed to pi
12+
// as `--extension <entry>` flags. Skips empty segments, expands a leading `~/`,
13+
// resolves a directory entry to its `index.ts` (preferred) or `index.js`, and
14+
// records a one-line warning for each missing/unusable path so a typo in the
15+
// env var doesn't kill the session — it just doesn't load that extension.
16+
export function parseExtraExtensions(
17+
envValue,
18+
{ home = homedir(), exists = existsSync, stat = statSync } = {},
19+
) {
20+
const entries = [];
21+
const warnings = [];
22+
for (const raw of String(envValue ?? "").split(delimiter)) {
23+
const trimmed = raw.trim();
24+
if (!trimmed) continue;
25+
const expanded = trimmed === "~"
26+
? home
27+
: trimmed.startsWith("~/")
28+
? home + trimmed.slice(1)
29+
: trimmed;
30+
if (!exists(expanded)) {
31+
warnings.push(
32+
`little-coder: LITTLE_CODER_EXTRA_EXTENSIONS path not found, skipping: ${expanded}`,
33+
);
34+
continue;
35+
}
36+
let entry = expanded;
37+
try {
38+
if (stat(expanded).isDirectory()) {
39+
const candidates = [join(expanded, "index.ts"), join(expanded, "index.js")];
40+
const found = candidates.find((p) => exists(p));
41+
if (!found) {
42+
warnings.push(
43+
`little-coder: LITTLE_CODER_EXTRA_EXTENSIONS dir has no index.ts/index.js, skipping: ${expanded}`,
44+
);
45+
continue;
46+
}
47+
entry = found;
48+
}
49+
} catch {
50+
// unreadable / racing stat — skip silently
51+
continue;
52+
}
53+
entries.push(entry);
54+
}
55+
return { entries, warnings };
56+
}

bin/extras.test.mjs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect } from "vitest";
2+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { delimiter, join } from "node:path";
5+
import { parseExtraExtensions } from "./extras.mjs";
6+
7+
function setupTmp() {
8+
return mkdtempSync(join(tmpdir(), "lc-extras-"));
9+
}
10+
11+
describe("parseExtraExtensions", () => {
12+
it("returns no entries when env is unset / empty", () => {
13+
expect(parseExtraExtensions(undefined).entries).toEqual([]);
14+
expect(parseExtraExtensions("").entries).toEqual([]);
15+
expect(parseExtraExtensions(delimiter + delimiter).entries).toEqual([]);
16+
});
17+
18+
it("forwards a direct file path verbatim", () => {
19+
const dir = setupTmp();
20+
try {
21+
const file = join(dir, "ponytail.js");
22+
writeFileSync(file, "export default function(){}");
23+
const { entries, warnings } = parseExtraExtensions(file);
24+
expect(entries).toEqual([file]);
25+
expect(warnings).toEqual([]);
26+
} finally {
27+
rmSync(dir, { recursive: true });
28+
}
29+
});
30+
31+
it("resolves a directory entry to its index.ts (preferred)", () => {
32+
const dir = setupTmp();
33+
try {
34+
const extDir = join(dir, "ponytail");
35+
mkdirSync(extDir);
36+
writeFileSync(join(extDir, "index.ts"), "");
37+
writeFileSync(join(extDir, "index.js"), "");
38+
const { entries } = parseExtraExtensions(extDir);
39+
expect(entries).toEqual([join(extDir, "index.ts")]);
40+
} finally {
41+
rmSync(dir, { recursive: true });
42+
}
43+
});
44+
45+
it("falls back to index.js when index.ts is absent", () => {
46+
const dir = setupTmp();
47+
try {
48+
const extDir = join(dir, "ponytail");
49+
mkdirSync(extDir);
50+
writeFileSync(join(extDir, "index.js"), "");
51+
const { entries } = parseExtraExtensions(extDir);
52+
expect(entries).toEqual([join(extDir, "index.js")]);
53+
} finally {
54+
rmSync(dir, { recursive: true });
55+
}
56+
});
57+
58+
it("warns and skips a directory without index.ts/index.js", () => {
59+
const dir = setupTmp();
60+
try {
61+
const extDir = join(dir, "empty");
62+
mkdirSync(extDir);
63+
const { entries, warnings } = parseExtraExtensions(extDir);
64+
expect(entries).toEqual([]);
65+
expect(warnings).toHaveLength(1);
66+
expect(warnings[0]).toContain("no index.ts/index.js");
67+
} finally {
68+
rmSync(dir, { recursive: true });
69+
}
70+
});
71+
72+
it("warns and skips a missing path (typo doesn't kill the session)", () => {
73+
const { entries, warnings } = parseExtraExtensions("/tmp/does-not-exist-zzz-xyz-123");
74+
expect(entries).toEqual([]);
75+
expect(warnings).toHaveLength(1);
76+
expect(warnings[0]).toContain("path not found");
77+
});
78+
79+
it("expands a leading ~/ using the supplied home", () => {
80+
const dir = setupTmp();
81+
try {
82+
const extDir = join(dir, "fake-home", "ext");
83+
mkdirSync(extDir, { recursive: true });
84+
writeFileSync(join(extDir, "index.js"), "");
85+
const { entries } = parseExtraExtensions("~/ext", {
86+
home: join(dir, "fake-home"),
87+
});
88+
expect(entries).toEqual([join(extDir, "index.js")]);
89+
} finally {
90+
rmSync(dir, { recursive: true });
91+
}
92+
});
93+
94+
it("layers multiple extensions from one path-delimited list", () => {
95+
const dir = setupTmp();
96+
try {
97+
const a = join(dir, "a.js");
98+
writeFileSync(a, "");
99+
const b = join(dir, "b.js");
100+
writeFileSync(b, "");
101+
const { entries } = parseExtraExtensions([a, b].join(delimiter));
102+
expect(entries).toEqual([a, b]);
103+
} finally {
104+
rmSync(dir, { recursive: true });
105+
}
106+
});
107+
108+
it("trims whitespace around list entries", () => {
109+
const dir = setupTmp();
110+
try {
111+
const a = join(dir, "a.js");
112+
writeFileSync(a, "");
113+
const { entries } = parseExtraExtensions(` ${a} `);
114+
expect(entries).toEqual([a]);
115+
} finally {
116+
rmSync(dir, { recursive: true });
117+
}
118+
});
119+
});

bin/little-coder.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
1717
import { dirname, join, resolve } from "node:path";
1818
import { fileURLToPath } from "node:url";
1919
import { checkForUpdate } from "./update-check.mjs";
20+
import { parseExtraExtensions } from "./extras.mjs";
2021

2122
// ---- 1. Node version preflight (>= 22.19.0, matching pi.dev) ----
2223
const MIN_NODE = [22, 19, 0];
@@ -110,6 +111,21 @@ if (existsSync(extDir)) {
110111
}
111112
}
112113

114+
// ---- 4b. Third-party extensions via LITTLE_CODER_EXTRA_EXTENSIONS ----
115+
// Path-delimited list (`:` on POSIX, `;` on Windows — node:path.delimiter)
116+
// of extra extension paths to load alongside the bundled ones. Each entry can
117+
// be either a direct file path (e.g. a pi-ponytail-style `extensions/ponytail.js`)
118+
// or a directory containing `index.ts` / `index.js`. Survives upgrades and
119+
// avoids the "fork the installed npm package" workaround that issue #46 hit.
120+
// Parsing rules — ~/ expansion, directory-with-index resolution, one-line
121+
// warning for missing/unusable entries — live in ./extras.mjs so they're
122+
// unit-testable in isolation.
123+
{
124+
const { entries, warnings } = parseExtraExtensions(process.env.LITTLE_CODER_EXTRA_EXTENSIONS);
125+
for (const w of warnings) console.error(w);
126+
for (const entry of entries) extArgs.push("--extension", entry);
127+
}
128+
113129
// ---- 5. Update check (best-effort, blocks on TTY prompt only) ----
114130
let currentVersion = "0.0.0";
115131
try {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "little-coder",
3-
"version": "1.9.2",
3+
"version": "1.9.3",
44
"description": "A pi-based coding agent optimized for small local language models. Reproduces the whitepaper's scaffold-model-fit adaptations as pi extensions.",
55
"homepage": "https://github.com/itayinbarr/little-coder",
66
"repository": {

0 commit comments

Comments
 (0)