Skip to content
Draft
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
87 changes: 87 additions & 0 deletions packages/argent-installer/src/init-adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { detectAdapters, ALL_ADAPTERS, type McpConfigAdapter } from "./mcp-configs.js";
import type { TopologyId } from "./topology.js";

// Step 1a — pick which editors get an MCP entry. In local mode the
// adapter universe is filtered to those that have a project-scoped
// config file (Windsurf / Hermes are excluded).

export interface AdapterSelection {
selected: McpConfigAdapter[];
universe: McpConfigAdapter[];
detected: McpConfigAdapter[];
/** Adapters dropped because of local-mode filtering. */
droppedForLocal: McpConfigAdapter[];
}

interface SelectArgs {
topology: TopologyId;
nonInteractive: boolean;
}

function buildUniverse(topology: TopologyId): {
universe: McpConfigAdapter[];
dropped: McpConfigAdapter[];
} {
if (topology !== "local") return { universe: [...ALL_ADAPTERS], dropped: [] };
const universe: McpConfigAdapter[] = [];
const dropped: McpConfigAdapter[] = [];
for (const a of ALL_ADAPTERS) {
(a.acceptsLocalInstall === false ? dropped : universe).push(a);
}
return { universe, dropped };
}

export async function chooseAdapters({
topology,
nonInteractive,
}: SelectArgs): Promise<AdapterSelection> {
const { universe, dropped: droppedForLocal } = buildUniverse(topology);
const detected = detectAdapters().filter((a) => universe.includes(a));

if (topology === "local" && droppedForLocal.length > 0) {
p.log.info(
pc.dim(
`Skipping ${droppedForLocal.map((a) => a.name).join(", ")} ` +
`(global-only — no project config file to commit).`
)
);
}

if (nonInteractive) {
const selected = detected.length > 0 ? detected : universe;
return { selected, universe, detected, droppedForLocal };
}

const detectedNames = new Set(detected.map((a) => a.name));
const choices = universe.map((a) => {
const parts: string[] = [];
if (detectedNames.has(a.name)) parts.push("detected");
const hasProject = a.projectPath(process.cwd()) != null;
const hasGlobal = a.globalPath() != null;
if (!hasProject && hasGlobal) {
parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s global config`)));
} else if (hasProject && !hasGlobal) {
parts.push(pc.italic(pc.cyan(`ⓘ will be installed into ${a.name}'s project config`)));
}
return { value: a, label: a.name, hint: parts.length > 0 ? parts.join(", ") : undefined };
});

p.log.message(pc.dim(" Use arrow keys to move, space to toggle, enter to confirm."));

const result = await p.multiselect({
message: "Which editors should Argent be configured for?",
options: choices,
initialValues: detected,
required: true,
});

if (p.isCancel(result)) {
p.cancel("Initialization cancelled.");
process.exit(0);
}

const selected = result as McpConfigAdapter[];
return { selected, universe, detected, droppedForLocal };
}
70 changes: 70 additions & 0 deletions packages/argent-installer/src/init-allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import type { McpConfigAdapter } from "./mcp-configs.js";

interface AllowlistArgs {
adapters: McpConfigAdapter[];
effectiveRoot: string;
scope: "local" | "global";
nonInteractive: boolean;
}

export interface AllowlistResult {
enabled: boolean;
lines: string[];
}

