@unifiedmemory/cli 1.0.1 → 1.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/.env.example +27 -6
- package/CHANGELOG.md +232 -0
- package/README.md +64 -3
- package/commands/init.js +246 -69
- package/commands/login.js +9 -95
- package/commands/org.js +9 -38
- package/index.js +17 -26
- package/lib/config.js +42 -24
- package/lib/jwt-utils.js +63 -0
- package/lib/mcp-server.js +1 -1
- package/lib/memory-instructions.js +72 -0
- package/lib/org-selection-ui.js +104 -0
- package/lib/provider-detector.js +69 -75
- package/lib/token-refresh.js +1 -18
- package/lib/token-storage.js +15 -2
- package/lib/welcome.js +40 -0
- package/package.json +6 -4
- package/HOOK_SETUP.md +0 -338
- package/lib/hooks.js +0 -43
package/lib/config.js
CHANGED
|
@@ -1,39 +1,57 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
1
|
import { fileURLToPath } from 'url';
|
|
4
|
-
import { dirname } from 'path';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
5
4
|
|
|
6
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
6
|
const __dirname = dirname(__filename);
|
|
8
7
|
|
|
9
|
-
// Load environment variables from .env file
|
|
10
|
-
|
|
11
|
-
if (fs.existsSync(envPath)) {
|
|
12
|
-
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
13
|
-
envContent.split('\n').forEach(line => {
|
|
14
|
-
const [key, ...valueParts] = line.split('=');
|
|
15
|
-
if (key && valueParts.length > 0) {
|
|
16
|
-
const value = valueParts.join('=').trim();
|
|
17
|
-
if (!process.env[key]) {
|
|
18
|
-
process.env[key] = value;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
}
|
|
8
|
+
// Load environment variables from .env file using dotenv
|
|
9
|
+
dotenvConfig({ path: join(__dirname, '..', '.env') });
|
|
23
10
|
|
|
24
11
|
export const config = {
|
|
25
|
-
//
|
|
26
|
-
clerkClientId: process.env.CLERK_CLIENT_ID
|
|
12
|
+
// Required: Clerk OAuth configuration
|
|
13
|
+
clerkClientId: process.env.CLERK_CLIENT_ID,
|
|
27
14
|
clerkClientSecret: process.env.CLERK_CLIENT_SECRET, // Optional for PKCE flow
|
|
28
|
-
clerkDomain: process.env.CLERK_DOMAIN
|
|
29
|
-
|
|
15
|
+
clerkDomain: process.env.CLERK_DOMAIN,
|
|
16
|
+
|
|
17
|
+
// Required: API configuration
|
|
18
|
+
apiEndpoint: process.env.API_ENDPOINT,
|
|
19
|
+
|
|
20
|
+
// Optional: OAuth flow configuration (non-sensitive defaults OK)
|
|
30
21
|
redirectUri: process.env.REDIRECT_URI || 'http://localhost:3333/callback',
|
|
31
22
|
port: parseInt(process.env.PORT || '3333', 10)
|
|
32
23
|
};
|
|
33
24
|
|
|
34
|
-
// Validation function
|
|
25
|
+
// Validation function - ensures required configuration is present
|
|
35
26
|
export function validateConfig() {
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
const missing = [];
|
|
28
|
+
|
|
29
|
+
if (!config.clerkClientId) {
|
|
30
|
+
missing.push('CLERK_CLIENT_ID');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!config.clerkDomain) {
|
|
34
|
+
missing.push('CLERK_DOMAIN');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!config.apiEndpoint) {
|
|
38
|
+
missing.push('API_ENDPOINT');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (missing.length > 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Missing required environment variables: ${missing.join(', ')}\n\n` +
|
|
44
|
+
`Please create a .env file in the project root with these values.\n` +
|
|
45
|
+
`See .env.example for a template.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Validate URL format for apiEndpoint
|
|
50
|
+
try {
|
|
51
|
+
new URL(config.apiEndpoint);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
throw new Error(`API_ENDPOINT must be a valid URL (got: ${config.apiEndpoint})`);
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
return true;
|
|
39
57
|
}
|
package/lib/jwt-utils.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Utility Functions
|
|
3
|
+
*
|
|
4
|
+
* Centralized JWT parsing, validation, and expiration checking.
|
|
5
|
+
* Extracted from duplicate implementations in login.js and token-refresh.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a JWT token and return its payload
|
|
10
|
+
* @param {string} token - The JWT token to parse
|
|
11
|
+
* @returns {Object|null} Decoded JWT payload or null if invalid
|
|
12
|
+
*/
|
|
13
|
+
export function parseJWT(token) {
|
|
14
|
+
try {
|
|
15
|
+
const parts = token.split('.');
|
|
16
|
+
if (parts.length !== 3) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const payload = Buffer.from(parts[1], 'base64').toString('utf8');
|
|
20
|
+
return JSON.parse(payload);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a JWT token is expired
|
|
28
|
+
* @param {Object} decoded - Decoded JWT payload with exp claim
|
|
29
|
+
* @param {number} bufferMs - Optional expiration buffer in milliseconds (default: 0)
|
|
30
|
+
* @returns {boolean} True if token is expired (including buffer)
|
|
31
|
+
*/
|
|
32
|
+
export function isJWTExpired(decoded, bufferMs = 0) {
|
|
33
|
+
if (!decoded?.exp) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const expirationTime = decoded.exp * 1000;
|
|
37
|
+
return Date.now() >= (expirationTime - bufferMs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate JWT structure has required fields
|
|
42
|
+
* @param {Object} decoded - Decoded JWT payload
|
|
43
|
+
* @returns {boolean} True if JWT has valid structure (sub and exp claims)
|
|
44
|
+
*/
|
|
45
|
+
export function validateJWTStructure(decoded) {
|
|
46
|
+
return decoded &&
|
|
47
|
+
typeof decoded === 'object' &&
|
|
48
|
+
decoded.sub &&
|
|
49
|
+
decoded.exp;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get time until JWT expiration
|
|
54
|
+
* @param {Object} decoded - Decoded JWT payload with exp claim
|
|
55
|
+
* @returns {number} Milliseconds until expiration (negative if already expired)
|
|
56
|
+
*/
|
|
57
|
+
export function getTimeUntilExpiration(decoded) {
|
|
58
|
+
if (!decoded?.exp) {
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
const expirationTime = decoded.exp * 1000;
|
|
62
|
+
return expirationTime - Date.now();
|
|
63
|
+
}
|
package/lib/mcp-server.js
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Unique marker to detect if instructions already present
|
|
2
|
+
export const MEMORY_INSTRUCTIONS_MARKER = '<!-- UNIFIEDMEMORY_INSTRUCTIONS_V1 -->';
|
|
3
|
+
|
|
4
|
+
export function getMemoryInstructions() {
|
|
5
|
+
return `${MEMORY_INSTRUCTIONS_MARKER}
|
|
6
|
+
|
|
7
|
+
# UnifiedMemory Automatic Integration
|
|
8
|
+
|
|
9
|
+
When working in this project, automatically integrate with UnifiedMemory to maintain project knowledge:
|
|
10
|
+
|
|
11
|
+
## 1. Search Before Responding
|
|
12
|
+
|
|
13
|
+
**When**: User submits any query or request
|
|
14
|
+
**Action**: Use the \`unifiedmemory\` MCP tool to search for relevant context
|
|
15
|
+
**Usage**:
|
|
16
|
+
- Send the user's query to the search tool
|
|
17
|
+
- Review the search results for relevant prior work, decisions, or context
|
|
18
|
+
- Incorporate findings into your response
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
\`\`\`
|
|
22
|
+
User: "Add dark mode to the dashboard"
|
|
23
|
+
→ Search: unifiedmemory search --query "dark mode dashboard styling"
|
|
24
|
+
→ Review results for existing patterns or decisions
|
|
25
|
+
→ Respond with context-aware answer
|
|
26
|
+
\`\`\`
|
|
27
|
+
|
|
28
|
+
## 2. Record Plans
|
|
29
|
+
|
|
30
|
+
**When**: Creating an implementation plan or design approach
|
|
31
|
+
**Action**: Record the plan as a note using the \`unifiedmemory\` tool
|
|
32
|
+
**Format**:
|
|
33
|
+
- Include: objective, approach, key steps, important decisions
|
|
34
|
+
- Keep concise but informative
|
|
35
|
+
- Limit to under 1000 characters for optimal indexing
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
\`\`\`
|
|
39
|
+
Plan created for dark mode feature
|
|
40
|
+
→ unifiedmemory create_note --content "Plan: Dark mode implementation. Approach: CSS variables + localStorage persistence. Steps: 1) Define color tokens, 2) Add toggle component, 3) Persist preference. Decision: Use system preference as default."
|
|
41
|
+
\`\`\`
|
|
42
|
+
|
|
43
|
+
## 3. Record Task Completions
|
|
44
|
+
|
|
45
|
+
**When**: After completing any task, feature, or significant milestone
|
|
46
|
+
**Action**: Create a summary note using the \`unifiedmemory\` tool
|
|
47
|
+
**Format**:
|
|
48
|
+
- Summarize what was accomplished and why
|
|
49
|
+
- Include key technical decisions or gotchas
|
|
50
|
+
- **IMPORTANT**: Limit to under 1000 characters
|
|
51
|
+
- Focus on outcomes, not detailed code
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
\`\`\`
|
|
55
|
+
Completed dark mode feature
|
|
56
|
+
→ unifiedmemory create_note --content "Completed: Dark mode toggle in dashboard. Implemented CSS custom properties with theme switcher component. User preference stored in localStorage. Respects system prefers-color-scheme by default. All components updated to use theme tokens."
|
|
57
|
+
\`\`\`
|
|
58
|
+
|
|
59
|
+
## Summary Guidelines
|
|
60
|
+
|
|
61
|
+
- ✅ **DO**: Search context before responding
|
|
62
|
+
- ✅ **DO**: Record plans and completions automatically
|
|
63
|
+
- ✅ **DO**: Keep notes under 1000 characters
|
|
64
|
+
- ✅ **DO**: Focus on decisions, outcomes, and context
|
|
65
|
+
- ❌ **DON'T**: Record trivial tasks (typo fixes, minor edits)
|
|
66
|
+
- ❌ **DON'T**: Include full code in notes
|
|
67
|
+
- ❌ **DON'T**: Duplicate information already in git commits
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
*Auto-generated by UnifiedMemory CLI v1.0*
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organization Selection UI
|
|
3
|
+
*
|
|
4
|
+
* Shared UI for prompting users to select an organization context.
|
|
5
|
+
* Extracted from duplicate implementations in login.js and org.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import inquirer from 'inquirer';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { formatOrganization } from './clerk-api.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Prompt user to select an organization context
|
|
14
|
+
* @param {Array} memberships - Array of organization memberships from Clerk
|
|
15
|
+
* @param {Object|null} currentOrg - Currently selected organization (optional)
|
|
16
|
+
* @param {Object} options - Display options
|
|
17
|
+
* @param {boolean} options.allowPersonal - Whether to show "Personal Account" option (default: true)
|
|
18
|
+
* @param {string} options.message - Custom prompt message (default: "Select account context:")
|
|
19
|
+
* @returns {Promise<Object|null>} Selected organization data or null for personal context
|
|
20
|
+
*/
|
|
21
|
+
export async function promptOrganizationSelection(memberships, currentOrg = null, options = {}) {
|
|
22
|
+
const {
|
|
23
|
+
allowPersonal = true,
|
|
24
|
+
message = 'Select account context (use arrow keys):',
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
if (memberships.length === 0) {
|
|
28
|
+
if (!allowPersonal) {
|
|
29
|
+
throw new Error('No organizations available');
|
|
30
|
+
}
|
|
31
|
+
console.log(chalk.gray('No organizations found. Using personal account context.'));
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(chalk.green(`\nFound ${memberships.length} organization(s)!`));
|
|
36
|
+
console.log(chalk.cyan('💡 Use ↑/↓ arrow keys to navigate, Enter to select'));
|
|
37
|
+
|
|
38
|
+
// Format organizations for display
|
|
39
|
+
const formattedOrgs = memberships.map(formatOrganization);
|
|
40
|
+
|
|
41
|
+
const currentOrgId = currentOrg?.id;
|
|
42
|
+
|
|
43
|
+
// Build choices for inquirer
|
|
44
|
+
const choices = [];
|
|
45
|
+
|
|
46
|
+
if (allowPersonal) {
|
|
47
|
+
choices.push({
|
|
48
|
+
name: chalk.cyan('Personal Account') +
|
|
49
|
+
chalk.gray(' (no organization)') +
|
|
50
|
+
(currentOrgId ? '' : chalk.green(' ← current')),
|
|
51
|
+
value: null,
|
|
52
|
+
short: 'Personal Account',
|
|
53
|
+
});
|
|
54
|
+
choices.push(new inquirer.Separator(chalk.gray('--- Organizations ---')));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
choices.push(...formattedOrgs.map(org => ({
|
|
58
|
+
name: `${chalk.green(org.name)} ${chalk.gray(`(${org.slug})`)} ${chalk.yellow(`[${org.role}]`)}${org.id === currentOrgId ? chalk.green(' ← current') : ''}`,
|
|
59
|
+
value: org,
|
|
60
|
+
short: org.name,
|
|
61
|
+
})));
|
|
62
|
+
|
|
63
|
+
// Calculate default selection
|
|
64
|
+
let defaultIndex = 0;
|
|
65
|
+
if (currentOrgId && allowPersonal) {
|
|
66
|
+
const orgIndex = formattedOrgs.findIndex(org => org.id === currentOrgId);
|
|
67
|
+
if (orgIndex >= 0) {
|
|
68
|
+
defaultIndex = orgIndex + 2; // +2 for personal + separator
|
|
69
|
+
}
|
|
70
|
+
} else if (currentOrgId && !allowPersonal) {
|
|
71
|
+
const orgIndex = formattedOrgs.findIndex(org => org.id === currentOrgId);
|
|
72
|
+
if (orgIndex >= 0) {
|
|
73
|
+
defaultIndex = orgIndex;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Prompt user to select
|
|
78
|
+
const answer = await inquirer.prompt([
|
|
79
|
+
{
|
|
80
|
+
type: 'select',
|
|
81
|
+
name: 'organization',
|
|
82
|
+
message: message,
|
|
83
|
+
choices: choices,
|
|
84
|
+
pageSize: 15,
|
|
85
|
+
default: defaultIndex,
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
return answer.organization;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Display organization selection result
|
|
94
|
+
* @param {Object|null} selectedOrg - Selected organization or null for personal account
|
|
95
|
+
*/
|
|
96
|
+
export function displayOrganizationSelection(selectedOrg) {
|
|
97
|
+
if (selectedOrg) {
|
|
98
|
+
console.log(chalk.green(`\n✅ Selected organization: ${chalk.bold(selectedOrg.name)}`));
|
|
99
|
+
console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
|
|
100
|
+
console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
|
|
101
|
+
} else {
|
|
102
|
+
console.log(chalk.green('\n✅ Using personal account context'));
|
|
103
|
+
}
|
|
104
|
+
}
|
package/lib/provider-detector.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import os from 'os';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
+
import TOML from '@iarna/toml';
|
|
5
|
+
import { getMemoryInstructions, MEMORY_INSTRUCTIONS_MARKER } from './memory-instructions.js';
|
|
4
6
|
|
|
5
7
|
class ProviderDetector {
|
|
6
8
|
constructor(projectDir = null) {
|
|
@@ -63,7 +65,7 @@ class BaseProvider {
|
|
|
63
65
|
const config = this.readConfig() || { mcpServers: {} };
|
|
64
66
|
|
|
65
67
|
config.mcpServers = config.mcpServers || {};
|
|
66
|
-
config.mcpServers.
|
|
68
|
+
config.mcpServers.unifiedmemory = {
|
|
67
69
|
command: "um",
|
|
68
70
|
args: ["mcp", "serve"]
|
|
69
71
|
};
|
|
@@ -71,9 +73,37 @@ class BaseProvider {
|
|
|
71
73
|
return this.writeConfig(config);
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Write memory instructions to specified file
|
|
78
|
+
* @param {string} filePath - Path to instruction file
|
|
79
|
+
* @param {string} assistantName - Display name for logging
|
|
80
|
+
* @returns {object} - Status object with status and optional error
|
|
81
|
+
*/
|
|
82
|
+
writeMemoryInstructions(filePath, assistantName = 'assistant') {
|
|
83
|
+
try {
|
|
84
|
+
const instructions = getMemoryInstructions();
|
|
85
|
+
|
|
86
|
+
// Check if file exists
|
|
87
|
+
if (fs.existsSync(filePath)) {
|
|
88
|
+
// Check if marker already present
|
|
89
|
+
const existing = fs.readFileSync(filePath, 'utf8');
|
|
90
|
+
if (existing.includes(MEMORY_INSTRUCTIONS_MARKER)) {
|
|
91
|
+
// Instructions already present, skip
|
|
92
|
+
return { status: 'skipped', reason: 'already_present' };
|
|
93
|
+
}
|
|
94
|
+
// Append to existing file
|
|
95
|
+
fs.appendFileSync(filePath, '\n\n' + instructions);
|
|
96
|
+
return { status: 'appended' };
|
|
97
|
+
} else {
|
|
98
|
+
// Create new file
|
|
99
|
+
fs.ensureFileSync(filePath);
|
|
100
|
+
fs.writeFileSync(filePath, instructions);
|
|
101
|
+
return { status: 'created' };
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(`Failed to write memory instructions: ${e.message}`);
|
|
105
|
+
return { status: 'error', error: e.message };
|
|
106
|
+
}
|
|
77
107
|
}
|
|
78
108
|
}
|
|
79
109
|
|
|
@@ -131,12 +161,12 @@ class ClaudeProvider extends BaseProvider {
|
|
|
131
161
|
// Add or update the required settings
|
|
132
162
|
settings.enableAllProjectMcpServers = true;
|
|
133
163
|
|
|
134
|
-
// Ensure
|
|
164
|
+
// Ensure unifiedmemory is in the enabled servers list
|
|
135
165
|
if (!settings.enabledMcpjsonServers) {
|
|
136
166
|
settings.enabledMcpjsonServers = [];
|
|
137
167
|
}
|
|
138
|
-
if (!settings.enabledMcpjsonServers.includes('
|
|
139
|
-
settings.enabledMcpjsonServers.push('
|
|
168
|
+
if (!settings.enabledMcpjsonServers.includes('unifiedmemory')) {
|
|
169
|
+
settings.enabledMcpjsonServers.push('unifiedmemory');
|
|
140
170
|
}
|
|
141
171
|
|
|
142
172
|
// Write settings
|
|
@@ -149,19 +179,10 @@ class ClaudeProvider extends BaseProvider {
|
|
|
149
179
|
}
|
|
150
180
|
}
|
|
151
181
|
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
fs.ensureDirSync(this.hooksDir);
|
|
159
|
-
fs.writeFileSync(hookPath, scriptContent, { mode: 0o755 });
|
|
160
|
-
return true;
|
|
161
|
-
} catch (e) {
|
|
162
|
-
console.error(`Failed to install hook: ${e.message}`);
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
182
|
+
configureMemoryInstructions() {
|
|
183
|
+
if (!this.projectDir) return { status: 'skipped', reason: 'no_project_dir' };
|
|
184
|
+
const instructionPath = path.join(this.projectDir, 'CLAUDE.md');
|
|
185
|
+
return this.writeMemoryInstructions(instructionPath, 'Claude Code');
|
|
165
186
|
}
|
|
166
187
|
}
|
|
167
188
|
|
|
@@ -172,6 +193,12 @@ class CursorProvider extends BaseProvider {
|
|
|
172
193
|
// Detect by directory existence, not config file
|
|
173
194
|
super('Cursor', configPath, false, baseDir);
|
|
174
195
|
}
|
|
196
|
+
|
|
197
|
+
configureMemoryInstructions() {
|
|
198
|
+
// Use project-level file (assuming we're in a project directory)
|
|
199
|
+
const instructionPath = path.join(process.cwd(), 'CURSOR.md');
|
|
200
|
+
return this.writeMemoryInstructions(instructionPath, 'Cursor');
|
|
201
|
+
}
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
class ClineProvider extends BaseProvider {
|
|
@@ -181,6 +208,12 @@ class ClineProvider extends BaseProvider {
|
|
|
181
208
|
// Detect by directory existence, not config file
|
|
182
209
|
super('Cline', configPath, false, baseDir);
|
|
183
210
|
}
|
|
211
|
+
|
|
212
|
+
configureMemoryInstructions() {
|
|
213
|
+
// Use project-level file
|
|
214
|
+
const instructionPath = path.join(process.cwd(), 'CLINE.md');
|
|
215
|
+
return this.writeMemoryInstructions(instructionPath, 'Cline');
|
|
216
|
+
}
|
|
184
217
|
}
|
|
185
218
|
|
|
186
219
|
class CodexProvider extends BaseProvider {
|
|
@@ -194,7 +227,7 @@ class CodexProvider extends BaseProvider {
|
|
|
194
227
|
if (!fs.existsSync(this.configPath)) return null;
|
|
195
228
|
try {
|
|
196
229
|
const content = fs.readFileSync(this.configPath, 'utf8');
|
|
197
|
-
return
|
|
230
|
+
return TOML.parse(content);
|
|
198
231
|
} catch (e) {
|
|
199
232
|
return null;
|
|
200
233
|
}
|
|
@@ -203,8 +236,8 @@ class CodexProvider extends BaseProvider {
|
|
|
203
236
|
writeConfig(config) {
|
|
204
237
|
try {
|
|
205
238
|
fs.ensureFileSync(this.configPath);
|
|
206
|
-
const tomlContent =
|
|
207
|
-
fs.writeFileSync(this.configPath, tomlContent);
|
|
239
|
+
const tomlContent = TOML.stringify(config);
|
|
240
|
+
fs.writeFileSync(this.configPath, tomlContent, 'utf8');
|
|
208
241
|
return true;
|
|
209
242
|
} catch (e) {
|
|
210
243
|
console.error(`Failed to write config: ${e.message}`);
|
|
@@ -212,65 +245,20 @@ class CodexProvider extends BaseProvider {
|
|
|
212
245
|
}
|
|
213
246
|
}
|
|
214
247
|
|
|
215
|
-
parseTOML(content) {
|
|
216
|
-
// Simple TOML parser for [mcp_servers.*] sections
|
|
217
|
-
const servers = {};
|
|
218
|
-
const lines = content.split('\n');
|
|
219
|
-
let currentServer = null;
|
|
220
|
-
|
|
221
|
-
for (const line of lines) {
|
|
222
|
-
const trimmed = line.trim();
|
|
223
|
-
if (trimmed.startsWith('[mcp_servers.')) {
|
|
224
|
-
const match = trimmed.match(/\[mcp_servers\.([^\]]+)\]/);
|
|
225
|
-
if (match) {
|
|
226
|
-
currentServer = match[1];
|
|
227
|
-
servers[currentServer] = {};
|
|
228
|
-
}
|
|
229
|
-
} else if (currentServer && trimmed.includes('=')) {
|
|
230
|
-
const [key, ...valueParts] = trimmed.split('=');
|
|
231
|
-
const value = valueParts.join('=').trim();
|
|
232
|
-
if (key.trim() === 'command') {
|
|
233
|
-
servers[currentServer].command = value.replace(/"/g, '');
|
|
234
|
-
} else if (key.trim() === 'args') {
|
|
235
|
-
// Parse array: ["arg1", "arg2"]
|
|
236
|
-
const argsMatch = value.match(/\[(.*)\]/);
|
|
237
|
-
if (argsMatch) {
|
|
238
|
-
servers[currentServer].args = argsMatch[1]
|
|
239
|
-
.split(',')
|
|
240
|
-
.map(s => s.trim().replace(/"/g, ''));
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return { mcp_servers: servers };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
generateTOML(config) {
|
|
250
|
-
let toml = '';
|
|
251
|
-
const servers = config.mcp_servers || {};
|
|
252
|
-
|
|
253
|
-
for (const [name, serverConfig] of Object.entries(servers)) {
|
|
254
|
-
toml += `[mcp_servers.${name}]\n`;
|
|
255
|
-
toml += `command = "${serverConfig.command}"\n`;
|
|
256
|
-
if (serverConfig.args && serverConfig.args.length > 0) {
|
|
257
|
-
const argsStr = serverConfig.args.map(arg => `"${arg}"`).join(', ');
|
|
258
|
-
toml += `args = [${argsStr}]\n`;
|
|
259
|
-
}
|
|
260
|
-
toml += '\n';
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return toml;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
248
|
configureMCP() {
|
|
267
249
|
const config = this.readConfig() || { mcp_servers: {} };
|
|
268
|
-
config.mcp_servers.
|
|
250
|
+
config.mcp_servers.unifiedmemory = {
|
|
269
251
|
command: "um",
|
|
270
252
|
args: ["mcp", "serve"]
|
|
271
253
|
};
|
|
272
254
|
return this.writeConfig(config);
|
|
273
255
|
}
|
|
256
|
+
|
|
257
|
+
configureMemoryInstructions() {
|
|
258
|
+
// Use project-level file
|
|
259
|
+
const instructionPath = path.join(process.cwd(), 'CODEX.md');
|
|
260
|
+
return this.writeMemoryInstructions(instructionPath, 'Codex CLI');
|
|
261
|
+
}
|
|
274
262
|
}
|
|
275
263
|
|
|
276
264
|
class GeminiProvider extends BaseProvider {
|
|
@@ -279,6 +267,12 @@ class GeminiProvider extends BaseProvider {
|
|
|
279
267
|
const detectionPath = path.dirname(configPath); // Detect by directory
|
|
280
268
|
super('Gemini CLI', configPath, false, detectionPath);
|
|
281
269
|
}
|
|
270
|
+
|
|
271
|
+
configureMemoryInstructions() {
|
|
272
|
+
// Use project-level file
|
|
273
|
+
const instructionPath = path.join(process.cwd(), 'GEMINI.md');
|
|
274
|
+
return this.writeMemoryInstructions(instructionPath, 'Gemini CLI');
|
|
275
|
+
}
|
|
282
276
|
}
|
|
283
277
|
|
|
284
278
|
export {
|
package/lib/token-refresh.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getToken, saveToken } from './token-storage.js';
|
|
2
2
|
import { config } from './config.js';
|
|
3
|
+
import { parseJWT } from './jwt-utils.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Check if token has expired
|
|
@@ -93,21 +94,3 @@ export async function refreshAccessToken(tokenData) {
|
|
|
93
94
|
throw new Error(`Token refresh failed: ${error.message}`);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Parse JWT token payload
|
|
99
|
-
* @param {string} token - JWT token
|
|
100
|
-
* @returns {Object|null} - Decoded payload
|
|
101
|
-
*/
|
|
102
|
-
function parseJWT(token) {
|
|
103
|
-
try {
|
|
104
|
-
const parts = token.split('.');
|
|
105
|
-
if (parts.length !== 3) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
const payload = Buffer.from(parts[1], 'base64').toString('utf8');
|
|
109
|
-
return JSON.parse(payload);
|
|
110
|
-
} catch (error) {
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
}
|
package/lib/token-storage.js
CHANGED
|
@@ -6,18 +6,24 @@ const TOKEN_DIR = path.join(os.homedir(), '.um');
|
|
|
6
6
|
const TOKEN_FILE = path.join(TOKEN_DIR, 'auth.json');
|
|
7
7
|
|
|
8
8
|
export function saveToken(tokenData) {
|
|
9
|
-
// Create directory if it doesn't exist
|
|
9
|
+
// Create directory if it doesn't exist (owner-only permissions)
|
|
10
10
|
if (!fs.existsSync(TOKEN_DIR)) {
|
|
11
|
-
fs.mkdirSync(TOKEN_DIR, { recursive: true });
|
|
11
|
+
fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Ensure directory permissions are correct (in case it was created with wrong permissions)
|
|
15
|
+
fs.chmodSync(TOKEN_DIR, 0o700);
|
|
16
|
+
|
|
14
17
|
// Preserve existing organization context if not explicitly overwritten
|
|
15
18
|
const existingData = getToken();
|
|
16
19
|
if (existingData && existingData.selectedOrg && !tokenData.selectedOrg) {
|
|
17
20
|
tokenData.selectedOrg = existingData.selectedOrg;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
// Write token file with owner-only read/write permissions (0600)
|
|
20
24
|
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
|
|
25
|
+
// Explicitly set file permissions (writeFileSync mode option doesn't always work with extended attributes)
|
|
26
|
+
fs.chmodSync(TOKEN_FILE, 0o600);
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
export function getToken() {
|
|
@@ -49,8 +55,15 @@ export function updateSelectedOrg(orgData) {
|
|
|
49
55
|
throw new Error('No token found. Please login first.');
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// Ensure directory permissions are correct
|
|
59
|
+
if (fs.existsSync(TOKEN_DIR)) {
|
|
60
|
+
fs.chmodSync(TOKEN_DIR, 0o700);
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
tokenData.selectedOrg = orgData;
|
|
64
|
+
// Write with owner-only read/write permissions (0600)
|
|
53
65
|
fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
|
|
66
|
+
fs.chmodSync(TOKEN_FILE, 0o600);
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/**
|
package/lib/welcome.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
8
|
+
const version = packageJson.version;
|
|
9
|
+
|
|
10
|
+
export function showWelcome() {
|
|
11
|
+
const pink = chalk.hex('#FF69B4');
|
|
12
|
+
const lightPink = chalk.hex('#FFB6C1');
|
|
13
|
+
const darkPink = chalk.hex('#C71585');
|
|
14
|
+
const gray = chalk.gray;
|
|
15
|
+
const white = chalk.white;
|
|
16
|
+
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log(white.bold(' UnifiedMemory CLI ') + gray(`v${version}`));
|
|
19
|
+
console.log(gray(' AI-powered knowledge assistant'));
|
|
20
|
+
console.log('');
|
|
21
|
+
console.log(white(' Quick Start:'));
|
|
22
|
+
console.log(gray(' um init ') + white('Initialize in current project'));
|
|
23
|
+
console.log(gray(' um status ') + white('Check configuration'));
|
|
24
|
+
console.log(gray(' um login ') + white('Authenticate with UnifiedMemory'));
|
|
25
|
+
console.log(gray(' um --help ') + white('Show all commands'));
|
|
26
|
+
console.log('');
|
|
27
|
+
console.log(gray(' Learn more: ') + chalk.cyan.underline('https://unifiedmemory.ai'));
|
|
28
|
+
console.log('');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function showShortWelcome() {
|
|
32
|
+
const pink = chalk.hex('#FF69B4');
|
|
33
|
+
const gray = chalk.gray;
|
|
34
|
+
const white = chalk.white;
|
|
35
|
+
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log(pink(' 🦎 ') + white.bold('UnifiedMemory CLI ') + gray(`v${version}`));
|
|
38
|
+
console.log(gray(' AI-powered knowledge assistant'));
|
|
39
|
+
console.log('');
|
|
40
|
+
}
|