Эх сурвалжийг харах

feat(packages): add package manager and quick-win packages

Package Manager (lua/core/):
- package.lua: Core package loading, installation, registry support
- package-commands.lua: M-x commands for package management
- registry.json: Bundled package registry

Quick-win Packages (lua/packages/):
- goto-line: Jump to specific line (M-g g)
- recentf: Track recently opened files (C-x C-r)
- bookmarks: Named bookmarks and quick marks
- visual-line: Soft word wrap mode

The package manager supports:
- Installing from git URLs
- Loading from local directories
- Registry-based short names
- User packages.lua configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Bernardo Magri 1 сар өмнө
parent
commit
31fa1c8dbd

+ 307 - 0
lua/core/package-commands.lua

@@ -0,0 +1,307 @@
+-- package-commands.lua
+-- Editor commands for the package manager
+-- ============================================================================
+
+-- Get package manager from lumacs global (set by init.lua)
+local package_manager = lumacs.package
+
+-- ============================================================================
+-- Package Commands
+-- ============================================================================
+
+-- List installed/loaded packages
+editor:register_command("package-list", "List loaded packages", function(args)
+    local loaded = package_manager.list_loaded()
+
+    if #loaded == 0 then
+        return {success = true, message = "No packages loaded"}
+    end
+
+    local lines = {"Loaded packages:"}
+    for _, pkg in ipairs(loaded) do
+        local status = pkg.source == "builtin" and "[builtin]" or "[installed]"
+        table.insert(lines, string.format("  %s %s", pkg.name, status))
+    end
+
+    editor:set_echo_area(lines)
+    return {success = true, message = ""}
+end)
+
+-- Show package info
+editor:register_command("package-info", "Show information about a package", function(args)
+    if #args == 0 then
+        return {success = true, message = "Usage: package-info <name>"}
+    end
+
+    local name = args[1]
+    local info = package_manager.info(name)
+
+    local lines = {
+        "Package: " .. name,
+        "Status: " .. (info.loaded and "loaded" or "not loaded"),
+    }
+
+    if info.installed then
+        table.insert(lines, "Source: " .. info.source)
+        table.insert(lines, "Path: " .. info.path)
+    else
+        table.insert(lines, "Not installed locally")
+    end
+
+    if info.registry then
+        table.insert(lines, "Registry: " .. (info.registry.description or "available"))
+        if info.registry.url then
+            table.insert(lines, "URL: " .. info.registry.url)
+        end
+    end
+
+    editor:set_echo_area(lines)
+    return {success = true, message = ""}
+end, {}, true, "s")
+
+-- Install a package
+editor:register_command("package-install", "Install a package", function(args)
+    if #args == 0 then
+        return {success = true, message = "Usage: package-install <name-or-url>"}
+    end
+
+    local spec = args[1]
+
+    -- Check if it's a URL
+    if spec:match("^https?://") or spec:match("^git@") then
+        spec = { url = spec }
+    end
+
+    local ok, msg = package_manager.install(spec)
+    if ok then
+        -- Try to load it
+        local load_ok, load_msg = package_manager.load_package(spec)
+        if load_ok then
+            return {success = true, message = msg .. " and loaded"}
+        else
+            return {success = true, message = msg .. " (not loaded: " .. load_msg .. ")"}
+        end
+    else
+        return {success = false, message = msg}
+    end
+end, {}, true, "s")
+
+-- Update a package
+editor:register_command("package-update", "Update a package", function(args)
+    if #args == 0 then
+        return {success = true, message = "Usage: package-update <name>"}
+    end
+
+    local name = args[1]
+    local ok, msg = package_manager.update(name)
+
+    return {success = ok, message = msg}
+end, {}, true, "s")
+
+-- Update all packages
+editor:register_command("package-update-all", "Update all installed packages", function(args)
+    local loaded = package_manager.list_loaded()
+    local updated = 0
+    local failed = 0
+
+    for _, pkg in ipairs(loaded) do
+        if pkg.source == "installed" then
+            local ok, msg = package_manager.update(pkg.name)
+            if ok then
+                updated = updated + 1
+            else
+                failed = failed + 1
+            end
+        end
+    end
+
+    return {success = true, message = string.format("Updated %d packages, %d failed", updated, failed)}
+end)
+
+-- Remove a package
+editor:register_command("package-remove", "Remove an installed package", function(args)
+    if #args == 0 then
+        return {success = true, message = "Usage: package-remove <name>"}
+    end
+
+    local name = args[1]
+
+    -- Check if it's builtin
+    local info = package_manager.info(name)
+    if info.builtin then
+        return {success = false, message = "Cannot remove builtin package: " .. name}
+    end
+
+    local ok, msg = package_manager.remove(name)
+    return {success = ok, message = msg}
+end, {}, true, "s")
+
+-- Reload a package
+editor:register_command("package-reload", "Reload a package", function(args)
+    if #args == 0 then
+        return {success = true, message = "Usage: package-reload <name>"}
+    end
+
+    local name = args[1]
+
+    -- Clear from loaded
+    package_manager.loaded[name] = nil
+    package_manager.packages[name] = nil
+
+    -- Reload
+    local ok, msg = package_manager.load_package(name)
+    return {success = ok, message = ok and "Reloaded " .. name or msg}
+end, {}, true, "s")
+
+-- Update registry
+editor:register_command("package-refresh", "Refresh package registry from remote", function(args)
+    local ok = package_manager.update_registry()
+    if ok then
+        local count = 0
+        for _ in pairs(package_manager.registry) do count = count + 1 end
+        return {success = true, message = "Registry updated: " .. count .. " packages available"}
+    else
+        return {success = false, message = "Failed to update registry"}
+    end
+end)
+
+-- Search packages in registry
+editor:register_command("package-search", "Search for packages in registry", function(args)
+    if #args == 0 then
+        -- List all available
+        local count = 0
+        local lines = {"Available packages:"}
+        for name, entry in pairs(package_manager.registry) do
+            local desc = entry.description or ""
+            if #desc > 40 then desc = desc:sub(1, 37) .. "..." end
+            table.insert(lines, string.format("  %s - %s", name, desc))
+            count = count + 1
+        end
+
+        if count == 0 then
+            return {success = true, message = "No packages in registry. Run package-refresh first."}
+        end
+
+        table.sort(lines)
+        editor:set_echo_area(lines)
+        return {success = true, message = ""}
+    end
+
+    local query = args[1]:lower()
+    local matches = {}
+
+    for name, entry in pairs(package_manager.registry) do
+        local desc = (entry.description or ""):lower()
+        if name:lower():find(query) or desc:find(query) then
+            table.insert(matches, {name = name, description = entry.description or ""})
+        end
+    end
+
+    if #matches == 0 then
+        return {success = true, message = "No packages matching: " .. query}
+    end
+
+    local lines = {"Matching packages:"}
+    for _, m in ipairs(matches) do
+        local desc = m.description
+        if #desc > 40 then desc = desc:sub(1, 37) .. "..." end
+        table.insert(lines, string.format("  %s - %s", m.name, desc))
+    end
+
+    editor:set_echo_area(lines)
+    return {success = true, message = ""}
+end, {}, true, "s")
+
+-- Sync packages (install missing from packages.lua)
+editor:register_command("package-sync", "Sync packages from packages.lua", function(args)
+    local installed = 0
+    local failed = 0
+    local skipped = 0
+
+    for _, spec in ipairs(package_manager.package_specs) do
+        local normalized = package_manager.normalize_spec and package_manager.normalize_spec(spec)
+        if not normalized then
+            -- Try to normalize manually
+            if type(spec) == "string" then
+                normalized = {name = spec, source = "name"}
+            elseif type(spec) == "table" and spec[1] then
+                normalized = {name = spec[1], source = "name"}
+            end
+        end
+
+        if normalized and normalized.enabled ~= false then
+            local info = package_manager.info(normalized.name)
+            if not info.installed then
+                local ok, msg = package_manager.install(spec)
+                if ok then
+                    installed = installed + 1
+                else
+                    failed = failed + 1
+                end
+            else
+                skipped = skipped + 1
+            end
+        end
+    end
+
+    return {success = true, message = string.format("Sync complete: %d installed, %d failed, %d already present", installed, failed, skipped)}
+end)
+
+-- Create sample packages.lua
+editor:register_command("package-init", "Create a sample packages.lua file", function(args)
+    local packages_file = os.getenv("HOME") .. "/.lumacs/packages.lua"
+
+    -- Check if exists
+    local f = io.open(packages_file, "r")
+    if f then
+        f:close()
+        return {success = false, message = "packages.lua already exists at ~/.lumacs/packages.lua"}
+    end
+
+    -- Create directory
+    os.execute("mkdir -p " .. os.getenv("HOME") .. "/.lumacs")
+
+    -- Write sample file
+    local sample = [[
+-- Lumacs Package Configuration
+-- ============================================================================
+-- Add packages here. They will be automatically installed and loaded.
+--
+-- Formats:
+--   "package-name"                          -- From registry
+--   { "package-name", lazy = true }         -- Lazy loaded
+--   { url = "https://github.com/user/pkg" } -- Git URL
+--   { dir = "~/my-package" }                -- Local directory
+-- ============================================================================
+
+return {
+    -- Example packages (uncomment to enable):
+    -- "evil-mode",           -- Vim emulation
+    -- "magit",               -- Git interface
+
+    -- Example with config:
+    -- { "which-key",
+    --   config = function()
+    --     require("which-key").setup({ delay = 500 })
+    --   end
+    -- },
+}
+]]
+
+    local file = io.open(packages_file, "w")
+    if file then
+        file:write(sample)
+        file:close()
+        return {success = true, message = "Created ~/.lumacs/packages.lua"}
+    else
+        return {success = false, message = "Failed to create packages.lua"}
+    end
+end)
+
+-- ============================================================================
+-- Keybindings
+-- ============================================================================
+
+-- No default keybindings - access via M-x
+
+print("[Lua] package-commands loaded")

