From 7580e0fdc20a194e4ae3ad9e39b5cce65d987062 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 12:05:11 +0000 Subject: [PATCH] Add persistent terminal aliases Co-authored-by: Jakob Langtry --- README.md | 14 ++++ src/lib/terminal/index.js | 86 ++++++++++++++++++++ src/scripts/terminal.js | 157 ++++++++++++++++++++++++++++++++++-- tests/unit/terminal.test.js | 72 +++++++++++++++++ 4 files changed, 324 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 22b4468..a422abd 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ repo clone # print a git clone command The catalog is static and sourced from `public/data/projects.json`, which keeps the website usable without GitHub API credentials at runtime. +## Terminal Aliases + +The terminal supports persistent, shell-style command aliases: + +```bash +alias h='history 10' # create a shortcut +alias sys='repo --systems' # alias commands with arguments +alias # list saved aliases +unalias h # remove a custom alias +``` + +Aliases are stored in browser `localStorage`. Built-in aliases such as +`exit='close'` are read-only. + ## Build ```bash diff --git a/src/lib/terminal/index.js b/src/lib/terminal/index.js index 8501964..021b814 100644 --- a/src/lib/terminal/index.js +++ b/src/lib/terminal/index.js @@ -33,6 +33,7 @@ export const COMMAND_LIST = [ "snake", "stats", "theme", + "unalias", "uptime", "weather", "which", @@ -49,6 +50,91 @@ export const COMMAND_LIST = [ "exit", ]; +export const DEFAULT_ALIASES = Object.freeze({ + exit: "close", +}); + +export function isValidAliasName(name) { + return /^[a-z][a-z0-9_-]*$/i.test(String(name || "")); +} + +export function parseAliasDefinition(input) { + const source = String(input || "").trim(); + const match = source.match(/^([a-z][a-z0-9_-]*)=(.+)$/i); + if (!match) { + return { + error: "Usage: alias name='command'", + }; + } + + const name = match[1].toLowerCase(); + let command = match[2].trim(); + const quote = command[0]; + if ((quote === "'" || quote === '"') && command.endsWith(quote)) { + command = command.slice(1, -1).trim(); + } + + if (!command) { + return { + error: "Alias command cannot be empty.", + }; + } + + return { name, command }; +} + +export function formatAliasOutput(aliases) { + const entries = Object.entries(aliases || {}).sort(([a], [b]) => + a.localeCompare(b), + ); + + if (entries.length === 0) { + return "No aliases configured.\nCreate one with: alias h='history 10'"; + } + + return entries + .map(([name, command]) => { + const escaped = String(command).replace(/'/g, "'\\''"); + return `alias ${name}='${escaped}'`; + }) + .join("\n"); +} + +export function expandAliasCommand(input, aliases, maxDepth = 8) { + let command = String(input || "").trim(); + const seen = []; + + for (let depth = 0; depth < maxDepth; depth++) { + const match = command.match(/^(\S+)(.*)$/); + if (!match) { + return { command, expanded: seen.length > 0 }; + } + + const name = match[1].toLowerCase(); + const target = aliases?.[name]; + if (!target) { + return { command, expanded: seen.length > 0 }; + } + + if (seen.includes(name)) { + return { + command, + expanded: seen.length > 0, + error: `Alias loop detected: ${[...seen, name].join(" -> ")}`, + }; + } + + seen.push(name); + command = `${target}${match[2] || ""}`.trim(); + } + + return { + command, + expanded: seen.length > 0, + error: "Alias expansion exceeded maximum depth.", + }; +} + export function buildProjectsListOutput(projects) { if (!Array.isArray(projects) || projects.length === 0) { return "No projects are configured yet."; diff --git a/src/scripts/terminal.js b/src/scripts/terminal.js index cae331d..aefb03e 100644 --- a/src/scripts/terminal.js +++ b/src/scripts/terminal.js @@ -22,6 +22,11 @@ import { flipText, safeCalc, renderBigTime, + DEFAULT_ALIASES, + isValidAliasName, + parseAliasDefinition, + formatAliasOutput, + expandAliasCommand, } from "../lib/terminal/index.js"; // Global variables for managing input and command history @@ -41,6 +46,9 @@ let countdownActive = false; let countdownInterval = null; let editorState = null; // null | { phase: "title" } | { phase: "body", title, lines } | { phase: "login" } let isAdmin = false; +let customAliases = {}; + +const ALIAS_STORAGE_KEY = "terminal-aliases"; async function hashPassword(password) { const data = new TextEncoder().encode(password); @@ -97,6 +105,100 @@ function saveCustomWhoami(text) { // ignore } } + +function loadCustomAliases() { + try { + const raw = localStorage.getItem(ALIAS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + return Object.fromEntries( + Object.entries(parsed).filter( + ([name, command]) => + isValidAliasName(name) && + typeof command === "string" && + command.trim(), + ), + ); + } catch { + return {}; + } +} + +function saveCustomAliases() { + try { + localStorage.setItem(ALIAS_STORAGE_KEY, JSON.stringify(customAliases)); + } catch { + // ignore + } +} + +function getAliasMap() { + return { ...DEFAULT_ALIASES, ...customAliases }; +} + +function displayAliases() { + appendOutput(formatAliasOutput(getAliasMap()), "info-text"); +} + +function setAlias(args) { + const parsed = parseAliasDefinition(args); + if (parsed.error) { + appendOutput(parsed.error, "error-text"); + return; + } + + if (parsed.name === "alias" || parsed.name === "unalias") { + appendOutput(`alias: '${parsed.name}' is reserved.`, "error-text"); + return; + } + + if (DEFAULT_ALIASES[parsed.name]) { + appendOutput( + `alias: '${parsed.name}' is a built-in alias and cannot be overwritten.`, + "error-text", + ); + return; + } + + customAliases[parsed.name] = parsed.command; + saveCustomAliases(); + appendOutput( + formatAliasOutput({ [parsed.name]: parsed.command }), + "success-text", + ); +} + +function removeAlias(args) { + const name = String(args || "") + .trim() + .toLowerCase(); + + if (!name) { + appendOutput("Usage: unalias [name]", "info-text"); + return; + } + + if (DEFAULT_ALIASES[name]) { + appendOutput( + `unalias: '${name}' is a built-in alias and cannot be removed.`, + "error-text", + ); + return; + } + + if (!customAliases[name]) { + appendOutput(`unalias: '${name}' not found.`, "error-text"); + return; + } + + delete customAliases[name]; + saveCustomAliases(); + appendOutput(`Removed alias '${name}'.`, "success-text"); +} + function getAllPosts() { const staticPosts = terminalData.posts || []; const localPosts = loadLocalPosts(); @@ -157,6 +259,7 @@ try { } catch (e) { // ignore } +customAliases = loadCustomAliases(); let historyIndex = commandHistory.length; let currentInputBuffer = ""; let cursor; // Global cursor element @@ -243,6 +346,7 @@ const commandList = [ "snake", "stats", "theme", + "unalias", "uptime", "weather", "which", @@ -665,7 +769,7 @@ function displayOnboardingCommands() { const hints = document.createElement("div"); hints.className = "keyboard-hints log-text"; hints.textContent = - "Tip: Tab to autocomplete ↑↓ history Ctrl+L clear Ctrl+C cancel"; + "Tip: Tab autocomplete ↑↓ history alias h='history 10' Ctrl+L clear"; const container = document.createElement("div"); container.className = "onboarding-block"; @@ -801,6 +905,13 @@ function executeCommand(command, options = {}) { cliOutput.insertBefore(commandLine, inputLine); } + const aliasExpansion = expandAliasCommand(command, getAliasMap()); + if (aliasExpansion.error) { + appendOutput(aliasExpansion.error, "error-text"); + return; + } + command = aliasExpansion.command; + const normalizedCommand = command.toLowerCase(); switch (normalizedCommand) { case "help": @@ -840,6 +951,7 @@ function executeCommand(command, options = {}) { hostname show hostname which find a command alias manage aliases + unalias remove aliases cd change directory rss RSS feed URL sudo sudo mode @@ -910,7 +1022,10 @@ Currently seeking opportunities in software engineering.`, appendOutput("jjalangtry.com", "info-text"); break; case "alias": - appendOutput("alias exit='close'", "info-text"); + displayAliases(); + break; + case "unalias": + appendOutput("Usage: unalias [name]", "info-text"); break; case "skills": displaySkills(); @@ -1185,6 +1300,12 @@ Currently seeking opportunities in software engineering.`, } } break; + } else if (normalizedCommand.startsWith("alias ")) { + setAlias(command.substring(6).trim()); + break; + } else if (normalizedCommand.startsWith("unalias ")) { + removeAlias(command.substring(8).trim()); + break; } else if (normalizedCommand.startsWith("blog ")) { const slug = command.substring(5).trim(); if (!slug) { @@ -2910,6 +3031,13 @@ function displayManPage(cmd) { function getHelpDetails() { return { + alias: { + desc: "Create, list, and persist command aliases.", + usage: "alias [name='command']", + examples: ["alias", "alias h='history 10'", "alias sys='repo --systems'"], + notes: + "Aliases expand the first word of a command before execution and are saved in localStorage. Built-in aliases such as exit='close' are read-only.", + }, banner: { desc: "Display the ASCII art banner for the terminal.", usage: "banner", @@ -3154,6 +3282,13 @@ function getHelpDetails() { notes: "Without arguments, toggles to the opposite theme. Preference is saved in your browser.", }, + unalias: { + desc: "Remove a custom command alias.", + usage: "unalias [name]", + examples: ["unalias h", "unalias sys"], + notes: + "Removes custom aliases saved in localStorage. Built-in aliases cannot be removed.", + }, uptime: { desc: "Display how long the current terminal session has been active.", usage: "uptime", @@ -4037,7 +4172,13 @@ function executePipeline(input) { echoLine.textContent = `guest@jjalangtry.com:~$ ${input}`; cliOutput.insertBefore(echoLine, inputLine); - const firstCmd = filtered[0].trim().toLowerCase(); + const firstExpansion = expandAliasCommand(filtered[0], getAliasMap()); + if (firstExpansion.error) { + appendOutput(firstExpansion.error, "error-text"); + return; + } + + const firstCmd = firstExpansion.command.trim().toLowerCase(); if ( firstCmd === "repos" || firstCmd.startsWith("weather ") || @@ -4053,7 +4194,7 @@ function executePipeline(input) { // Capture output from first command captureMode = true; capturedLines = []; - executeCommand(filtered[0], { skipEcho: true }); + executeCommand(firstExpansion.command, { skipEcho: true }); captureMode = false; // Combine captured text @@ -4061,7 +4202,13 @@ function executePipeline(input) { // Process pipe segments for (let i = 1; i < filtered.length; i++) { - const pipeCmd = filtered[i].trim(); + const pipeExpansion = expandAliasCommand(filtered[i].trim(), getAliasMap()); + if (pipeExpansion.error) { + appendOutput(pipeExpansion.error, "error-text"); + return; + } + + const pipeCmd = pipeExpansion.command; const pipeCmdLower = pipeCmd.toLowerCase(); if (pipeCmdLower.startsWith("grep ")) { diff --git a/tests/unit/terminal.test.js b/tests/unit/terminal.test.js index d3bce00..9262002 100644 --- a/tests/unit/terminal.test.js +++ b/tests/unit/terminal.test.js @@ -39,6 +39,11 @@ import { flipText, safeCalc, renderBigTime, + DEFAULT_ALIASES, + isValidAliasName, + parseAliasDefinition, + formatAliasOutput, + expandAliasCommand, } from "../../src/lib/terminal/index.js"; describe("terminal helpers", () => { @@ -62,6 +67,7 @@ describe("terminal helpers", () => { expect(COMMAND_LIST).toContain("pwd"); expect(COMMAND_LIST).toContain("hostname"); expect(COMMAND_LIST).toContain("alias"); + expect(COMMAND_LIST).toContain("unalias"); expect(COMMAND_LIST).toContain("which"); expect(COMMAND_LIST).toContain("login"); expect(COMMAND_LIST).toContain("logout"); @@ -516,6 +522,72 @@ describe("terminal helpers", () => { expect(output).toContain(" 12 cmd11"); }); + it("validates and parses shell-style alias definitions", () => { + expect(DEFAULT_ALIASES).toEqual({ exit: "close" }); + expect(isValidAliasName("h")).toBe(true); + expect(isValidAliasName("repo-sys")).toBe(true); + expect(isValidAliasName("1bad")).toBe(false); + expect(isValidAliasName("bad name")).toBe(false); + + expect(parseAliasDefinition("h='history 10'")).toEqual({ + name: "h", + command: "history 10", + }); + expect(parseAliasDefinition('sys="repo --systems"')).toEqual({ + name: "sys", + command: "repo --systems", + }); + expect(parseAliasDefinition("bad")).toEqual({ + error: "Usage: alias name='command'", + }); + expect(parseAliasDefinition("empty=''")).toEqual({ + error: "Alias command cannot be empty.", + }); + }); + + it("formats aliases in deterministic terminal syntax", () => { + const output = formatAliasOutput({ + sys: "repo --systems", + h: "history 10", + }); + expect(output.split("\n")).toEqual([ + "alias h='history 10'", + "alias sys='repo --systems'", + ]); + expect(formatAliasOutput({})).toContain("No aliases configured"); + }); + + it("expands aliases with arguments and detects recursive loops", () => { + const aliases = { + h: "history 10", + sys: "repo --systems", + r: "sys", + a: "b", + b: "a", + }; + + expect(expandAliasCommand("h", aliases)).toEqual({ + command: "history 10", + expanded: true, + }); + expect(expandAliasCommand("sys C", aliases)).toEqual({ + command: "repo --systems C", + expanded: true, + }); + expect(expandAliasCommand("r", aliases)).toEqual({ + command: "repo --systems", + expanded: true, + }); + expect(expandAliasCommand("help", aliases)).toEqual({ + command: "help", + expanded: false, + }); + expect(expandAliasCommand("a", aliases)).toMatchObject({ + expanded: true, + error: "Alias loop detected: a -> b -> a", + }); + }); + it("grepFilter matches with simple substring (default)", () => { expect(grepFilter("", "foo")).toEqual([]); expect(grepFilter("hello\nworld", "")).toEqual([]);