@ashlr/stack 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +65 -0
- package/src/__tests__/apply.test.ts +80 -0
- package/src/__tests__/recommend.test.ts +109 -0
- package/src/commands/add.ts +163 -0
- package/src/commands/apply.ts +244 -0
- package/src/commands/ci.ts +110 -0
- package/src/commands/clone.ts +113 -0
- package/src/commands/completion.ts +187 -0
- package/src/commands/deps.ts +79 -0
- package/src/commands/doctor.ts +192 -0
- package/src/commands/env.ts +87 -0
- package/src/commands/exec.ts +54 -0
- package/src/commands/import.ts +120 -0
- package/src/commands/info.ts +85 -0
- package/src/commands/init.ts +93 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/login.ts +28 -0
- package/src/commands/open.ts +32 -0
- package/src/commands/projects.ts +92 -0
- package/src/commands/providers.ts +38 -0
- package/src/commands/recommend.ts +302 -0
- package/src/commands/remove.ts +124 -0
- package/src/commands/scan.ts +123 -0
- package/src/commands/status.ts +39 -0
- package/src/commands/sync.ts +37 -0
- package/src/commands/templates.ts +130 -0
- package/src/commands/upgrade.ts +66 -0
- package/src/index.ts +91 -0
- package/src/ui.ts +39 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import {
|
|
3
|
+
type StackConfig,
|
|
4
|
+
emptyConfig,
|
|
5
|
+
hasConfig,
|
|
6
|
+
isPhantomInstalled,
|
|
7
|
+
listTemplates,
|
|
8
|
+
loadTemplate,
|
|
9
|
+
resolveConfigPath,
|
|
10
|
+
writeConfig,
|
|
11
|
+
} from "@ashlr/stack-core";
|
|
12
|
+
import { colors, intro, outro, outroError, prompts } from "../ui.ts";
|
|
13
|
+
|
|
14
|
+
export const initCommand = defineCommand({
|
|
15
|
+
meta: {
|
|
16
|
+
name: "init",
|
|
17
|
+
description: "Scaffold a .stack.toml in the current directory.",
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
template: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Name of a starter template to apply (e.g. nextjs-supabase-posthog).",
|
|
23
|
+
},
|
|
24
|
+
force: {
|
|
25
|
+
type: "boolean",
|
|
26
|
+
default: false,
|
|
27
|
+
description: "Overwrite an existing .stack.toml.",
|
|
28
|
+
},
|
|
29
|
+
noInteractive: {
|
|
30
|
+
type: "boolean",
|
|
31
|
+
default: false,
|
|
32
|
+
description: "Skip template picker — always creates a blank .stack.toml.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
async run({ args }) {
|
|
36
|
+
intro("stack init");
|
|
37
|
+
|
|
38
|
+
const cwd = process.cwd();
|
|
39
|
+
if (hasConfig(cwd) && !args.force) {
|
|
40
|
+
outroError(
|
|
41
|
+
`${colors.bold(".stack.toml")} already exists at ${resolveConfigPath(cwd)}. Pass --force to overwrite.`,
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!(await isPhantomInstalled())) {
|
|
47
|
+
prompts.log.warn(
|
|
48
|
+
colors.yellow(
|
|
49
|
+
"Phantom Secrets isn't on your PATH. Stack will create .stack.toml anyway, but `stack add` needs Phantom.",
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
prompts.log.info(" Install: brew install ashlrai/phantom/phantom");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let templateName = args.template ? String(args.template) : undefined;
|
|
56
|
+
|
|
57
|
+
if (!templateName && !args.noInteractive && process.stdout.isTTY) {
|
|
58
|
+
const available = listTemplates();
|
|
59
|
+
if (available.length > 0) {
|
|
60
|
+
const picked = await prompts.select({
|
|
61
|
+
message: "Pick a starter template (or blank):",
|
|
62
|
+
options: [
|
|
63
|
+
{ value: "", label: "Blank — add services later with `stack add`" },
|
|
64
|
+
...available.map((name) => ({ value: name, label: name })),
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
if (prompts.isCancel(picked)) {
|
|
68
|
+
outroError("Cancelled.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (picked) templateName = String(picked);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let config: StackConfig;
|
|
76
|
+
if (templateName) {
|
|
77
|
+
const loaded = await loadTemplate(templateName);
|
|
78
|
+
if (!loaded) {
|
|
79
|
+
outroError(`Unknown template: ${templateName}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
config = loaded;
|
|
83
|
+
config.stack.template = templateName;
|
|
84
|
+
} else {
|
|
85
|
+
config = emptyConfig();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await writeConfig(config, cwd);
|
|
89
|
+
outro(
|
|
90
|
+
`Wrote ${colors.bold(".stack.toml")} (${config.stack.project_id})${templateName ? ` from template ${colors.bold(templateName)}` : ""}.`,
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { readConfig } from "@ashlr/stack-core";
|
|
3
|
+
import { colors } from "../ui.ts";
|
|
4
|
+
|
|
5
|
+
export const listCommand = defineCommand({
|
|
6
|
+
meta: { name: "list", description: "List services configured in this stack." },
|
|
7
|
+
async run() {
|
|
8
|
+
const config = await readConfig();
|
|
9
|
+
const entries = Object.entries(config.services);
|
|
10
|
+
if (entries.length === 0) {
|
|
11
|
+
console.log(colors.dim("No services yet. Try `stack add supabase`."));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log();
|
|
15
|
+
console.log(colors.bold(` ${config.stack.project_id} `) + colors.dim(`(${entries.length} services)`));
|
|
16
|
+
console.log();
|
|
17
|
+
for (const [name, entry] of entries) {
|
|
18
|
+
const mcp = entry.mcp ? colors.cyan(` mcp:${entry.mcp}`) : "";
|
|
19
|
+
const region = entry.region ? colors.dim(` @${entry.region}`) : "";
|
|
20
|
+
console.log(` ${colors.green("●")} ${colors.bold(name)}${region}${mcp}`);
|
|
21
|
+
console.log(
|
|
22
|
+
` ${colors.dim("resource:")} ${entry.resource_id ?? "-"} ${colors.dim("secrets:")} ${entry.secrets.join(", ") || "-"}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
console.log();
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { type ProviderContext, getProvider } from "@ashlr/stack-core";
|
|
3
|
+
import { colors, intro, logEvent, outro, outroError } from "../ui.ts";
|
|
4
|
+
|
|
5
|
+
export const loginCommand = defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "login",
|
|
8
|
+
description: "Refresh OAuth / PAT credentials for a specific provider.",
|
|
9
|
+
},
|
|
10
|
+
args: {
|
|
11
|
+
service: { type: "positional", required: true, description: "Provider name." },
|
|
12
|
+
},
|
|
13
|
+
async run({ args }) {
|
|
14
|
+
intro(`stack login ${args.service}`);
|
|
15
|
+
try {
|
|
16
|
+
const provider = await getProvider(args.service);
|
|
17
|
+
const ctx: ProviderContext = {
|
|
18
|
+
cwd: process.cwd(),
|
|
19
|
+
interactive: process.stdout.isTTY === true,
|
|
20
|
+
log: logEvent,
|
|
21
|
+
};
|
|
22
|
+
await provider.login(ctx);
|
|
23
|
+
outro(colors.green(`${provider.displayName}: signed in.`));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
outroError((err as Error).message);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { getProvider, readConfig } from "@ashlr/stack-core";
|
|
5
|
+
import { colors, intro, outro, outroError } from "../ui.ts";
|
|
6
|
+
|
|
7
|
+
export const openCommand = defineCommand({
|
|
8
|
+
meta: { name: "open", description: "Open a service's dashboard in your browser." },
|
|
9
|
+
args: {
|
|
10
|
+
service: { type: "positional", required: true, description: "Service name." },
|
|
11
|
+
},
|
|
12
|
+
async run({ args }) {
|
|
13
|
+
intro(`stack open ${args.service}`);
|
|
14
|
+
const config = await readConfig();
|
|
15
|
+
const entry = config.services[args.service];
|
|
16
|
+
if (!entry) {
|
|
17
|
+
outroError(`${args.service} is not in this stack.`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const provider = await getProvider(args.service);
|
|
21
|
+
const url = provider.dashboardUrl?.(entry);
|
|
22
|
+
if (!url) {
|
|
23
|
+
outroError(`${provider.displayName} has no dashboard URL configured.`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const opener =
|
|
28
|
+
platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
|
|
29
|
+
spawn(opener, [url], { stdio: "ignore", detached: true }).unref();
|
|
30
|
+
outro(colors.green(`Opening ${url}`));
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import {
|
|
3
|
+
findProjectByName,
|
|
4
|
+
listProjects,
|
|
5
|
+
registerProject,
|
|
6
|
+
unregisterProject,
|
|
7
|
+
} from "@ashlr/stack-core";
|
|
8
|
+
import { colors, intro, outro, outroError } from "../ui.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `stack projects` — work across every Stack-enabled project on this machine.
|
|
12
|
+
*
|
|
13
|
+
* stack projects list
|
|
14
|
+
* stack projects register (register the current cwd)
|
|
15
|
+
* stack projects remove <name|path>
|
|
16
|
+
* stack projects where <name>
|
|
17
|
+
*/
|
|
18
|
+
export const projectsCommand = defineCommand({
|
|
19
|
+
meta: {
|
|
20
|
+
name: "projects",
|
|
21
|
+
description: "Manage the cross-project registry at ~/.stack/projects.json.",
|
|
22
|
+
},
|
|
23
|
+
subCommands: {
|
|
24
|
+
list: defineCommand({
|
|
25
|
+
meta: { name: "list", description: "List every Stack-enabled project on this machine." },
|
|
26
|
+
async run() {
|
|
27
|
+
intro("stack projects");
|
|
28
|
+
const projects = await listProjects();
|
|
29
|
+
if (projects.length === 0) {
|
|
30
|
+
outro(colors.dim("No projects registered yet. Run `stack init` in one to start."));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.log();
|
|
34
|
+
for (const p of projects) {
|
|
35
|
+
const template = p.template ? colors.dim(` [${p.template}]`) : "";
|
|
36
|
+
const services = p.services.length
|
|
37
|
+
? colors.dim(` · ${p.services.join(", ")}`)
|
|
38
|
+
: colors.dim(" · no services");
|
|
39
|
+
console.log(
|
|
40
|
+
` ${colors.green("●")} ${colors.bold(p.name)}${template} ${colors.dim(p.project_id)}`,
|
|
41
|
+
);
|
|
42
|
+
console.log(` ${colors.dim(p.path)}${services}`);
|
|
43
|
+
}
|
|
44
|
+
console.log();
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
register: defineCommand({
|
|
48
|
+
meta: { name: "register", description: "Register the current directory in the registry." },
|
|
49
|
+
async run() {
|
|
50
|
+
intro("stack projects register");
|
|
51
|
+
const entry = await registerProject(process.cwd());
|
|
52
|
+
if (!entry) {
|
|
53
|
+
outroError("No .stack.toml in the current directory — run `stack init` first.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
outro(colors.green(`Registered ${entry.name} (${entry.project_id}).`));
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
remove: defineCommand({
|
|
60
|
+
meta: { name: "remove", description: "Remove a project from the registry (does not delete files)." },
|
|
61
|
+
args: {
|
|
62
|
+
target: { type: "positional", required: true, description: "Project name or absolute path." },
|
|
63
|
+
},
|
|
64
|
+
async run({ args }) {
|
|
65
|
+
intro("stack projects remove");
|
|
66
|
+
const target = String(args.target);
|
|
67
|
+
const match = target.startsWith("/") ? target : (await findProjectByName(target))?.path;
|
|
68
|
+
if (!match) {
|
|
69
|
+
outroError(`No registered project matches "${target}".`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await unregisterProject(match);
|
|
73
|
+
outro(colors.green(`Removed ${target} from registry.`));
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
where: defineCommand({
|
|
77
|
+
meta: { name: "where", description: "Print the path of a registered project." },
|
|
78
|
+
args: {
|
|
79
|
+
name: { type: "positional", required: true, description: "Project name." },
|
|
80
|
+
},
|
|
81
|
+
async run({ args }) {
|
|
82
|
+
const project = await findProjectByName(String(args.name));
|
|
83
|
+
if (!project) {
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
console.error(`No registered project named "${args.name}".`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log(project.path);
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { getProvider, listProviderNames } from "@ashlr/stack-core";
|
|
3
|
+
import { colors, intro } from "../ui.ts";
|
|
4
|
+
|
|
5
|
+
export const providersCommand = defineCommand({
|
|
6
|
+
meta: { name: "providers", description: "List every curated provider Stack can wire up." },
|
|
7
|
+
async run() {
|
|
8
|
+
intro("stack providers");
|
|
9
|
+
const names = listProviderNames();
|
|
10
|
+
const byCategory: Record<string, Array<{ name: string; displayName: string; auth: string }>> = {};
|
|
11
|
+
for (const name of names) {
|
|
12
|
+
try {
|
|
13
|
+
const p = await getProvider(name);
|
|
14
|
+
(byCategory[p.category] ??= []).push({
|
|
15
|
+
name: p.name,
|
|
16
|
+
displayName: p.displayName,
|
|
17
|
+
auth: p.authKind,
|
|
18
|
+
});
|
|
19
|
+
} catch {
|
|
20
|
+
/* ignore load failures — the registry is authoritative */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const categories = Object.keys(byCategory).sort();
|
|
25
|
+
console.log();
|
|
26
|
+
for (const cat of categories) {
|
|
27
|
+
console.log(` ${colors.bold(cat)}`);
|
|
28
|
+
for (const p of byCategory[cat]) {
|
|
29
|
+
console.log(
|
|
30
|
+
` ${colors.cyan("·")} ${p.name.padEnd(12)} ${colors.dim(`(${p.auth})`)} ${p.displayName}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
console.log();
|
|
34
|
+
}
|
|
35
|
+
console.log(colors.dim(` ${names.length} providers total. Add one with: stack add <name>`));
|
|
36
|
+
console.log();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import {
|
|
3
|
+
PROVIDER_CATEGORIES,
|
|
4
|
+
getInferenceBackend,
|
|
5
|
+
NoInferenceBackendError,
|
|
6
|
+
recipeFromRetrieval,
|
|
7
|
+
retrieve,
|
|
8
|
+
retrieveByCategory,
|
|
9
|
+
writeRecipe,
|
|
10
|
+
type Recipe,
|
|
11
|
+
type RetrievalHit,
|
|
12
|
+
} from "@ashlr/stack-core";
|
|
13
|
+
import { colors, intro, outro } from "../ui.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `stack recommend` — free-text → curated providers.
|
|
17
|
+
*
|
|
18
|
+
* Three execution paths:
|
|
19
|
+
* 1. Default — pure BM25 retrieval. Instant, zero-IO, no LLM. The shape
|
|
20
|
+
* Claude consumes via the `stack_recommend` MCP tool.
|
|
21
|
+
* 2. `--save` — also freezes the result to `.stack/recipes/<id>.toml` so
|
|
22
|
+
* the user can follow up with `stack apply <id>`.
|
|
23
|
+
* 3. `--synth` — pipes the retrieval candidates into the local SLM backend
|
|
24
|
+
* (LM Studio / Ollama via OpenAI-compatible HTTP) and swaps the
|
|
25
|
+
* retrieval-only rationales for model-authored ones. No-ops gracefully
|
|
26
|
+
* when neither endpoint is reachable.
|
|
27
|
+
*
|
|
28
|
+
* Output modes:
|
|
29
|
+
* - default: human-readable list, grouped by category
|
|
30
|
+
* - --json: machine-readable blob (used by the MCP server + site /api)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
interface RecommendOutputHit {
|
|
34
|
+
name: string;
|
|
35
|
+
displayName: string;
|
|
36
|
+
category: string;
|
|
37
|
+
authKind: string;
|
|
38
|
+
secrets: string[];
|
|
39
|
+
blurb: string;
|
|
40
|
+
score: number;
|
|
41
|
+
matched: string[];
|
|
42
|
+
rationale?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RecommendOutput {
|
|
46
|
+
query: string;
|
|
47
|
+
hits: RecommendOutputHit[];
|
|
48
|
+
byCategory: Record<string, Array<{ name: string; score: number }>>;
|
|
49
|
+
guidance: string;
|
|
50
|
+
recipe?: { id: string; path: string };
|
|
51
|
+
inference?: { mode: "synth" | "mcp-delegated"; backend: string; note?: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toOutputHit(
|
|
55
|
+
hit: RetrievalHit,
|
|
56
|
+
rationale?: string,
|
|
57
|
+
): RecommendOutputHit {
|
|
58
|
+
return {
|
|
59
|
+
name: hit.provider.name,
|
|
60
|
+
displayName: hit.provider.displayName,
|
|
61
|
+
category: hit.provider.category,
|
|
62
|
+
authKind: hit.provider.authKind,
|
|
63
|
+
secrets: hit.provider.secrets,
|
|
64
|
+
blurb: hit.provider.blurb,
|
|
65
|
+
score: Number(hit.score.toFixed(3)),
|
|
66
|
+
matched: hit.matched,
|
|
67
|
+
rationale,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildGuidance(hits: RetrievalHit[], categories: string[]): string {
|
|
72
|
+
if (hits.length === 0) {
|
|
73
|
+
return "No strong matches. Try describing the concrete capability you need (e.g. 'postgres database', 'stripe subscriptions', 'deploy frontend').";
|
|
74
|
+
}
|
|
75
|
+
const covered = new Set(hits.map((h) => h.provider.category));
|
|
76
|
+
const missing = categories.filter(
|
|
77
|
+
(c) => !covered.has(c as (typeof PROVIDER_CATEGORIES)[number]),
|
|
78
|
+
);
|
|
79
|
+
const topByCat = Object.entries(
|
|
80
|
+
hits.reduce<Record<string, RetrievalHit>>((acc, h) => {
|
|
81
|
+
if (!acc[h.provider.category] || acc[h.provider.category].score < h.score) {
|
|
82
|
+
acc[h.provider.category] = h;
|
|
83
|
+
}
|
|
84
|
+
return acc;
|
|
85
|
+
}, {}),
|
|
86
|
+
)
|
|
87
|
+
.sort((a, b) => b[1].score - a[1].score)
|
|
88
|
+
.map(([cat, h]) => `${cat}→${h.provider.name}`);
|
|
89
|
+
const parts = [`Top pick per category: ${topByCat.join(", ")}.`];
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
parts.push(
|
|
92
|
+
`No matches surfaced for: ${missing.join(", ")}. Add terms for those categories if the user needs them.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
parts.push(
|
|
96
|
+
`Apply a chosen set with: stack add ${hits.slice(0, 3).map((h) => h.provider.name).join(" ")} (or supply your own list).`,
|
|
97
|
+
);
|
|
98
|
+
return parts.join(" ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildCatalogContext(hits: RetrievalHit[]): string {
|
|
102
|
+
return hits
|
|
103
|
+
.map(
|
|
104
|
+
(h) =>
|
|
105
|
+
`- ${h.provider.name} (${h.provider.category}): ${h.provider.blurb} [secrets: ${h.provider.secrets.join(", ")}]`,
|
|
106
|
+
)
|
|
107
|
+
.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function trySynth(
|
|
111
|
+
query: string,
|
|
112
|
+
hits: RetrievalHit[],
|
|
113
|
+
): Promise<{ rationales: Map<string, string>; note?: string; backend: string; mode: "synth" | "mcp-delegated" } | null> {
|
|
114
|
+
try {
|
|
115
|
+
const backend = await getInferenceBackend({ preferLocal: true });
|
|
116
|
+
const result = await backend.infer({
|
|
117
|
+
query,
|
|
118
|
+
catalogContext: buildCatalogContext(hits),
|
|
119
|
+
});
|
|
120
|
+
const rationales = new Map<string, string>();
|
|
121
|
+
for (const p of result.recipe?.providers ?? []) {
|
|
122
|
+
if (p.rationale) rationales.set(p.name, p.rationale);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
rationales,
|
|
126
|
+
note: result.recipe?.notes,
|
|
127
|
+
backend: backend.name,
|
|
128
|
+
mode: result.mode,
|
|
129
|
+
};
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof NoInferenceBackendError) return null;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const recommendCommand = defineCommand({
|
|
137
|
+
meta: {
|
|
138
|
+
name: "recommend",
|
|
139
|
+
description: "Pick the right providers for what you're building (AI-assisted).",
|
|
140
|
+
},
|
|
141
|
+
args: {
|
|
142
|
+
query: {
|
|
143
|
+
type: "positional",
|
|
144
|
+
description: "Free-text description of the project (in quotes).",
|
|
145
|
+
required: false,
|
|
146
|
+
},
|
|
147
|
+
k: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "Max results to return (default 6).",
|
|
150
|
+
required: false,
|
|
151
|
+
},
|
|
152
|
+
category: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Restrict to a single category (e.g. Database, Auth).",
|
|
155
|
+
required: false,
|
|
156
|
+
},
|
|
157
|
+
json: {
|
|
158
|
+
type: "boolean",
|
|
159
|
+
description: "Machine-readable JSON output.",
|
|
160
|
+
required: false,
|
|
161
|
+
},
|
|
162
|
+
save: {
|
|
163
|
+
type: "boolean",
|
|
164
|
+
description:
|
|
165
|
+
"Persist the result as .stack/recipes/<id>.toml so you can run `stack apply <id>`.",
|
|
166
|
+
required: false,
|
|
167
|
+
},
|
|
168
|
+
synth: {
|
|
169
|
+
type: "boolean",
|
|
170
|
+
description:
|
|
171
|
+
"Call the local SLM (LM Studio / Ollama) to synthesize rationales. Silently falls back to retrieval-only when no endpoint is reachable.",
|
|
172
|
+
required: false,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
async run({ args }) {
|
|
176
|
+
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
177
|
+
if (!query) {
|
|
178
|
+
if (args.json) {
|
|
179
|
+
console.log(
|
|
180
|
+
JSON.stringify(
|
|
181
|
+
{
|
|
182
|
+
query: "",
|
|
183
|
+
hits: [],
|
|
184
|
+
byCategory: {},
|
|
185
|
+
guidance: "Provide a query: stack recommend \"what you're building\".",
|
|
186
|
+
} satisfies RecommendOutput,
|
|
187
|
+
null,
|
|
188
|
+
2,
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
intro("stack recommend");
|
|
194
|
+
console.log(
|
|
195
|
+
colors.dim(
|
|
196
|
+
" Usage: stack recommend \"B2B SaaS with auth, AI, and payments\"\n",
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
outro("No query — nothing to recommend.");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const k = Number.parseInt(String(args.k ?? "6"), 10);
|
|
204
|
+
const categories = args.category ? [String(args.category)] : undefined;
|
|
205
|
+
const hits = retrieve(query, {
|
|
206
|
+
k: Number.isFinite(k) && k > 0 ? k : 6,
|
|
207
|
+
categories: categories as RetrievalHit["provider"]["category"][] | undefined,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
let synthOutcome: Awaited<ReturnType<typeof trySynth>> = null;
|
|
211
|
+
if (args.synth && hits.length > 0) {
|
|
212
|
+
synthOutcome = await trySynth(query, hits);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const outputHits: RecommendOutputHit[] = hits.map((h) =>
|
|
216
|
+
toOutputHit(h, synthOutcome?.rationales.get(h.provider.name)),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
let recipeInfo: RecommendOutput["recipe"];
|
|
220
|
+
if (args.save && hits.length > 0) {
|
|
221
|
+
const guidance = buildGuidance(hits, [...PROVIDER_CATEGORIES]);
|
|
222
|
+
const recipe: Recipe = recipeFromRetrieval(query, hits, guidance);
|
|
223
|
+
// Prefer synth-authored rationales over raw matched-term strings.
|
|
224
|
+
if (synthOutcome) {
|
|
225
|
+
recipe.providers = recipe.providers.map((p) => ({
|
|
226
|
+
...p,
|
|
227
|
+
rationale: synthOutcome!.rationales.get(p.name) ?? p.rationale,
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
const path = await writeRecipe(recipe);
|
|
231
|
+
recipeInfo = { id: recipe.id, path };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const payload: RecommendOutput = {
|
|
235
|
+
query,
|
|
236
|
+
hits: outputHits,
|
|
237
|
+
byCategory: Object.fromEntries(
|
|
238
|
+
Object.entries(retrieveByCategory(query, { k: 20 })).map(([cat, catHits]) => [
|
|
239
|
+
cat,
|
|
240
|
+
catHits.map((h) => ({
|
|
241
|
+
name: h.provider.name,
|
|
242
|
+
score: Number(h.score.toFixed(3)),
|
|
243
|
+
})),
|
|
244
|
+
]),
|
|
245
|
+
),
|
|
246
|
+
guidance: buildGuidance(hits, [...PROVIDER_CATEGORIES]),
|
|
247
|
+
recipe: recipeInfo,
|
|
248
|
+
inference: synthOutcome
|
|
249
|
+
? { mode: synthOutcome.mode, backend: synthOutcome.backend, note: synthOutcome.note }
|
|
250
|
+
: undefined,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (args.json) {
|
|
254
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
intro("stack recommend");
|
|
259
|
+
console.log(colors.dim(` query: ${query}\n`));
|
|
260
|
+
if (hits.length === 0) {
|
|
261
|
+
console.log(colors.yellow(" No strong matches.") + "\n");
|
|
262
|
+
console.log(colors.dim(` ${payload.guidance}\n`));
|
|
263
|
+
outro("Try a more specific query.");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
for (const hit of outputHits) {
|
|
267
|
+
const scoreTag = colors.dim(`(${hit.score.toFixed(2)})`);
|
|
268
|
+
console.log(
|
|
269
|
+
` ${colors.green("●")} ${colors.bold(hit.displayName)} ${colors.cyan(hit.category)} ${scoreTag}`,
|
|
270
|
+
);
|
|
271
|
+
console.log(` ${colors.dim(hit.blurb)}`);
|
|
272
|
+
if (hit.rationale) {
|
|
273
|
+
console.log(` ${colors.dim("why:")} ${hit.rationale}`);
|
|
274
|
+
}
|
|
275
|
+
console.log(
|
|
276
|
+
` ${colors.dim("add with:")} ${colors.reset("stack add ")}${hit.name}`,
|
|
277
|
+
);
|
|
278
|
+
console.log();
|
|
279
|
+
}
|
|
280
|
+
if (synthOutcome?.note) {
|
|
281
|
+
console.log(colors.dim(` notes: ${synthOutcome.note}\n`));
|
|
282
|
+
}
|
|
283
|
+
console.log(colors.dim(` ${payload.guidance}`));
|
|
284
|
+
if (recipeInfo) {
|
|
285
|
+
console.log();
|
|
286
|
+
console.log(
|
|
287
|
+
colors.dim(" Saved recipe:") +
|
|
288
|
+
` ${colors.bold(recipeInfo.id)} ` +
|
|
289
|
+
colors.dim(`(${recipeInfo.path})`),
|
|
290
|
+
);
|
|
291
|
+
console.log(
|
|
292
|
+
colors.dim(" Apply with: ") + colors.reset(`stack apply ${recipeInfo.id}`),
|
|
293
|
+
);
|
|
294
|
+
} else if (!args.json) {
|
|
295
|
+
console.log();
|
|
296
|
+
console.log(
|
|
297
|
+
colors.dim(" Tip: rerun with `--save` to freeze as a recipe for `stack apply`."),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
outro("Tell Claude or run `stack add <name>` to continue.");
|
|
301
|
+
},
|
|
302
|
+
});
|