+ 532 - 0
lua/core/package.lua

@@ -0,0 +1,532 @@
+-- package.lua
+-- Lumacs Package Manager
+-- ============================================================================
+-- Provides package installation, loading, and management similar to MELPA/lazy.nvim
+--
+-- Usage in packages.lua:
+--   return {
+--     "which-key",                              -- Short name (from registry)
+--     { "evil-mode", lazy = true },             -- Lazy loaded
+--     { url = "https://github.com/user/pkg" }, -- Git URL
+--     { dir = "~/my-package" },                 -- Local directory
+--   }
+-- ============================================================================
+
+local M = {}
+
+-- Package manager state
+M.packages = {}           -- Loaded packages: name -> module
+M.package_specs = {}      -- Package specifications from packages.lua
+M.loaded = {}             -- Track which packages are loaded
+M.errors = {}             -- Track loading errors
+
+-- Configuration
+M.config = {
+    -- Where user packages are installed
+    install_path = os.getenv("HOME") .. "/.lumacs/packages",
+    -- Where built-in packages live (in lumacs repo)
+    builtin_path = "lua/packages",
+    -- Bundled registry (shipped with lumacs)
+    bundled_registry = "lua/core/registry.json",
+    -- Registry URL (for short name resolution)
+    registry_url = "https://raw.githubusercontent.com/lumacs/registry/main/registry.json",
+    -- Local registry cache
+    registry_cache = os.getenv("HOME") .. "/.lumacs/registry.json",
+    -- Auto-install missing packages
+    auto_install = true,
+    -- Check for updates on startup
+    check_updates = false,
+}
+
+-- Registry cache (populated from registry.json)
+M.registry = {}
+
+-- ============================================================================
+-- Utility Functions
+-- ============================================================================
+
+-- Check if a file exists
+local function file_exists(path)
+    local f = io.open(path, "r")
+    if f then
+        f:close()
+        return true
+    end
+    return false
+end
+
+-- Check if a directory exists
+local function dir_exists(path)
+    -- Use Lua's ability to open directory as file (returns nil for dirs on most systems)
+    -- This is a simple heuristic; we'll improve with C++ integration later
+    local f = io.open(path .. "/.", "r")
+    if f then
+        f:close()
+        return true
+    end
+    -- Try another approach - check for init.lua or package.lua
+    return file_exists(path .. "/init.lua") or file_exists(path .. "/package.lua")
+end
+
+-- Expand ~ to home directory
+local function expand_path(path)
+    if path:sub(1, 1) == "~" then
+        return os.getenv("HOME") .. path:sub(2)
+    end
+    return path
+end
+
+-- Read entire file
+local function read_file(path)
+    local f = io.open(path, "r")
+    if not f then return nil end
+    local content = f:read("*a")
+    f:close()
+    return content
+end
+
+-- Write file
+local function write_file(path, content)
+    local f = io.open(path, "w")
+    if not f then return false end
+    f:write(content)
+    f:close()
+    return true
+end
+
+-- Simple JSON parser for registry (basic implementation)
+local function parse_json_simple(str)
+    -- Very basic JSON object parser for our simple registry format
+    -- Format: { "name": { "url": "...", "description": "..." }, ... }
+    local result = {}
+
+    -- Remove outer braces and whitespace
+    str = str:match("^%s*{(.*)}")
+    if not str then return result end
+
+    -- Match each package entry
+    for name, content in str:gmatch('"([^"]+)"%s*:%s*({[^}]*})') do
+        local entry = {}
+        entry.url = content:match('"url"%s*:%s*"([^"]*)"')
+        entry.description = content:match('"description"%s*:%s*"([^"]*)"')
+        entry.version = content:match('"version"%s*:%s*"([^"]*)"')
+        result[name] = entry
+    end
+
+    return result
+end
+
+-- ============================================================================
+-- Package Specification Handling
+-- ============================================================================
+
+-- Normalize a package spec to standard format
+local function normalize_spec(spec)
+    if type(spec) == "string" then
+        -- Simple string: package name
+        return {
+            name = spec,
+            source = "name",
+        }
+    elseif type(spec) == "table" then
+        local normalized = {}
+
+        -- Handle array-style: { "name", options... }
+        if spec[1] then
+            normalized.name = spec[1]
+            normalized.source = "name"
+        end
+
+        -- Handle explicit fields
+        if spec.url then
+            normalized.url = spec.url
+            normalized.source = "git"
+            normalized.name = normalized.name or spec.url:match("([^/]+)%.git$") or spec.url:match("([^/]+)$")
+        end
+
+        if spec.dir then
+            normalized.dir = expand_path(spec.dir)
+            normalized.source = "local"
+            normalized.name = normalized.name or spec.dir:match("([^/]+)$")
+        end
+
+        -- Copy other options
+        normalized.lazy = spec.lazy
+        normalized.config = spec.config
+        normalized.dependencies = spec.dependencies
+        normalized.branch = spec.branch
+        normalized.tag = spec.tag
+        normalized.commit = spec.commit
+        normalized.enabled = spec.enabled ~= false  -- Default to enabled
+
+        return normalized
+    end
+
+    return nil
+end
+
+-- ============================================================================
+-- Package Loading
+-- ============================================================================
+
+-- Find package path (checks builtin, then installed)
+local function find_package_path(name)
+    -- Check builtin packages first
+    local builtin = M.config.builtin_path .. "/" .. name .. ".lua"
+    if file_exists(builtin) then
+        return builtin, "builtin"
+    end
+
+    -- Check builtin as directory
+    local builtin_dir = M.config.builtin_path .. "/" .. name
+    if file_exists(builtin_dir .. "/init.lua") then
+        return builtin_dir .. "/init.lua", "builtin"
+    end
+
+    -- Check installed packages
+    local installed = M.config.install_path .. "/" .. name
+    if file_exists(installed .. "/init.lua") then
+        return installed .. "/init.lua", "installed"
+    end
+
+    -- Check for single-file installed package
+    local installed_file = M.config.install_path .. "/" .. name .. ".lua"
+    if file_exists(installed_file) then
+        return installed_file, "installed"
+    end
+
+    return nil, nil
+end
+
+-- Load a single package
+function M.load_package(spec)
+    local normalized = normalize_spec(spec)
+    if not normalized then
+        return false, "Invalid package specification"
+    end
+
+    if not normalized.enabled then
+        return true, "Package disabled"
+    end
+
+    local name = normalized.name
+
+    -- Already loaded?
+    if M.loaded[name] then
+        return true, "Already loaded"
+    end
+
+    -- Find the package
+    local path, source
+
+    if normalized.source == "local" then
+        -- Local directory
+        if file_exists(normalized.dir .. "/init.lua") then
+            path = normalized.dir .. "/init.lua"
+            source = "local"
+        elseif file_exists(normalized.dir .. ".lua") then
+            path = normalized.dir .. ".lua"
+            source = "local"
+        end
+    else
+        path, source = find_package_path(name)
+    end
+
+    if not path then
+        -- Package not found - might need installation
+        return false, "Package not found: " .. name
+    end
+
+    -- Load the package
+    local ok, result = pcall(dofile, path)
+    if not ok then
+        M.errors[name] = result
+        return false, "Error loading " .. name .. ": " .. tostring(result)
+    end
+
+    -- Store the package module
+    M.packages[name] = result
+    M.loaded[name] = {
+        path = path,
+        source = source,
+        spec = normalized,
+    }
+
+    -- Run config function if provided
+    if normalized.config and type(normalized.config) == "function" then
+        local config_ok, config_err = pcall(normalized.config)
+        if not config_ok then
+            print("[package] Warning: config error for " .. name .. ": " .. tostring(config_err))
+        end
+    end
+
+    return true, "Loaded from " .. source
+end
+
+-- Load all packages from specs
+function M.load_all()
+    local loaded_count = 0
+    local error_count = 0
+
+    for _, spec in ipairs(M.package_specs) do
+        local normalized = normalize_spec(spec)
+        if normalized and normalized.enabled and not normalized.lazy then
+            local ok, msg = M.load_package(spec)
+            if ok then
+                loaded_count = loaded_count + 1
+            else
+                error_count = error_count + 1
+                print("[package] " .. msg)
+            end
+        end
+    end
+
+    return loaded_count, error_count
+end
+
+-- ============================================================================
+-- Package Installation (Git-based)
+-- ============================================================================
+
+-- Install a package from git
+function M.install(spec)
+    local normalized = normalize_spec(spec)
+    if not normalized then
+        return false, "Invalid package specification"
+    end
+
+    local name = normalized.name
+    local url = normalized.url
+
+    -- Resolve URL from registry if needed
+    if not url and M.registry[name] then
+        url = M.registry[name].url
+    end
+
+    if not url then
+        return false, "No URL found for package: " .. name
+    end
+
+    -- Create install directory
+    local install_dir = M.config.install_path .. "/" .. name
+
+    -- Check if already installed
+    if dir_exists(install_dir) then
+        return true, "Already installed"
+    end
+
+    -- Ensure parent directory exists
+    os.execute("mkdir -p " .. M.config.install_path)
+
+    -- Clone the repository
+    local branch_opt = ""
+    if normalized.branch then
+        branch_opt = " -b " .. normalized.branch
+    end
+
+    local cmd = string.format("git clone --depth 1%s %s %s 2>&1", branch_opt, url, install_dir)
+    local handle = io.popen(cmd)
+    local result = handle:read("*a")
+    local success = handle:close()
+
+    if success then
+        return true, "Installed " .. name
+    else
+        return false, "Failed to install " .. name .. ": " .. result
+    end
+end
+
+-- Update a package
+function M.update(name)
+    local install_dir = M.config.install_path .. "/" .. name
+
+    if not dir_exists(install_dir) then
+        return false, "Package not installed: " .. name
+    end
+
+    local cmd = string.format("cd %s && git pull 2>&1", install_dir)
+    local handle = io.popen(cmd)
+    local result = handle:read("*a")
+    local success = handle:close()
+
+    if success then
+        return true, result
+    else
+        return false, "Failed to update " .. name .. ": " .. result
+    end
+end
+
+-- Remove a package
+function M.remove(name)
+    local install_dir = M.config.install_path .. "/" .. name
+
+    if not dir_exists(install_dir) then
+        return false, "Package not installed: " .. name
+    end
+
+    local cmd = string.format("rm -rf %s 2>&1", install_dir)
+    local success = os.execute(cmd)
+
+    if success then
+        M.packages[name] = nil
+        M.loaded[name] = nil
+        return true, "Removed " .. name
+    else
+        return false, "Failed to remove " .. name
+    end
+end
+
+-- ============================================================================
+-- Registry Management
+-- ============================================================================
+
+-- Load registry from cache or bundled
+function M.load_registry()
+    -- Try user cache first
+    local content = read_file(M.config.registry_cache)
+    if content then
+        M.registry = parse_json_simple(content)
+        return true
+    end
+
+    -- Fall back to bundled registry
+    content = read_file(M.config.bundled_registry)
+    if content then
+        M.registry = parse_json_simple(content)
+        return true
+    end
+
+    return false
+end
+
+-- Update registry from remote
+function M.update_registry()
+    -- This would use curl/wget - for now we'll use a Lua HTTP library or shell
+    local cmd = string.format("curl -s %s -o %s 2>&1", M.config.registry_url, M.config.registry_cache)
+    local success = os.execute(cmd)
+
+    if success then
+        return M.load_registry()
+    end
+    return false
+end
+
+-- ============================================================================
+-- Setup and Initialization
+-- ============================================================================
+
+-- Setup the package manager
+function M.setup(opts)
+    opts = opts or {}
+
+    -- Merge config
+    for k, v in pairs(opts) do
+        M.config[k] = v
+    end
+
+    -- Ensure directories exist
+    os.execute("mkdir -p " .. M.config.install_path)
+
+    -- Load registry
+    M.load_registry()
+
+    -- Load package specs if packages.lua exists
+    local packages_file = os.getenv("HOME") .. "/.lumacs/packages.lua"
+    if file_exists(packages_file) then
+        local ok, specs = pcall(dofile, packages_file)
+        if ok and type(specs) == "table" then
+            M.package_specs = specs
+        end
+    end
+
+    -- Auto-install missing packages
+    if M.config.auto_install then
+        for _, spec in ipairs(M.package_specs) do
+            local normalized = normalize_spec(spec)
+            if normalized and normalized.enabled and normalized.source == "name" then
+                local path = find_package_path(normalized.name)
+                if not path and M.registry[normalized.name] then
+                    print("[package] Auto-installing " .. normalized.name .. "...")
+                    M.install(spec)
+                end
+            end
+        end
+    end
+
+    return M
+end
+
+-- Get package info
+function M.info(name)
+    local info = {
+        name = name,
+        loaded = M.loaded[name] ~= nil,
+        installed = false,
+        builtin = false,
+        registry = M.registry[name],
+    }
+
+    local path, source = find_package_path(name)
+    if path then
+        info.installed = true
+        info.path = path
+        info.source = source
+        info.builtin = (source == "builtin")
+    end
+
+    if M.loaded[name] then
+        info.load_info = M.loaded[name]
+    end
+
+    return info
+end
+
+-- List all available packages
+function M.list_available()
+    local packages = {}
+
+    -- Builtin packages
+    local builtin_dir = M.config.builtin_path
+    -- Note: We'd need to enumerate directory - for now, rely on what's loaded
+
+    -- Registry packages
+    for name, entry in pairs(M.registry) do
+        packages[name] = {
+            name = name,
+            source = "registry",
+            description = entry.description,
+            url = entry.url,
+        }
+    end
+
+    -- Loaded packages
+    for name, info in pairs(M.loaded) do
+        packages[name] = packages[name] or {}
+        packages[name].name = name
+        packages[name].loaded = true
+        packages[name].source = info.source
+        packages[name].path = info.path
+    end
+
+    return packages
+end
+
+-- List loaded packages
+function M.list_loaded()
+    local result = {}
+    for name, info in pairs(M.loaded) do
+        table.insert(result, {
+            name = name,
+            source = info.source,
+            path = info.path,
+        })
+    end
+    table.sort(result, function(a, b) return a.name < b.name end)
+    return result
+end
+
+-- ============================================================================
+-- Module Export
+-- ============================================================================
+
+return M

