second-advisor 0.1.0

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/src/config.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ ampModes,
7
+ ampThinking,
8
+ type Config,
9
+ claudeModels,
10
+ claudeThinking,
11
+ codexThinking,
12
+ getThinkingOptions,
13
+ isAdvisor,
14
+ piThinking,
15
+ } from "./advisors.js";
16
+
17
+ export const configPath = path.join(
18
+ homedir(),
19
+ ".config",
20
+ "second-advisor",
21
+ "config.json",
22
+ );
23
+
24
+ export function parseConfig(value: unknown): Config {
25
+ if (!isRecord(value)) throw new Error("Config must be an object");
26
+ if (!isAdvisor(value.advisor)) throw new Error("Invalid advisor");
27
+ if (typeof value.model !== "string" || value.model.length === 0) {
28
+ throw new Error("Config model must be a non-empty string");
29
+ }
30
+
31
+ if (value.advisor === "kimi") {
32
+ if ("thinking" in value) throw new Error("Kimi does not support thinking");
33
+ return { advisor: value.advisor, model: value.model };
34
+ }
35
+
36
+ if (typeof value.thinking !== "string" || value.thinking.length === 0) {
37
+ throw new Error("Config thinking must be a non-empty string");
38
+ }
39
+
40
+ if (value.advisor === "amp") {
41
+ if (!ampModes.includes(value.model as (typeof ampModes)[number])) {
42
+ throw new Error("Invalid Amp mode");
43
+ }
44
+ if (!ampThinking.includes(value.thinking as (typeof ampThinking)[number])) {
45
+ throw new Error("Invalid Amp thinking");
46
+ }
47
+ if (!getThinkingOptions("amp", value.model).includes(value.thinking)) {
48
+ throw new Error("Invalid Amp thinking");
49
+ }
50
+ return {
51
+ advisor: value.advisor,
52
+ model: value.model as (typeof ampModes)[number],
53
+ thinking: value.thinking as (typeof ampThinking)[number],
54
+ };
55
+ }
56
+
57
+ if (value.advisor === "claude") {
58
+ if (!claudeModels.includes(value.model as (typeof claudeModels)[number])) {
59
+ throw new Error("Invalid Claude model");
60
+ }
61
+ if (
62
+ !claudeThinking.includes(
63
+ value.thinking as (typeof claudeThinking)[number],
64
+ )
65
+ ) {
66
+ throw new Error("Invalid Claude thinking");
67
+ }
68
+ return {
69
+ advisor: value.advisor,
70
+ model: value.model as (typeof claudeModels)[number],
71
+ thinking: value.thinking as (typeof claudeThinking)[number],
72
+ };
73
+ }
74
+
75
+ if (value.advisor === "codex") {
76
+ if (
77
+ !codexThinking.includes(value.thinking as (typeof codexThinking)[number])
78
+ ) {
79
+ throw new Error("Invalid Codex thinking");
80
+ }
81
+ return {
82
+ advisor: value.advisor,
83
+ model: value.model,
84
+ thinking: value.thinking as (typeof codexThinking)[number],
85
+ };
86
+ }
87
+
88
+ if (value.advisor === "pi") {
89
+ if (!piThinking.includes(value.thinking as (typeof piThinking)[number])) {
90
+ throw new Error("Invalid Pi thinking");
91
+ }
92
+ return {
93
+ advisor: value.advisor,
94
+ model: value.model,
95
+ thinking: value.thinking as (typeof piThinking)[number],
96
+ };
97
+ }
98
+
99
+ return {
100
+ advisor: value.advisor,
101
+ model: value.model,
102
+ thinking: value.thinking,
103
+ };
104
+ }
105
+
106
+ export async function readConfigIfPresent() {
107
+ if (!existsSync(configPath)) return undefined;
108
+ return parseConfig(JSON.parse(await readFile(configPath, "utf8")));
109
+ }
110
+
111
+ export async function writeConfig(config: Config) {
112
+ await mkdir(path.dirname(configPath), { recursive: true });
113
+ await writeFile(configPath, `${JSON.stringify(config, undefined, 2)}\n`);
114
+ }
115
+
116
+ function isRecord(value: unknown): value is Record<string, unknown> {
117
+ return typeof value === "object" && value !== null && !Array.isArray(value);
118
+ }
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import { parseCliRequest } from "./routing.js";
4
+ import {
5
+ runDoctor,
6
+ runInit,
7
+ runMenu,
8
+ runModels,
9
+ runPrompt,
10
+ runSetup,
11
+ runStatus,
12
+ } from "./ui.js";
13
+
14
+ export {
15
+ advisorChoices,
16
+ buildAdvisorCommand,
17
+ getModelListCommand,
18
+ summarizeConfig,
19
+ } from "./advisors.js";
20
+ export { parseConfig } from "./config.js";
21
+ export { parseCliRequest } from "./routing.js";
22
+
23
+ async function main() {
24
+ const program = new Command()
25
+ .name("second-advisor")
26
+ .description("Consult a configured coding CLI for a second opinion.")
27
+ .argument("[input...]", "prompt to send to the configured advisor")
28
+ .option("--debug", "print the coding CLI command before running it")
29
+ .allowUnknownOption(false)
30
+ .helpOption("-h, --help", "display help")
31
+ .addHelpText(
32
+ "after",
33
+ `
34
+ Examples:
35
+ second-advisor
36
+ second-advisor init
37
+ second-advisor doctor
38
+ second-advisor models
39
+ second-advisor setup
40
+ second-advisor "what do you think about this implementation?"
41
+ `,
42
+ );
43
+
44
+ program.parse(process.argv);
45
+ const request = parseCliRequest(program.args);
46
+ const options = program.opts<{ debug?: boolean }>();
47
+
48
+ if (request.kind === "menu") {
49
+ await runMenu();
50
+ return;
51
+ }
52
+
53
+ if (request.kind === "prompt") {
54
+ await runPrompt(request.prompt, { debug: options.debug === true });
55
+ return;
56
+ }
57
+
58
+ if (request.command === "init") {
59
+ await runInit();
60
+ return;
61
+ }
62
+
63
+ if (request.command === "doctor") {
64
+ await runDoctor();
65
+ return;
66
+ }
67
+
68
+ if (request.command === "models") {
69
+ await runModels(request.args[0]);
70
+ return;
71
+ }
72
+
73
+ if (request.command === "setup") {
74
+ await runSetup();
75
+ return;
76
+ }
77
+
78
+ await runStatus();
79
+ }
80
+
81
+ if (import.meta.main) {
82
+ main().catch((error: unknown) => {
83
+ console.error(error instanceof Error ? error.message : String(error));
84
+ process.exit(1);
85
+ });
86
+ }
package/src/models.ts ADDED
@@ -0,0 +1,87 @@
1
+ import {
2
+ type Advisor,
3
+ ampModes,
4
+ claudeModels,
5
+ getModelListCommand,
6
+ } from "./advisors.js";
7
+ import { runCommand } from "./process.js";
8
+
9
+ export async function loadModelChoices(advisor: Advisor) {
10
+ if (advisor === "amp") return [...ampModes];
11
+ if (advisor === "claude") return [...claudeModels];
12
+ const command = getModelListCommand(advisor);
13
+ if (!command) return [];
14
+
15
+ const result = await runCommand(command.command, command.args, {
16
+ pipeOutput: true,
17
+ });
18
+ if (result.exitCode !== 0) return [];
19
+ return parseModelChoices(advisor, result.stdout).slice(0, 80);
20
+ }
21
+
22
+ export function parseModelChoices(advisor: Advisor, output: string) {
23
+ if (advisor === "codex") return extractCodexModels(output);
24
+ if (advisor === "grok") return extractGrokModels(output);
25
+ if (advisor === "kimi") return extractKimiModels(output);
26
+ if (advisor === "droid") return extractDroidModels(output);
27
+ return output
28
+ .split(/\r?\n/)
29
+ .map((line) => line.trim())
30
+ .filter((line) => line.length > 0 && !line.startsWith("Warning:"))
31
+ .map((line) => line.replace(/^[-*]\s*/, ""))
32
+ .filter((line) => line.length > 0);
33
+ }
34
+
35
+ function extractKimiModels(output: string) {
36
+ try {
37
+ const json = JSON.parse(output);
38
+ if (!isRecord(json) || !isRecord(json.models)) return [];
39
+ return Object.keys(json.models).filter((model) => model.length > 0);
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ function extractGrokModels(output: string) {
46
+ const models = output.match(/Available models:\n(?<body>[\s\S]*)/)?.groups
47
+ ?.body;
48
+ if (!models) return [];
49
+ return models
50
+ .split(/\r?\n/)
51
+ .map((line) =>
52
+ line
53
+ .trim()
54
+ .replace(/^[-*]\s*/, "")
55
+ .replace(/\s+\(default\)$/, ""),
56
+ )
57
+ .filter((value) => value.length > 0);
58
+ }
59
+
60
+ function extractCodexModels(output: string) {
61
+ try {
62
+ const json = JSON.parse(output);
63
+ if (!isRecord(json) || !Array.isArray(json.models)) return [];
64
+ return json.models
65
+ .map((model) => (isRecord(model) ? model.slug : undefined))
66
+ .filter(
67
+ (slug): slug is string => typeof slug === "string" && slug.length > 0,
68
+ );
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ function extractDroidModels(output: string) {
75
+ const models = output.match(
76
+ /Available Models:\n(?<body>[\s\S]*?)\n\nModel details:/,
77
+ )?.groups?.body;
78
+ if (!models) return [];
79
+ return models
80
+ .split(/\r?\n/)
81
+ .map((line) => line.trim().split(/\s+/)[0])
82
+ .filter((value) => value.length > 0);
83
+ }
84
+
85
+ function isRecord(value: unknown): value is Record<string, unknown> {
86
+ return typeof value === "object" && value !== null && !Array.isArray(value);
87
+ }
package/src/process.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export function resolveExecutable(command: string) {
6
+ const pathValue = process.env.PATH || "";
7
+ return pathValue
8
+ .split(path.delimiter)
9
+ .map((directory) => path.join(directory, command))
10
+ .find((candidate) => existsSync(candidate));
11
+ }
12
+
13
+ export function getVersionArgs() {
14
+ return ["--version"];
15
+ }
16
+
17
+ export function runCommand(
18
+ command: string,
19
+ args: string[],
20
+ options: { pipeOutput: boolean },
21
+ ) {
22
+ return new Promise<{ exitCode: number; stdout: string; stderr: string }>(
23
+ (resolve) => {
24
+ const child = spawn(command, args, {
25
+ stdio: options.pipeOutput ? ["ignore", "pipe", "pipe"] : "inherit",
26
+ });
27
+ const stdout: Buffer[] = [];
28
+ const stderr: Buffer[] = [];
29
+
30
+ if (options.pipeOutput) {
31
+ child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
32
+ child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
33
+ }
34
+
35
+ child.on("error", (error) =>
36
+ resolve({ exitCode: 1, stdout: "", stderr: error.message }),
37
+ );
38
+ child.on("close", (code) =>
39
+ resolve({
40
+ exitCode: code || 0,
41
+ stdout: Buffer.concat(stdout).toString("utf8"),
42
+ stderr: Buffer.concat(stderr).toString("utf8"),
43
+ }),
44
+ );
45
+ },
46
+ );
47
+ }
package/src/routing.ts ADDED
@@ -0,0 +1,19 @@
1
+ const commands = ["init", "doctor", "models", "setup", "status"] as const;
2
+
3
+ type CommandName = (typeof commands)[number];
4
+
5
+ export type CliRequest =
6
+ | { kind: "menu" }
7
+ | { kind: "prompt"; prompt: string }
8
+ | { kind: "command"; command: CommandName; args: string[] };
9
+
10
+ export function parseCliRequest(args: string[]): CliRequest {
11
+ if (args.length === 0) return { kind: "menu" };
12
+ if (isCommandName(args[0]))
13
+ return { kind: "command", command: args[0], args: args.slice(1) };
14
+ return { kind: "prompt", prompt: args.join(" ") };
15
+ }
16
+
17
+ function isCommandName(value: string): value is CommandName {
18
+ return commands.includes(value as CommandName);
19
+ }