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",
|
"command -v *": "allow",
|
||||||
"find *": "allow",
|
"find *": "allow",
|
||||||
"hypa *": "allow",
|
"hypa *": "allow",
|
||||||
|
"hypa_find *": "allow",
|
||||||
|
|
||||||
"git status": "allow",
|
"git status": "allow",
|
||||||
"git status *": "allow",
|
"git status *": "allow",
|
||||||
|
|||||||
+23
-1
@@ -10,7 +10,10 @@
|
|||||||
},
|
},
|
||||||
"gpt-5.6-luna": {
|
"gpt-5.6-luna": {
|
||||||
"contextWindow": 1050000
|
"contextWindow": 1050000
|
||||||
}
|
},
|
||||||
|
"gpt-6-astra": {
|
||||||
|
"contextWindow": 1050000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"openai-codex": {
|
"openai-codex": {
|
||||||
@@ -25,6 +28,25 @@
|
|||||||
"contextWindow": 1050000
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"quietStartup": true,
|
"quietStartup": true,
|
||||||
"lastChangelogVersion": "0.84.2",
|
"lastChangelogVersion": "0.85.1",
|
||||||
"defaultProvider": "openai-codex",
|
"defaultProvider": "openai-codex",
|
||||||
"defaultModel": "gpt-5.6-terra",
|
"defaultModel": "gpt-5.6-sol",
|
||||||
"defaultThinkingLevel": "high",
|
"defaultThinkingLevel": "high",
|
||||||
"packages": [
|
"packages": [
|
||||||
"npm:pi-subagents",
|
"npm:pi-subagents",
|
||||||
@@ -14,15 +14,22 @@
|
|||||||
"npm:@juicesharp/rpiv-btw",
|
"npm:@juicesharp/rpiv-btw",
|
||||||
"npm:pi-powerline-footer",
|
"npm:pi-powerline-footer",
|
||||||
"npm:pi-studio",
|
"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:@hypabolic/pi-hypa",
|
||||||
"npm:bigpowers",
|
"npm:bigpowers",
|
||||||
"npm:@gotgenes/pi-permission-system",
|
|
||||||
"npm:pi-intercom",
|
"npm:pi-intercom",
|
||||||
"npm:gentle-engram@0.1.8",
|
"npm:gentle-engram@0.1.8",
|
||||||
"npm:pi-lens",
|
"npm:pi-lens",
|
||||||
"npm:@juicesharp/rpiv-todo",
|
"npm:@juicesharp/rpiv-todo",
|
||||||
"npm:@majorgilles/pi-grill-me",
|
"npm:@majorgilles/pi-grill-me",
|
||||||
"npm:pi-mcp-adapter"
|
"npm:pi-mcp-adapter",
|
||||||
|
"npm:pi-caveman",
|
||||||
|
"npm:@dietrichgebert/ponytail"
|
||||||
],
|
],
|
||||||
"powerline": "full",
|
"powerline": "full",
|
||||||
"hideThinkingBlock": false,
|
"hideThinkingBlock": false,
|
||||||
@@ -33,4 +40,4 @@
|
|||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"transport": "auto",
|
"transport": "auto",
|
||||||
"tuiMode": "fullscreen"
|
"tuiMode": "fullscreen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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
|
## Ownership map
|
||||||
|
|
||||||
| Path | Owns |
|
| Path | Owns |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Root dotfiles (`.zshrc`, `.config/`, `.scripts/`, etc.) | Ordinary yadm-managed user configuration and helpers |
|
| 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 desktop |
|
||||||
| `system-config/` | Declarative Decman configuration for this Arch laptop |
|
|
||||||
| `system-config/config/system/` | Files deployed into system locations; do **not** edit deployed `/etc` targets |
|
| `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/config/user/` | Selected user dotfiles deployed by Decman; do **not** edit deployed copies |
|
||||||
| `system-config/packages/` | Native and AUR package lists organized by concern |
|
| `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
|
## 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`
|
1. `files`
|
||||||
2. `pacman`
|
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:
|
`hosts.py` composes host profiles from package concerns:
|
||||||
|
|
||||||
- Shared concerns: `common/core`, `wayland`, `fonts`, `development`, `productivity`, `media`, `research`, `network`, and `services`.
|
- Shared concerns: `common/core`, `common/hardware`, `common/wayland`, `common/fonts`, `common/development`, `common/productivity`, `common/media`, `common/research`, `common/network`, and `common/services`.
|
||||||
- Laptop-specific concern: `laptop/hardware`.
|
- Host-specific concerns: `laptop/hardware` and `desktop/local`.
|
||||||
- Desktop support is intentionally sparse and future-facing.
|
- 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:
|
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.
|
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
|
## 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).
|
- 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.
|
- 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
|
## 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.
|
- `.zshrc`, `.bashrc`, `.terminal_aliases`, `.tmux.conf`, and `.p10k.zsh` — shell and terminal setup.
|
||||||
- `.config/` — desktop and application configuration.
|
- `.config/` — desktop and application configuration.
|
||||||
- `.scripts/` — optional shell, network, virtual-environment, and update helpers.
|
- `.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.
|
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
|
```fish
|
||||||
source .scripts/updating.sh
|
./system-config/decman verify
|
||||||
update_system
|
./system-config/decman dry-run
|
||||||
# or
|
|
||||||
update_pacman_packages
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **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.
|
> **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.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Custom `st` terminal
|
## Custom `st` terminal
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Desktop Package Audit
|
# 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
|
## 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-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-radeon` | `1:26.1.5-1` | REVIEW — provisional desktop/local retention candidate |
|
||||||
| `vulkan-tools` | `1.4.350.1-1` | KEEP — declared common desktop concern |
|
| `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 |
|
| `wdisplays` | `1.1.3-2` | REVIEW — provisional desktop/local retention candidate |
|
||||||
| `wget` | `1.25.0-6` | 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `pycharm-professional` | `2025.2.2-1` | REVIEW — provisional desktop/local retention candidate |
|
||||||
| `rustrover` | `2026.1.3-1` | REVIEW — provisional desktop/local retention candidate |
|
| `rustrover` | `2026.1.3-1` | REVIEW — provisional desktop/local retention candidate |
|
||||||
@@ -197,6 +197,7 @@ rustrover
|
|||||||
ueberzug
|
ueberzug
|
||||||
vkd3d
|
vkd3d
|
||||||
vulkan-mesa-layers
|
vulkan-mesa-layers
|
||||||
|
waybar
|
||||||
wine
|
wine
|
||||||
winetricks
|
winetricks
|
||||||
xclicker
|
xclicker
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ A small, declarative Arch configuration managed by Decman.
|
|||||||
## Design
|
## Design
|
||||||
|
|
||||||
- **Concerns live in `packages/`**: each concern has a native (`*-repo.txt`)
|
- **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
|
- **Hosts live in `hosts.py`**: a host composes package concerns and explicit
|
||||||
system state. `desktop()` owns this desktop's boot, storage, NVIDIA, input,
|
system state. `desktop()` owns this desktop's boot, storage, NVIDIA, input,
|
||||||
SDDM, network-policy, and user-session configuration.
|
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/` |
|
| 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
|
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/common/<concern>-{system,user}.txt` or
|
||||||
`manifests/laptop/<concern>-system.txt`.
|
`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
|
`verify.py` proves that every currently explicit package appears exactly once
|
||||||
in the active laptop profile. The ignored dependency lists prevent Decman's
|
in the active desktop profile, including global npm command packages. The
|
||||||
orphan cleanup from removing dependencies during adoption.
|
ignored dependency lists prevent Decman's orphan cleanup from removing
|
||||||
|
dependencies during adoption.
|
||||||
|
|
||||||
After intentional package changes, regenerate and review concern lists:
|
After intentional package changes, regenerate and review concern lists:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[Theme]
|
[Theme]
|
||||||
ThemeDir=/usr/share/sddm/themes
|
ThemeDir=/usr/share/sddm/themes
|
||||||
Current=maya
|
Current=noctalia
|
||||||
EnableAvatars=false
|
EnableAvatars=false
|
||||||
|
|
||||||
[Users]
|
[Users]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
PI_STATUS_BRIDGE_CLIENT=/home/alex/.local/bin/pi-status-bridge-client
|
PI_STATUS_BRIDGE_CLIENT=/home/alex/.local/bin/pi-status-bridge-client
|
||||||
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui
|
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.
|
# 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 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_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_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.
|
-- 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 .. " + SUPER_L", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"), { release = true })
|
||||||
hl.bind(main_mod .. " + RETURN", hl.dsp.exec_cmd(terminal))
|
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 .. " + 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 .. " + 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 .. " + 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 .. " + 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 .. " + 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 + F", hl.dsp.exec_cmd(noctalia_ipc .. "bar-toggle main"))
|
||||||
hl.bind(main_mod .. " + SHIFT + R", hl.dsp.exec_cmd("hyprctl reload"))
|
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 }))
|
hl.bind(main_mod .. " + " .. direction, hl.dsp.focus({ direction = direction }))
|
||||||
end
|
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 }))
|
hl.bind(main_mod .. " + SHIFT + " .. key, hl.dsp.window.move({ direction = direction }))
|
||||||
end
|
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.
|
-- 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 .. " + 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 + 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.
|
-- Keep the i3 floating and fullscreen shortcuts. Layout-specific binds are deferred.
|
||||||
hl.bind(main_mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
|
hl.bind(main_mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
|
||||||
@@ -149,8 +154,8 @@ local function float_rule(name, match, border_size)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
hl.window_rule({ name = "obsidian-workspace", match = { class = "^obsidian$" }, workspace = "8" })
|
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-workspace", match = { class = "^Thunderbird$" }, workspace = "9" })
|
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("pi-status-ui", { class = "^(pi-status-ui|Pi-status-ui)$" }, 1)
|
||||||
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
|
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
|
||||||
float_rule("qalculate", { title = "^Qalculate!$" })
|
float_rule("qalculate", { title = "^Qalculate!$" })
|
||||||
@@ -161,8 +166,8 @@ float_rule("volume-control", { title = "^Volume Control$" })
|
|||||||
float_rule("virtualbox", { class = ".*VirtualBox.*" })
|
float_rule("virtualbox", { class = ".*VirtualBox.*" })
|
||||||
hl.window_rule({ name = "virtualbox-manager-tiles", match = { class = ".*VirtualBox Manager.*" }, tile = true })
|
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 })
|
hl.window_rule({ name = "virtualbox-machine-tiles", match = { class = ".*VirtualBox Machine.*" }, tile = true })
|
||||||
-- Send music clients to the existing scratchpad without stealing startup focus.
|
-- Send music clients to hidden workspaces without stealing startup focus.
|
||||||
hl.window_rule({ name = "spotify-music", match = { class = "^Spotify$" }, workspace = "special:scratchpad silent", no_initial_focus = true })
|
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.window_rule({ name = "deezer-music", match = { class = "^Deezer$" }, workspace = "special:scratchpad silent", no_initial_focus = true })
|
||||||
|
|
||||||
hl.on("hyprland.start", function()
|
hl.on("hyprland.start", function()
|
||||||
|
|||||||
@@ -6,7 +6,24 @@ x-scheme-handler/webcal=userapp-Thunderbird-0DWKA3.desktop
|
|||||||
text/calendar=userapp-Thunderbird-0DWKA3.desktop
|
text/calendar=userapp-Thunderbird-0DWKA3.desktop
|
||||||
application/x-extension-ics=userapp-Thunderbird-0DWKA3.desktop
|
application/x-extension-ics=userapp-Thunderbird-0DWKA3.desktop
|
||||||
x-scheme-handler/webcals=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]
|
[Added Associations]
|
||||||
x-scheme-handler/mailto=userapp-Thunderbird-JBPLA3.desktop;
|
x-scheme-handler/mailto=userapp-Thunderbird-JBPLA3.desktop;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ return {
|
|||||||
{
|
{
|
||||||
output = "HDMI-A-1",
|
output = "HDMI-A-1",
|
||||||
mode = "3840x2160@120",
|
mode = "3840x2160@120",
|
||||||
|
bitdepth = 10,
|
||||||
|
cm = "hdr",
|
||||||
position = "0x0",
|
position = "0x0",
|
||||||
scale = 1,
|
scale = 1,
|
||||||
},
|
},
|
||||||
@@ -18,6 +20,7 @@ return {
|
|||||||
"nextcloud",
|
"nextcloud",
|
||||||
"spotify",
|
"spotify",
|
||||||
"brave",
|
"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
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g2"
|
id = "g2"
|
||||||
members = ["network", "bluetooth"]
|
members = ["network", "network-download", "network-upload", "bluetooth"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
@@ -73,6 +73,18 @@ opacity = 1.0
|
|||||||
padding = 6.0
|
padding = 6.0
|
||||||
radius = 5.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]
|
[widget.gpu]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "gpu_usage"
|
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 polling is disabled by Noctalia by default; enable it for the RTX 3090 widgets.
|
||||||
gpu_poll_seconds = 2.0
|
gpu_poll_seconds = 2.0
|
||||||
|
|
||||||
# The desktop's Hypridle listener provides a five-minute input-dismissable black
|
# Hypridle provides the five-minute input-dismissable black OLED overlay.
|
||||||
# OLED overlay. Keep Noctalia from issuing a competing DPMS screen-off action.
|
# 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]
|
[idle.behavior.screen-off]
|
||||||
enabled = false
|
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"
|
"config/system/desktop/network/NetworkManager.conf"
|
||||||
),
|
),
|
||||||
"/etc/sddm.conf.d/10-theme.conf": "config/system/desktop/sddm/10-theme.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",
|
"/etc/udev/rules.d/50-zsa.rules": "config/system/desktop/input/50-zsa.rules",
|
||||||
},
|
},
|
||||||
system_manifests=("desktop/hardware-system.txt",),
|
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
|
import decman
|
||||||
from decman.plugins import aur, pacman
|
from decman.plugins import aur, pacman
|
||||||
|
|
||||||
|
from modules import npm_packages
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
PACKAGES = ROOT / "packages"
|
PACKAGES = ROOT / "packages"
|
||||||
|
|
||||||
@@ -34,6 +36,11 @@ class PackageConcern(decman.Module):
|
|||||||
def aur_packages(self) -> set[str]:
|
def aur_packages(self) -> set[str]:
|
||||||
return _names(PACKAGES / f"{self.concern}-aur.txt")
|
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:
|
def configure_no_removal_guard() -> None:
|
||||||
"""Keep Decman from pruning current dependency-orphans during adoption."""
|
"""Keep Decman from pruning current dependency-orphans during adoption."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Package lists
|
# 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.
|
- `common/` is portable workstation functionality.
|
||||||
- `laptop/` is hardware and power support for this laptop.
|
- `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.
|
- `ignored/` contains dependency packages Decman must not prune during adoption.
|
||||||
|
|
||||||
A host composes concerns in `../hosts.py`. Add a package to the narrowest
|
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
|
ripgrep
|
||||||
rsync
|
rsync
|
||||||
starship
|
starship
|
||||||
|
trash-cli
|
||||||
unzip
|
unzip
|
||||||
yadm
|
yadm
|
||||||
zellij
|
zellij
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ decman
|
|||||||
engram-bin
|
engram-bin
|
||||||
gti
|
gti
|
||||||
mongodb-compass-bin
|
mongodb-compass-bin
|
||||||
|
onorca-bin
|
||||||
pi
|
pi
|
||||||
postman-bin
|
postman-bin
|
||||||
vagrant
|
vagrant
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
@hypabolic/hypa
|
||||||
@@ -1 +1 @@
|
|||||||
|
sddm-theme-noctalia-git
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ hypridle
|
|||||||
hyprland
|
hyprland
|
||||||
hyprlock
|
hyprlock
|
||||||
kitty
|
kitty
|
||||||
mako
|
|
||||||
neovim
|
neovim
|
||||||
pamixer
|
pamixer
|
||||||
pavucontrol
|
pavucontrol
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ rustup
|
|||||||
screengrab
|
screengrab
|
||||||
seahorse
|
seahorse
|
||||||
sudo
|
sudo
|
||||||
sway
|
|
||||||
texlive-bin
|
texlive-bin
|
||||||
texlive-meta
|
texlive-meta
|
||||||
tree
|
tree
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import runpy
|
import runpy
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter
|
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:
|
def main() -> None:
|
||||||
repo_files = list(PACKAGES.glob("**/*-repo.txt"))
|
repo_files = list(PACKAGES.glob("**/*-repo.txt"))
|
||||||
aur_files = list(PACKAGES.glob("**/*-aur.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)]
|
repo = [name for path in repo_files for name in lines(path)]
|
||||||
aur = [name for path in aur_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 = {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:
|
if duplicate:
|
||||||
raise SystemExit(f"duplicate package ownership: {sorted(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_repo = actual("-Qqen")
|
||||||
installed_aur = actual("-Qqem")
|
installed_aur = actual("-Qqem")
|
||||||
|
installed_npm = npm_actual()
|
||||||
|
|
||||||
os.chdir(ROOT)
|
os.chdir(ROOT)
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
import decman
|
import decman
|
||||||
|
|
||||||
runpy.run_path(str(ROOT / "source.py"))
|
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_repo: set[str] = set()
|
||||||
loaded_aur: set[str] = set()
|
loaded_aur: set[str] = set()
|
||||||
|
loaded_npm: set[str] = set()
|
||||||
for module in decman.modules:
|
for module in decman.modules:
|
||||||
native = getattr(module, "native_packages", None)
|
native = getattr(module, "native_packages", None)
|
||||||
foreign = getattr(module, "aur_packages", None)
|
foreign = getattr(module, "aur_packages", None)
|
||||||
|
global_npm = getattr(module, "global_npm_packages", None)
|
||||||
if callable(native):
|
if callable(native):
|
||||||
loaded_repo |= cast(Callable[[], set[str]], native)()
|
loaded_repo |= cast(Callable[[], set[str]], native)()
|
||||||
if callable(foreign):
|
if callable(foreign):
|
||||||
loaded_aur |= cast(Callable[[], set[str]], foreign)()
|
loaded_aur |= cast(Callable[[], set[str]], foreign)()
|
||||||
if loaded_repo != set(repo) or loaded_aur != set(aur):
|
if callable(global_npm):
|
||||||
raise SystemExit("active profile does not compose the full laptop package set")
|
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(
|
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(
|
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_system: set[str] = set()
|
||||||
loaded_user: set[str] = set()
|
loaded_user: set[str] = set()
|
||||||
@@ -77,15 +141,27 @@ def main() -> None:
|
|||||||
"alex", set()
|
"alex", set()
|
||||||
)
|
)
|
||||||
if loaded_system != expected_system or loaded_user != expected_user:
|
if loaded_system != expected_system or loaded_user != expected_user:
|
||||||
raise SystemExit("active profile does not compose the complete unit manifests")
|
raise SystemExit("active profile does not compose its unit manifests")
|
||||||
pending_remove = sorted((installed_repo - set(repo)) | (installed_aur - set(aur)))
|
|
||||||
pending_install = sorted((set(repo) - installed_repo) | (set(aur) - installed_aur))
|
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(
|
print(
|
||||||
f"OK: repo={len(repo)} aur={len(aur)} system={len(loaded_system)} "
|
f"OK: repo={len(expected_repo)} aur={len(expected_aur)} "
|
||||||
f"user={len(loaded_user)} concerns={len(repo_files)} profile=laptop"
|
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 REMOVE: {' '.join(pending_remove) or 'none'}")
|
||||||
print(f"PENDING INSTALL: {' '.join(pending_install) 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__":
|
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.
|
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 modules.packages import configure_no_removal_guard
|
||||||
from hosts import desktop
|
from hosts import desktop
|
||||||
|
|
||||||
|
decman.plugins[Npm.NAME] = Npm()
|
||||||
configure_no_removal_guard()
|
configure_no_removal_guard()
|
||||||
decman.modules += desktop()
|
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