@heysalad/cheri-cli 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/README.md +60 -0
- package/bin/cheri.js +28 -0
- package/package.json +46 -0
- package/src/commands/config.js +71 -0
- package/src/commands/init.js +168 -0
- package/src/commands/login.js +57 -0
- package/src/commands/memory.js +127 -0
- package/src/commands/status.js +56 -0
- package/src/commands/workspace.js +126 -0
- package/src/lib/api-client.js +86 -0
- package/src/lib/config-store.js +78 -0
- package/src/lib/logger.js +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Cheri CLI
|
|
2
|
+
|
|
3
|
+
AI-powered cloud IDE by [HeySalad](https://heysalad.app). Like Claude Code, but for cloud workspaces.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @heysalad/cheri-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Login to your Cheri account
|
|
15
|
+
cheri login
|
|
16
|
+
|
|
17
|
+
# Check account status
|
|
18
|
+
cheri status
|
|
19
|
+
|
|
20
|
+
# Launch a cloud workspace
|
|
21
|
+
cheri workspace launch owner/repo
|
|
22
|
+
|
|
23
|
+
# List your workspaces
|
|
24
|
+
cheri workspace list
|
|
25
|
+
|
|
26
|
+
# Stop a workspace
|
|
27
|
+
cheri workspace stop
|
|
28
|
+
|
|
29
|
+
# Initialize AI project config
|
|
30
|
+
cheri init
|
|
31
|
+
|
|
32
|
+
# Manage persistent memory
|
|
33
|
+
cheri memory show
|
|
34
|
+
cheri memory add "Always use TypeScript strict mode"
|
|
35
|
+
cheri memory clear
|
|
36
|
+
|
|
37
|
+
# View/update configuration
|
|
38
|
+
cheri config list
|
|
39
|
+
cheri config set apiUrl https://cheri.heysalad.app
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
1. **`cheri login`** opens your browser for GitHub OAuth, then you paste your API token
|
|
45
|
+
2. **`cheri workspace launch`** spins up a cloud workspace with code-server (VS Code in browser)
|
|
46
|
+
3. **`cheri memory`** stores persistent context that follows you across sessions
|
|
47
|
+
4. **`cheri init`** creates a local `.ai/` directory with project constitution files
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- Node.js >= 18
|
|
52
|
+
|
|
53
|
+
## Links
|
|
54
|
+
|
|
55
|
+
- [Cheri Cloud IDE](https://cheri.heysalad.app)
|
|
56
|
+
- [GitHub](https://github.com/Hey-Salad/cheri-cli)
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT - HeySalad
|
package/bin/cheri.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import { registerInitCommand } from "../src/commands/init.js";
|
|
5
|
+
import { registerLoginCommand } from "../src/commands/login.js";
|
|
6
|
+
import { registerStatusCommand } from "../src/commands/status.js";
|
|
7
|
+
import { registerMemoryCommand } from "../src/commands/memory.js";
|
|
8
|
+
import { registerConfigCommand } from "../src/commands/config.js";
|
|
9
|
+
import { registerWorkspaceCommand } from "../src/commands/workspace.js";
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name("cheri")
|
|
13
|
+
.description("Cheri CLI - AI-powered cloud IDE by HeySalad")
|
|
14
|
+
.version("0.1.0");
|
|
15
|
+
|
|
16
|
+
registerLoginCommand(program);
|
|
17
|
+
registerInitCommand(program);
|
|
18
|
+
registerStatusCommand(program);
|
|
19
|
+
registerMemoryCommand(program);
|
|
20
|
+
registerConfigCommand(program);
|
|
21
|
+
registerWorkspaceCommand(program);
|
|
22
|
+
|
|
23
|
+
program.parse(process.argv);
|
|
24
|
+
|
|
25
|
+
// Show help if no command provided
|
|
26
|
+
if (!process.argv.slice(2).length) {
|
|
27
|
+
program.outputHelp();
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@heysalad/cheri-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cheri CLI - AI-powered cloud IDE by HeySalad. Like Claude Code, but for cloud workspaces.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cheri": "./bin/cheri.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node bin/cheri.js",
|
|
16
|
+
"dev": "node bin/cheri.js",
|
|
17
|
+
"release:patch": "npm version patch && npm publish --access public && git push && git push --tags",
|
|
18
|
+
"release:minor": "npm version minor && npm publish --access public && git push && git push --tags",
|
|
19
|
+
"release:major": "npm version major && npm publish --access public && git push && git push --tags"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cloud-ide",
|
|
23
|
+
"ai",
|
|
24
|
+
"code-server",
|
|
25
|
+
"workspace",
|
|
26
|
+
"cli",
|
|
27
|
+
"cheri",
|
|
28
|
+
"heysalad"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/Hey-Salad/cheri-cli.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://cheri.heysalad.app",
|
|
35
|
+
"author": "HeySalad",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"chalk": "^5.3.0",
|
|
42
|
+
"commander": "^12.1.0",
|
|
43
|
+
"inquirer": "^9.2.23",
|
|
44
|
+
"ora": "^8.0.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getConfig, getConfigValue, setConfigValue } from "../lib/config-store.js";
|
|
3
|
+
import { log } from "../lib/logger.js";
|
|
4
|
+
|
|
5
|
+
export function registerConfigCommand(program) {
|
|
6
|
+
const config = program
|
|
7
|
+
.command("config")
|
|
8
|
+
.description("Manage CLI configuration");
|
|
9
|
+
|
|
10
|
+
config
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("Show all configuration values")
|
|
13
|
+
.action(() => {
|
|
14
|
+
const cfg = getConfig();
|
|
15
|
+
|
|
16
|
+
log.blank();
|
|
17
|
+
log.brand("Configuration");
|
|
18
|
+
log.blank();
|
|
19
|
+
|
|
20
|
+
printObject(cfg, "");
|
|
21
|
+
log.blank();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
config
|
|
25
|
+
.command("get <key>")
|
|
26
|
+
.description("Get a configuration value")
|
|
27
|
+
.action((key) => {
|
|
28
|
+
const value = getConfigValue(key);
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
log.error(`Key '${key}' not found.`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === "object") {
|
|
34
|
+
console.log(JSON.stringify(value, null, 2));
|
|
35
|
+
} else {
|
|
36
|
+
console.log(value);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
config
|
|
41
|
+
.command("set <key> <value>")
|
|
42
|
+
.description("Set a configuration value")
|
|
43
|
+
.action((key, value) => {
|
|
44
|
+
// Try to parse as JSON (for arrays, numbers, booleans)
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(value);
|
|
48
|
+
} catch {
|
|
49
|
+
parsed = value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setConfigValue(key, parsed);
|
|
53
|
+
log.success(`Set ${chalk.cyan(key)} = ${chalk.white(value)}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printObject(obj, prefix) {
|
|
58
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
59
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
60
|
+
if (key === "token" && value) {
|
|
61
|
+
console.log(` ${chalk.dim(fullKey.padEnd(28))} ${chalk.dim("****" + String(value).slice(-8))}`);
|
|
62
|
+
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
63
|
+
console.log(` ${chalk.dim(fullKey)}`);
|
|
64
|
+
printObject(value, fullKey);
|
|
65
|
+
} else if (Array.isArray(value)) {
|
|
66
|
+
console.log(` ${chalk.dim(fullKey.padEnd(28))} ${chalk.white(value.join(", "))}`);
|
|
67
|
+
} else {
|
|
68
|
+
console.log(` ${chalk.dim(fullKey.padEnd(28))} ${chalk.white(value)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import ora from "ora";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { setConfigValue } from "../lib/config-store.js";
|
|
7
|
+
import { log } from "../lib/logger.js";
|
|
8
|
+
|
|
9
|
+
const AI_DIR = ".ai";
|
|
10
|
+
|
|
11
|
+
const TEMPLATES = {
|
|
12
|
+
"style.md": `# Code Style Guide
|
|
13
|
+
|
|
14
|
+
## Naming Conventions
|
|
15
|
+
- camelCase for variables and functions
|
|
16
|
+
- PascalCase for components and classes
|
|
17
|
+
- UPPER_SNAKE_CASE for constants
|
|
18
|
+
|
|
19
|
+
## TypeScript
|
|
20
|
+
- Use strict mode
|
|
21
|
+
- Prefer named exports over default exports
|
|
22
|
+
- Use Zod for all validation schemas
|
|
23
|
+
- Prefer interfaces over type aliases for object shapes
|
|
24
|
+
|
|
25
|
+
## Formatting
|
|
26
|
+
- 2 space indentation
|
|
27
|
+
- Single quotes for strings
|
|
28
|
+
- Trailing commas in multi-line expressions
|
|
29
|
+
- Max line length: 100 characters
|
|
30
|
+
`,
|
|
31
|
+
|
|
32
|
+
"architecture.md": `# Architecture
|
|
33
|
+
|
|
34
|
+
## API Layer
|
|
35
|
+
- Use Hono for all API routes
|
|
36
|
+
- File-based routing organized by feature
|
|
37
|
+
- Middleware-first pattern for auth, validation, logging
|
|
38
|
+
|
|
39
|
+
## Data Layer
|
|
40
|
+
- Repository pattern for all data access
|
|
41
|
+
- Raw SQL queries (no ORMs)
|
|
42
|
+
- Migrations tracked in /migrations
|
|
43
|
+
|
|
44
|
+
## Frontend
|
|
45
|
+
- React with functional components
|
|
46
|
+
- Server components by default
|
|
47
|
+
- Client components only when interactivity needed
|
|
48
|
+
- Colocate styles with components
|
|
49
|
+
|
|
50
|
+
## Deployment
|
|
51
|
+
- Edge-first deployment strategy
|
|
52
|
+
- Environment-based configuration
|
|
53
|
+
- Feature flags for staged rollouts
|
|
54
|
+
`,
|
|
55
|
+
|
|
56
|
+
"constraints.md": `# Constraints
|
|
57
|
+
|
|
58
|
+
## Security
|
|
59
|
+
- No secrets in environment variables at build time
|
|
60
|
+
- All endpoints must have rate limiting
|
|
61
|
+
- Input validation on every API endpoint
|
|
62
|
+
- Parameterized queries only (no string interpolation in SQL)
|
|
63
|
+
|
|
64
|
+
## Performance
|
|
65
|
+
- Bundle size < 100KB (client-side)
|
|
66
|
+
- API response time < 200ms (p95)
|
|
67
|
+
- No N+1 queries
|
|
68
|
+
|
|
69
|
+
## Dependencies
|
|
70
|
+
- Minimize third-party dependencies
|
|
71
|
+
- No client-side state management libraries
|
|
72
|
+
- No CSS-in-JS runtime libraries
|
|
73
|
+
- Audit all new dependencies before adding
|
|
74
|
+
|
|
75
|
+
## Compatibility
|
|
76
|
+
- Support latest 2 major browser versions
|
|
77
|
+
- Node.js >= 20
|
|
78
|
+
- Mobile-first responsive design
|
|
79
|
+
`,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function registerInitCommand(program) {
|
|
83
|
+
program
|
|
84
|
+
.command("init")
|
|
85
|
+
.description("Initialize a new Cheri project with AI constitution")
|
|
86
|
+
.option("-n, --name <name>", "Project name")
|
|
87
|
+
.option("-y, --yes", "Skip prompts and use defaults")
|
|
88
|
+
.action(async (options) => {
|
|
89
|
+
log.blank();
|
|
90
|
+
log.brand("Initializing project...");
|
|
91
|
+
log.blank();
|
|
92
|
+
|
|
93
|
+
const aiDir = join(process.cwd(), AI_DIR);
|
|
94
|
+
|
|
95
|
+
if (existsSync(aiDir) && !options.yes) {
|
|
96
|
+
const { overwrite } = await inquirer.prompt([
|
|
97
|
+
{
|
|
98
|
+
type: "confirm",
|
|
99
|
+
name: "overwrite",
|
|
100
|
+
message: "A .ai/ directory already exists. Add missing files?",
|
|
101
|
+
default: true,
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
if (!overwrite) {
|
|
105
|
+
log.info("Initialization cancelled.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let projectName = options.name;
|
|
111
|
+
if (!projectName && !options.yes) {
|
|
112
|
+
const answers = await inquirer.prompt([
|
|
113
|
+
{
|
|
114
|
+
type: "input",
|
|
115
|
+
name: "name",
|
|
116
|
+
message: "Project name:",
|
|
117
|
+
default: process.cwd().split("/").pop(),
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
projectName = answers.name;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
projectName = projectName || process.cwd().split("/").pop();
|
|
124
|
+
|
|
125
|
+
const spinner = ora("Creating project constitution...").start();
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(aiDir)) {
|
|
129
|
+
mkdirSync(aiDir, { recursive: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const created = [];
|
|
133
|
+
for (const [filename, content] of Object.entries(TEMPLATES)) {
|
|
134
|
+
const filePath = join(aiDir, filename);
|
|
135
|
+
if (!existsSync(filePath)) {
|
|
136
|
+
writeFileSync(filePath, content);
|
|
137
|
+
created.push(filename);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
spinner.succeed("Project constitution created");
|
|
142
|
+
log.blank();
|
|
143
|
+
|
|
144
|
+
if (created.length > 0) {
|
|
145
|
+
created.forEach((file) => {
|
|
146
|
+
console.log(` ${chalk.green("+")} .ai/${file}`);
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
log.info("All constitution files already exist.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Save project config
|
|
153
|
+
setConfigValue("project.name", projectName);
|
|
154
|
+
setConfigValue("project.initializedAt", new Date().toISOString());
|
|
155
|
+
|
|
156
|
+
log.blank();
|
|
157
|
+
log.success(`Project '${chalk.bold(projectName)}' initialized.`);
|
|
158
|
+
log.dim(
|
|
159
|
+
`Run ${chalk.cyan("cheri workspace launch owner/repo")} to start coding in the cloud.`
|
|
160
|
+
);
|
|
161
|
+
log.blank();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
spinner.fail("Failed to initialize project");
|
|
164
|
+
log.error(err.message);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { setConfigValue, getConfigValue } from "../lib/config-store.js";
|
|
4
|
+
import { apiClient } from "../lib/api-client.js";
|
|
5
|
+
import { log } from "../lib/logger.js";
|
|
6
|
+
|
|
7
|
+
export function registerLoginCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command("login")
|
|
10
|
+
.description("Authenticate with Cheri cloud IDE")
|
|
11
|
+
.action(async () => {
|
|
12
|
+
const apiUrl = getConfigValue("apiUrl") || "https://cheri.heysalad.app";
|
|
13
|
+
|
|
14
|
+
log.blank();
|
|
15
|
+
log.brand("Login to Cheri");
|
|
16
|
+
log.blank();
|
|
17
|
+
|
|
18
|
+
log.info("Step 1: Open this URL in your browser to authenticate:");
|
|
19
|
+
log.blank();
|
|
20
|
+
console.log(` ${chalk.cyan.underline(`${apiUrl}/auth/github`)}`);
|
|
21
|
+
log.blank();
|
|
22
|
+
log.info("Step 2: After login, visit the token page:");
|
|
23
|
+
console.log(` ${chalk.cyan.underline(`${apiUrl}/auth/token?user=YOUR_USERNAME`)}`);
|
|
24
|
+
log.blank();
|
|
25
|
+
log.info("Step 3: Copy your API token and paste it below.");
|
|
26
|
+
log.blank();
|
|
27
|
+
|
|
28
|
+
const { token } = await inquirer.prompt([
|
|
29
|
+
{
|
|
30
|
+
type: "password",
|
|
31
|
+
name: "token",
|
|
32
|
+
message: "Paste your API token:",
|
|
33
|
+
mask: "*",
|
|
34
|
+
},
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
if (!token || !token.trim()) {
|
|
38
|
+
log.error("No token provided.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setConfigValue("token", token.trim());
|
|
43
|
+
|
|
44
|
+
// Verify token works
|
|
45
|
+
try {
|
|
46
|
+
const me = await apiClient.getMe();
|
|
47
|
+
log.blank();
|
|
48
|
+
log.success(`Logged in as ${chalk.cyan(me.ghLogin || me.userId)}`);
|
|
49
|
+
log.keyValue("Plan", me.plan === "pro" ? chalk.green("Pro") : "Free");
|
|
50
|
+
log.blank();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
log.error(`Token verification failed: ${err.message}`);
|
|
53
|
+
setConfigValue("token", "");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { writeFileSync } from "fs";
|
|
4
|
+
import { apiClient } from "../lib/api-client.js";
|
|
5
|
+
import { log } from "../lib/logger.js";
|
|
6
|
+
|
|
7
|
+
export function registerMemoryCommand(program) {
|
|
8
|
+
const memory = program
|
|
9
|
+
.command("memory")
|
|
10
|
+
.description("Manage AI context memory");
|
|
11
|
+
|
|
12
|
+
memory
|
|
13
|
+
.command("show")
|
|
14
|
+
.description("Show current memory entries")
|
|
15
|
+
.option("-l, --limit <n>", "Number of entries to show", "10")
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
const spinner = ora("Fetching memories...").start();
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const { memories } = await apiClient.getMemory();
|
|
21
|
+
spinner.stop();
|
|
22
|
+
|
|
23
|
+
log.blank();
|
|
24
|
+
log.brand("AI Memory");
|
|
25
|
+
log.blank();
|
|
26
|
+
log.keyValue("Entries", `${memories.length}`);
|
|
27
|
+
|
|
28
|
+
if (memories.length === 0) {
|
|
29
|
+
log.blank();
|
|
30
|
+
log.dim(" No memories yet. Use 'cheri memory add' to store context.");
|
|
31
|
+
log.blank();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const limit = parseInt(options.limit);
|
|
36
|
+
const recent = memories.slice(-limit);
|
|
37
|
+
|
|
38
|
+
log.keyValue(
|
|
39
|
+
"Last updated",
|
|
40
|
+
memories.length > 0
|
|
41
|
+
? timeSince(new Date(memories[memories.length - 1].timestamp))
|
|
42
|
+
: "never"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
log.blank();
|
|
46
|
+
log.dim(" Recent memories:");
|
|
47
|
+
|
|
48
|
+
recent.forEach((entry) => {
|
|
49
|
+
const category = chalk.dim(`(${entry.category})`);
|
|
50
|
+
const content =
|
|
51
|
+
entry.content.length > 60
|
|
52
|
+
? entry.content.slice(0, 60) + "..."
|
|
53
|
+
: entry.content;
|
|
54
|
+
console.log(` ${chalk.dim("-")} "${content}" ${category}`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
log.blank();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
spinner.fail("Failed to fetch memories");
|
|
60
|
+
log.error(err.message);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
memory
|
|
66
|
+
.command("add")
|
|
67
|
+
.description("Add a memory entry")
|
|
68
|
+
.argument("<content>", "Memory content")
|
|
69
|
+
.option("-c, --category <category>", "Category", "general")
|
|
70
|
+
.action(async (content, options) => {
|
|
71
|
+
try {
|
|
72
|
+
const { entry, count } = await apiClient.addMemory(content, options.category);
|
|
73
|
+
log.success(`Memory saved (${count} total). Category: ${chalk.cyan(entry.category)}`);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log.error(err.message);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
memory
|
|
81
|
+
.command("clear")
|
|
82
|
+
.description("Clear all memory")
|
|
83
|
+
.action(async () => {
|
|
84
|
+
try {
|
|
85
|
+
await apiClient.clearMemory();
|
|
86
|
+
log.success("All memories cleared.");
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.error(err.message);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
memory
|
|
94
|
+
.command("export")
|
|
95
|
+
.description("Export memory to JSON file")
|
|
96
|
+
.option("-o, --output <file>", "Output file path")
|
|
97
|
+
.action(async (options) => {
|
|
98
|
+
const spinner = ora("Exporting memories...").start();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const { memories } = await apiClient.getMemory();
|
|
102
|
+
|
|
103
|
+
const exportData = {
|
|
104
|
+
version: "0.1.0",
|
|
105
|
+
exportedAt: new Date().toISOString(),
|
|
106
|
+
entryCount: memories.length,
|
|
107
|
+
entries: memories,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const outputFile = options.output || `cheri-memory-export.json`;
|
|
111
|
+
writeFileSync(outputFile, JSON.stringify(exportData, null, 2));
|
|
112
|
+
spinner.succeed(`Exported ${memories.length} entries to ${chalk.cyan(outputFile)}`);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
spinner.fail("Failed to export memories");
|
|
115
|
+
log.error(err.message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function timeSince(date) {
|
|
122
|
+
const seconds = Math.floor((new Date() - date) / 1000);
|
|
123
|
+
if (seconds < 60) return "just now";
|
|
124
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
|
|
125
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
|
|
126
|
+
return `${Math.floor(seconds / 86400)} days ago`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { getConfigValue } from "../lib/config-store.js";
|
|
4
|
+
import { apiClient } from "../lib/api-client.js";
|
|
5
|
+
import { log } from "../lib/logger.js";
|
|
6
|
+
|
|
7
|
+
export function registerStatusCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command("status")
|
|
10
|
+
.description("Show account and workspace status")
|
|
11
|
+
.action(async () => {
|
|
12
|
+
log.blank();
|
|
13
|
+
log.brand("Status");
|
|
14
|
+
|
|
15
|
+
// Account info
|
|
16
|
+
log.header("Account");
|
|
17
|
+
const spinner = ora("Fetching account info...").start();
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const me = await apiClient.getMe();
|
|
21
|
+
spinner.stop();
|
|
22
|
+
|
|
23
|
+
log.keyValue("User", chalk.cyan(me.ghLogin || me.userId));
|
|
24
|
+
log.keyValue("Plan", me.plan === "pro" ? chalk.green("Pro") : "Free");
|
|
25
|
+
} catch (err) {
|
|
26
|
+
spinner.stop();
|
|
27
|
+
log.keyValue("User", chalk.yellow("Not logged in"));
|
|
28
|
+
log.dim(` Run ${chalk.cyan("cheri login")} to authenticate.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Workspaces
|
|
32
|
+
log.header("Workspaces");
|
|
33
|
+
try {
|
|
34
|
+
const { workspaces } = await apiClient.listWorkspaces();
|
|
35
|
+
if (workspaces.length === 0) {
|
|
36
|
+
log.keyValue("Count", "0");
|
|
37
|
+
log.dim(` Run ${chalk.cyan("cheri workspace launch owner/repo")} to create one.`);
|
|
38
|
+
} else {
|
|
39
|
+
log.keyValue("Count", `${workspaces.length}`);
|
|
40
|
+
workspaces.forEach((ws) => {
|
|
41
|
+
const statusColor = ws.status === "running" ? chalk.green : chalk.dim;
|
|
42
|
+
console.log(` ${statusColor("●")} ${ws.id} (${ws.repo || ws.id}) — ${statusColor(ws.status)}`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
log.dim(" Could not fetch workspaces.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Config info
|
|
50
|
+
log.header("Configuration");
|
|
51
|
+
log.keyValue("API URL", getConfigValue("apiUrl") || "https://cheri.heysalad.app");
|
|
52
|
+
log.keyValue("Config dir", chalk.dim("~/.cheri/"));
|
|
53
|
+
|
|
54
|
+
log.blank();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { apiClient } from "../lib/api-client.js";
|
|
4
|
+
import { log } from "../lib/logger.js";
|
|
5
|
+
|
|
6
|
+
export function registerWorkspaceCommand(program) {
|
|
7
|
+
const workspace = program
|
|
8
|
+
.command("workspace")
|
|
9
|
+
.description("Manage cloud workspaces");
|
|
10
|
+
|
|
11
|
+
workspace
|
|
12
|
+
.command("launch")
|
|
13
|
+
.description("Launch a new cloud workspace")
|
|
14
|
+
.argument("<repo>", "GitHub repository (owner/repo)")
|
|
15
|
+
.action(async (repo) => {
|
|
16
|
+
log.blank();
|
|
17
|
+
log.brand("Launching workspace");
|
|
18
|
+
log.blank();
|
|
19
|
+
|
|
20
|
+
const spinner = ora("Creating workspace...").start();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const { workspace: ws } = await apiClient.createWorkspace(repo);
|
|
24
|
+
spinner.succeed("Workspace is live!");
|
|
25
|
+
|
|
26
|
+
log.blank();
|
|
27
|
+
log.keyValue("ID", chalk.cyan(ws.id));
|
|
28
|
+
log.keyValue("Repo", chalk.cyan(ws.repo));
|
|
29
|
+
log.keyValue("Status", chalk.green(ws.status));
|
|
30
|
+
log.keyValue("URL", chalk.cyan.underline(ws.url));
|
|
31
|
+
log.blank();
|
|
32
|
+
log.dim("Open the URL above in your browser to start coding.");
|
|
33
|
+
log.blank();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
spinner.fail("Failed to launch workspace");
|
|
36
|
+
log.error(err.message);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
workspace
|
|
42
|
+
.command("stop")
|
|
43
|
+
.description("Stop a running workspace")
|
|
44
|
+
.argument("<id>", "Workspace ID")
|
|
45
|
+
.action(async (id) => {
|
|
46
|
+
const spinner = ora(`Stopping workspace: ${id}...`).start();
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await apiClient.deleteWorkspace(id);
|
|
50
|
+
spinner.succeed(`Workspace '${chalk.cyan(id)}' stopped.`);
|
|
51
|
+
log.dim(" Data preserved. Launch again to resume.");
|
|
52
|
+
} catch (err) {
|
|
53
|
+
spinner.fail("Failed to stop workspace");
|
|
54
|
+
log.error(err.message);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
workspace
|
|
60
|
+
.command("list")
|
|
61
|
+
.description("List all workspaces")
|
|
62
|
+
.action(async () => {
|
|
63
|
+
const spinner = ora("Fetching workspaces...").start();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const { workspaces } = await apiClient.listWorkspaces();
|
|
67
|
+
spinner.stop();
|
|
68
|
+
|
|
69
|
+
log.blank();
|
|
70
|
+
log.brand("Workspaces");
|
|
71
|
+
log.blank();
|
|
72
|
+
|
|
73
|
+
if (workspaces.length === 0) {
|
|
74
|
+
log.dim(" No workspaces found. Run 'cheri workspace launch owner/repo' to create one.");
|
|
75
|
+
log.blank();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Table header
|
|
80
|
+
console.log(
|
|
81
|
+
` ${chalk.dim("ID".padEnd(24))} ${chalk.dim("REPO".padEnd(24))} ${chalk.dim("STATUS".padEnd(10))} ${chalk.dim("URL")}`
|
|
82
|
+
);
|
|
83
|
+
console.log(chalk.dim(" " + "-".repeat(90)));
|
|
84
|
+
|
|
85
|
+
workspaces.forEach((ws) => {
|
|
86
|
+
const statusColor =
|
|
87
|
+
ws.status === "running" ? chalk.green : chalk.dim;
|
|
88
|
+
console.log(
|
|
89
|
+
` ${ws.id.padEnd(24)} ${(ws.repo || "").padEnd(24)} ${statusColor(ws.status.padEnd(10))} ${chalk.cyan(ws.url || "")}`
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
log.blank();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
spinner.fail("Failed to fetch workspaces");
|
|
96
|
+
log.error(err.message);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
workspace
|
|
102
|
+
.command("status")
|
|
103
|
+
.description("Get workspace status")
|
|
104
|
+
.argument("<id>", "Workspace ID")
|
|
105
|
+
.action(async (id) => {
|
|
106
|
+
const spinner = ora("Fetching workspace status...").start();
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const { workspace: ws } = await apiClient.getWorkspaceStatus(id);
|
|
110
|
+
spinner.stop();
|
|
111
|
+
|
|
112
|
+
log.blank();
|
|
113
|
+
log.brand(`Workspace: ${ws.id}`);
|
|
114
|
+
log.blank();
|
|
115
|
+
log.keyValue("Status", ws.status === "running" ? chalk.green(ws.status) : chalk.dim(ws.status));
|
|
116
|
+
log.keyValue("Repo", chalk.cyan(ws.repo || ""));
|
|
117
|
+
log.keyValue("URL", chalk.cyan.underline(ws.url || ""));
|
|
118
|
+
log.keyValue("Created", new Date(ws.createdAt).toLocaleString());
|
|
119
|
+
log.blank();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
spinner.fail("Failed to fetch workspace status");
|
|
122
|
+
log.error(err.message);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getConfigValue } from "./config-store.js";
|
|
2
|
+
|
|
3
|
+
function getBaseUrl() {
|
|
4
|
+
return getConfigValue("apiUrl") || "https://cheri.heysalad.app";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function getToken() {
|
|
8
|
+
return getConfigValue("token") || "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function request(path, options = {}) {
|
|
12
|
+
const baseUrl = getBaseUrl();
|
|
13
|
+
const token = getToken();
|
|
14
|
+
|
|
15
|
+
if (!token) {
|
|
16
|
+
throw new Error("Not logged in. Run 'cheri login' first.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const url = `${baseUrl}${path}`;
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
...options,
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${token}`,
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
...options.headers,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const body = await res.text();
|
|
31
|
+
let msg;
|
|
32
|
+
try {
|
|
33
|
+
msg = JSON.parse(body).error || body;
|
|
34
|
+
} catch {
|
|
35
|
+
msg = body;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`API error (${res.status}): ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return res.json();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const apiClient = {
|
|
44
|
+
// Auth
|
|
45
|
+
async getMe() {
|
|
46
|
+
return request("/api/me");
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// Workspaces
|
|
50
|
+
async listWorkspaces() {
|
|
51
|
+
return request("/api/workspaces");
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async createWorkspace(repo) {
|
|
55
|
+
return request("/api/workspaces", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
body: JSON.stringify({ repo }),
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async deleteWorkspace(id) {
|
|
62
|
+
return request(`/api/workspaces/${encodeURIComponent(id)}`, {
|
|
63
|
+
method: "DELETE",
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async getWorkspaceStatus(id) {
|
|
68
|
+
return request(`/api/workspaces/${encodeURIComponent(id)}/status`);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Memory
|
|
72
|
+
async getMemory() {
|
|
73
|
+
return request("/api/memory");
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async addMemory(content, category = "general") {
|
|
77
|
+
return request("/api/memory", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
body: JSON.stringify({ content, category }),
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async clearMemory() {
|
|
84
|
+
return request("/api/memory", { method: "DELETE" });
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".cheri");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
+
|
|
8
|
+
function ensureConfigDir() {
|
|
9
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
10
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getConfig() {
|
|
15
|
+
ensureConfigDir();
|
|
16
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
17
|
+
return getDefaultConfig();
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
21
|
+
} catch {
|
|
22
|
+
return getDefaultConfig();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setConfig(config) {
|
|
27
|
+
ensureConfigDir();
|
|
28
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getConfigValue(key) {
|
|
32
|
+
const config = getConfig();
|
|
33
|
+
return key.split(".").reduce((obj, k) => obj?.[k], config);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function setConfigValue(key, value) {
|
|
37
|
+
const config = getConfig();
|
|
38
|
+
const keys = key.split(".");
|
|
39
|
+
let current = config;
|
|
40
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
41
|
+
if (!current[keys[i]] || typeof current[keys[i]] !== "object") {
|
|
42
|
+
current[keys[i]] = {};
|
|
43
|
+
}
|
|
44
|
+
current = current[keys[i]];
|
|
45
|
+
}
|
|
46
|
+
current[keys[keys.length - 1]] = value;
|
|
47
|
+
setConfig(config);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getDefaultConfig() {
|
|
51
|
+
return {
|
|
52
|
+
apiUrl: "https://cheri.heysalad.app",
|
|
53
|
+
token: "",
|
|
54
|
+
workspace: {
|
|
55
|
+
defaultResources: {
|
|
56
|
+
cpu: 2,
|
|
57
|
+
ram: "4GB",
|
|
58
|
+
disk: "10GB",
|
|
59
|
+
},
|
|
60
|
+
idleTimeout: 600,
|
|
61
|
+
},
|
|
62
|
+
sync: {
|
|
63
|
+
ignore: [
|
|
64
|
+
"node_modules",
|
|
65
|
+
".git",
|
|
66
|
+
".next",
|
|
67
|
+
"dist",
|
|
68
|
+
".env",
|
|
69
|
+
".env.local",
|
|
70
|
+
],
|
|
71
|
+
debounce: 300,
|
|
72
|
+
},
|
|
73
|
+
editor: {
|
|
74
|
+
theme: "dark",
|
|
75
|
+
fontSize: 14,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export const log = {
|
|
4
|
+
info(msg) {
|
|
5
|
+
console.log(chalk.blue("info") + " " + msg);
|
|
6
|
+
},
|
|
7
|
+
success(msg) {
|
|
8
|
+
console.log(chalk.green("done") + " " + msg);
|
|
9
|
+
},
|
|
10
|
+
warn(msg) {
|
|
11
|
+
console.log(chalk.yellow("warn") + " " + msg);
|
|
12
|
+
},
|
|
13
|
+
error(msg) {
|
|
14
|
+
console.log(chalk.red("error") + " " + msg);
|
|
15
|
+
},
|
|
16
|
+
dim(msg) {
|
|
17
|
+
console.log(chalk.dim(msg));
|
|
18
|
+
},
|
|
19
|
+
brand(msg) {
|
|
20
|
+
console.log(chalk.red.bold("cheri") + " " + msg);
|
|
21
|
+
},
|
|
22
|
+
blank() {
|
|
23
|
+
console.log();
|
|
24
|
+
},
|
|
25
|
+
header(title) {
|
|
26
|
+
console.log();
|
|
27
|
+
console.log(chalk.bold(title));
|
|
28
|
+
console.log(chalk.dim("─".repeat(title.length)));
|
|
29
|
+
},
|
|
30
|
+
keyValue(key, value) {
|
|
31
|
+
console.log(` ${chalk.dim(key.padEnd(16))} ${value}`);
|
|
32
|
+
},
|
|
33
|
+
tree(items) {
|
|
34
|
+
items.forEach((item, i) => {
|
|
35
|
+
const prefix = i === items.length - 1 ? " └─" : " ├─";
|
|
36
|
+
console.log(chalk.dim(prefix) + " " + item);
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|