+ 52 - 0
lua/core/registry.json

@@ -0,0 +1,52 @@
+{
+  "evil-mode": {
+    "url": "https://github.com/lumacs/evil-mode",
+    "description": "Vim emulation layer for lumacs",
+    "version": "0.1.0"
+  },
+  "magit": {
+    "url": "https://github.com/lumacs/magit",
+    "description": "Git interface inspired by Emacs Magit",
+    "version": "0.1.0"
+  },
+  "treemacs": {
+    "url": "https://github.com/lumacs/treemacs",
+    "description": "File tree sidebar",
+    "version": "0.1.0"
+  },
+  "lsp-mode": {
+    "url": "https://github.com/lumacs/lsp-mode",
+    "description": "Language Server Protocol support",
+    "version": "0.1.0"
+  },
+  "flycheck": {
+    "url": "https://github.com/lumacs/flycheck",
+    "description": "On-the-fly syntax checking",
+    "version": "0.1.0"
+  },
+  "dashboard": {
+    "url": "https://github.com/lumacs/dashboard",
+    "description": "Startup dashboard with recent files",
+    "version": "0.1.0"
+  },
+  "org-mode": {
+    "url": "https://github.com/lumacs/org-mode",
+    "description": "Org-mode inspired note taking",
+    "version": "0.1.0"
+  },
+  "markdown-mode": {
+    "url": "https://github.com/lumacs/markdown-mode",
+    "description": "Markdown editing support",
+    "version": "0.1.0"
+  },
+  "zen-mode": {
+    "url": "https://github.com/lumacs/zen-mode",
+    "description": "Distraction-free writing mode",
+    "version": "0.1.0"
+  },
+  "git-gutter": {
+    "url": "https://github.com/lumacs/git-gutter",
+    "description": "Show git diff in the gutter",
+    "version": "0.1.0"
+  }
+}

