Startup Script (init.ts)
Fresh auto-loads ~/.config/fresh/init.ts on startup. It's a TypeScript file that runs once with full access to the plugin API, and it complements the declarative config — it doesn't replace it.
When to use which
The declarative config (config.json, the Settings UI, keybindings editor, theme selector) and init.ts cover different needs. Reach for the declarative side first:
- Static preferences (tab size, line numbers, default theme) —
config.json/ Settings UI. - Key bindings — Keybinding editor.
- A reusable feature you'd want on more than one machine, or want to share — publish a plugin package.
Reach for init.ts when the decision depends on where or how Fresh is starting, and can't be baked into a shared config file without lying to the next person who opens it:
- Different settings when launched over SSH vs. locally.
- Host-specific tool paths (e.g.
rust-analyzerlives in a different prefix on your work laptop). - Environment-driven profiles (
FRESH_PROFILE=writing fresh→ wrap at 80, turn off diagnostics). - Extending a bundled plugin with your own data — e.g. adding a custom section to the Dashboard.
- One-off startup effects — e.g. fade in to your theme on launch.
Complementary with plugins
If you're building something reusable, prefer a plugin — it's installable, shareable, and gets the plugin lifecycle for free.
Sometimes a plugin is the right unit, but part of its behavior only makes sense at startup or depends on the environment. In that case expose the knobs as a plugin API and call them from init.ts. Plugin APIs are typed automatically — editor.getPluginApi("dashboard") returns the right interface or null, no as-cast needed:
// In init.ts — plug the parts together for this machine.
editor.on("plugins_loaded", () => {
// Add a custom section to the Dashboard plugin.
const dash = editor.getPluginApi("dashboard");
if (dash) {
dash.registerSection("todo", async (ctx) => {
ctx.kv("open", "3", "warn");
ctx.newline();
});
}
// Configure another plugin for this environment.
const todo = editor.getPluginApi("todo-highlighter");
if (todo) todo.configure({ tags: ["TODO", "FIXME", "HACK"] });
});That way the plugin stays declarative and shareable, and the environment-specific glue lives in the one file that isn't meant to be shared.
Small examples
// Calmer UI over SSH. setSetting writes to a runtime layer — nothing
// is persisted, so removing this file is a complete undo.
if (editor.getEnv("SSH_TTY")) {
editor.setSetting("editor.diagnostics_inline_text", false);
editor.setSetting("terminal.mouse", false);
}
// Host-specific LSP path.
if (editor.getEnv("HOSTNAME") === "work-mac") {
editor.registerLspServer("rust", {
command: "/opt/homebrew/bin/rust-analyzer",
args: [],
autoStart: true,
});
}
// Env-driven profile: FRESH_PROFILE=writing fresh
if (editor.getEnv("FRESH_PROFILE") === "writing") {
editor.setSetting("editor.line_wrap", true);
editor.setSetting("editor.wrap_column", 80);
}Editing and reloading
init: Edit init.tsfrom the command palette opens (or creates)~/.config/fresh/init.tswith a starter template. The same command also refreshestypes/fresh.d.ts, writestypes/plugins.d.ts(soeditor.getPluginApi("dashboard")and friends are typed), and creates atsconfig.jsonon first run.init: Reload init.tsre-runs the file without restarting Fresh.init: Check init.tsruns a syntax check (oxc parser) and reports parse errors. It does not run a full TypeScript type-check.fresh --no-init(alias--safe) skips loading for a single launch — useful if the file errors out.- Crash fuse: if
init.tsfails to evaluate three times in a row within five minutes, the next launch auto-skips it until you fix or remove the file. A successful evaluation clears the counter.
The full API surface is the same as plugins — see the Plugin API reference.
See it in action: What's New in 0.3.0 → init.ts.