11 Commits

Author SHA1 Message Date
alex 5c7663802c changes and fixes 2026-09-17 11:52:56 +02:00
alex c7750a3285 fix(hyprland): match desktop app window classes
Obsidian and Thunderbird now publish reverse-DNS classes, so the old exact rules never routed their autostart windows. Preserve legacy class variants while matching current clients.
2026-09-07 15:15:50 +02:00
alex 3e12379694 feat(hyprland): route apps through special workspaces 2026-09-07 14:27:58 +02:00
alex 1f69906bc5 docs(agents): document knowledge vault workflow 2026-09-07 14:27:58 +02:00
alex cb39b659d5 feat(pi): enable ponytail coding guidance 2026-09-07 14:27:58 +02:00
alex bcf729f39c feat(pi): discover LocalAI models dynamically 2026-08-27 16:26:41 +02:00
alex bc47d828a3 chore(scripts): remove legacy updating.sh 2026-08-27 15:50:31 +02:00
alex 15b44304c1 config(user): desktop session and pi-status-bridge tweaks 2026-08-27 15:50:31 +02:00
alex cd9b50feae feat(decman): manage npm packages and deploy sddm theme assets 2026-08-27 15:50:31 +02:00
alex b6ae6887a3 feat(pi): add localai provider and desktop workflow packages 2026-08-27 15:50:30 +02:00
alex 10c214fc36 docs: align agent and repo docs with desktop Decman profile 2026-08-27 15:50:30 +02:00
28 changed files with 973 additions and 88 deletions
@@ -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
View File
@@ -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
View File
@@ -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"
} }
-19
View File
@@ -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
}
+15 -6
View File
@@ -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:
+9 -22
View File
@@ -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
+4 -3
View File
@@ -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
+8 -4
View File
@@ -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
+6
View File
@@ -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",),
+103
View File
@@ -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
View File
@@ -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."""
+5 -2
View File
@@ -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
+85 -9
View File
@@ -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__":
+3 -1
View File
@@ -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"]