+ 231 - 0
lua/packages/bookmarks.lua

@@ -0,0 +1,231 @@
+-- bookmarks.lua
+-- Save and jump between named positions in files
+-- ============================================================================
+
+local bookmarks = {}
+
+-- Configuration
+bookmarks.config = {
+    enabled = true,
+    max_bookmarks = 100,
+}
+
+-- Bookmarks storage: name -> {filepath, line, column}
+bookmarks.marks = {}
+
+-- Quick marks (single character, like Vim marks)
+bookmarks.quick_marks = {}
+
+-- Set a named bookmark at current position
+function bookmarks.set(name)
+    if not name or name == "" then
+        return {success = false, message = "Bookmark name required"}
+    end
+
+    local buffer = editor:current_buffer()
+    local filepath = buffer.filepath or buffer.name
+    local pos = editor:cursor_pos()
+
+    bookmarks.marks[name] = {
+        filepath = filepath,
+        line = pos.line,
+        column = pos.column,
+        created = os.time(),
+    }
+
+    return {success = true, message = string.format("Bookmark '%s' set at %s:%d", name, filepath, pos.line + 1)}
+end
+
+-- Jump to a named bookmark
+function bookmarks.jump(name)
+    if not name or name == "" then
+        return {success = false, message = "Bookmark name required"}
+    end
+
+    local mark = bookmarks.marks[name]
+    if not mark then
+        return {success = false, message = "Bookmark not found: " .. name}
+    end
+
+    -- Check if we need to switch buffers
+    local buffer = editor:current_buffer()
+    local current_file = buffer.filepath or buffer.name
+
+    if current_file ~= mark.filepath then
+        -- Try to open the file
+        local result = editor:execute_command("find-file", {mark.filepath})
+        if not result.success then
+            return {success = false, message = "Cannot open file: " .. mark.filepath}
+        end
+    end
+
+    -- Move to the bookmark position
+    editor:set_cursor(mark.line, mark.column)
+
+    return {success = true, message = string.format("Jumped to '%s'", name)}
+end
+
+-- Delete a bookmark
+function bookmarks.delete(name)
+    if not name or name == "" then
+        return {success = false, message = "Bookmark name required"}
+    end
+
+    if not bookmarks.marks[name] then
+        return {success = false, message = "Bookmark not found: " .. name}
+    end
+
+    bookmarks.marks[name] = nil
+    return {success = true, message = "Deleted bookmark: " .. name}
+end
+
+-- List all bookmarks
+function bookmarks.list()
+    local result = {}
+    for name, mark in pairs(bookmarks.marks) do
+        local display_file = mark.filepath:gsub("^" .. (os.getenv("HOME") or ""), "~")
+        table.insert(result, {
+            name = name,
+            filepath = mark.filepath,
+            display = string.format("%s: %s:%d", name, display_file, mark.line + 1),
+        })
+    end
+    -- Sort by name
+    table.sort(result, function(a, b) return a.name < b.name end)
+    return result
+end
+
+-- Quick mark (single character, for quick navigation)
+function bookmarks.set_quick(char)
+    if not char or #char ~= 1 then
+        return {success = false, message = "Quick mark must be a single character"}
+    end
+
+    local buffer = editor:current_buffer()
+    local filepath = buffer.filepath or buffer.name
+    local pos = editor:cursor_pos()
+
+    bookmarks.quick_marks[char] = {
+        filepath = filepath,
+        line = pos.line,
+        column = pos.column,
+    }
+
+    return {success = true, message = string.format("Mark '%s' set", char)}
+end
+
+-- Jump to quick mark
+function bookmarks.jump_quick(char)
+    if not char or #char ~= 1 then
+        return {success = false, message = "Quick mark must be a single character"}
+    end
+
+    local mark = bookmarks.quick_marks[char]
+    if not mark then
+        return {success = false, message = "Mark not set: " .. char}
+    end
+
+    local buffer = editor:current_buffer()
+    local current_file = buffer.filepath or buffer.name
+
+    if current_file ~= mark.filepath then
+        local result = editor:execute_command("find-file", {mark.filepath})
+        if not result.success then
+            return {success = false, message = "Cannot open file: " .. mark.filepath}
+        end
+    end
+
+    editor:set_cursor(mark.line, mark.column)
+    return {success = true, message = string.format("Jumped to mark '%s'", char)}
+end
+
+-- Setup function
+function bookmarks.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        bookmarks.config[k] = v
+    end
+
+    if not bookmarks.config.enabled then
+        return
+    end
+
+    -- Register commands
+    editor:register_command("bookmark-set", "Set a named bookmark at current position", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: bookmark-set <name>"}
+        end
+        return bookmarks.set(args[1])
+    end, {}, true, "s")
+
+    editor:register_command("bookmark-jump", "Jump to a named bookmark", function(args)
+        if #args == 0 then
+            -- List available bookmarks
+            local list = bookmarks.list()
+            if #list == 0 then
+                return {success = true, message = "No bookmarks set"}
+            end
+            local displays = {}
+            for _, b in ipairs(list) do
+                table.insert(displays, b.display)
+            end
+            editor:set_echo_area(displays)
+            return {success = true, message = ""}
+        end
+        return bookmarks.jump(args[1])
+    end, {}, true, "s")
+
+    editor:register_command("bookmark-delete", "Delete a bookmark", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: bookmark-delete <name>"}
+        end
+        return bookmarks.delete(args[1])
+    end, {}, true, "s")
+
+    editor:register_command("bookmark-list", "List all bookmarks", function(args)
+        local list = bookmarks.list()
+        if #list == 0 then
+            return {success = true, message = "No bookmarks set"}
+        end
+        local displays = {}
+        for _, b in ipairs(list) do
+            table.insert(displays, b.display)
+        end
+        editor:set_echo_area(displays)
+        return {success = true, message = ""}
+    end)
+
+    -- Quick marks (Vim-style m<char> and '<char>)
+    editor:register_command("mark-set", "Set a quick mark (single character)", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: mark-set <char>"}
+        end
+        return bookmarks.set_quick(args[1])
+    end, {}, true, "c")
+
+    editor:register_command("mark-jump", "Jump to a quick mark", function(args)
+        if #args == 0 then
+            return {success = true, message = "Usage: mark-jump <char>"}
+        end
+        return bookmarks.jump_quick(args[1])
+    end, {}, true, "c")
+
+    -- Key bindings
+    -- C-x r m - set bookmark (Emacs)
+    -- C-x r b - jump to bookmark (Emacs)
+    -- C-x r l - list bookmarks (Emacs)
+    editor:bind_key("C-x r m", "bookmark-set", "Set bookmark")
+    editor:bind_key("C-x r b", "bookmark-jump", "Jump to bookmark")
+    editor:bind_key("C-x r l", "bookmark-list", "List bookmarks")
+
+    -- Quick marks with F-keys for convenience
+    editor:bind_key("F2", "mark-set", "Set quick mark")
+    editor:bind_key("S-F2", "mark-jump", "Jump to quick mark")
+
+    print("[Lua] bookmarks package loaded")
+end
+
+-- Auto-setup with defaults
+bookmarks.setup()
+
+return bookmarks

+ 81 - 0
lua/packages/goto-line.lua

@@ -0,0 +1,81 @@
+-- goto-line.lua
+-- Go to a specific line number (M-g g in Emacs)
+-- ============================================================================
+
+local goto_line = {}
+
+-- Configuration
+goto_line.config = {
+    enabled = true,
+}
+
+-- Go to a specific line number
+function goto_line.goto_line(line_num)
+    if not line_num then
+        return {success = false, message = "No line number specified"}
+    end
+
+    local num = tonumber(line_num)
+    if not num or num < 1 then
+        return {success = false, message = "Invalid line number: " .. tostring(line_num)}
+    end
+
+    -- Convert to 0-indexed for internal use
+    local target_line = num - 1
+    local line_count = editor:line_count()
+
+    if target_line >= line_count then
+        target_line = line_count - 1
+    end
+
+    -- Move cursor to the beginning of the target line
+    editor:set_cursor(target_line, 0)
+
+    return {success = true, message = "Line " .. num}
+end
+
+-- Interactive go-to-line that prompts for line number
+function goto_line.goto_line_interactive()
+    -- This will be called by M-g g, prompting via minibuffer
+    local pos = editor:cursor_pos()
+    local current_line = pos.line + 1  -- 1-indexed for display
+    return {success = true, message = "Goto line (current: " .. current_line .. "): "}
+end
+
+-- Setup function
+function goto_line.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        goto_line.config[k] = v
+    end
+
+    if not goto_line.config.enabled then
+        return
+    end
+
+    -- Register the go-to-line command
+    editor:register_command("goto-line", "Go to a specific line number", function(args)
+        if #args == 0 then
+            -- Show current line info
+            local pos = editor:cursor_pos()
+            local current_line = pos.line + 1
+            local line_count = editor:line_count()
+            return {success = true, message = string.format("Line %d of %d", current_line, line_count)}
+        end
+        return goto_line.goto_line(args[1])
+    end, {}, true, "n")  -- "n" for number argument
+
+    -- M-g g binding (Emacs standard)
+    editor:bind_key("M-g g", "goto-line", "Go to line number")
+    editor:bind_key("M-g M-g", "goto-line", "Go to line number")
+
+    -- Also support C-g as alternative (common in some editors)
+    -- Note: C-g is typically keyboard-quit in Emacs, so we use M-g prefix
+
+    print("[Lua] goto-line package loaded")
+end
+
+-- Auto-setup with defaults
+goto_line.setup()
+
+return goto_line

+ 156 - 0
lua/packages/recentf.lua

@@ -0,0 +1,156 @@
+-- recentf.lua
+-- Track and access recently opened files (like Emacs recentf-mode)
+-- ============================================================================
+
+local recentf = {}
+
+-- Configuration
+recentf.config = {
+    enabled = true,
+    max_saved = 20,      -- Maximum number of recent files to track
+    exclude_patterns = {  -- Patterns to exclude from recent files
+        "^/tmp/",
+        "%.git/",
+        "COMMIT_EDITMSG$",
+    },
+}
+
+-- Recent files list (most recent first)
+recentf.list = {}
+
+-- Check if a file should be excluded
+local function should_exclude(filepath)
+    if not filepath or filepath == "" then
+        return true
+    end
+    -- Exclude *scratch* and other special buffers
+    if filepath:match("^%*") then
+        return true
+    end
+    for _, pattern in ipairs(recentf.config.exclude_patterns) do
+        if filepath:match(pattern) then
+            return true
+        end
+    end
+    return false
+end
+
+-- Add a file to the recent list
+function recentf.add(filepath)
+    if should_exclude(filepath) then
+        return
+    end
+
+    -- Remove if already in list (to move to front)
+    for i, f in ipairs(recentf.list) do
+        if f == filepath then
+            table.remove(recentf.list, i)
+            break
+        end
+    end
+
+    -- Add to front
+    table.insert(recentf.list, 1, filepath)
+
+    -- Trim to max size
+    while #recentf.list > recentf.config.max_saved do
+        table.remove(recentf.list)
+    end
+end
+
+-- Get the recent files list
+function recentf.get_list()
+    return recentf.list
+end
+
+-- Clear the recent files list
+function recentf.clear()
+    recentf.list = {}
+end
+
+-- Open a recent file by index (1-based)
+function recentf.open(index)
+    local idx = tonumber(index)
+    if not idx or idx < 1 or idx > #recentf.list then
+        return {success = false, message = "Invalid index: " .. tostring(index)}
+    end
+
+    local filepath = recentf.list[idx]
+    return editor:execute_command("find-file", {filepath})
+end
+
+-- Format the recent files list for display
+function recentf.format_list()
+    local lines = {}
+    for i, filepath in ipairs(recentf.list) do
+        -- Shorten home directory
+        local display = filepath:gsub("^" .. os.getenv("HOME"), "~")
+        table.insert(lines, string.format("%2d: %s", i, display))
+    end
+    return lines
+end
+
+-- Setup function
+function recentf.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        recentf.config[k] = v
+    end
+
+    if not recentf.config.enabled then
+        return
+    end
+
+    -- Register commands
+    editor:register_command("recentf-open", "Open a recent file by number", function(args)
+        if #args == 0 then
+            -- List recent files
+            local list = recentf.format_list()
+            if #list == 0 then
+                return {success = true, message = "No recent files"}
+            end
+            -- Show in echo area (up to 5 files)
+            local preview = {}
+            for i = 1, math.min(5, #list) do
+                table.insert(preview, list[i])
+            end
+            local msg = table.concat(preview, " | ")
+            if #list > 5 then
+                msg = msg .. " ..."
+            end
+            return {success = true, message = msg}
+        end
+        return recentf.open(args[1])
+    end, {}, true, "n")
+
+    editor:register_command("recentf-list", "List all recent files", function(args)
+        local list = recentf.format_list()
+        if #list == 0 then
+            return {success = true, message = "No recent files"}
+        end
+        -- Use echo area for multi-line display
+        editor:set_echo_area(list)
+        return {success = true, message = ""}
+    end)
+
+    editor:register_command("recentf-clear", "Clear the recent files list", function(args)
+        recentf.clear()
+        return {success = true, message = "Recent files list cleared"}
+    end)
+
+    -- Key binding (similar to Emacs)
+    editor:bind_key("C-x C-r", "recentf-open", "Open recent file")
+
+    -- Hook into find-file to track opens
+    -- We'll expose a global function that the C++ can call
+    lumacs.recentf_track = function(filepath)
+        recentf.add(filepath)
+    end
+
+    print("[Lua] recentf package loaded")
+end
+
+-- Auto-setup with defaults
+recentf.setup()
+
+return recentf

+ 64 - 0
lua/packages/visual-line.lua

@@ -0,0 +1,64 @@
+-- visual-line.lua
+-- Soft word wrap mode (like Emacs visual-line-mode)
+-- ============================================================================
+
+local visual_line = {}
+
+-- Configuration
+visual_line.config = {
+    enabled = true,
+}
+
+-- Toggle visual line mode
+function visual_line.toggle()
+    local current = editor:visual_line_mode()
+    editor:set_visual_line_mode(not current)
+    if not current then
+        return {success = true, message = "Visual line mode enabled (word wrap on)"}
+    else
+        return {success = true, message = "Visual line mode disabled"}
+    end
+end
+
+-- Enable visual line mode
+function visual_line.enable()
+    editor:set_visual_line_mode(true)
+    return {success = true, message = "Visual line mode enabled"}
+end
+
+-- Disable visual line mode
+function visual_line.disable()
+    editor:set_visual_line_mode(false)
+    return {success = true, message = "Visual line mode disabled"}
+end
+
+-- Setup function
+function visual_line.setup(opts)
+    opts = opts or {}
+    for k, v in pairs(opts) do
+        visual_line.config[k] = v
+    end
+
+    if not visual_line.config.enabled then
+        return
+    end
+
+    -- Register commands
+    editor:register_command("visual-line-mode", "Toggle soft word wrap", function(args)
+        return visual_line.toggle()
+    end)
+
+    editor:register_command("toggle-word-wrap", "Toggle soft word wrap (alias)", function(args)
+        return visual_line.toggle()
+    end)
+
+    -- Key binding (similar to Emacs M-x visual-line-mode)
+    -- No default keybinding, access via M-x
+
+    print("[Lua] visual-line package loaded")
+end
+
+-- Auto-setup with defaults
+visual_line.setup()
+
+return visual_line