export async function configureAllowlist({
adapters,
effectiveRoot,
scope,
nonInteractive,
}: AllowlistArgs): Promise<AllowlistResult> {
const withApi = adapters.filter((a) => a.addAllowlist);
const withoutApi = adapters.filter((a) => !a.addAllowlist);

if (withApi.length === 0) return { enabled: false, lines: [] };

p.log.info(
`By default, editors ask for confirmation before running each MCP tool.\n` +
` Adding Argent to the auto-approve allowlist lets tools run without\n` +
` repeated prompts. This is ${pc.cyan("recommended")} for a smooth experience.`
);

let enabled = nonInteractive;
if (!nonInteractive) {
p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm."));
const choice = await p.confirm({
message: "Add Argent tools to editor auto-approve lists? - recommended",
initialValue: true,
});
if (p.isCancel(choice)) {
p.cancel("Initialization cancelled.");
process.exit(0);
}
enabled = choice as boolean;
}

if (!enabled) return { enabled: false, lines: [] };

const lines: string[] = [];
for (const adapter of withApi) {
const hasPath = scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot);
if (!hasPath) {
lines.push(`${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config for this scope)")}`);
continue;
}
try {
adapter.addAllowlist!(effectiveRoot, scope);
lines.push(`${pc.green("+")} ${adapter.name}`);
} catch (err) {
lines.push(`${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`);
}
}
for (const adapter of withoutApi) {
lines.push(
`${pc.yellow("-")} ${adapter.name} ${pc.dim("(no auto-approve API - configure manually)")}`
);
}
return { enabled: true, lines };
}
61 changes: 61 additions & 0 deletions packages/argent-installer/src/init-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import pc from "picocolors";
import type { TopologyId } from "./topology.js";

// Parsed view of `argent init <args>`. Single source of truth for what the
// user typed; downstream code reads named fields instead of grepping the
// raw argv. validateInitArgs enforces cross-flag invariants.

export interface InitArgs {
/** --yes / -y */
nonInteractive: boolean;
/** --from <path> reinstall from a local tarball/path */
fromTar: string | null;
/** --devdep / --local-install forces the local topology */
forcedTopology: TopologyId | null;
/** --scope local|global, when present */
explicitScope: "local" | "global" | null;
}

function extractValueFlag(args: string[], flag: string): string | null {
const idx = args.indexOf(flag);
if (idx === -1 || idx + 1 >= args.length) return null;
return args[idx + 1] ?? null;
}

export function parseInitArgs(args: string[]): InitArgs {
const nonInteractive = args.includes("--yes") || args.includes("-y");
const fromTar = extractValueFlag(args, "--from");
const devdep = args.includes("--devdep") || args.includes("--local-install");
const scope = extractValueFlag(args, "--scope");
const explicitScope = scope === "local" || scope === "global" ? scope : null;

return {
nonInteractive,
fromTar,
forcedTopology: devdep ? "local" : null,
explicitScope,
};
}

// Cross-flag validation. Throws to a process.exit(1) at the call site so
// the error string can be tested without a TUI in the loop.
export class InitArgsError extends Error {
constructor(message: string) {
super(message);
this.name = "InitArgsError";
}
}

export function validateInitArgs(parsed: InitArgs): void {
if (parsed.forcedTopology === "local" && parsed.explicitScope === "global") {
throw new InitArgsError(
"--devdep is incompatible with --scope global " +
"(local installs must use the project-scoped MCP config)."
);
}
}

// Tiny stderr formatter so the dispatcher doesn't have to know about pc.
export function reportInitArgsError(err: InitArgsError): void {
process.stderr.write(`${pc.red("error")}: ${err.message}\n`);
}
89 changes: 89 additions & 0 deletions packages/argent-installer/src/init-mcp-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import pc from "picocolors";
import { getMcpEntry, type McpConfigAdapter, type McpEntryMode } from "./mcp-configs.js";
import type { TopologyId } from "./topology.js";
import type { Scope } from "./init-scope.js";

// Step 1c — write the MCP config files for the selected adapters.
// Returns one line per adapter for the summary note.

interface WriteArgs {
adapters: McpConfigAdapter[];
topology: TopologyId;
scope: Scope;
/** projectRoot OR customRoot, depending on scope. */
effectiveRoot: string;
/** Always projectRoot (for "fallback to project" message paths). */
projectRoot: string;
}

function entryModeFor(topology: TopologyId, effectiveRoot: string): McpEntryMode {
return topology === "local" ? { kind: "local", projectRoot: effectiveRoot } : { kind: "global" };
}

function configPathFor(
adapter: McpConfigAdapter,
scope: Scope,
effectiveRoot: string
): string | null {
return scope === "global" ? adapter.globalPath() : adapter.projectPath(effectiveRoot);
}

