memoir-cli 2.2.0 → 2.5.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/README.md +91 -113
- package/bin/memoir.js +151 -12
- package/package.json +7 -3
- package/src/adapters/index.js +1 -1
- package/src/cloud/auth.js +112 -0
- package/src/cloud/constants.js +5 -0
- package/src/cloud/storage.js +212 -0
- package/src/commands/cloud.js +173 -0
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +2 -2
- package/src/commands/history.js +65 -0
- package/src/commands/login.js +93 -0
- package/src/commands/profile.js +199 -0
- package/src/commands/push.js +4 -2
- package/src/commands/restore.js +1 -1
- package/src/commands/resume.js +1 -1
- package/src/commands/snapshot.js +1 -1
- package/src/commands/status.js +2 -2
- package/src/commands/view.js +2 -2
- package/src/config.js +94 -7
- package/src/tools/chatgpt.js +24 -0
- package/src/tools/index.js +2 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import boxen from 'boxen';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import {
|
|
6
|
+
getRawConfig, listProfiles, getActiveProfileName,
|
|
7
|
+
createProfile, switchProfile, deleteProfile
|
|
8
|
+
} from '../config.js';
|
|
9
|
+
|
|
10
|
+
function getGitHubUsername() {
|
|
11
|
+
try {
|
|
12
|
+
return execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8' }).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
try {
|
|
15
|
+
return execFileSync('git', ['config', '--global', 'user.name'], { encoding: 'utf8' }).trim();
|
|
16
|
+
} catch { return ''; }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function profileListCommand() {
|
|
21
|
+
const profiles = await listProfiles();
|
|
22
|
+
const active = await getActiveProfileName();
|
|
23
|
+
const raw = await getRawConfig();
|
|
24
|
+
|
|
25
|
+
if (profiles.length === 0) {
|
|
26
|
+
console.log('\n' + chalk.yellow('No profiles configured. Run ') + chalk.cyan('memoir init') + chalk.yellow(' first.\n'));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log();
|
|
31
|
+
console.log(chalk.bold.white(' Profiles:\n'));
|
|
32
|
+
|
|
33
|
+
for (const name of profiles) {
|
|
34
|
+
const isActive = name === active;
|
|
35
|
+
const marker = isActive ? chalk.green(' ✔ ') : chalk.gray(' ');
|
|
36
|
+
const label = isActive ? chalk.white.bold(name) : chalk.white(name);
|
|
37
|
+
|
|
38
|
+
// Get profile details
|
|
39
|
+
let detail = '';
|
|
40
|
+
if (raw.version >= 2 && raw.profiles?.[name]) {
|
|
41
|
+
const p = raw.profiles[name];
|
|
42
|
+
const dest = p.provider === 'git' ? p.gitRepo : p.localPath;
|
|
43
|
+
detail = chalk.gray(` → ${dest}`);
|
|
44
|
+
if (p.only) detail += chalk.gray(` (${p.only.join(', ')})`);
|
|
45
|
+
} else if (!raw.version) {
|
|
46
|
+
const dest = raw.provider === 'git' ? raw.gitRepo : raw.localPath;
|
|
47
|
+
detail = chalk.gray(` → ${dest}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(`${marker}${label}${detail}`);
|
|
51
|
+
}
|
|
52
|
+
console.log();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function profileCreateCommand(name) {
|
|
56
|
+
const profiles = await listProfiles();
|
|
57
|
+
if (profiles.includes(name)) {
|
|
58
|
+
console.log(chalk.red(`\n✖ Profile "${name}" already exists.\n`));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log('\n' + chalk.cyan(`Creating profile: ${chalk.bold(name)}\n`));
|
|
63
|
+
|
|
64
|
+
const detectedUser = getGitHubUsername();
|
|
65
|
+
|
|
66
|
+
const { provider } = await inquirer.prompt([{
|
|
67
|
+
type: 'list',
|
|
68
|
+
name: 'provider',
|
|
69
|
+
message: 'Storage for this profile?',
|
|
70
|
+
choices: [
|
|
71
|
+
{ name: 'GitHub', value: 'git' },
|
|
72
|
+
{ name: 'Local folder', value: 'local' }
|
|
73
|
+
]
|
|
74
|
+
}]);
|
|
75
|
+
|
|
76
|
+
const profileConfig = { provider };
|
|
77
|
+
|
|
78
|
+
if (provider === 'local') {
|
|
79
|
+
const { localPath } = await inquirer.prompt([{
|
|
80
|
+
type: 'input',
|
|
81
|
+
name: 'localPath',
|
|
82
|
+
message: 'Save to:',
|
|
83
|
+
validate: (input) => input.trim() ? true : 'Required'
|
|
84
|
+
}]);
|
|
85
|
+
profileConfig.localPath = localPath;
|
|
86
|
+
} else {
|
|
87
|
+
const answers = await inquirer.prompt([
|
|
88
|
+
{
|
|
89
|
+
type: 'input',
|
|
90
|
+
name: 'username',
|
|
91
|
+
message: 'GitHub username:',
|
|
92
|
+
default: detectedUser || undefined,
|
|
93
|
+
validate: (input) => input.trim() ? true : 'Required'
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: 'input',
|
|
97
|
+
name: 'repo',
|
|
98
|
+
message: 'Repo name:',
|
|
99
|
+
default: `ai-memory-${name}`,
|
|
100
|
+
validate: (input) => input.trim() ? true : 'Required'
|
|
101
|
+
}
|
|
102
|
+
]);
|
|
103
|
+
const username = answers.username.trim();
|
|
104
|
+
const repo = answers.repo.trim();
|
|
105
|
+
profileConfig.gitRepo = `https://github.com/${username}/${repo}.git`;
|
|
106
|
+
|
|
107
|
+
// Auto-create repo if possible
|
|
108
|
+
try {
|
|
109
|
+
execFileSync('gh', ['repo', 'view', `${username}/${repo}`], { stdio: 'ignore' });
|
|
110
|
+
console.log(chalk.gray(` ✔ Repo exists`));
|
|
111
|
+
} catch {
|
|
112
|
+
try {
|
|
113
|
+
execFileSync('gh', ['repo', 'create', `${username}/${repo}`, '--private', '--description', `AI memory backup - ${name} (memoir-cli)`], { stdio: 'ignore' });
|
|
114
|
+
console.log(chalk.green(` ✔ Created private repo`));
|
|
115
|
+
} catch {
|
|
116
|
+
console.log(chalk.yellow(` ⚠ Could not auto-create repo. Create it manually on GitHub.`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Ask which tools to sync (optional filter)
|
|
122
|
+
const { filterTools } = await inquirer.prompt([{
|
|
123
|
+
type: 'confirm',
|
|
124
|
+
name: 'filterTools',
|
|
125
|
+
message: 'Limit this profile to specific tools?',
|
|
126
|
+
default: false
|
|
127
|
+
}]);
|
|
128
|
+
|
|
129
|
+
if (filterTools) {
|
|
130
|
+
const { tools } = await inquirer.prompt([{
|
|
131
|
+
type: 'checkbox',
|
|
132
|
+
name: 'tools',
|
|
133
|
+
message: 'Which tools should this profile sync?',
|
|
134
|
+
choices: [
|
|
135
|
+
{ name: 'Claude Code', value: 'claude' },
|
|
136
|
+
{ name: 'Gemini CLI', value: 'gemini' },
|
|
137
|
+
{ name: 'OpenAI Codex', value: 'codex' },
|
|
138
|
+
{ name: 'Cursor', value: 'cursor' },
|
|
139
|
+
{ name: 'GitHub Copilot', value: 'copilot' },
|
|
140
|
+
{ name: 'Windsurf', value: 'windsurf' },
|
|
141
|
+
{ name: 'Zed', value: 'zed' },
|
|
142
|
+
{ name: 'Cline', value: 'cline' },
|
|
143
|
+
{ name: 'Continue.dev', value: 'continue' },
|
|
144
|
+
{ name: 'Aider', value: 'aider' }
|
|
145
|
+
]
|
|
146
|
+
}]);
|
|
147
|
+
if (tools.length > 0) {
|
|
148
|
+
profileConfig.only = tools;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await createProfile(name, profileConfig);
|
|
153
|
+
|
|
154
|
+
// Ask if they want to switch to it
|
|
155
|
+
const { switchNow } = await inquirer.prompt([{
|
|
156
|
+
type: 'confirm',
|
|
157
|
+
name: 'switchNow',
|
|
158
|
+
message: `Switch to "${name}" now?`,
|
|
159
|
+
default: true
|
|
160
|
+
}]);
|
|
161
|
+
|
|
162
|
+
if (switchNow) {
|
|
163
|
+
await switchProfile(name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log('\n' + boxen(
|
|
167
|
+
chalk.green(`✔ Profile "${name}" created`) +
|
|
168
|
+
(switchNow ? chalk.gray(` (now active)`) : ''),
|
|
169
|
+
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: 'green', dimBorder: true }
|
|
170
|
+
) + '\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function profileSwitchCommand(name) {
|
|
174
|
+
try {
|
|
175
|
+
await switchProfile(name);
|
|
176
|
+
console.log('\n' + chalk.green(`✔ Switched to profile "${name}"\n`));
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.log('\n' + chalk.red(`✖ ${err.message}\n`));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function profileDeleteCommand(name) {
|
|
183
|
+
try {
|
|
184
|
+
const { confirm } = await inquirer.prompt([{
|
|
185
|
+
type: 'confirm',
|
|
186
|
+
name: 'confirm',
|
|
187
|
+
message: `Delete profile "${name}"? This cannot be undone.`,
|
|
188
|
+
default: false
|
|
189
|
+
}]);
|
|
190
|
+
if (!confirm) {
|
|
191
|
+
console.log(chalk.gray('\nCancelled.\n'));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await deleteProfile(name);
|
|
195
|
+
console.log('\n' + chalk.green(`✔ Profile "${name}" deleted\n`));
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.log('\n' + chalk.red(`✖ ${err.message}\n`));
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/commands/push.js
CHANGED
|
@@ -10,7 +10,7 @@ import { extractMemories, adapters } from '../adapters/index.js';
|
|
|
10
10
|
import { syncToLocal, syncToGit } from '../providers/index.js';
|
|
11
11
|
|
|
12
12
|
export async function pushCommand(options = {}) {
|
|
13
|
-
const config = await getConfig();
|
|
13
|
+
const config = await getConfig(options.profile);
|
|
14
14
|
|
|
15
15
|
if (!config) {
|
|
16
16
|
console.log('\n' + boxen(
|
|
@@ -28,7 +28,9 @@ export async function pushCommand(options = {}) {
|
|
|
28
28
|
await fs.ensureDir(stagingDir);
|
|
29
29
|
|
|
30
30
|
try {
|
|
31
|
-
|
|
31
|
+
// Profile-level tool filter (config.only) merged with CLI --only flag
|
|
32
|
+
const onlyRaw = options.only || (config.only ? config.only.join(',') : null);
|
|
33
|
+
const onlyFilter = onlyRaw ? onlyRaw.split(',').map(t => t.trim().toLowerCase()) : null;
|
|
32
34
|
const foundAny = await extractMemories(stagingDir, spinner, onlyFilter);
|
|
33
35
|
|
|
34
36
|
if (!foundAny) {
|
package/src/commands/restore.js
CHANGED
|
@@ -9,7 +9,7 @@ import { getConfig } from '../config.js';
|
|
|
9
9
|
import { fetchFromLocal, fetchFromGit } from '../providers/restore.js';
|
|
10
10
|
|
|
11
11
|
export async function restoreCommand(options = {}) {
|
|
12
|
-
const config = await getConfig();
|
|
12
|
+
const config = await getConfig(options.profile);
|
|
13
13
|
|
|
14
14
|
if (!config) {
|
|
15
15
|
console.log('\n' + boxen(
|
package/src/commands/resume.js
CHANGED
package/src/commands/snapshot.js
CHANGED
|
@@ -264,7 +264,7 @@ Keep it under 300 words total. Be specific about file names and features.`;
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
export async function snapshotCommand(options = {}) {
|
|
267
|
-
const config = await getConfig();
|
|
267
|
+
const config = await getConfig(options.profile);
|
|
268
268
|
|
|
269
269
|
console.log();
|
|
270
270
|
const spinner = ora({ text: chalk.gray('Finding latest session...'), spinner: 'dots' }).start();
|
package/src/commands/status.js
CHANGED
|
@@ -6,8 +6,8 @@ import gradient from 'gradient-string';
|
|
|
6
6
|
import { getConfig } from '../config.js';
|
|
7
7
|
import { adapters } from '../adapters/index.js';
|
|
8
8
|
|
|
9
|
-
export async function statusCommand() {
|
|
10
|
-
const config = await getConfig();
|
|
9
|
+
export async function statusCommand(options = {}) {
|
|
10
|
+
const config = await getConfig(options.profile);
|
|
11
11
|
|
|
12
12
|
console.log();
|
|
13
13
|
|
package/src/commands/view.js
CHANGED
|
@@ -36,8 +36,8 @@ function isBinaryFile(filePath) {
|
|
|
36
36
|
return binaryExts.includes(path.extname(filePath).toLowerCase());
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function viewCommand() {
|
|
40
|
-
const config = await getConfig();
|
|
39
|
+
export async function viewCommand(options = {}) {
|
|
40
|
+
const config = await getConfig(options.profile);
|
|
41
41
|
if (!config) {
|
|
42
42
|
console.log(chalk.red('\n✖ Not configured yet. Run: memoir init\n'));
|
|
43
43
|
return;
|
package/src/config.js
CHANGED
|
@@ -7,7 +7,8 @@ const CONFIG_DIR = process.platform === 'win32'
|
|
|
7
7
|
: path.join(os.homedir(), '.config', 'memoir');
|
|
8
8
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Read the raw config file as-is
|
|
11
|
+
export async function getRawConfig() {
|
|
11
12
|
if (await fs.pathExists(CONFIG_FILE)) {
|
|
12
13
|
try {
|
|
13
14
|
return await fs.readJson(CONFIG_FILE);
|
|
@@ -18,22 +19,108 @@ export async function getConfig() {
|
|
|
18
19
|
return null;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// Get resolved config for a specific profile (or active profile)
|
|
23
|
+
// Backwards compatible: v1 flat configs return as-is
|
|
24
|
+
export async function getConfig(profileName = null) {
|
|
25
|
+
const raw = await getRawConfig();
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
|
|
28
|
+
// v1 flat config — no profiles, return as-is
|
|
29
|
+
if (!raw.version || raw.version < 2) return raw;
|
|
30
|
+
|
|
31
|
+
// v2 — resolve profile
|
|
32
|
+
const name = profileName || raw.activeProfile || 'default';
|
|
33
|
+
const profile = raw.profiles?.[name];
|
|
34
|
+
if (!profile) return null;
|
|
35
|
+
|
|
36
|
+
// Merge top-level shared keys into profile
|
|
37
|
+
return { ...profile, geminiApiKey: raw.geminiApiKey };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Save entire raw config
|
|
21
41
|
export async function saveConfig(config) {
|
|
22
42
|
await fs.ensureDir(CONFIG_DIR);
|
|
23
43
|
await fs.writeJson(CONFIG_FILE, config, { spaces: 2 });
|
|
24
|
-
// Restrict permissions — config may contain API keys
|
|
25
44
|
if (process.platform !== 'win32') {
|
|
26
45
|
await fs.chmod(CONFIG_FILE, 0o600);
|
|
27
46
|
}
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
// Save config for a specific profile (creates v2 format if needed)
|
|
50
|
+
export async function saveProfileConfig(profileName, profileData) {
|
|
51
|
+
let raw = await getRawConfig() || {};
|
|
52
|
+
if (!raw.version || raw.version < 2) {
|
|
53
|
+
raw = migrateConfigToV2(raw);
|
|
54
|
+
}
|
|
55
|
+
raw.profiles[profileName] = profileData;
|
|
56
|
+
await saveConfig(raw);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Migrate v1 flat config to v2 profiles format
|
|
60
|
+
export function migrateConfigToV2(flat) {
|
|
61
|
+
const { provider, gitRepo, localPath, geminiApiKey, ...rest } = flat;
|
|
62
|
+
return {
|
|
63
|
+
version: 2,
|
|
64
|
+
activeProfile: 'default',
|
|
65
|
+
geminiApiKey: geminiApiKey || undefined,
|
|
66
|
+
profiles: {
|
|
67
|
+
default: { provider, gitRepo, localPath, ...rest }
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getActiveProfileName() {
|
|
73
|
+
const raw = await getRawConfig();
|
|
74
|
+
if (!raw || !raw.version || raw.version < 2) return 'default';
|
|
75
|
+
return raw.activeProfile || 'default';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function listProfiles() {
|
|
79
|
+
const raw = await getRawConfig();
|
|
80
|
+
if (!raw) return [];
|
|
81
|
+
if (!raw.version || raw.version < 2) return ['default'];
|
|
82
|
+
return Object.keys(raw.profiles || {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function createProfile(name, profileConfig) {
|
|
86
|
+
let raw = await getRawConfig() || {};
|
|
87
|
+
if (!raw.version || raw.version < 2) {
|
|
88
|
+
raw = migrateConfigToV2(raw);
|
|
89
|
+
}
|
|
90
|
+
raw.profiles[name] = profileConfig;
|
|
91
|
+
await saveConfig(raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function switchProfile(name) {
|
|
95
|
+
let raw = await getRawConfig();
|
|
96
|
+
if (!raw) throw new Error('Not configured. Run memoir init first.');
|
|
97
|
+
if (!raw.version || raw.version < 2) {
|
|
98
|
+
raw = migrateConfigToV2(raw);
|
|
99
|
+
}
|
|
100
|
+
if (!raw.profiles[name]) throw new Error(`Profile "${name}" does not exist.`);
|
|
101
|
+
raw.activeProfile = name;
|
|
102
|
+
await saveConfig(raw);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function deleteProfile(name) {
|
|
106
|
+
const raw = await getRawConfig();
|
|
107
|
+
if (!raw || !raw.version || raw.version < 2) {
|
|
108
|
+
throw new Error('No profiles configured.');
|
|
109
|
+
}
|
|
110
|
+
if (!raw.profiles[name]) throw new Error(`Profile "${name}" does not exist.`);
|
|
111
|
+
if (raw.activeProfile === name) throw new Error(`Cannot delete the active profile. Switch first with: memoir profile switch <name>`);
|
|
112
|
+
if (Object.keys(raw.profiles).length <= 1) throw new Error('Cannot delete the last profile.');
|
|
113
|
+
delete raw.profiles[name];
|
|
114
|
+
await saveConfig(raw);
|
|
115
|
+
}
|
|
116
|
+
|
|
30
117
|
export async function getGeminiApiKey() {
|
|
31
|
-
const
|
|
32
|
-
return
|
|
118
|
+
const raw = await getRawConfig();
|
|
119
|
+
return raw?.geminiApiKey || process.env.GEMINI_API_KEY || null;
|
|
33
120
|
}
|
|
34
121
|
|
|
35
122
|
export async function saveGeminiApiKey(apiKey) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
await saveConfig(
|
|
123
|
+
let raw = await getRawConfig() || {};
|
|
124
|
+
raw.geminiApiKey = apiKey;
|
|
125
|
+
await saveConfig(raw);
|
|
39
126
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const cwd = process.cwd();
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
key: 'chatgpt',
|
|
8
|
+
name: 'ChatGPT',
|
|
9
|
+
icon: '💬',
|
|
10
|
+
format: 'Markdown custom instructions in CHATGPT.md. Written as instructions for ChatGPT — your preferences, coding style, response format, and project context. Paste into ChatGPT\'s Custom Instructions or Memory settings.',
|
|
11
|
+
|
|
12
|
+
discover() {
|
|
13
|
+
const files = [];
|
|
14
|
+
const projectFile = path.join(cwd, 'CHATGPT.md');
|
|
15
|
+
if (fs.existsSync(projectFile)) {
|
|
16
|
+
files.push({ filePath: projectFile, content: fs.readFileSync(projectFile, 'utf-8'), scope: 'project' });
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
targetPath() {
|
|
22
|
+
return path.join(cwd, 'CHATGPT.md');
|
|
23
|
+
}
|
|
24
|
+
};
|
package/src/tools/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import claude from './claude.js';
|
|
2
2
|
import gemini from './gemini.js';
|
|
3
|
+
import chatgpt from './chatgpt.js';
|
|
3
4
|
import codex from './codex.js';
|
|
4
5
|
import cursor from './cursor.js';
|
|
5
6
|
import copilot from './copilot.js';
|
|
@@ -10,7 +11,7 @@ import continuedev from './continuedev.js';
|
|
|
10
11
|
import aider from './aider.js';
|
|
11
12
|
|
|
12
13
|
const registry = {};
|
|
13
|
-
for (const tool of [claude, gemini, codex, cursor, copilot, windsurf, zed, cline, continuedev, aider]) {
|
|
14
|
+
for (const tool of [claude, gemini, chatgpt, codex, cursor, copilot, windsurf, zed, cline, continuedev, aider]) {
|
|
14
15
|
registry[tool.key] = tool;
|
|
15
16
|
}
|
|
16
17
|
|