Compare commits
11 Commits
00781a3607
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c7663802c | |||
| c7750a3285 | |||
| 3e12379694 | |||
| 1f69906bc5 | |||
| cb39b659d5 | |||
| bcf729f39c | |||
| bc47d828a3 | |||
| 15b44304c1 | |||
| cd9b50feae | |||
| b6ae6887a3 | |||
| 10c214fc36 |
@@ -0,0 +1,367 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createLocalAiModelsExtension,
|
||||
discoverLocalAiModels,
|
||||
mapLocalAiModels,
|
||||
resolveConfigValue,
|
||||
} from "./index.ts";
|
||||
|
||||
function response(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function configFile() {
|
||||
const root = await mkdtemp(join(tmpdir(), "localai-models-"));
|
||||
const path = join(root, "models.json");
|
||||
await writeFile(path, JSON.stringify({
|
||||
providers: {
|
||||
localai: {
|
||||
baseUrl: "http://local.invalid/v1",
|
||||
apiKey: "local-test",
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
return { root, path };
|
||||
}
|
||||
|
||||
function abortAwareFetch(): typeof fetch {
|
||||
return async (_url, init) => new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
const abort = () => reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function runPiCatalog(agentDir: string) {
|
||||
const sessionDir = join(agentDir, "sessions");
|
||||
await mkdir(sessionDir, { recursive: true });
|
||||
const child = spawn("pi", ["--mode", "rpc", "--session-dir", sessionDir], {
|
||||
cwd: agentDir,
|
||||
detached: true,
|
||||
env: { ...process.env, PI_CODING_AGENT_DIR: agentDir },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
try {
|
||||
return await new Promise<any[]>((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const timeout = setTimeout(() => reject(new Error("Pi RPC catalog probe timed out")), 15_000);
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
while (buffer.includes("\n")) {
|
||||
const newline = buffer.indexOf("\n");
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (!line.trim()) continue;
|
||||
const frame = JSON.parse(line);
|
||||
if (frame.type === "response" && frame.id === "catalog-test") {
|
||||
clearTimeout(timeout);
|
||||
if (!frame.success) reject(new Error(frame.error ?? "Pi RPC catalog probe failed"));
|
||||
else resolve(frame.data?.models ?? []);
|
||||
}
|
||||
}
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => reject(new Error(`Pi exited before catalog response (${code})`)));
|
||||
child.stdin.write(`${JSON.stringify({ type: "get_available_models", id: "catalog-test" })}\n`);
|
||||
});
|
||||
} finally {
|
||||
if (child.pid) {
|
||||
try { process.kill(-child.pid, "SIGTERM"); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("maps unique chat models, stamps compatibility, and filters obvious non-chat families", () => {
|
||||
const compat = { supportsDeveloperRole: false, maxTokensField: "max_tokens" };
|
||||
const models = mapLocalAiModels([
|
||||
{ id: "qwen3.8-27b-q4" },
|
||||
{ id: "qwen3.8-27b-heretic-abliterated-uncensored" },
|
||||
{ id: "qwen3.8-27b-q4" },
|
||||
{ id: "qwen3-asr-1.7b" },
|
||||
{ id: "whisper-large-v3" },
|
||||
{ id: "bge-m3" },
|
||||
{ id: "nomic-embed-text" },
|
||||
{ id: "flux.2-klein-9b" },
|
||||
{ id: "sd-3.5-large-ggml" },
|
||||
], compat);
|
||||
assert.deepEqual(models.map((model) => model.id), [
|
||||
"qwen3.8-27b-heretic-abliterated-uncensored",
|
||||
"qwen3.8-27b-q4",
|
||||
]);
|
||||
assert.deepEqual(models[0].compat, compat);
|
||||
});
|
||||
|
||||
test("resolves literal, interpolated, escaped, and command-backed config values", async () => {
|
||||
const previous = process.env.LOCALAI_TEST_KEY;
|
||||
const previousEmpty = process.env.LOCALAI_EMPTY_KEY;
|
||||
process.env.LOCALAI_TEST_KEY = "environment-key";
|
||||
process.env.LOCALAI_EMPTY_KEY = "";
|
||||
try {
|
||||
assert.equal(await resolveConfigValue("literal-key"), "literal-key");
|
||||
assert.equal(await resolveConfigValue("$LOCALAI_TEST_KEY"), "environment-key");
|
||||
assert.equal(await resolveConfigValue("prefix-${LOCALAI_TEST_KEY}"), "prefix-environment-key");
|
||||
assert.equal(await resolveConfigValue("$$money-$!bang"), "$money-!bang");
|
||||
assert.equal(await resolveConfigValue("!printf command-key"), "command-key");
|
||||
assert.equal(await resolveConfigValue("$MISSING_LOCALAI_KEY"), undefined);
|
||||
assert.equal(await resolveConfigValue("$LOCALAI_EMPTY_KEY"), undefined);
|
||||
assert.equal(await resolveConfigValue("prefix-${LOCALAI_EMPTY_KEY}"), undefined);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.LOCALAI_TEST_KEY;
|
||||
else process.env.LOCALAI_TEST_KEY = previous;
|
||||
if (previousEmpty === undefined) delete process.env.LOCALAI_EMPTY_KEY;
|
||||
else process.env.LOCALAI_EMPTY_KEY = previousEmpty;
|
||||
}
|
||||
});
|
||||
|
||||
test("aborts command-backed auth resolution with the caller signal", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "localai-command-abort-"));
|
||||
const pidFile = join(root, "child.pid");
|
||||
const controller = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const resolving = resolveConfigValue(
|
||||
`!sleep 30 & child=$!; printf '%s' "$child" > '${pidFile}'; wait`,
|
||||
20_000,
|
||||
controller.signal,
|
||||
);
|
||||
let childPid = 0;
|
||||
for (let attempt = 0; attempt < 100 && !childPid; attempt += 1) {
|
||||
try {
|
||||
childPid = Number((await readFile(pidFile, "utf8")).trim());
|
||||
} catch {}
|
||||
if (!childPid) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.ok(childPid > 0, "descendant pid was recorded");
|
||||
controller.abort(new DOMException("Cancelled", "AbortError"));
|
||||
await assert.rejects(resolving, /abort|cancel/i);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
process.kill(childPid, 0);
|
||||
} catch {
|
||||
childPid = 0;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(childPid, 0, "descendant process was terminated");
|
||||
assert.ok(Date.now() - startedAt < 1_500, "abort settled promptly");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("discovers changing payload variants without caching and sends auth only when resolved", async () => {
|
||||
let call = 0;
|
||||
const seenHeaders: Array<HeadersInit | undefined> = [];
|
||||
const fetchImpl: typeof fetch = async (_url, init) => {
|
||||
call += 1;
|
||||
seenHeaders.push(init?.headers);
|
||||
return call === 1
|
||||
? response({ data: [{ id: "qwen3.8-27b-q4" }] })
|
||||
: response({ models: [{ id: "new-chat-model" }] });
|
||||
};
|
||||
const first = await discoverLocalAiModels(
|
||||
{ baseUrl: "http://local.invalid/v1", apiKey: "local" },
|
||||
{ fetch: fetchImpl },
|
||||
);
|
||||
const second = await discoverLocalAiModels(
|
||||
{ baseUrl: "http://local.invalid/v1" },
|
||||
{ fetch: fetchImpl },
|
||||
);
|
||||
assert.deepEqual(first.map((model) => model.id), ["qwen3.8-27b-q4"]);
|
||||
assert.deepEqual(second.map((model) => model.id), ["new-chat-model"]);
|
||||
assert.deepEqual(seenHeaders[0], { Authorization: "Bearer local" });
|
||||
assert.equal(seenHeaders[1], undefined);
|
||||
});
|
||||
|
||||
test("rejects malformed, failed, timed-out, and externally aborted discovery", async () => {
|
||||
const settings = { baseUrl: "http://local.invalid/v1" };
|
||||
await assert.rejects(
|
||||
discoverLocalAiModels(settings, { fetch: async () => response({ object: "list" }) }),
|
||||
/malformed payload/,
|
||||
);
|
||||
await assert.rejects(
|
||||
discoverLocalAiModels(settings, { fetch: async () => response({}, 503) }),
|
||||
/HTTP 503/,
|
||||
);
|
||||
await assert.rejects(
|
||||
discoverLocalAiModels(settings, { fetch: abortAwareFetch(), timeoutMs: 5 }),
|
||||
/timeout|abort/i,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
controller.abort(new DOMException("Cancelled", "AbortError"));
|
||||
await assert.rejects(
|
||||
discoverLocalAiModels(settings, { fetch: abortAwareFetch(), signal: controller.signal }),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("registers startup models with compatibility and refresh replaces them", async () => {
|
||||
const { root, path } = await configFile();
|
||||
try {
|
||||
let call = 0;
|
||||
const fetchImpl: typeof fetch = async () => {
|
||||
call += 1;
|
||||
return response({ data: [{ id: call === 1 ? "qwen3.8-27b-q4" : "next-chat-model" }] });
|
||||
};
|
||||
let registered: any;
|
||||
const pi = {
|
||||
registerProvider(_id: string, config: unknown) {
|
||||
registered = config;
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
await createLocalAiModelsExtension({ configPath: path, fetch: fetchImpl })(pi as any);
|
||||
assert.deepEqual(registered.models.map((model: any) => model.id), ["qwen3.8-27b-q4"]);
|
||||
assert.deepEqual(registered.models[0].compat, {
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
maxTokensField: "max_tokens",
|
||||
});
|
||||
const refreshed = await registered.refreshModels({ signal: new AbortController().signal });
|
||||
assert.deepEqual(refreshed.map((model: any) => model.id), ["next-chat-model"]);
|
||||
assert.deepEqual(refreshed[0].compat, registered.models[0].compat);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Pi startup composes discovered models with configured overrides", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "localai-pi-integration-"));
|
||||
const server = createServer((request, responseStream) => {
|
||||
if (request.url === "/v1/models") {
|
||||
responseStream.writeHead(200, { "content-type": "application/json" });
|
||||
responseStream.end(JSON.stringify({
|
||||
data: [
|
||||
{ id: "qwen3.8-27b-heretic-abliterated-uncensored" },
|
||||
{ id: "qwen3.8-27b-q4" },
|
||||
],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
responseStream.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const address = server.address() as { port: number };
|
||||
const extensionPath = join(dirname(fileURLToPath(import.meta.url)), "index.ts");
|
||||
await writeFile(join(root, "settings.json"), JSON.stringify({ extensions: [extensionPath] }));
|
||||
await writeFile(join(root, "models.json"), JSON.stringify({
|
||||
providers: {
|
||||
localai: {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
api: "openai-completions",
|
||||
apiKey: "local-test",
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
modelOverrides: {
|
||||
"qwen3.8-27b-heretic-abliterated-uncensored": {
|
||||
name: "Overridden Heretic Name",
|
||||
input: ["text", "image"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
const models = (await runPiCatalog(root)).filter((model) => model.provider === "localai");
|
||||
assert.deepEqual(models.map((model) => model.id).sort(), [
|
||||
"qwen3.8-27b-heretic-abliterated-uncensored",
|
||||
"qwen3.8-27b-q4",
|
||||
]);
|
||||
const overridden = models.find((model) => model.id.includes("heretic"));
|
||||
assert.equal(overridden.name, "Overridden Heretic Name");
|
||||
assert.deepEqual(overridden.input, ["text", "image"]);
|
||||
assert.deepEqual(overridden.compat, {
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
maxTokensField: "max_tokens",
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("successful startup followed by outage returns an empty refreshed catalog", async () => {
|
||||
const { root, path } = await configFile();
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
let call = 0;
|
||||
let registered: any;
|
||||
const pi = {
|
||||
registerProvider(_id: string, config: unknown) {
|
||||
registered = config;
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
await createLocalAiModelsExtension({
|
||||
configPath: path,
|
||||
fetch: async () => {
|
||||
call += 1;
|
||||
if (call === 1) return response({ data: [{ id: "qwen3.8-27b-q4" }] });
|
||||
throw new Error("offline");
|
||||
},
|
||||
})(pi as any);
|
||||
assert.deepEqual(registered.models.map((model: any) => model.id), ["qwen3.8-27b-q4"]);
|
||||
assert.deepEqual(await registered.refreshModels({ signal: new AbortController().signal }), []);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("continues with an empty catalog and warning when LocalAI is unavailable", async () => {
|
||||
const { root, path } = await configFile();
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
console.warn = (message?: unknown) => warnings.push(String(message));
|
||||
try {
|
||||
let registered: any;
|
||||
let sessionStart: ((event: unknown, ctx: any) => void) | undefined;
|
||||
const pi = {
|
||||
registerProvider(_id: string, config: unknown) {
|
||||
registered = config;
|
||||
},
|
||||
on(event: string, handler: (event: unknown, ctx: any) => void) {
|
||||
if (event === "session_start") sessionStart = handler;
|
||||
},
|
||||
};
|
||||
await createLocalAiModelsExtension({
|
||||
configPath: path,
|
||||
fetch: async () => { throw new Error("offline"); },
|
||||
})(pi as any);
|
||||
assert.deepEqual(registered.models, []);
|
||||
assert.deepEqual(await registered.refreshModels({ signal: new AbortController().signal }), []);
|
||||
const notices: unknown[] = [];
|
||||
sessionStart?.({}, {
|
||||
hasUI: true,
|
||||
ui: { notify: (...args: unknown[]) => notices.push(args) },
|
||||
});
|
||||
assert.equal(notices.length, 1);
|
||||
assert.ok(warnings.length >= 1);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const PROVIDER_ID = "localai";
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const NON_CHAT_MODEL = /(?:^|[-_.])(?:asr|stt|tts|whisper|speech|embed|embedding|embeddings|rerank|bge|e5|flux|sd)(?:$|[-_.])|nomic[-_.]?embed|stable[-_.]?diffusion/i;
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
type ProviderSettings = {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
compat?: Record<string, unknown>;
|
||||
};
|
||||
type ModelsFile = {
|
||||
providers?: Record<string, {
|
||||
baseUrl?: unknown;
|
||||
apiKey?: unknown;
|
||||
compat?: unknown;
|
||||
}>;
|
||||
};
|
||||
type RemoteModel = { id?: unknown; name?: unknown } | string;
|
||||
|
||||
export type LocalAiModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: false;
|
||||
input: ["text"];
|
||||
cost: { input: 0; output: 0; cacheRead: 0; cacheWrite: 0 };
|
||||
contextWindow: 128_000;
|
||||
maxTokens: 16_384;
|
||||
compat?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function agentDirectory() {
|
||||
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||
}
|
||||
|
||||
function runShellCommand(command: string, timeoutMs: number, signal?: AbortSignal) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const child = spawn("/bin/sh", ["-lc", command], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let settled = false;
|
||||
let forceKill: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const killGroup = (signalName: NodeJS.Signals) => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
process.kill(-child.pid, signalName);
|
||||
} catch {}
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const finish = (error?: Error, value?: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
child.unref();
|
||||
if (error) reject(error);
|
||||
else resolve(value ?? "");
|
||||
};
|
||||
const terminate = (error: Error) => {
|
||||
killGroup("SIGTERM");
|
||||
forceKill = setTimeout(() => killGroup("SIGKILL"), 250);
|
||||
forceKill.unref();
|
||||
finish(error);
|
||||
};
|
||||
const onAbort = () => terminate(
|
||||
signal?.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException("Cancelled", "AbortError"),
|
||||
);
|
||||
const timeout = setTimeout(
|
||||
() => terminate(new DOMException("Command timed out", "TimeoutError")),
|
||||
timeoutMs,
|
||||
);
|
||||
timeout.unref();
|
||||
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
if (stdout.length > 64 * 1024) {
|
||||
terminate(new Error("Command output exceeded 64KB"));
|
||||
}
|
||||
});
|
||||
child.on("error", (error) => finish(error));
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) finish(undefined, stdout.trim());
|
||||
else finish(new Error(`Command exited with status ${code ?? "unknown"}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveConfigValue(
|
||||
value: string | undefined,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (!value) return undefined;
|
||||
if (value.startsWith("!")) {
|
||||
const command = value.slice(1).trim();
|
||||
if (!command) return undefined;
|
||||
return (await runShellCommand(command, timeoutMs, signal)) || undefined;
|
||||
}
|
||||
|
||||
const dollar = "\u0000LOCALAI_DOLLAR\u0000";
|
||||
const bang = "\u0000LOCALAI_BANG\u0000";
|
||||
let resolved = value.replace(/\$\$/g, dollar).replace(/\$!/g, bang);
|
||||
let missing = false;
|
||||
resolved = resolved.replace(
|
||||
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
||||
(_match, braced: string | undefined, plain: string | undefined) => {
|
||||
const environmentValue = process.env[braced ?? plain ?? ""];
|
||||
if (!environmentValue) missing = true;
|
||||
return environmentValue ?? "";
|
||||
},
|
||||
);
|
||||
if (missing) return undefined;
|
||||
return resolved.replaceAll(dollar, "$").replaceAll(bang, "!");
|
||||
}
|
||||
|
||||
export function modelsEndpoint(baseUrl: string) {
|
||||
return `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
}
|
||||
|
||||
export function mapLocalAiModels(
|
||||
entries: RemoteModel[],
|
||||
compat?: Record<string, unknown>,
|
||||
): LocalAiModel[] {
|
||||
const seen = new Set<string>();
|
||||
const models: LocalAiModel[] = [];
|
||||
for (const entry of entries) {
|
||||
const id = (typeof entry === "string" ? entry : entry?.id)?.toString().trim();
|
||||
if (!id || seen.has(id) || NON_CHAT_MODEL.test(id)) continue;
|
||||
seen.add(id);
|
||||
const remoteName = typeof entry === "object" && typeof entry.name === "string" ? entry.name.trim() : "";
|
||||
models.push({
|
||||
id,
|
||||
name: remoteName || `${id} (LocalAI)`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 16_384,
|
||||
...(compat ? { compat: { ...compat } } : {}),
|
||||
});
|
||||
}
|
||||
return models.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function discoverLocalAiModels(
|
||||
settings: ProviderSettings,
|
||||
options: { fetch?: FetchLike; signal?: AbortSignal; timeoutMs?: number } = {},
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
||||
const apiKey = await resolveConfigValue(settings.apiKey, timeoutMs, signal);
|
||||
const response = await fetchImpl(modelsEndpoint(settings.baseUrl), {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`LocalAI model discovery failed with HTTP ${response.status}`);
|
||||
const payload = await response.json() as { data?: unknown; models?: unknown };
|
||||
const entries = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.models) ? payload.models : undefined;
|
||||
if (!entries) throw new Error("LocalAI model discovery returned a malformed payload");
|
||||
return mapLocalAiModels(entries as RemoteModel[], settings.compat);
|
||||
}
|
||||
|
||||
export function createLocalAiModelsExtension(options: {
|
||||
configPath?: string;
|
||||
fetch?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
} = {}) {
|
||||
return async function localAiModelsExtension(pi: ExtensionAPI) {
|
||||
const configPath = options.configPath ?? join(agentDirectory(), "models.json");
|
||||
let settings: ProviderSettings;
|
||||
try {
|
||||
const config = JSON.parse(await readFile(configPath, "utf8")) as ModelsFile;
|
||||
const provider = config.providers?.[PROVIDER_ID];
|
||||
if (!provider || typeof provider.baseUrl !== "string" || !provider.baseUrl.trim()) {
|
||||
console.warn("LocalAI provider configuration is missing; continuing without LocalAI models.");
|
||||
return;
|
||||
}
|
||||
settings = {
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: typeof provider.apiKey === "string" ? provider.apiKey : undefined,
|
||||
compat: provider.compat && typeof provider.compat === "object"
|
||||
? { ...(provider.compat as Record<string, unknown>) }
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
console.warn("LocalAI provider configuration could not be read; continuing without LocalAI models.");
|
||||
return;
|
||||
}
|
||||
|
||||
let startupFailed = false;
|
||||
let initialModels: LocalAiModel[] = [];
|
||||
try {
|
||||
initialModels = await discoverLocalAiModels(settings, {
|
||||
fetch: options.fetch,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
} catch {
|
||||
startupFailed = true;
|
||||
console.warn("LocalAI model discovery unavailable; continuing without LocalAI models.");
|
||||
}
|
||||
|
||||
pi.registerProvider(PROVIDER_ID, {
|
||||
name: "LocalAI",
|
||||
baseUrl: settings.baseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
api: "openai-completions",
|
||||
models: initialModels,
|
||||
async refreshModels({ signal }: { signal: AbortSignal }) {
|
||||
try {
|
||||
return await discoverLocalAiModels(settings, {
|
||||
fetch: options.fetch,
|
||||
signal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
} catch {
|
||||
console.warn("LocalAI model refresh unavailable; continuing without LocalAI models.");
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (startupFailed) {
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
if (ctx.hasUI) ctx.ui.notify("LocalAI unavailable; continuing without LocalAI models.", "warning");
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default createLocalAiModelsExtension();
|
||||
@@ -86,6 +86,7 @@
|
||||
"command -v *": "allow",
|
||||
"find *": "allow",
|
||||
"hypa *": "allow",
|
||||
"hypa_find *": "allow",
|
||||
|
||||
"git status": "allow",
|
||||
"git status *": "allow",
|
||||
|
||||
+23
-1
@@ -10,7 +10,10 @@
|
||||
},
|
||||
"gpt-5.6-luna": {
|
||||
"contextWindow": 1050000
|
||||
}
|
||||
},
|
||||
"gpt-6-astra": {
|
||||
"contextWindow": 1050000
|
||||
}
|
||||
}
|
||||
},
|
||||
"openai-codex": {
|
||||
@@ -25,6 +28,25 @@
|
||||
"contextWindow": 1050000
|
||||
}
|
||||
}
|
||||
},
|
||||
"localai": {
|
||||
"baseUrl": "http://127.0.0.1:8080/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "localai",
|
||||
"compat": {
|
||||
"supportsDeveloperRole": false,
|
||||
"supportsReasoningEffort": false,
|
||||
"maxTokensField": "max_tokens"
|
||||
},
|
||||
"modelOverrides": {
|
||||
"qwen3.8-27b-heretic-abliterated-uncensored": {
|
||||
"name": "Qwen 3.8 27B Heretic (LocalAI)",
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"quietStartup": true,
|
||||
"lastChangelogVersion": "0.84.2",
|
||||
"lastChangelogVersion": "0.85.1",
|
||||
"defaultProvider": "openai-codex",
|
||||
"defaultModel": "gpt-5.6-terra",
|
||||
"defaultModel": "gpt-5.6-sol",
|
||||
"defaultThinkingLevel": "high",
|
||||
"packages": [
|
||||
"npm:pi-subagents",
|
||||
@@ -14,15 +14,22 @@
|
||||
"npm:@juicesharp/rpiv-btw",
|
||||
"npm:pi-powerline-footer",
|
||||
"npm:pi-studio",
|
||||
"npm:pi-ui-design",
|
||||
"npm:pi-frontend-check",
|
||||
"npm:@senad-d/drawme",
|
||||
"npm:pi-powerpoint",
|
||||
"npm:@joemccann/pi-pdf",
|
||||
"npm:pi-universal-view",
|
||||
"npm:@hypabolic/pi-hypa",
|
||||
"npm:bigpowers",
|
||||
"npm:@gotgenes/pi-permission-system",
|
||||
"npm:pi-intercom",
|
||||
"npm:gentle-engram@0.1.8",
|
||||
"npm:pi-lens",
|
||||
"npm:@juicesharp/rpiv-todo",
|
||||
"npm:@majorgilles/pi-grill-me",
|
||||
"npm:pi-mcp-adapter"
|
||||
"npm:pi-mcp-adapter",
|
||||
"npm:pi-caveman",
|
||||
"npm:@dietrichgebert/ponytail"
|
||||
],
|
||||
"powerline": "full",
|
||||
"hideThinkingBlock": false,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# script for updating all packages but excluding a list of packages
|
||||
|
||||
# set path to list of packages to exclude
|
||||
exclude_list_file=~/.setup/packages/exclude_from_updating.list
|
||||
|
||||
# load list of packages to exclude
|
||||
exclude_list=$(cat $exclude_list_file)
|
||||
|
||||
# define function that calls yay update and excludes packages from exclude_list
|
||||
|
||||
function update_system {
|
||||
yay -Syu --noconfirm --needed --noprovides --ignore $exclude_list
|
||||
}
|
||||
|
||||
# also define function that only updates packages from pacman
|
||||
function update_pacman_packages {
|
||||
sudo pacman -Syu --noconfirm --needed --noprovides --ignore $exclude_list
|
||||
}
|
||||
|
||||
@@ -12,13 +12,22 @@ This directory (`/home/alex`) is the **yadm worktree** for personal, Arch-orient
|
||||
|
||||
Start with the root `README.md` for the dotfile-specific constraints and manual-validation guidance.
|
||||
|
||||
## Shell
|
||||
|
||||
- Treat Fish (`fish`) as the default interactive shell. Write user-facing shell commands using Fish-compatible syntax unless another shell is explicitly requested or required.
|
||||
|
||||
## Knowledge base
|
||||
|
||||
- The knowledge base is an Obsidian vault at `~/cloud/knowledge`.
|
||||
- Search the vault when a task depends on specific existing knowledge.
|
||||
- Add new knowledge to the vault when it is durable and noteworthy; skip routine or transient details.
|
||||
|
||||
## Ownership map
|
||||
|
||||
| Path | Owns |
|
||||
| --- | --- |
|
||||
| Root dotfiles (`.zshrc`, `.config/`, `.scripts/`, etc.) | Ordinary yadm-managed user configuration and helpers |
|
||||
| `.setup/` | Legacy/manual setup assets and package lists; it is not a complete installer |
|
||||
| `system-config/` | Declarative Decman configuration for this Arch laptop |
|
||||
| `system-config/` | Declarative Decman configuration for this Arch desktop |
|
||||
| `system-config/config/system/` | Files deployed into system locations; do **not** edit deployed `/etc` targets |
|
||||
| `system-config/config/user/` | Selected user dotfiles deployed by Decman; do **not** edit deployed copies |
|
||||
| `system-config/packages/` | Native and AUR package lists organized by concern |
|
||||
@@ -29,7 +38,7 @@ Start with the root `README.md` for the dotfile-specific constraints and manual-
|
||||
|
||||
## Decman architecture
|
||||
|
||||
`system-config/source.py` is the Decman entrypoint. It currently selects the `laptop()` profile from `hosts.py` and applies modules in this order:
|
||||
`system-config/source.py` is the Decman entrypoint. It currently selects the `desktop()` profile from `hosts.py` and applies modules in this order:
|
||||
|
||||
1. `files`
|
||||
2. `pacman`
|
||||
@@ -38,9 +47,9 @@ Start with the root `README.md` for the dotfile-specific constraints and manual-
|
||||
|
||||
`hosts.py` composes host profiles from package concerns:
|
||||
|
||||
- Shared concerns: `common/core`, `wayland`, `fonts`, `development`, `productivity`, `media`, `research`, `network`, and `services`.
|
||||
- Laptop-specific concern: `laptop/hardware`.
|
||||
- Desktop support is intentionally sparse and future-facing.
|
||||
- Shared concerns: `common/core`, `common/hardware`, `common/wayland`, `common/fonts`, `common/development`, `common/productivity`, `common/media`, `common/research`, `common/network`, and `common/services`.
|
||||
- Host-specific concerns: `laptop/hardware` and `desktop/local`.
|
||||
- The active desktop profile combines the shared concerns, `desktop/local`, and desktop system/user configuration.
|
||||
|
||||
Keep policy in the declared data directories, not in Python glue:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Personal, Arch-oriented user dotfiles and support scripts for shells, window man
|
||||
|
||||
There is no authoritative root installation, linking, backup, overwrite, or rollback script. Do not infer one: review and apply only the individual files appropriate to a machine. Some filenames use `##class.arch_*` suffixes to distinguish machine classes. The repository also includes a nested README for the custom/vendored `st` terminal under `.config/st/`; it documents that component, not installation of this repository.
|
||||
|
||||
Several scripts assume paths below `~/.setup`, Arch tooling, and root access. Some repository scripts contain machine-specific connection details; do not publish, copy, or document such values. Use local, private configuration for hosts, addresses, accounts, and credentials.
|
||||
Several scripts assume Arch tooling and root access. Some repository scripts contain machine-specific connection details; do not publish, copy, or document such values. Use local, private configuration for hosts, addresses, accounts, and credentials.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -15,7 +15,7 @@ Several scripts assume paths below `~/.setup`, Arch tooling, and root access. So
|
||||
- Desktop/session tools matching the chosen configuration (for example X11/XMonad-related files where applicable).
|
||||
- Root access only for the system-changing scripts described below.
|
||||
|
||||
Package lists in `.setup/packages/` are inputs for a user-managed installation; no repository command defines how to install an entire list. Inspect package names and select lists for the intended machine before installing anything.
|
||||
Package and service policy lives under `system-config/` and is applied through Decman. Review `system-config/README.md`, the active host profile, and a dry run before changing system state.
|
||||
|
||||
## Safe review and user-level configuration
|
||||
|
||||
@@ -24,33 +24,20 @@ Start by reviewing the intended files and any machine-class variants. Typical ar
|
||||
- `.zshrc`, `.bashrc`, `.terminal_aliases`, `.tmux.conf`, and `.p10k.zsh` — shell and terminal setup.
|
||||
- `.config/` — desktop and application configuration.
|
||||
- `.scripts/` — optional shell, network, virtual-environment, and update helpers.
|
||||
- `.setup/packages/` — package lists grouped by purpose.
|
||||
- `system-config/` — Decman-managed packages, system files, selected user configuration, and service manifests.
|
||||
|
||||
Back up existing configuration before manually placing or linking a file. No test suite is provided; validate a change in the relevant shell/session or application and keep a rollback copy of the previous configuration.
|
||||
|
||||
## Package updates
|
||||
## System-changing configuration
|
||||
|
||||
`.scripts/updating.sh` defines, but does not invoke, two functions. It expects `~/.setup/packages/exclude_from_updating.list`:
|
||||
`system-config/` is the source of truth for managed packages, system files, selected user configuration, and enabled services. Use its review-first wrapper:
|
||||
|
||||
```sh
|
||||
source .scripts/updating.sh
|
||||
update_system
|
||||
# or
|
||||
update_pacman_packages
|
||||
```fish
|
||||
./system-config/decman verify
|
||||
./system-config/decman dry-run
|
||||
```
|
||||
|
||||
> **Warning: system-wide, potentially disruptive operation.** `update_system` runs `yay -Syu` and `update_pacman_packages` runs `sudo pacman -Syu`, both non-interactively and with the exclusion list. Review the exclusion list and pending package changes first. These functions update the running system; they are not tests.
|
||||
|
||||
## System-changing scripts
|
||||
|
||||
The following scripts mutate files outside this repository and must be inspected before use:
|
||||
|
||||
- `.setup/config/exclude_from_update.sh` edits `/etc/pacman.conf` with `sudo` based on `~/.setup/packages/exclude_from_updating.list`.
|
||||
- `.setup/config/dinit_add_logs_to_all_services.sh` edits files in `/etc/dinit.d` and writes log specifications under `/var/log/dinit`; its `-o` option removes existing logfile lines before adding them.
|
||||
|
||||
> **Warning: destructive/system-wide operations.** These scripts can alter system package policy, service definitions, or input configuration. They have no repository-provided dry run or rollback. Run only on a machine you control after backups and after checking paths, permissions, and the script contents.
|
||||
|
||||
`.setup/config/set_default_programs.sh` sets the default HTTP/HTTPS handler through `xdg-mime`; review the desktop entry before using it.
|
||||
> **Warning: system-wide, potentially disruptive operation.** `./system-config/decman apply` changes packages, system files, boot artifacts, and services. Run it only after reviewing a dry run. No root-level bootstrap or rollback command exists.
|
||||
|
||||
## Custom `st` terminal
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Desktop Package Audit
|
||||
|
||||
Generated from `pacman -Qen` (official repository, explicitly installed) and `pacman -Qem` (AUR/foreign, explicitly installed). Package versions are included so this is an exact inventory.
|
||||
Originally generated from `pacman -Qen` and `pacman -Qem` during desktop adoption. Versions record that snapshot; the package lists under `packages/` are authoritative for current policy.
|
||||
|
||||
## Decision key
|
||||
|
||||
@@ -119,7 +119,7 @@ Generated from `pacman -Qen` (official repository, explicitly installed) and `pa
|
||||
| `vulkan-mesa-layers` | `1:26.1.5-1` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `vulkan-radeon` | `1:26.1.5-1` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `vulkan-tools` | `1.4.350.1-1` | KEEP — declared common desktop concern |
|
||||
| `waybar` | `0.15.0-2` | KEEP — declared common desktop concern |
|
||||
| `waybar` | `0.15.0-2` | RETIRE — replaced by Noctalia and no longer declared |
|
||||
| `wdisplays` | `1.1.3-2` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `wget` | `1.25.0-6` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `wine` | `11.14-2` | REVIEW — provisional desktop/local retention candidate |
|
||||
@@ -150,7 +150,7 @@ Generated from `pacman -Qen` (official repository, explicitly installed) and `pa
|
||||
| `lutris-git` | `0.5.22.r394.g1b0f116-1` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `mongodb-compass-bin` | `1.49.11-2` | KEEP — declared common desktop concern |
|
||||
| `mullvad-vpn-beta-bin` | `2026.3.stable-1` | KEEP — declared common desktop concern |
|
||||
| `noctalia` | `5.0.0_beta.3-2` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `noctalia-git` | `5.0.0.r5317.ga4324409f-1` | KEEP — declared common Wayland concern |
|
||||
| `nomacs-git` | `3.23.2.r0.gccb6ff42-1` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `pycharm-professional` | `2025.2.2-1` | REVIEW — provisional desktop/local retention candidate |
|
||||
| `rustrover` | `2026.1.3-1` | REVIEW — provisional desktop/local retention candidate |
|
||||
@@ -197,6 +197,7 @@ rustrover
|
||||
ueberzug
|
||||
vkd3d
|
||||
vulkan-mesa-layers
|
||||
waybar
|
||||
wine
|
||||
winetricks
|
||||
xclicker
|
||||
|
||||
@@ -5,7 +5,8 @@ A small, declarative Arch configuration managed by Decman.
|
||||
## Design
|
||||
|
||||
- **Concerns live in `packages/`**: each concern has a native (`*-repo.txt`)
|
||||
and AUR (`*-aur.txt`) list.
|
||||
and AUR (`*-aur.txt`) list, plus an optional global npm (`*-npm.txt`) list
|
||||
for command packages unavailable through Arch packaging.
|
||||
- **Hosts live in `hosts.py`**: a host composes package concerns and explicit
|
||||
system state. `desktop()` owns this desktop's boot, storage, NVIDIA, input,
|
||||
SDDM, network-policy, and user-session configuration.
|
||||
@@ -55,7 +56,9 @@ remove those explicitly in a separate reviewed package change.
|
||||
| Desktop | shared concerns, `common/hardware`, and a reviewable `desktop/local` set; host state is in `config/system/desktop/` |
|
||||
|
||||
Every package must have a named purpose. Add a new concern when an existing one
|
||||
does not explain a package. System and user units use the same naming:
|
||||
does not explain a package. Missing packages from `*-npm.txt` are installed
|
||||
globally after the pacman and AUR steps; unrelated global npm packages are not
|
||||
removed. System and user units use the same naming:
|
||||
`manifests/common/<concern>-{system,user}.txt` or
|
||||
`manifests/laptop/<concern>-system.txt`.
|
||||
|
||||
@@ -87,8 +90,9 @@ sudo decman --source source.py --no-hooks
|
||||
```
|
||||
|
||||
`verify.py` proves that every currently explicit package appears exactly once
|
||||
in the active laptop profile. The ignored dependency lists prevent Decman's
|
||||
orphan cleanup from removing dependencies during adoption.
|
||||
in the active desktop profile, including global npm command packages. The
|
||||
ignored dependency lists prevent Decman's orphan cleanup from removing
|
||||
dependencies during adoption.
|
||||
|
||||
After intentional package changes, regenerate and review concern lists:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[Theme]
|
||||
ThemeDir=/usr/share/sddm/themes
|
||||
Current=maya
|
||||
Current=noctalia
|
||||
EnableAvatars=false
|
||||
|
||||
[Users]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
PI_STATUS_BRIDGE_CLIENT=/home/alex/.local/bin/pi-status-bridge-client
|
||||
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui
|
||||
# Ctrl+Super+Space starts a fresh Pi session in this worktree. Change as needed.
|
||||
PI_STATUS_DEFAULT_WORKTREE=/home/alex/projects/pi-status-bridge
|
||||
PI_STATUS_DEFAULT_WORKTREE=/home/alex/projects/pi-gui
|
||||
|
||||
@@ -70,6 +70,7 @@ hl.workspace_rule({
|
||||
local noctalia_ipc = "noctalia msg "
|
||||
local pi_status_ui_toggle = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --toggle >/tmp/pi-status-ui.log 2>&1; fi'
|
||||
local pi_status_ui_new_session = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --new >/tmp/pi-status-ui.log 2>&1; fi'
|
||||
local pi_status_ui_quick = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --quick >/tmp/pi-status-ui.log 2>&1; fi'
|
||||
-- Tap and release Super on its own to open Noctalia's control-center home.
|
||||
hl.bind(main_mod .. " + SUPER_L", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"), { release = true })
|
||||
hl.bind(main_mod .. " + RETURN", hl.dsp.exec_cmd(terminal))
|
||||
@@ -77,8 +78,8 @@ hl.bind(main_mod .. " + CTRL + RETURN", hl.dsp.exec_cmd(terminal .. " -e zellij"
|
||||
hl.bind(main_mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("rofi -show combi"))
|
||||
hl.bind(main_mod .. " + SPACE", hl.dsp.exec_cmd(pi_status_ui_toggle))
|
||||
hl.bind(main_mod .. " + CTRL + SPACE", hl.dsp.exec_cmd(pi_status_ui_new_session))
|
||||
hl.bind(main_mod .. " + S", hl.dsp.exec_cmd(pi_status_ui_quick))
|
||||
hl.bind(main_mod .. " + N", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle alex/knowledge-lookup:panel"))
|
||||
hl.bind(main_mod .. " + S", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"))
|
||||
hl.bind(main_mod .. " + 0", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle session"))
|
||||
hl.bind(main_mod .. " + SHIFT + F", hl.dsp.exec_cmd(noctalia_ipc .. "bar-toggle main"))
|
||||
hl.bind(main_mod .. " + SHIFT + R", hl.dsp.exec_cmd("hyprctl reload"))
|
||||
@@ -112,7 +113,8 @@ for _, direction in ipairs({ "left", "down", "up", "right" }) do
|
||||
hl.bind(main_mod .. " + " .. direction, hl.dsp.focus({ direction = direction }))
|
||||
end
|
||||
|
||||
for key, direction in pairs({ H = "l", J = "d", K = "u", L = "r", LEFT = "l", DOWN = "d", UP = "u", RIGHT = "r" }) do
|
||||
-- Super+Shift+K is reserved for Obsidian; Super+Shift+Up still moves windows up.
|
||||
for key, direction in pairs({ H = "l", J = "d", L = "r", LEFT = "l", DOWN = "d", UP = "u", RIGHT = "r" }) do
|
||||
hl.bind(main_mod .. " + SHIFT + " .. key, hl.dsp.window.move({ direction = direction }))
|
||||
end
|
||||
|
||||
@@ -132,6 +134,9 @@ hl.bind(main_mod .. " + CTRL + LEFT", hl.dsp.focus({ workspace = "-1" }))
|
||||
-- Hyprland special workspaces are the Wayland analogue of the i3 scratchpad.
|
||||
hl.bind(main_mod .. " + MINUS", hl.dsp.workspace.toggle_special("scratchpad"))
|
||||
hl.bind(main_mod .. " + SHIFT + MINUS", hl.dsp.window.move({ workspace = "special:scratchpad" }))
|
||||
hl.bind(main_mod .. " + SHIFT + S", hl.dsp.workspace.toggle_special("spotify"))
|
||||
hl.bind(main_mod .. " + SHIFT + M", hl.dsp.workspace.toggle_special("mail"))
|
||||
hl.bind(main_mod .. " + SHIFT + K", hl.dsp.workspace.toggle_special("obsidian"))
|
||||
|
||||
-- Keep the i3 floating and fullscreen shortcuts. Layout-specific binds are deferred.
|
||||
hl.bind(main_mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
|
||||
@@ -149,8 +154,8 @@ local function float_rule(name, match, border_size)
|
||||
})
|
||||
end
|
||||
|
||||
hl.window_rule({ name = "obsidian-workspace", match = { class = "^obsidian$" }, workspace = "8" })
|
||||
hl.window_rule({ name = "thunderbird-workspace", match = { class = "^Thunderbird$" }, workspace = "9" })
|
||||
hl.window_rule({ name = "obsidian-workspace", match = { class = "^(obsidian|md[.]obsidian[.]Obsidian)$" }, workspace = "special:obsidian silent", no_initial_focus = true })
|
||||
hl.window_rule({ name = "thunderbird-mail", match = { class = "^(Thunderbird|thunderbird|org[.]mozilla[.]Thunderbird)$" }, workspace = "special:mail silent", no_initial_focus = true })
|
||||
float_rule("pi-status-ui", { class = "^(pi-status-ui|Pi-status-ui)$" }, 1)
|
||||
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
|
||||
float_rule("qalculate", { title = "^Qalculate!$" })
|
||||
@@ -161,8 +166,8 @@ float_rule("volume-control", { title = "^Volume Control$" })
|
||||
float_rule("virtualbox", { class = ".*VirtualBox.*" })
|
||||
hl.window_rule({ name = "virtualbox-manager-tiles", match = { class = ".*VirtualBox Manager.*" }, tile = true })
|
||||
hl.window_rule({ name = "virtualbox-machine-tiles", match = { class = ".*VirtualBox Machine.*" }, tile = true })
|
||||
-- Send music clients to the existing scratchpad without stealing startup focus.
|
||||
hl.window_rule({ name = "spotify-music", match = { class = "^Spotify$" }, workspace = "special:scratchpad silent", no_initial_focus = true })
|
||||
-- Send music clients to hidden workspaces without stealing startup focus.
|
||||
hl.window_rule({ name = "spotify-music", match = { class = "^Spotify$" }, workspace = "special:spotify silent", no_initial_focus = true })
|
||||
hl.window_rule({ name = "deezer-music", match = { class = "^Deezer$" }, workspace = "special:scratchpad silent", no_initial_focus = true })
|
||||
|
||||
hl.on("hyprland.start", function()
|
||||
|
||||
@@ -6,7 +6,24 @@ x-scheme-handler/webcal=userapp-Thunderbird-0DWKA3.desktop
|
||||
text/calendar=userapp-Thunderbird-0DWKA3.desktop
|
||||
application/x-extension-ics=userapp-Thunderbird-0DWKA3.desktop
|
||||
x-scheme-handler/webcals=userapp-Thunderbird-0DWKA3.desktop
|
||||
image/png=nomacs.desktop
|
||||
application/pdf=org.gnome.Evince.desktop
|
||||
image/avif=org.nomacs.ImageLounge.desktop
|
||||
image/bmp=org.nomacs.ImageLounge.desktop
|
||||
image/gif=org.nomacs.ImageLounge.desktop
|
||||
image/heic=org.nomacs.ImageLounge.desktop
|
||||
image/heif=org.nomacs.ImageLounge.desktop
|
||||
image/jpeg=org.nomacs.ImageLounge.desktop
|
||||
image/jxl=org.nomacs.ImageLounge.desktop
|
||||
image/png=org.nomacs.ImageLounge.desktop
|
||||
image/tiff=org.nomacs.ImageLounge.desktop
|
||||
image/webp=org.nomacs.ImageLounge.desktop
|
||||
image/x-eps=org.nomacs.ImageLounge.desktop
|
||||
image/x-ico=org.nomacs.ImageLounge.desktop
|
||||
image/x-portable-bitmap=org.nomacs.ImageLounge.desktop
|
||||
image/x-portable-graymap=org.nomacs.ImageLounge.desktop
|
||||
image/x-portable-pixmap=org.nomacs.ImageLounge.desktop
|
||||
image/x-xbitmap=org.nomacs.ImageLounge.desktop
|
||||
image/x-xpixmap=org.nomacs.ImageLounge.desktop
|
||||
|
||||
[Added Associations]
|
||||
x-scheme-handler/mailto=userapp-Thunderbird-JBPLA3.desktop;
|
||||
|
||||
@@ -4,6 +4,8 @@ return {
|
||||
{
|
||||
output = "HDMI-A-1",
|
||||
mode = "3840x2160@120",
|
||||
bitdepth = 10,
|
||||
cm = "hdr",
|
||||
position = "0x0",
|
||||
scale = 1,
|
||||
},
|
||||
@@ -18,6 +20,7 @@ return {
|
||||
"nextcloud",
|
||||
"spotify",
|
||||
"brave",
|
||||
"alacritty -e tmux",
|
||||
-- Kitty keeps Yazi previews terminal-native; Alacritty needs Überzug++ windows.
|
||||
"kitty -e tmux",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ padding = 6.0
|
||||
enabled = true
|
||||
fill = "surface_variant"
|
||||
id = "g2"
|
||||
members = ["network", "bluetooth"]
|
||||
members = ["network", "network-download", "network-upload", "bluetooth"]
|
||||
opacity = 1.0
|
||||
padding = 6.0
|
||||
|
||||
@@ -73,6 +73,18 @@ opacity = 1.0
|
||||
padding = 6.0
|
||||
radius = 5.0
|
||||
|
||||
[widget.network-download]
|
||||
type = "sysmon"
|
||||
stat = "net_rx"
|
||||
visualization = "none"
|
||||
show_value = true
|
||||
|
||||
[widget.network-upload]
|
||||
type = "sysmon"
|
||||
stat = "net_tx"
|
||||
visualization = "none"
|
||||
show_value = true
|
||||
|
||||
[widget.gpu]
|
||||
type = "sysmon"
|
||||
stat = "gpu_usage"
|
||||
@@ -94,7 +106,26 @@ width = 880
|
||||
# GPU polling is disabled by Noctalia by default; enable it for the RTX 3090 widgets.
|
||||
gpu_poll_seconds = 2.0
|
||||
|
||||
# The desktop's Hypridle listener provides a five-minute input-dismissable black
|
||||
# OLED overlay. Keep Noctalia from issuing a competing DPMS screen-off action.
|
||||
# Hypridle provides the five-minute input-dismissable black OLED overlay.
|
||||
# On this desktop, Noctalia's only enabled idle action is plain suspend after
|
||||
# thirty minutes; do not lock or transition into hibernation automatically.
|
||||
[idle]
|
||||
behavior_order = ["suspend"]
|
||||
|
||||
[idle.behavior.lock]
|
||||
enabled = false
|
||||
|
||||
[idle.behavior.screen-off]
|
||||
enabled = false
|
||||
|
||||
[idle.behavior.suspend-then-hibernate]
|
||||
enabled = false
|
||||
|
||||
[idle.behavior.suspend]
|
||||
action = "suspend"
|
||||
enabled = true
|
||||
timeout = 1800
|
||||
lock_before_suspend = false
|
||||
|
||||
[lockscreen]
|
||||
lock_before_suspend = false
|
||||
|
||||
@@ -295,6 +295,12 @@ def desktop_system() -> list[SystemConcern | UserConfigConcern]:
|
||||
"config/system/desktop/network/NetworkManager.conf"
|
||||
),
|
||||
"/etc/sddm.conf.d/10-theme.conf": "config/system/desktop/sddm/10-theme.conf",
|
||||
"/usr/share/sddm/themes/noctalia/Assets/ember-oled-background.svg": (
|
||||
"config/system/desktop/sddm/ember-oled-background.svg"
|
||||
),
|
||||
"/usr/share/sddm/themes/noctalia/theme.conf.user": (
|
||||
"config/system/desktop/sddm/theme.conf.user"
|
||||
),
|
||||
"/etc/udev/rules.d/50-zsa.rules": "config/system/desktop/input/50-zsa.rules",
|
||||
},
|
||||
system_manifests=("desktop/hardware-system.txt",),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Declarative global npm command packages for Decman profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
|
||||
import decman
|
||||
import decman.core.error as errors
|
||||
import decman.core.module as module
|
||||
import decman.core.output as output
|
||||
import decman.core.store as store_module
|
||||
from decman import Plugin
|
||||
from decman.plugins import run_methods_with_attribute
|
||||
|
||||
|
||||
def packages(fn):
|
||||
"""Annotate a method that returns globally installed npm package names."""
|
||||
fn.__npm_packages__ = True
|
||||
return fn
|
||||
|
||||
|
||||
class Npm(Plugin):
|
||||
"""Install declared global npm packages without pruning unrelated packages."""
|
||||
|
||||
NAME = "npm"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.packages: set[str] = set()
|
||||
|
||||
def process_modules(
|
||||
self, store: store_module.Store, modules: list[module.Module]
|
||||
) -> None:
|
||||
store.ensure("npm_packages_for_module", {})
|
||||
|
||||
for mod in modules:
|
||||
store["npm_packages_for_module"].setdefault(mod.name, set())
|
||||
declared = set().union(
|
||||
*run_methods_with_attribute(mod, "__npm_packages__")
|
||||
)
|
||||
|
||||
if store["npm_packages_for_module"][mod.name] != declared:
|
||||
mod._changed = True
|
||||
output.print_debug(
|
||||
f"Module '{mod.name}' changed due to modified global npm packages."
|
||||
)
|
||||
|
||||
self.packages |= declared
|
||||
store["npm_packages_for_module"][mod.name] = declared
|
||||
|
||||
def apply(
|
||||
self,
|
||||
store: store_module.Store,
|
||||
dry_run: bool = False,
|
||||
params: list[str] | None = None,
|
||||
) -> bool:
|
||||
if not self.packages:
|
||||
return True
|
||||
|
||||
if shutil.which("npm") is None:
|
||||
if dry_run:
|
||||
output.print_list(
|
||||
"Installing global npm packages after npm becomes available:",
|
||||
sorted(self.packages),
|
||||
)
|
||||
return True
|
||||
output.print_error("npm is unavailable after the pacman step.")
|
||||
return False
|
||||
|
||||
try:
|
||||
installed = self._installed_packages()
|
||||
to_install = self.packages - installed
|
||||
output.print_list("Installing global npm packages:", sorted(to_install))
|
||||
if to_install and not dry_run:
|
||||
decman.prg(
|
||||
[
|
||||
"npm",
|
||||
"install",
|
||||
"--global",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
*sorted(to_install),
|
||||
]
|
||||
)
|
||||
except (errors.CommandFailedError, ValueError) as error:
|
||||
output.print_error(f"Managing global npm packages failed: {error}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _installed_packages() -> set[str]:
|
||||
raw = decman.prg(
|
||||
["npm", "list", "--global", "--depth=0", "--json"], pty=False
|
||||
)
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError("npm returned invalid JSON") from error
|
||||
dependencies = result.get("dependencies", {})
|
||||
if not isinstance(dependencies, dict):
|
||||
raise ValueError("npm dependencies must be an object")
|
||||
return set(dependencies)
|
||||
@@ -7,6 +7,8 @@ from pathlib import Path
|
||||
import decman
|
||||
from decman.plugins import aur, pacman
|
||||
|
||||
from modules import npm_packages
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGES = ROOT / "packages"
|
||||
|
||||
@@ -34,6 +36,11 @@ class PackageConcern(decman.Module):
|
||||
def aur_packages(self) -> set[str]:
|
||||
return _names(PACKAGES / f"{self.concern}-aur.txt")
|
||||
|
||||
@npm_packages.packages
|
||||
def global_npm_packages(self) -> set[str]:
|
||||
path = PACKAGES / f"{self.concern}-npm.txt"
|
||||
return _names(path) if path.exists() else set()
|
||||
|
||||
|
||||
def configure_no_removal_guard() -> None:
|
||||
"""Keep Decman from pruning current dependency-orphans during adoption."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Package lists
|
||||
|
||||
Each concern owns package names exactly once:
|
||||
Each concern owns package names exactly once. Native, AUR, and global npm
|
||||
packages use `*-repo.txt`, `*-aur.txt`, and optional `*-npm.txt` files:
|
||||
|
||||
- `common/` is portable workstation functionality.
|
||||
- `laptop/` is hardware and power support for this laptop.
|
||||
@@ -8,4 +9,6 @@ Each concern owns package names exactly once:
|
||||
- `ignored/` contains dependency packages Decman must not prune during adoption.
|
||||
|
||||
A host composes concerns in `../hosts.py`. Add a package to the narrowest
|
||||
concern that explains why it exists, then run `../scripts/verify.py`.
|
||||
concern that explains why it exists, then run `../scripts/verify.py`. The npm
|
||||
plugin installs missing declared command packages globally after the pacman and
|
||||
AUR steps; it does not remove unrelated global npm packages.
|
||||
|
||||
@@ -30,6 +30,7 @@ refind
|
||||
ripgrep
|
||||
rsync
|
||||
starship
|
||||
trash-cli
|
||||
unzip
|
||||
yadm
|
||||
zellij
|
||||
|
||||
@@ -2,6 +2,7 @@ decman
|
||||
engram-bin
|
||||
gti
|
||||
mongodb-compass-bin
|
||||
onorca-bin
|
||||
pi
|
||||
postman-bin
|
||||
vagrant
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@hypabolic/hypa
|
||||
@@ -1 +1 @@
|
||||
|
||||
sddm-theme-noctalia-git
|
||||
|
||||
@@ -9,7 +9,6 @@ hypridle
|
||||
hyprland
|
||||
hyprlock
|
||||
kitty
|
||||
mako
|
||||
neovim
|
||||
pamixer
|
||||
pavucontrol
|
||||
|
||||
@@ -21,7 +21,6 @@ rustup
|
||||
screengrab
|
||||
seahorse
|
||||
sudo
|
||||
sway
|
||||
texlive-bin
|
||||
texlive-meta
|
||||
tree
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
@@ -32,38 +34,100 @@ def actual(command: str) -> set[str]:
|
||||
)
|
||||
|
||||
|
||||
def npm_actual() -> set[str]:
|
||||
if shutil.which("npm") is None:
|
||||
return set()
|
||||
raw = subprocess.run(
|
||||
["npm", "list", "--global", "--depth=0", "--json"],
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
).stdout
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
raise SystemExit("npm returned invalid JSON") from error
|
||||
dependencies = result.get("dependencies", {})
|
||||
if not isinstance(dependencies, dict):
|
||||
raise SystemExit("npm dependencies must be an object")
|
||||
return set(dependencies)
|
||||
|
||||
|
||||
def optional_lines(path: Path) -> set[str]:
|
||||
return lines(path) if path.exists() else set()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repo_files = list(PACKAGES.glob("**/*-repo.txt"))
|
||||
aur_files = list(PACKAGES.glob("**/*-aur.txt"))
|
||||
npm_files = list(PACKAGES.glob("**/*-npm.txt"))
|
||||
repo = [name for path in repo_files for name in lines(path)]
|
||||
aur = [name for path in aur_files for name in lines(path)]
|
||||
npm = [name for path in npm_files for name in lines(path)]
|
||||
duplicate = {name for name, count in Counter(repo + aur).items() if count > 1}
|
||||
duplicate_npm = {name for name, count in Counter(npm).items() if count > 1}
|
||||
if duplicate:
|
||||
raise SystemExit(f"duplicate package ownership: {sorted(duplicate)}")
|
||||
if duplicate_npm:
|
||||
raise SystemExit(f"duplicate npm package ownership: {sorted(duplicate_npm)}")
|
||||
installed_repo = actual("-Qqen")
|
||||
installed_aur = actual("-Qqem")
|
||||
installed_npm = npm_actual()
|
||||
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
import decman
|
||||
|
||||
runpy.run_path(str(ROOT / "source.py"))
|
||||
active_concerns = {
|
||||
concern
|
||||
for module in decman.modules
|
||||
if isinstance((concern := getattr(module, "concern", None)), str)
|
||||
}
|
||||
expected_repo = set().union(
|
||||
*(lines(PACKAGES / f"{concern}-repo.txt") for concern in active_concerns)
|
||||
)
|
||||
expected_aur = set().union(
|
||||
*(lines(PACKAGES / f"{concern}-aur.txt") for concern in active_concerns)
|
||||
)
|
||||
expected_npm = set().union(
|
||||
*(optional_lines(PACKAGES / f"{concern}-npm.txt") for concern in active_concerns)
|
||||
)
|
||||
loaded_repo: set[str] = set()
|
||||
loaded_aur: set[str] = set()
|
||||
loaded_npm: set[str] = set()
|
||||
for module in decman.modules:
|
||||
native = getattr(module, "native_packages", None)
|
||||
foreign = getattr(module, "aur_packages", None)
|
||||
global_npm = getattr(module, "global_npm_packages", None)
|
||||
if callable(native):
|
||||
loaded_repo |= cast(Callable[[], set[str]], native)()
|
||||
if callable(foreign):
|
||||
loaded_aur |= cast(Callable[[], set[str]], foreign)()
|
||||
if loaded_repo != set(repo) or loaded_aur != set(aur):
|
||||
raise SystemExit("active profile does not compose the full laptop package set")
|
||||
if callable(global_npm):
|
||||
loaded_npm |= cast(Callable[[], set[str]], global_npm)()
|
||||
if (
|
||||
loaded_repo != expected_repo
|
||||
or loaded_aur != expected_aur
|
||||
or loaded_npm != expected_npm
|
||||
):
|
||||
raise SystemExit("active profile does not compose its package concerns")
|
||||
|
||||
system_manifests = {
|
||||
name
|
||||
for module in decman.modules
|
||||
for name in getattr(module, "_system_manifests", ())
|
||||
}
|
||||
user_manifests = {
|
||||
name
|
||||
for module in decman.modules
|
||||
for name in getattr(module, "_user_manifests", ())
|
||||
}
|
||||
expected_system = set().union(
|
||||
*(lines(path) for path in (ROOT / "manifests").glob("**/*-system.txt"))
|
||||
*(lines(ROOT / "manifests" / name) for name in system_manifests)
|
||||
)
|
||||
expected_user = set().union(
|
||||
*(lines(path) for path in (ROOT / "manifests").glob("**/*-user.txt"))
|
||||
*(lines(ROOT / "manifests" / name) for name in user_manifests)
|
||||
)
|
||||
loaded_system: set[str] = set()
|
||||
loaded_user: set[str] = set()
|
||||
@@ -77,15 +141,27 @@ def main() -> None:
|
||||
"alex", set()
|
||||
)
|
||||
if loaded_system != expected_system or loaded_user != expected_user:
|
||||
raise SystemExit("active profile does not compose the complete unit manifests")
|
||||
pending_remove = sorted((installed_repo - set(repo)) | (installed_aur - set(aur)))
|
||||
pending_install = sorted((set(repo) - installed_repo) | (set(aur) - installed_aur))
|
||||
raise SystemExit("active profile does not compose its unit manifests")
|
||||
|
||||
host_scopes = active_concerns & {"laptop/hardware", "desktop/local"}
|
||||
if len(host_scopes) != 1:
|
||||
raise SystemExit(f"active profile has ambiguous host concerns: {host_scopes}")
|
||||
profile = next(iter(host_scopes)).split("/", maxsplit=1)[0]
|
||||
pending_remove = sorted(
|
||||
(installed_repo - expected_repo) | (installed_aur - expected_aur)
|
||||
)
|
||||
pending_install = sorted(
|
||||
(expected_repo - installed_repo) | (expected_aur - installed_aur)
|
||||
)
|
||||
pending_npm_install = sorted(expected_npm - installed_npm)
|
||||
print(
|
||||
f"OK: repo={len(repo)} aur={len(aur)} system={len(loaded_system)} "
|
||||
f"user={len(loaded_user)} concerns={len(repo_files)} profile=laptop"
|
||||
f"OK: repo={len(expected_repo)} aur={len(expected_aur)} "
|
||||
f"npm={len(expected_npm)} system={len(loaded_system)} "
|
||||
f"user={len(loaded_user)} concerns={len(active_concerns)} profile={profile}"
|
||||
)
|
||||
print(f"PENDING REMOVE: {' '.join(pending_remove) or 'none'}")
|
||||
print(f"PENDING INSTALL: {' '.join(pending_install) or 'none'}")
|
||||
print(f"PENDING NPM INSTALL: {' '.join(pending_npm_install) or 'none'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,9 +6,11 @@ concerns and its desktop-specific Hyprland and Noctalia configuration.
|
||||
|
||||
import decman # pyright: ignore[reportMissingImports] # Provided by the Arch AUR package.
|
||||
|
||||
from modules.npm_packages import Npm
|
||||
from modules.packages import configure_no_removal_guard
|
||||
from hosts import desktop
|
||||
|
||||
decman.plugins[Npm.NAME] = Npm()
|
||||
configure_no_removal_guard()
|
||||
decman.modules += desktop()
|
||||
decman.execution_order = ["files", "pacman", "aur", "systemd"]
|
||||
decman.execution_order = ["files", "pacman", "aur", "npm", "systemd"]
|
||||
|
||||
Reference in New Issue
Block a user