function safeWrite(adapter: McpConfigAdapter, configPath: string, mode: McpEntryMode): string {
try {
adapter.write(configPath, getMcpEntry(mode, adapter));
return `${pc.green("+")} ${adapter.name} ${pc.dim(configPath)}`;
} catch (err) {
return `${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`;
}
}

function fallbackLine(
adapter: McpConfigAdapter,
fallback: string,
mode: McpEntryMode,
label: "local" | "global"
): string {
try {
adapter.write(fallback, getMcpEntry(mode, adapter));
return `${pc.green("+")} ${adapter.name} ${pc.dim(`(${label} fallback: ${fallback})`)}`;
} catch (err) {
return `${pc.red("x")} ${adapter.name}: ${pc.dim(String(err))}`;
}
}

export function writeMcpConfigs({
adapters,
topology,
scope,
effectiveRoot,
projectRoot,
}: WriteArgs): string[] {
const mode = entryModeFor(topology, effectiveRoot);
const results: string[] = [];

for (const adapter of adapters) {
const configPath = configPathFor(adapter, scope, effectiveRoot);
if (configPath) {
results.push(safeWrite(adapter, configPath, mode));
continue;
}

// No path for the requested scope — try the other scope as a fallback.
if (scope === "global") {
const projectFallback = adapter.projectPath(projectRoot);
if (projectFallback) {
results.push(fallbackLine(adapter, projectFallback, mode, "local"));
continue;
}
} else {
const globalFallback = adapter.globalPath();
if (globalFallback) {
results.push(fallbackLine(adapter, globalFallback, mode, "global"));
continue;
}
}
results.push(`${pc.yellow("-")} ${adapter.name} ${pc.dim("(no config path for this scope)")}`);
}

return results;
}
72 changes: 72 additions & 0 deletions packages/argent-installer/src/init-mode-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import type { TopologyId } from "./topology.js";

// Step-0 install-mode selection. Encapsulates the prompt loop so init.ts
// just receives a final TopologyId (or exits on cancel).

interface PromptArgs {
/** True if argent is already a project devDep on disk. */
locallyInstalled: boolean;
}

const PROMPT_MESSAGE = (locallyInstalled: boolean): string =>
locallyInstalled
? "How would you like to configure argent?"
: "Argent isn't installed yet. How would you like to set it up?";

const LOCAL_CAVEAT =
"The locally-installed argent will only work if your agent runs from the " +
"root directory of your project. If a teammate's editor fails to start " +
"argent, verify they are in the root directory first.";

function buildOptions(locallyInstalled: boolean) {
return [
{
value: "global" as const,
label: "Global (recommended)",
hint: "Makes the argent command available everywhere",
},
{
value: "local" as const,
label: locallyInstalled
? "Local (devDependency, already installed)"
: "Local (devDependency)",
hint: "Might be used by teams to share configuration",
},
{ value: "cancel" as const, label: "Cancel installation" },
];
}

// Returns the chosen TopologyId. Exits the process on cancel — the prompt
// is interactive only, callers must guard with a nonInteractive check.
export async function promptInstallMode({ locallyInstalled }: PromptArgs): Promise<TopologyId> {
while (true) {
const choice = await p.select({
message: PROMPT_MESSAGE(locallyInstalled),
initialValue: "global" as const,
options: buildOptions(locallyInstalled),
});

if (p.isCancel(choice) || choice === "cancel") {
p.cancel("Installation cancelled.");
process.exit(0);
}

if (choice === "global") return "global";

// Local — surface the caveat as decision context, not noise.
p.log.warn(LOCAL_CAVEAT);
p.log.message(pc.dim(" Press y for yes, n for no, enter to confirm."));
const confirmLocal = await p.confirm({
message: "Proceed with the Local devDependency install?",
initialValue: true,
});
if (p.isCancel(confirmLocal)) {
p.cancel("Installation cancelled.");
process.exit(0);
}
if (confirmLocal) return "local";
// Decline → loop back to the mode select.
}
}
Loading