@jaybeeuu/agent-uplink 1.0.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/.github/workflows/ci.yml +129 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/eslint.config.js +15 -0
- package/package.json +58 -0
- package/src/capabilities.test.ts +139 -0
- package/src/capabilities.ts +141 -0
- package/src/cli.ts +35 -0
- package/src/commands/capability.ts +98 -0
- package/src/commands/config.ts +108 -0
- package/src/commands/install.test.ts +155 -0
- package/src/commands/install.ts +323 -0
- package/src/commands/mcp.ts +35 -0
- package/src/commands/sync.ts +49 -0
- package/src/config.test.ts +84 -0
- package/src/config.ts +113 -0
- package/src/editor.ts +47 -0
- package/src/git.ts +84 -0
- package/src/logger.ts +60 -0
- package/src/mcp.ts +156 -0
- package/src/types.ts +40 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
createCapability,
|
|
5
|
+
listCapabilities,
|
|
6
|
+
deleteCapability,
|
|
7
|
+
getCapabilityPath,
|
|
8
|
+
capabilityExists,
|
|
9
|
+
} from "../capabilities.js";
|
|
10
|
+
import { getCapabilitiesDir, getConfigValue } from "../config.js";
|
|
11
|
+
import { openInEditor } from "../editor.js";
|
|
12
|
+
import { createLogger } from "../logger.js";
|
|
13
|
+
import type { CapabilityType } from "../types.js";
|
|
14
|
+
|
|
15
|
+
export function makeCapabilityCommand(type: CapabilityType): Command {
|
|
16
|
+
const log = createLogger(`capability:${type}`);
|
|
17
|
+
|
|
18
|
+
const cmd = new Command(type);
|
|
19
|
+
cmd.description(`Manage ${type}s`);
|
|
20
|
+
|
|
21
|
+
cmd
|
|
22
|
+
.command("create <name>")
|
|
23
|
+
.description(`Create a new ${type}`)
|
|
24
|
+
.action(async (name: string) => {
|
|
25
|
+
const capabilitiesDir = getCapabilitiesDir();
|
|
26
|
+
const capability = createCapability(capabilitiesDir, type, name);
|
|
27
|
+
log.success(`Created ${type} '${name}' at ${capability.path}`);
|
|
28
|
+
await openInEditor(capability.path, getConfigValue("editor"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
cmd
|
|
32
|
+
.command("edit <name>")
|
|
33
|
+
.description(`Edit an existing ${type}`)
|
|
34
|
+
.action(async (name: string) => {
|
|
35
|
+
const capabilitiesDir = getCapabilitiesDir();
|
|
36
|
+
if (!capabilityExists(capabilitiesDir, type, name)) {
|
|
37
|
+
throw new Error(`${type} '${name}' not found`);
|
|
38
|
+
}
|
|
39
|
+
const path = getCapabilityPath(capabilitiesDir, type, name);
|
|
40
|
+
await openInEditor(path, getConfigValue("editor"));
|
|
41
|
+
log.success(`Done editing ${type} '${name}'`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
cmd
|
|
45
|
+
.command("list")
|
|
46
|
+
.description(`List all ${type}s`)
|
|
47
|
+
.action(() => {
|
|
48
|
+
const capabilitiesDir = getCapabilitiesDir();
|
|
49
|
+
const items = listCapabilities(capabilitiesDir, type);
|
|
50
|
+
if (items.length === 0) {
|
|
51
|
+
log.warn(`No ${type}s found.`);
|
|
52
|
+
} else {
|
|
53
|
+
log.info(`${type}s (${items.length}):`);
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
log.info(` • ${item}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
cmd
|
|
61
|
+
.command("delete <name>")
|
|
62
|
+
.description(`Delete a ${type}`)
|
|
63
|
+
.option("-f, --force", "Skip confirmation")
|
|
64
|
+
.action(async (name: string, opts: { force?: boolean }) => {
|
|
65
|
+
const capabilitiesDir = getCapabilitiesDir();
|
|
66
|
+
if (!opts.force) {
|
|
67
|
+
const { prompt } = await import("enquirer");
|
|
68
|
+
const response = await prompt<{ confirmed: boolean }>({
|
|
69
|
+
type: "confirm",
|
|
70
|
+
name: "confirmed",
|
|
71
|
+
message: `Delete ${type} '${name}'?`,
|
|
72
|
+
initial: false,
|
|
73
|
+
});
|
|
74
|
+
if (!response.confirmed) {
|
|
75
|
+
log.info("Aborted.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
deleteCapability(capabilitiesDir, type, name);
|
|
80
|
+
log.success(`Deleted ${type} '${name}'`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
cmd
|
|
84
|
+
.command("show <name>")
|
|
85
|
+
.description(`Show the content of a ${type}`)
|
|
86
|
+
.action((name: string) => {
|
|
87
|
+
const capabilitiesDir = getCapabilitiesDir();
|
|
88
|
+
const path = getCapabilityPath(capabilitiesDir, type, name);
|
|
89
|
+
if (!capabilityExists(capabilitiesDir, type, name)) {
|
|
90
|
+
throw new Error(`${type} '${name}' not found`);
|
|
91
|
+
}
|
|
92
|
+
const content = readFileSync(path, "utf-8");
|
|
93
|
+
log.info(`--- ${type}: ${name} ---`);
|
|
94
|
+
log.info(content);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return cmd;
|
|
98
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
getConfigValue,
|
|
4
|
+
setConfigValue,
|
|
5
|
+
getConfigEntries,
|
|
6
|
+
getConfigFilePath,
|
|
7
|
+
ensureConfigFile,
|
|
8
|
+
resetConfig,
|
|
9
|
+
} from "../config.js";
|
|
10
|
+
import { openInEditor } from "../editor.js";
|
|
11
|
+
import { createLogger } from "../logger.js";
|
|
12
|
+
import type { Config } from "../types.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("config-cmd");
|
|
15
|
+
|
|
16
|
+
// Using a satisfies check to ensure this record covers every key of Config.
|
|
17
|
+
// TypeScript will error if a key is added to Config but omitted here.
|
|
18
|
+
const CONFIG_KEYS_RECORD = {
|
|
19
|
+
capabilitiesDir: "capabilitiesDir",
|
|
20
|
+
editor: "editor",
|
|
21
|
+
gitRemote: "gitRemote",
|
|
22
|
+
} as const satisfies { [K in keyof Config]: K };
|
|
23
|
+
|
|
24
|
+
const VALID_KEYS = Object.keys(CONFIG_KEYS_RECORD) as (keyof Config)[];
|
|
25
|
+
|
|
26
|
+
function formatDefault(value: string | undefined): string {
|
|
27
|
+
return value !== undefined ? `(default: ${value})` : "(not set)";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function makeConfigCommand(): Command {
|
|
31
|
+
const cmd = new Command("config");
|
|
32
|
+
cmd.description("Manage uplink configuration");
|
|
33
|
+
|
|
34
|
+
cmd
|
|
35
|
+
.command("get [key]")
|
|
36
|
+
.description("Get a configuration value (or all values if no key given)")
|
|
37
|
+
.action((key?: string) => {
|
|
38
|
+
if (!key) {
|
|
39
|
+
log.info("Current configuration:");
|
|
40
|
+
for (const { key: k, value, defaultValue } of getConfigEntries()) {
|
|
41
|
+
const display = value !== undefined ? value : formatDefault(defaultValue);
|
|
42
|
+
log.info(` ${k}: ${display}`);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!VALID_KEYS.includes(key as keyof Config)) {
|
|
48
|
+
log.error(`Unknown config key '${key}'. Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const entry = getConfigEntries().find((e) => e.key === key);
|
|
53
|
+
if (entry?.value === undefined) {
|
|
54
|
+
log.info(`${key}: ${formatDefault(entry?.defaultValue)}`);
|
|
55
|
+
} else {
|
|
56
|
+
log.info(`${key}: ${entry.value}`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
cmd
|
|
61
|
+
.command("set <key> <value>")
|
|
62
|
+
.description("Set a configuration value")
|
|
63
|
+
.action((key: string, value: string) => {
|
|
64
|
+
if (!VALID_KEYS.includes(key as keyof Config)) {
|
|
65
|
+
log.error(`Unknown config key '${key}'. Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
setConfigValue(key as keyof Config, value as Config[keyof Config]);
|
|
69
|
+
log.success(`Set ${key} = ${value}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
cmd
|
|
73
|
+
.command("list")
|
|
74
|
+
.description("List all configuration values")
|
|
75
|
+
.action(() => {
|
|
76
|
+
log.info("Configuration:");
|
|
77
|
+
for (const { key, value, defaultValue } of getConfigEntries()) {
|
|
78
|
+
const display = value !== undefined ? value : formatDefault(defaultValue);
|
|
79
|
+
log.info(` ${key}: ${display}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
cmd
|
|
84
|
+
.command("edit")
|
|
85
|
+
.description("Open the configuration file in your editor")
|
|
86
|
+
.action(async () => {
|
|
87
|
+
const configFile = getConfigFilePath();
|
|
88
|
+
// Ensure the config file exists before opening
|
|
89
|
+
ensureConfigFile();
|
|
90
|
+
try {
|
|
91
|
+
await openInEditor(configFile, getConfigValue("editor"));
|
|
92
|
+
log.success(`Done editing config`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log.error(err);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
cmd
|
|
100
|
+
.command("reset")
|
|
101
|
+
.description("Reset configuration to defaults")
|
|
102
|
+
.action(() => {
|
|
103
|
+
resetConfig();
|
|
104
|
+
log.success("Configuration reset to defaults");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return cmd;
|
|
108
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, rmSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import {
|
|
6
|
+
INTEGRATIONS,
|
|
7
|
+
} from "./install.js";
|
|
8
|
+
|
|
9
|
+
const TMP = join(tmpdir(), `uplink-install-test-${String(process.pid)}`);
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
mkdirSync(TMP, { recursive: true });
|
|
13
|
+
process.env["HOME"] = TMP;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(TMP, { recursive: true, force: true });
|
|
18
|
+
delete process.env["HOME"];
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the VS Code user dir for the current fake HOME, by replicating the
|
|
27
|
+
* same logic as getVSCodeUserDir() but using the overridden HOME.
|
|
28
|
+
*/
|
|
29
|
+
function vscodeUserDir(): string {
|
|
30
|
+
// On Linux the path is ~/.config/Code/User
|
|
31
|
+
// We override HOME so homedir() returns TMP, but getVSCodeUserDir() calls
|
|
32
|
+
// homedir() which is cached at module load. Use the direct path.
|
|
33
|
+
return join(TMP, ".config", "Code", "User");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readJson(path: string): { [key: string]: unknown } {
|
|
37
|
+
return JSON.parse(readFileSync(path, "utf-8")) as { [key: string]: unknown };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// vscode integration
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe("vscode install", () => {
|
|
45
|
+
it("creates settings.json and mcp.json in VS Code user dir", () => {
|
|
46
|
+
const capabilitiesDir = join(TMP, "capabilities");
|
|
47
|
+
const userDir = vscodeUserDir();
|
|
48
|
+
mkdirSync(userDir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
const vscode = INTEGRATIONS["vscode"];
|
|
51
|
+
if (!vscode) throw new Error("vscode integration not defined");
|
|
52
|
+
vscode.install(capabilitiesDir);
|
|
53
|
+
|
|
54
|
+
const settings = readJson(join(userDir, "settings.json"));
|
|
55
|
+
expect(settings).toMatchObject({
|
|
56
|
+
"chat.agentSkillsLocations": { [join(capabilitiesDir, "skills")]: false },
|
|
57
|
+
"chat.instructionsFilesLocations": { [join(capabilitiesDir, "instructions")]: false },
|
|
58
|
+
"chat.agentFilesLocations": { [join(capabilitiesDir, "agents")]: false },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const mcp = readJson(join(userDir, "mcp.json"));
|
|
62
|
+
expect(mcp).toHaveProperty("servers.agent-uplink");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("merges into an existing settings.json without overwriting other keys", () => {
|
|
66
|
+
const capabilitiesDir = join(TMP, "capabilities");
|
|
67
|
+
const userDir = vscodeUserDir();
|
|
68
|
+
mkdirSync(userDir, { recursive: true });
|
|
69
|
+
|
|
70
|
+
writeFileSync(
|
|
71
|
+
join(userDir, "settings.json"),
|
|
72
|
+
JSON.stringify({ "editor.fontSize": 14 }, null, 2),
|
|
73
|
+
"utf-8"
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const vscode = INTEGRATIONS["vscode"];
|
|
77
|
+
if (!vscode) throw new Error("vscode integration not defined");
|
|
78
|
+
vscode.install(capabilitiesDir);
|
|
79
|
+
|
|
80
|
+
const settings = readJson(join(userDir, "settings.json"));
|
|
81
|
+
expect(settings).toMatchObject({
|
|
82
|
+
"editor.fontSize": 14,
|
|
83
|
+
"chat.agentSkillsLocations": { [join(capabilitiesDir, "skills")]: false },
|
|
84
|
+
"chat.instructionsFilesLocations": { [join(capabilitiesDir, "instructions")]: false },
|
|
85
|
+
"chat.agentFilesLocations": { [join(capabilitiesDir, "agents")]: false },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("uninstall removes agent-uplink keys from settings.json and mcp.json", () => {
|
|
90
|
+
const capabilitiesDir = join(TMP, "capabilities");
|
|
91
|
+
const userDir = vscodeUserDir();
|
|
92
|
+
mkdirSync(userDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const vscode = INTEGRATIONS["vscode"];
|
|
95
|
+
if (!vscode) throw new Error("vscode integration not defined");
|
|
96
|
+
vscode.install(capabilitiesDir);
|
|
97
|
+
vscode.uninstall();
|
|
98
|
+
|
|
99
|
+
const settings = readJson(join(userDir, "settings.json"));
|
|
100
|
+
expect(settings).not.toHaveProperty("chat.agentSkillsLocations");
|
|
101
|
+
expect(settings).not.toHaveProperty("chat.instructionsFilesLocations");
|
|
102
|
+
expect(settings).not.toHaveProperty("chat.agentFilesLocations");
|
|
103
|
+
|
|
104
|
+
const mcp = readJson(join(userDir, "mcp.json"));
|
|
105
|
+
expect(mcp).not.toHaveProperty("servers.agent-uplink");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("uninstall only removes our entries, leaving others intact", () => {
|
|
109
|
+
const capabilitiesDir = join(TMP, "capabilities");
|
|
110
|
+
const userDir = vscodeUserDir();
|
|
111
|
+
mkdirSync(userDir, { recursive: true });
|
|
112
|
+
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(userDir, "settings.json"),
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
"chat.agentSkillsLocations": { "/other/skills": false },
|
|
117
|
+
"chat.instructionsFilesLocations": { "/other/instructions": false },
|
|
118
|
+
"chat.agentFilesLocations": { "/other/agents": false },
|
|
119
|
+
}, null, 2),
|
|
120
|
+
"utf-8"
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const vscode = INTEGRATIONS["vscode"];
|
|
124
|
+
if (!vscode) throw new Error("vscode integration not defined");
|
|
125
|
+
vscode.install(capabilitiesDir);
|
|
126
|
+
|
|
127
|
+
const settingsAfterInstall = readJson(join(userDir, "settings.json"));
|
|
128
|
+
expect(settingsAfterInstall).toMatchObject({
|
|
129
|
+
"chat.agentSkillsLocations": {
|
|
130
|
+
"/other/skills": false,
|
|
131
|
+
[join(capabilitiesDir, "skills")]: false,
|
|
132
|
+
},
|
|
133
|
+
"chat.instructionsFilesLocations": {
|
|
134
|
+
"/other/instructions": false,
|
|
135
|
+
[join(capabilitiesDir, "instructions")]: false,
|
|
136
|
+
},
|
|
137
|
+
"chat.agentFilesLocations": {
|
|
138
|
+
"/other/agents": false,
|
|
139
|
+
[join(capabilitiesDir, "agents")]: false,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
vscode.uninstall();
|
|
144
|
+
|
|
145
|
+
const settingsAfterUninstall = readJson(join(userDir, "settings.json"));
|
|
146
|
+
expect(settingsAfterUninstall).toMatchObject({
|
|
147
|
+
"chat.agentSkillsLocations": { "/other/skills": false },
|
|
148
|
+
"chat.instructionsFilesLocations": { "/other/instructions": false },
|
|
149
|
+
"chat.agentFilesLocations": { "/other/agents": false },
|
|
150
|
+
});
|
|
151
|
+
expect(settingsAfterUninstall["chat.agentSkillsLocations"]).not.toHaveProperty(join(capabilitiesDir, "skills"));
|
|
152
|
+
expect(settingsAfterUninstall["chat.instructionsFilesLocations"]).not.toHaveProperty(join(capabilitiesDir, "instructions"));
|
|
153
|
+
expect(settingsAfterUninstall["chat.agentFilesLocations"]).not.toHaveProperty(join(capabilitiesDir, "agents"));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir, platform } from "os";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { getCapabilitiesDir } from "../config.js";
|
|
7
|
+
import { createLogger } from "../logger.js";
|
|
8
|
+
|
|
9
|
+
const log = createLogger("install");
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Zod schemas for parsing VS Code config files
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const LocationsSchema = z.record(z.string(), z.boolean());
|
|
16
|
+
const McpServerSchema = z.object({
|
|
17
|
+
env: z.object({ UPLINK_CAPABILITIES_DIR: z.string().optional() }).optional(),
|
|
18
|
+
}).loose();
|
|
19
|
+
const McpServersSchema = z.record(z.string(), McpServerSchema);
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function deepMerge(
|
|
26
|
+
target: { [key: string]: unknown },
|
|
27
|
+
source: { [key: string]: unknown }
|
|
28
|
+
): { [key: string]: unknown } {
|
|
29
|
+
const result = { ...target };
|
|
30
|
+
for (const [key, value] of Object.entries(source)) {
|
|
31
|
+
if (
|
|
32
|
+
value !== null &&
|
|
33
|
+
typeof value === "object" &&
|
|
34
|
+
!Array.isArray(value) &&
|
|
35
|
+
typeof result[key] === "object" &&
|
|
36
|
+
result[key] !== null &&
|
|
37
|
+
!Array.isArray(result[key])
|
|
38
|
+
) {
|
|
39
|
+
result[key] = deepMerge(
|
|
40
|
+
result[key] as { [key: string]: unknown },
|
|
41
|
+
value as { [key: string]: unknown }
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
result[key] = value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJsonFile(path: string): { [key: string]: unknown } {
|
|
51
|
+
if (!existsSync(path)) return {};
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(path, "utf-8")) as { [key: string]: unknown };
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeJsonFile(path: string, data: unknown): void {
|
|
60
|
+
const dir = join(path, "..");
|
|
61
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
62
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns the path to the VS Code user-level configuration directory.
|
|
67
|
+
* Platform-specific: Linux uses ~/.config/Code/User, macOS uses
|
|
68
|
+
* ~/Library/Application Support/Code/User, Windows uses %APPDATA%/Code/User.
|
|
69
|
+
*/
|
|
70
|
+
export function getVSCodeUserDir(): string {
|
|
71
|
+
const home = homedir();
|
|
72
|
+
const p = platform();
|
|
73
|
+
if (p === "darwin") {
|
|
74
|
+
return join(home, "Library", "Application Support", "Code", "User");
|
|
75
|
+
}
|
|
76
|
+
if (p === "win32") {
|
|
77
|
+
return join(
|
|
78
|
+
process.env["APPDATA"] ?? join(home, "AppData", "Roaming"),
|
|
79
|
+
"Code",
|
|
80
|
+
"User"
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return join(home, ".config", "Code", "User");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Integration definitions
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export interface Integration {
|
|
91
|
+
name: string;
|
|
92
|
+
description: string;
|
|
93
|
+
install(capabilitiesDir: string): void;
|
|
94
|
+
uninstall(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const INTEGRATIONS: { [key: string]: Integration | undefined } = {
|
|
98
|
+
vscode: {
|
|
99
|
+
name: "VS Code",
|
|
100
|
+
description:
|
|
101
|
+
"Configure user-level VS Code settings and MCP server for GitHub Copilot",
|
|
102
|
+
|
|
103
|
+
install(capabilitiesDir: string) {
|
|
104
|
+
const userDir = getVSCodeUserDir();
|
|
105
|
+
|
|
106
|
+
// User settings.json
|
|
107
|
+
const settingsPath = join(userDir, "settings.json");
|
|
108
|
+
const settings = readJsonFile(settingsPath);
|
|
109
|
+
|
|
110
|
+
// Skills → chat.agentSkillsLocations
|
|
111
|
+
const skillsDir = join(capabilitiesDir, "skills");
|
|
112
|
+
const existingSkillsLocations = LocationsSchema.catch({}).parse(
|
|
113
|
+
settings["chat.agentSkillsLocations"]
|
|
114
|
+
);
|
|
115
|
+
settings["chat.agentSkillsLocations"] = {
|
|
116
|
+
...existingSkillsLocations,
|
|
117
|
+
[skillsDir]: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Instructions → chat.instructionsFilesLocations
|
|
121
|
+
const instructionsDir = join(capabilitiesDir, "instructions");
|
|
122
|
+
const existingInstructionsLocations = LocationsSchema.catch({}).parse(
|
|
123
|
+
settings["chat.instructionsFilesLocations"]
|
|
124
|
+
);
|
|
125
|
+
settings["chat.instructionsFilesLocations"] = {
|
|
126
|
+
...existingInstructionsLocations,
|
|
127
|
+
[instructionsDir]: false,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Agents → chat.agentFilesLocations
|
|
131
|
+
const agentsDir = join(capabilitiesDir, "agents");
|
|
132
|
+
const existingAgentLocations = LocationsSchema.catch({}).parse(
|
|
133
|
+
settings["chat.agentFilesLocations"]
|
|
134
|
+
);
|
|
135
|
+
settings["chat.agentFilesLocations"] = {
|
|
136
|
+
...existingAgentLocations,
|
|
137
|
+
[agentsDir]: false,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
writeJsonFile(settingsPath, settings);
|
|
141
|
+
log.dim(`Updated ${settingsPath}`);
|
|
142
|
+
|
|
143
|
+
// User mcp.json — register the uplink MCP server
|
|
144
|
+
const mcpPath = join(userDir, "mcp.json");
|
|
145
|
+
const mergedMcp = deepMerge(readJsonFile(mcpPath), {
|
|
146
|
+
servers: {
|
|
147
|
+
"agent-uplink": {
|
|
148
|
+
type: "stdio",
|
|
149
|
+
command: "uplink",
|
|
150
|
+
args: ["mcp", "start", "--stdio"],
|
|
151
|
+
env: { UPLINK_CAPABILITIES_DIR: capabilitiesDir },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
writeJsonFile(mcpPath, mergedMcp);
|
|
156
|
+
log.dim(`Updated ${mcpPath}`);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
uninstall() {
|
|
160
|
+
const userDir = getVSCodeUserDir();
|
|
161
|
+
|
|
162
|
+
// Determine capabilitiesDir from stored MCP config (fallback to current config)
|
|
163
|
+
const mcpPath = join(userDir, "mcp.json");
|
|
164
|
+
let capabilitiesDir: string;
|
|
165
|
+
if (existsSync(mcpPath)) {
|
|
166
|
+
const mcp = readJsonFile(mcpPath);
|
|
167
|
+
const servers = McpServersSchema.nullable().catch(null).parse(mcp["servers"]);
|
|
168
|
+
capabilitiesDir =
|
|
169
|
+
servers?.["agent-uplink"]?.env?.UPLINK_CAPABILITIES_DIR ?? getCapabilitiesDir();
|
|
170
|
+
} else {
|
|
171
|
+
capabilitiesDir = getCapabilitiesDir();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Remove our skill/instruction entries from the instructions array in settings.json
|
|
175
|
+
const settingsPath = join(userDir, "settings.json");
|
|
176
|
+
if (existsSync(settingsPath)) {
|
|
177
|
+
const settings = readJsonFile(settingsPath);
|
|
178
|
+
|
|
179
|
+
// Remove our skills entry from chat.agentSkillsLocations
|
|
180
|
+
const skillsDir = join(capabilitiesDir, "skills");
|
|
181
|
+
const skillsLocations = LocationsSchema.nullable().catch(null).parse(
|
|
182
|
+
settings["chat.agentSkillsLocations"]
|
|
183
|
+
);
|
|
184
|
+
if (skillsLocations) {
|
|
185
|
+
const { [skillsDir]: _removedSkills, ...restSkills } = skillsLocations;
|
|
186
|
+
if (Object.keys(restSkills).length === 0) {
|
|
187
|
+
delete settings["chat.agentSkillsLocations"];
|
|
188
|
+
} else {
|
|
189
|
+
settings["chat.agentSkillsLocations"] = restSkills;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove our instruction entry from chat.instructionsFilesLocations
|
|
194
|
+
const instructionsDir = join(capabilitiesDir, "instructions");
|
|
195
|
+
const instructionsLocations = LocationsSchema.nullable().catch(null).parse(
|
|
196
|
+
settings["chat.instructionsFilesLocations"]
|
|
197
|
+
);
|
|
198
|
+
if (instructionsLocations) {
|
|
199
|
+
const { [instructionsDir]: _removedInstructions, ...restInstructions } = instructionsLocations;
|
|
200
|
+
if (Object.keys(restInstructions).length === 0) {
|
|
201
|
+
delete settings["chat.instructionsFilesLocations"];
|
|
202
|
+
} else {
|
|
203
|
+
settings["chat.instructionsFilesLocations"] = restInstructions;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Remove agents directory from chat.agentFilesLocations
|
|
208
|
+
const agentsDir = join(capabilitiesDir, "agents");
|
|
209
|
+
const agentLocations = LocationsSchema.nullable().catch(null).parse(
|
|
210
|
+
settings["chat.agentFilesLocations"]
|
|
211
|
+
);
|
|
212
|
+
if (agentLocations) {
|
|
213
|
+
const { [agentsDir]: _removedAgents, ...restAgents } = agentLocations;
|
|
214
|
+
if (Object.keys(restAgents).length === 0) {
|
|
215
|
+
delete settings["chat.agentFilesLocations"];
|
|
216
|
+
} else {
|
|
217
|
+
settings["chat.agentFilesLocations"] = restAgents;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
writeJsonFile(settingsPath, settings);
|
|
222
|
+
log.dim(`Cleaned ${settingsPath}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Remove agent-uplink server from mcp.json
|
|
226
|
+
if (existsSync(mcpPath)) {
|
|
227
|
+
const mcp = readJsonFile(mcpPath);
|
|
228
|
+
const servers = McpServersSchema.nullable().catch(null).parse(mcp["servers"]);
|
|
229
|
+
if (servers) {
|
|
230
|
+
const { "agent-uplink": _removed, ...rest } = servers;
|
|
231
|
+
if (Object.keys(rest).length === 0) {
|
|
232
|
+
delete mcp["servers"];
|
|
233
|
+
} else {
|
|
234
|
+
mcp["servers"] = rest;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
writeJsonFile(mcpPath, mcp);
|
|
238
|
+
log.dim(`Cleaned ${mcpPath}`);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const INTEGRATION_NAMES = Object.keys(INTEGRATIONS).join(", ");
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Commands
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export function makeInstallCommand(): Command {
|
|
251
|
+
const cmd = new Command("install");
|
|
252
|
+
cmd
|
|
253
|
+
.description(
|
|
254
|
+
`Install a tool integration globally (${INTEGRATION_NAMES})`
|
|
255
|
+
)
|
|
256
|
+
.argument("<integration>", `Integration to install (${INTEGRATION_NAMES})`)
|
|
257
|
+
.action((integration: string) => {
|
|
258
|
+
const capabilitiesDir = getCapabilitiesDir();
|
|
259
|
+
const integrationDef = INTEGRATIONS[integration];
|
|
260
|
+
|
|
261
|
+
if (!integrationDef) {
|
|
262
|
+
log.error(
|
|
263
|
+
`Unknown integration '${integration}'. Available: ${INTEGRATION_NAMES}`
|
|
264
|
+
);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
log.info(`Installing ${integrationDef.name} integration...`);
|
|
269
|
+
log.dim(`Capabilities directory: ${capabilitiesDir}`);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
integrationDef.install(capabilitiesDir);
|
|
273
|
+
log.success(
|
|
274
|
+
`${integrationDef.name} integration installed successfully.`
|
|
275
|
+
);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
log.error(
|
|
278
|
+
`Installation failed: ${err instanceof Error ? err.message : String(err)}`
|
|
279
|
+
);
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return cmd;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function makeUninstallCommand(): Command {
|
|
288
|
+
const cmd = new Command("uninstall");
|
|
289
|
+
cmd
|
|
290
|
+
.description(
|
|
291
|
+
`Uninstall a tool integration (${INTEGRATION_NAMES})`
|
|
292
|
+
)
|
|
293
|
+
.argument(
|
|
294
|
+
"<integration>",
|
|
295
|
+
`Integration to remove (${INTEGRATION_NAMES})`
|
|
296
|
+
)
|
|
297
|
+
.action((integration: string) => {
|
|
298
|
+
const integrationDef = INTEGRATIONS[integration];
|
|
299
|
+
|
|
300
|
+
if (!integrationDef) {
|
|
301
|
+
log.error(
|
|
302
|
+
`Unknown integration '${integration}'. Available: ${INTEGRATION_NAMES}`
|
|
303
|
+
);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
log.info(`Uninstalling ${integrationDef.name} integration...`);
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
integrationDef.uninstall();
|
|
311
|
+
log.success(
|
|
312
|
+
`${integrationDef.name} integration uninstalled successfully.`
|
|
313
|
+
);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
log.error(
|
|
316
|
+
`Uninstall failed: ${err instanceof Error ? err.message : String(err)}`
|
|
317
|
+
);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return cmd;
|
|
323
|
+
}
|