Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
"command": "amicode.openChat",
"title": "Amicode: Open Chat"
},
{
"command": "amicode.setupVault",
"title": "Amicode: Set up a personal vault"
},
{
"command": "amicode.openInspector",
"title": "Amicode: Open Run Inspector"
Expand Down
130 changes: 130 additions & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
resolveJuliaProject,
buildOpencodeConfigContent,
resolveModelPin,
profileHasIdentity,
} from "./opencode_config";
import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths";
import { resolveLabTomlPath, checkLabToml } from "./lab_config";
Expand All @@ -23,6 +24,8 @@ import { stageDemoRun } from "./demo_replay";
import { writeStopFile, savePulseTo, catalogPulsesDir, stopPlan, forceStop, runLogMtime } from "./run_controls";
import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode";
import { amicodeOpsDir } from "./substrate/vault_store";
import { createLocalPersonalVault, sanitizeVaultName, suggestVaultName, shouldOfferVaultSetup } from "./substrate/vault_setup";
import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store";
import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller";
import * as os from "node:os";
import { readTomlSafe } from "./run_dir_reader";
Expand Down Expand Up @@ -488,6 +491,133 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
});
}

// Vault setup (#13): first-run popup + `amicode.setupVault` command that creates
// a LOCAL personal vault (dotfolder-style; no GitHub). This is the first step of
// a broader workspace setup — synced tiers (team/public) and the Julia env are
// intended follow-ups. Hot-refresh (no window reload): re-prep + respawn the
// server + re-arm the distiller, mirroring the solver-mode switch above.
const respawnForVault = async (): Promise<void> => {
if (!binary || !serverManager) {
const c = await vscode.window.showInformationMessage(
"Amicode: personal vault created. Reload the window to activate it.",
"Reload window",
);
if (c === "Reload window") void vscode.commands.executeCommand("workbench.action.reloadWindow");
return;
}
const port = vscode.workspace.getConfiguration("amicode").get<number>("opencodePort", 0);
const project2 = prepareOpencodeProject({
agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"),
templateSrc: path.resolve(
ctx.extensionPath,
"templates",
readSolverModeState().mode === "hp" ? "solve_template_hp.jl" : "solve_template.jl",
),
juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", "")),
skillRoots: cfgArr("skillRoots"),
skillLibraryRoots: cfgArr("skillLibraryRoots"),
vaultDir: vscode.workspace.getConfiguration("amicode").get<string>("vaultDir", "") || undefined,
projectDir: path.join((ctx.storageUri ?? ctx.globalStorageUri).fsPath, "opencode-project"),
});
await serverManager.stop();
serverManager = new ServerManager({
binary,
cwd: project2.projectDir,
port: port > 0 ? port : undefined,
env: {
PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`,
OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(
project2.agentsPath,
project2.templatePath,
runsRoot,
undefined,
undefined,
project2.skillPaths,
project2.skillsStageDir,
project2.vaultDir,
project2.mounts,
vscode.workspace.getConfiguration("amicode").get<string>("defaultModel", "").trim() || resolveModelPin(),
),
},
channel: opencodeChannel,
});
serverManager.onReady((url) => {
opencodeReadyUrl = url;
statusBar?.setServerReady(true);
sseClient?.connect(url);
});
await serverManager.start();
if (project2.vaultDir) {
try {
distillerSetup = {
binary,
distillerMdPath: path.resolve(ctx.extensionPath, "DISTILLER.md"),
vaultDir: project2.vaultDir,
opsDir: amicodeOpsDir(),
problemsRoot: path.join(os.homedir(), ".amico", "problems"),
runsRoot,
model: vscode.workspace.getConfiguration("amicode").get<string>("distillerModel", "opencode/big-pickle"),
};
initDistillerTransport(distillerSetup);
opencodeChannel.appendLine(`[vault] distiller armed (vault: ${project2.vaultDir})`);
} catch (e) {
opencodeChannel.appendLine(`[vault] distiller transport failed: ${e}`);
distillerSetup = undefined;
}
}
};

const runVaultSetup = async (fromCommand: boolean): Promise<void> => {
if (personalMount(resolveMountStack())) {
if (fromCommand) void vscode.window.showInformationMessage("Amicode: a personal vault is already set up.");
return;
}
if (!fromCommand && ctx.globalState.get<boolean>("amicode.vaultSetup.dismissed") === true) return;
if (!fromCommand) {
const choice = await vscode.window.showInformationMessage(
"Set up a personal vault? Amico will remember your systems, pulses, and problems across sessions — stored locally under ~/.amico/vaults.",
"Create vault",
"Not now",
"Don't ask again",
);
if (choice === "Don't ask again") {
await ctx.globalState.update("amicode.vaultSetup.dismissed", true);
return;
}
if (choice !== "Create vault") return;
}
const raw = await vscode.window.showInputBox({
title: "Set up a personal vault",
prompt: "Name your vault — created locally at ~/.amico/vaults/<name> (no GitHub)",
value: suggestVaultName(),
validateInput: (v) => (v && sanitizeVaultName(v) ? undefined : "enter a name"),
});
if (!raw) return;
let created;
try {
created = createLocalPersonalVault(defaultVaultsRoot(), raw);
} catch (e) {
void vscode.window.showErrorMessage(`Amicode: vault setup failed — ${(e as Error).message}`);
return;
}
opencodeChannel.appendLine(`[vault] created local personal vault: ${created.path} (git=${created.gitInit})`);
await respawnForVault();
void vscode.window.showInformationMessage(`Amicode: personal vault "${created.name}" created and active.`);
};
ctx.subscriptions.push(vscode.commands.registerCommand("amicode.setupVault", () => void runVaultSetup(true)));
// Auto-offer ONLY for a returning user: a profile exists here but no personal
// vault (changed machines / set up elsewhere). First-timers (no profile) get the
// onboarding wizard instead; a resolved vault needs nothing. (Command bypasses.)
if (
shouldOfferVaultSetup({
hasPersonalVault: !!opencodeProject.vaultDir,
hasProfile: profileHasIdentity(),
dismissed: ctx.globalState.get<boolean>("amicode.vaultSetup.dismissed") === true,
})
) {
void runVaultSetup(false);
}

// 5. Commands
ctx.subscriptions.push(
vscode.commands.registerCommand("amicode.openChat", async () => {
Expand Down
91 changes: 91 additions & 0 deletions packages/extension/src/substrate/vault_setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// First-run LOCAL personal-vault setup (spec: #13, dotfolder-style).
//
// Creates a local personal vault — a `~/.amico/vaults/<name>/` dir with a
// `.amico-vault.toml` marker (kind=personal) and a local `git init`, NO remote.
// It deliberately does NOT seed the `amicode/` memory substrate: the distiller
// (scripts/distill_batch.mjs) already `mkdir`s that skeleton on first run, so
// seeding here would duplicate it. The mount then resolves via
// resolveMountStack() like any other vault.
//
// v1 is personal-only + local; the synced tiers (team/public, e.g. armonissima)
// and a broader first-run "workspace setup" (Julia env, providers) are intended
// follow-ups — this is the first step of that setup, framed as a dotfolder init.
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

/** Fold a proposed name into a safe vault dir / mount name (lowercase kebab). */
export function sanitizeVaultName(raw: string): string {
const s = (raw ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "");
return s || "personal";
}

/** Suggested default name. Prefer an explicit hint (e.g. the onboarding profile
* name — a future wiring), else the OS username, else "personal". */
export function suggestVaultName(hint?: string): string {
const base = (hint && hint.trim()) || safeUsername();
return sanitizeVaultName(base);
}

function safeUsername(): string {
try {
return os.userInfo().username || "personal";
} catch {
return "personal";
}
}

/**
* Whether to AUTO-offer vault setup (the first-run notification). Fires ONLY for a
* returning user — a profile already exists on this machine but no personal vault
* resolves (e.g. they set up elsewhere / changed machines). Two cases deliberately
* do NOT notify: a resolved vault (nothing to do), and a genuine first-timer with no
* profile (the onboarding WIZARD walks them through vault creation, not this popup).
* The `amicode.setupVault` command bypasses this — it's an explicit user action.
*/
export function shouldOfferVaultSetup(o: {
hasPersonalVault: boolean;
hasProfile: boolean;
dismissed: boolean;
}): boolean {
return !o.hasPersonalVault && o.hasProfile && !o.dismissed;
}

export interface CreatedVault {
path: string;
name: string;
gitInit: boolean;
}

/**
* Create a local personal vault. Refuses to clobber an existing directory.
* `git init` is best-effort (the vault is fully functional without git).
*/
export function createLocalPersonalVault(
vaultsRoot: string,
rawName: string,
opts: { gitInit?: boolean } = {},
): CreatedVault {
const name = sanitizeVaultName(rawName);
const dir = path.join(vaultsRoot, name);
if (fs.existsSync(dir)) throw new Error(`a vault already exists at ${dir}`);

fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, ".amico-vault.toml"), `kind = "personal"\nname = "${name}"\n`);

let gitInit = false;
if (opts.gitInit !== false) {
try {
execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
gitInit = true;
} catch {
// git is optional — the vault resolves and works without a repo.
}
}
return { path: dir, name, gitInit };
}
93 changes: 93 additions & 0 deletions packages/extension/test/substrate/vault_setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
createLocalPersonalVault,
sanitizeVaultName,
suggestVaultName,
shouldOfferVaultSetup,
} from "../../src/substrate/vault_setup";
import { resolveMountStack, personalMount } from "../../src/substrate/mount_store";

const mkTmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "vault-setup-"));

describe("sanitizeVaultName", () => {
it("folds to lowercase kebab and strips junk", () => {
expect(sanitizeVaultName("Jack Champagne")).toBe("jack-champagne");
expect(sanitizeVaultName(" My Lab!! ")).toBe("my-lab");
expect(sanitizeVaultName("--a__b--")).toBe("a__b");
});
it("falls back to 'personal' when empty", () => {
expect(sanitizeVaultName("")).toBe("personal");
expect(sanitizeVaultName("///")).toBe("personal");
});
});

describe("suggestVaultName", () => {
it("uses an explicit hint when given", () => {
expect(suggestVaultName("Quantum Lab")).toBe("quantum-lab");
});
it("returns a non-empty sanitized name with no hint", () => {
expect(suggestVaultName()).toMatch(/^[a-z0-9._-]+$/);
});
});

describe("shouldOfferVaultSetup — the three flows", () => {
it("returning user (profile here, no vault) → offer", () => {
expect(shouldOfferVaultSetup({ hasPersonalVault: false, hasProfile: true, dismissed: false })).toBe(true);
});
it("first-timer (no profile, no vault) → NO offer (onboarding wizard owns it)", () => {
expect(shouldOfferVaultSetup({ hasPersonalVault: false, hasProfile: false, dismissed: false })).toBe(false);
});
it("already has a vault → NO offer", () => {
expect(shouldOfferVaultSetup({ hasPersonalVault: true, hasProfile: true, dismissed: false })).toBe(false);
expect(shouldOfferVaultSetup({ hasPersonalVault: true, hasProfile: false, dismissed: false })).toBe(false);
});
it("dismissed → NO offer even for a returning user", () => {
expect(shouldOfferVaultSetup({ hasPersonalVault: false, hasProfile: true, dismissed: true })).toBe(false);
});
});

describe("createLocalPersonalVault", () => {
it("creates a local personal vault that resolveMountStack picks up", () => {
const root = mkTmp();
const created = createLocalPersonalVault(root, "Jack Champagne");
expect(created.name).toBe("jack-champagne");
expect(created.path).toBe(path.join(root, "jack-champagne"));

const marker = fs.readFileSync(path.join(created.path, ".amico-vault.toml"), "utf8");
expect(marker).toContain('kind = "personal"');
expect(marker).toContain('name = "jack-champagne"');

// resolves as the personal mount
const stack = resolveMountStack(root);
const personal = personalMount(stack);
expect(personal?.name).toBe("jack-champagne");
expect(personal?.kind).toBe("personal");
expect(personal?.writable).toBe(true);
});

it("git-inits by default (best-effort) and can be disabled", () => {
const root = mkTmp();
const withGit = createLocalPersonalVault(root, "with-git");
// git is best-effort; if it ran, the repo dir exists
if (withGit.gitInit) expect(fs.existsSync(path.join(withGit.path, ".git"))).toBe(true);

const noGit = createLocalPersonalVault(root, "no-git", { gitInit: false });
expect(noGit.gitInit).toBe(false);
expect(fs.existsSync(path.join(noGit.path, ".git"))).toBe(false);
});

it("refuses to clobber an existing vault", () => {
const root = mkTmp();
createLocalPersonalVault(root, "dup");
expect(() => createLocalPersonalVault(root, "dup")).toThrow(/already exists/);
});

it("does NOT seed the amicode/ substrate (the distiller owns it)", () => {
const root = mkTmp();
const created = createLocalPersonalVault(root, "clean");
expect(fs.existsSync(path.join(created.path, "amicode"))).toBe(false);
});
});
Loading