From 2027c67b03eb672ebb21548cd8a6989446709cb7 Mon Sep 17 00:00:00 2001 From: tangcent Date: Thu, 2 Apr 2026 21:12:45 +0800 Subject: [PATCH] feat: add one-ide-cli and editor-context skill --- README.md | 10 +++ cli/.gitignore | 7 ++ cli/README.md | 79 ++++++++++++++++++++ cli/package-lock.json | 51 +++++++++++++ cli/package.json | 45 ++++++++++++ cli/scripts/publish.sh | 89 ++++++++++++++++++++++ cli/scripts/release.sh | 68 +++++++++++++++++ cli/src/help.ts | 22 ++++++ cli/src/index.ts | 130 +++++++++++++++++++++++++++++++++ cli/src/state.ts | 31 ++++++++ cli/src/types.ts | 26 +++++++ cli/tsconfig.json | 15 ++++ skills/README.md | 9 +++ skills/editor-context/SKILL.md | 67 +++++++++++++++++ 14 files changed, 649 insertions(+) create mode 100644 cli/.gitignore create mode 100644 cli/README.md create mode 100644 cli/package-lock.json create mode 100644 cli/package.json create mode 100755 cli/scripts/publish.sh create mode 100755 cli/scripts/release.sh create mode 100644 cli/src/help.ts create mode 100644 cli/src/index.ts create mode 100644 cli/src/state.ts create mode 100644 cli/src/types.ts create mode 100644 cli/tsconfig.json create mode 100644 skills/README.md create mode 100644 skills/editor-context/SKILL.md diff --git a/README.md b/README.md index 80b05c6..cf93520 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,16 @@ Alternatively, you can download it from the [JetBrains Marketplace](https://plug Alternatively, you can install it from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=tangcent.one-ide). +## Skills + +Install the `editor-context` skill to give terminal AI tools (claude, gemini-cli, kiro-cli, codex, etc.) access to your live IDE editor state: + +```bash +npx skills add tangcent/one-IDE -g -y +``` + +See the [skills catalog](./skills/README.md) for details. + ## Development ### Prerequisites diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..f1c4eba --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +build/ +*.log +.DS_Store +.env +coverage/ +.idea/ diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..d7349db --- /dev/null +++ b/cli/README.md @@ -0,0 +1,79 @@ +# one-ide-cli + +CLI to access live IDE editor state published by the [One-IDE](https://github.com/tangcent/one-IDE) plugin. + +When running AI tools in the terminal (Claude CLI, Codex, Kiro CLI, etc.), the tool has no direct access to the IDE. The One-IDE plugin (JetBrains / VS Code) continuously writes editor state to `~/.one-ide/cluster/state.json`. `one-ide-cli` reads that file and exposes the data as simple commands. + +## Installation + +```bash +npm install -g one-ide-cli +``` + +## Prerequisites + +The [One-IDE plugin](https://github.com/tangcent/one-IDE) must be installed and active in at least one IDE (JetBrains or VS Code/fork). + +## Commands + +### Active project root + +```bash +one-ide-cli active-project +# /Users/you/project +``` + +### Active file in a project + +```bash +one-ide-cli active-file /Users/you/project +# /Users/you/project/src/main.ts +``` + +Returns the file currently focused in the IDE. Exits with an error if the given project path does not match the active project. + +### Open files in a project + +```bash +one-ide-cli opened-files /Users/you/project +``` + +```json +[ + "/Users/you/project/src/main.ts", + "/Users/you/project/src/utils.ts", + "/Users/you/project/README.md" +] +``` + +### Active IDE name + +```bash +one-ide-cli ide +# VSCode +``` + +### Help + +```bash +one-ide-cli --help +``` + +## Using Editor Context in AI Prompts + +```bash +# Pass active file to Claude CLI +PROJECT=$(one-ide-cli active-project) +ACTIVE=$(one-ide-cli active-file "$PROJECT") +claude "Review $ACTIVE for potential bugs." + +# Pass all open files as context +PROJECT=$(one-ide-cli active-project) +FILES=$(one-ide-cli opened-files "$PROJECT") +claude "I have these files open: $FILES. Help me understand the overall structure." + +# Kiro CLI +PROJECT=$(one-ide-cli active-project) +ACTIVE=$(one-ide-cli active-file "$PROJECT") +kiro "Review the file at $ACTIVE" +``` diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..64e142d --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "one-ide-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "one-ide-cli", + "version": "1.0.0", + "license": "MIT", + "bin": { + "one-ide-cli": "build/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..d9b94bf --- /dev/null +++ b/cli/package.json @@ -0,0 +1,45 @@ +{ + "name": "one-ide-cli", + "version": "1.0.0", + "description": "CLI to access live IDE editor state published by the One-IDE plugin", + "main": "build/index.js", + "type": "module", + "bin": { + "one-ide-cli": "build/index.js" + }, + "files": [ + "build", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "start": "node build/index.js", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tangcent/one-IDE.git", + "directory": "cli" + }, + "keywords": [ + "one-ide", + "ide", + "editor", + "cli", + "cursor", + "vscode", + "jetbrains" + ], + "author": "tangcent", + "license": "MIT", + "bugs": { + "url": "https://github.com/tangcent/one-IDE/issues" + }, + "homepage": "https://github.com/tangcent/one-IDE/tree/main/cli#readme", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0" + } +} diff --git a/cli/scripts/publish.sh b/cli/scripts/publish.sh new file mode 100755 index 0000000..7354601 --- /dev/null +++ b/cli/scripts/publish.sh @@ -0,0 +1,89 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" || exit 1 + +publish_npm() { + echo "🚀 Publishing to npmjs.com..." + npm publish --access public +} + +publish_github() { + echo "🚀 Publishing to GitHub Packages..." + + if ! npm whoami --registry=https://npm.pkg.github.com >/dev/null 2>&1; then + echo "⚠️ You are not logged in to GitHub Packages." + echo "📦 You need a GitHub Personal Access Token (classic) with:" + echo " ✅ write:packages" + echo " ✅ read:packages" + echo "" + echo "🔗 Create one here: https://github.com/settings/tokens/new?scopes=write:packages,read:packages&description=one-ide-cli%20Publish" + echo "" + read -p "Would you like to log in now? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + npm login --registry=https://npm.pkg.github.com || { echo "❌ Login failed."; exit 1; } + else + echo "❌ Authentication required. Run 'npm login --registry=https://npm.pkg.github.com' and try again." + exit 1 + fi + fi + + cp package.json package.json.bak + + node -e " + const fs = require('fs'); + const pkg = require('./package.json'); + pkg.name = '@tangcent/one-ide-cli'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2)); + " + + cleanup() { + rm -f .npmrc + mv package.json.bak package.json + } + trap cleanup EXIT + + npm publish --registry=https://npm.pkg.github.com + + cleanup + trap - EXIT +} + +TARGET=$1 + +if [ -z "$TARGET" ]; then + echo "Select registry to publish to:" + echo "1) All (GitHub & NPM)" + echo "2) NPM only (npmjs.com)" + echo "3) GitHub only (npm.pkg.github.com)" + read -p "Enter choice [1-3]: " choice + case $choice in + 1) TARGET="all" ;; + 2) TARGET="npm" ;; + 3) TARGET="github" ;; + *) echo "❌ Invalid choice" && exit 1 ;; + esac +fi + +echo "👉 Selected target: $TARGET" + +case $TARGET in + npm) publish_npm ;; + github) publish_github ;; + all) + echo "📦 Publishing to ALL registries..." + publish_github && echo "✅ GitHub publish success." || echo "⚠️ GitHub publish failed. Continuing..." + publish_npm && echo "✅ npm publish success." || { echo "❌ npm publish failed."; exit 1; } + ;; + *) + echo "❌ Invalid option: $TARGET" + echo "Usage: ./scripts/publish.sh [github|npm|all]" + exit 1 + ;; +esac + +echo "🎉 Done!" diff --git a/cli/scripts/release.sh b/cli/scripts/release.sh new file mode 100755 index 0000000..b0118f0 --- /dev/null +++ b/cli/scripts/release.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" || exit 1 + +CURRENT_VERSION=$(node -p "require('./package.json').version") +echo "Current version: $CURRENT_VERSION" + +IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" +MAJOR=$(echo "${VERSION_PARTS[0]}" | grep -oE '^[0-9]+') +MINOR=$(echo "${VERSION_PARTS[1]}" | grep -oE '^[0-9]+') +PATCH=$(echo "${VERSION_PARTS[2]}" | grep -oE '^[0-9]+') + +NEXT_PATCH="$MAJOR.$MINOR.$((PATCH + 1))" +NEXT_MINOR="$MAJOR.$((MINOR + 1)).0" +NEXT_MAJOR="$((MAJOR + 1)).0.0" + +echo "" +echo "Select a new version to release:" +echo "1) Patch ($NEXT_PATCH)" +echo "2) Minor ($NEXT_MINOR)" +echo "3) Major ($NEXT_MAJOR)" +echo "4) Custom" +echo "5) Cancel" + +read -p "Enter your choice [1-5]: " CHOICE + +NEW_VERSION="" +case $CHOICE in + 1) NEW_VERSION="$NEXT_PATCH" ;; + 2) NEW_VERSION="$NEXT_MINOR" ;; + 3) NEW_VERSION="$NEXT_MAJOR" ;; + 4) + read -p "Enter custom version: " NEW_VERSION + [ -z "$NEW_VERSION" ] && echo "Version cannot be empty." && exit 1 + ;; + 5) echo "Release cancelled." && exit 0 ;; + *) echo "Invalid choice." && exit 1 ;; +esac + +echo "" +echo "You are about to release version: $NEW_VERSION" +read -p "Are you sure? (y/N) " CONFIRM +[[ ! "$CONFIRM" =~ ^([yY][eE][sS]|[yY])$ ]] && echo "Cancelled." && exit 0 + +echo "" +echo "Updating version to $NEW_VERSION..." +npm version "$NEW_VERSION" --git-tag-version=false || exit 1 + +echo "Building project..." +npm run build || { echo "Build failed. Aborting release."; exit 1; } + +echo "" +echo "Publishing to npm..." +npm publish --access public || { echo "Publish failed."; exit 1; } + +echo "" +echo "Successfully published version $NEW_VERSION!" + +read -p "Do you want to create a git tag and push? (y/N) " GIT_TAG +if [[ "$GIT_TAG" =~ ^([yY][eE][sS]|[yY])$ ]]; then + git add package.json package-lock.json + git commit -m "chore(cli): release $NEW_VERSION" + git tag "cli/v$NEW_VERSION" + git push && git push --tags +fi diff --git a/cli/src/help.ts b/cli/src/help.ts new file mode 100644 index 0000000..4280e26 --- /dev/null +++ b/cli/src/help.ts @@ -0,0 +1,22 @@ +import * as os from 'os'; +import * as path from 'path'; + +const STATE_FILE = path.join(os.homedir(), '.one-ide', 'cluster', 'state.json'); + +export function printHelp(): void { + console.log(` +one-ide-cli — access live IDE editor state from the terminal + +State file: ${STATE_FILE} + +Commands: + context Full editor context (active project, file, selection, open files, file text) + active-project Print the workspace root of the active project + active-file Print the active file in the given project + opened-files Print all open files in the given project (JSON array) + ide Print the active IDE name (e.g. VSCode, IntelliJ IDEA) + +Options: + --help, -h Show this help +`.trim()); +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..4aacbf7 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import * as fs from 'fs'; +import { readState } from './state.js'; +import { printHelp } from './help.js'; + +const args = process.argv.slice(2); + +if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + printHelp(); + process.exit(0); +} + +const [cmd, projectPath] = args; + +switch (cmd) { + case 'active-project': { + const state = readState(); + if (!state.root) { + console.error('No active project in current state.'); + process.exit(2); + } + console.log(state.root); + break; + } + + case 'active-file': { + if (!projectPath) { + console.error('Usage: one-ide-cli active-file '); + process.exit(1); + } + const state = readState(); + if (state.root && normalizePath(state.root) !== normalizePath(projectPath)) { + console.error(`Project "${projectPath}" is not the active project (active: ${state.root}).`); + process.exit(2); + } + const activeFile = state.editorState?.activeFile; + if (!activeFile?.filePath) { + console.error('No active file in current state.'); + process.exit(2); + } + console.log(activeFile.filePath); + break; + } + + case 'opened-files': { + if (!projectPath) { + console.error('Usage: one-ide-cli opened-files '); + process.exit(1); + } + const state = readState(); + if (state.root && normalizePath(state.root) !== normalizePath(projectPath)) { + console.error(`Project "${projectPath}" is not the active project (active: ${state.root}).`); + process.exit(2); + } + const files = state.editorState?.openedFiles ?? []; + console.log(JSON.stringify(files, null, 2)); + break; + } + + case 'context': { + if (!projectPath) { + console.error('Usage: one-ide-cli context '); + process.exit(1); + } + const state = readState(); + if (state.root && normalizePath(state.root) !== normalizePath(projectPath)) { + console.error(`Project "${projectPath}" is not the active project (active: ${state.root}).`); + process.exit(2); + } + const activeFile = state.editorState?.activeFile; + const filePath = activeFile?.filePath || null; + + let activeFileText: string | null = null; + let activeSelectionText: string | null = null; + + if (filePath && fs.existsSync(filePath)) { + activeFileText = fs.readFileSync(filePath, 'utf-8'); + + if (activeFile?.selectionEndCursor != null && activeFileText) { + const lines = activeFileText.split('\n'); + const startLine = (activeFile.cursor ?? 1) - 1; + const startCol = activeFile.column ?? 0; + const endLine = activeFile.selectionEndCursor - 1; + const endCol = activeFile.selectionEndColumn ?? 0; + + if (startLine === endLine) { + activeSelectionText = lines[startLine]?.slice(startCol, endCol) ?? null; + } else { + const parts = [lines[startLine]?.slice(startCol) ?? '']; + for (let i = startLine + 1; i < endLine; i++) parts.push(lines[i] ?? ''); + parts.push(lines[endLine]?.slice(0, endCol) ?? ''); + activeSelectionText = parts.join('\n'); + } + } + } + + const result: Record = { + activeProject: state.root || null, + activeFile: filePath, + activeSelection: (activeFile?.selectionEndCursor != null) + ? { + startLine: activeFile.cursor, + startColumn: activeFile.column, + endLine: activeFile.selectionEndCursor, + endColumn: activeFile.selectionEndColumn, + } + : null, + openedFiles: state.editorState?.openedFiles ?? [], + activeFileText, + activeSelectionText, + }; + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'ide': { + const state = readState(); + console.log(state.ide || 'unknown'); + break; + } + + default: + console.error(`Unknown command: ${cmd}`); + printHelp(); + process.exit(1); +} + +function normalizePath(p: string): string { + return p.replace(/\\/g, '/').toLowerCase(); +} diff --git a/cli/src/state.ts b/cli/src/state.ts new file mode 100644 index 0000000..70943b7 --- /dev/null +++ b/cli/src/state.ts @@ -0,0 +1,31 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { ClusterState, State } from './types.js'; + +const STATE_FILE = path.join(os.homedir(), '.one-ide', 'cluster', 'state.json'); +const STALE_THRESHOLD_MS = 30_000; + +export function readState(): State { + if (!fs.existsSync(STATE_FILE)) { + console.error(`No state file found at ${STATE_FILE}`); + console.error('Make sure the One-IDE plugin is installed and an IDE is open.'); + process.exit(2); + } + + let clusterState: ClusterState; + try { + clusterState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as ClusterState; + } catch (e) { + console.error(`Failed to read state file: ${(e as Error).message}`); + process.exit(2); + } + + const age = Date.now() - (clusterState.timestamp ?? 0); + if (age > STALE_THRESHOLD_MS) { + const ageS = Math.round(age / 1000); + process.stderr.write(`[one-ide-cli] Warning: state is ${ageS}s old — IDE may be closed or idle.\n`); + } + + return clusterState.state ?? ({} as State); +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 0000000..f5c288c --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,26 @@ +export interface ActiveFile { + filePath: string; + cursor: number; + column?: number; + selectionEndCursor?: number; + selectionEndColumn?: number; +} + +export interface EditorState { + openedFiles: string[]; + activeFile?: ActiveFile; +} + +export interface State { + timestamp: number; + source: string; + ide: string; + root: string; + editorState: EditorState; +} + +export interface ClusterState { + timestamp: number; + leaderId: string; + state: State; +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..a14bee0 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..5607f0e --- /dev/null +++ b/skills/README.md @@ -0,0 +1,9 @@ +# Skills + +Skills are reusable workflow guides that help AI agents perform specific tasks effectively in this repository. + +## Available Skills + +| Skill | Description | +|-------|-------------| +| [editor-context](./editor-context/SKILL.md) | For terminal AI tools (claude, gemini-cli, kiro-cli, codex, etc.) running inside an IDE terminal that can't see the editor. Uses `one-ide-cli` to fetch live editor state — active file, open files, selection, file content — so the AI knows what the user is working on. | diff --git a/skills/editor-context/SKILL.md b/skills/editor-context/SKILL.md new file mode 100644 index 0000000..349fc2d --- /dev/null +++ b/skills/editor-context/SKILL.md @@ -0,0 +1,67 @@ +--- +name: editor-context +description: Bridges the gap between terminal AI tools and the IDE editor. Activate when user says "this file", "this function", "this class", "explain this", "current file", "selected code", "what I have open", or asks about code without providing it. Skip if the AI already has the file content or path in context. Works with VS Code, Cursor, JetBrains IDEs and their forks via the One-IDE plugin. +--- + +# Editor Context via one-ide-cli + +Run the following to detect the current editor context: + +```bash +one-ide-cli context /path/to/current/workspace +``` + +## When to activate + +**Always run on the first coding turn of a new session** — before answering, fetch context so the response is grounded in what the user actually has open. + +Also activate when the question is code-adjacent and the active file is unknown. Trigger phrases include: +- explicit: "this file", "current file", "selected code", "what I have open" +- implicit: "this", "that", "it", "this field", "this function", "this class", "what does this mean", "explain this", "why is this" + +**Skip only when:** +- The AI already has the file content or path in context (e.g. user pasted code directly) +- The question clearly refers only to pasted content with no ambiguity + +## Output + +```json +{ + "activeProject": "/Users/you/project", + "activeFile": "/Users/you/project/src/main.ts", + "activeSelection": { + "startLine": 10, + "startColumn": 0, + "endLine": 20, + "endColumn": 5 + }, + "openedFiles": [ + "/Users/you/project/src/main.ts", + "/Users/you/project/src/utils.ts" + ], + "activeFileText": "...full file content...", + "activeSelectionText": "...selected text..." +} +``` + +- `activeFile` — absolute path of the focused file, `null` if no file is open +- `activeSelection` — line/column range of selected text, `null` if nothing is selected +- `activeFileText` — full content of the active file, `null` if no file is open +- `activeSelectionText` — the selected text, `null` if nothing is selected +- `openedFiles` — all open editor tabs, empty array if none + +## Compatibility + +Works with any IDE that has the [One-IDE plugin](https://github.com/tangcent/one-IDE) installed: VS Code, Cursor, Windsurf, JetBrains IDEs (IntelliJ, WebStorm, PyCharm, etc.) and their forks. + +> If `one-ide-cli` is not found, install it: `npm install -g one-ide-cli` + +## Other Commands + +```bash +one-ide-cli active-project # workspace root of the active project +one-ide-cli active-file # active file in the given project +one-ide-cli opened-files # all open files in the given project (JSON array) +one-ide-cli ide # active IDE name (e.g. VSCode, IntelliJ IDEA) +one-ide-cli --help +```