diff --git a/.github/workflows/release-xmemo-skill.yml b/.github/workflows/release-xmemo-skill.yml index 03c124e..2804156 100644 --- a/.github/workflows/release-xmemo-skill.yml +++ b/.github/workflows/release-xmemo-skill.yml @@ -71,6 +71,8 @@ jobs: trap 'rm -rf "$staging_dir" "$tar_extract_dir" "$zip_extract_dir"' EXIT test -f "$source_dir/scripts/xmemo-skill.mjs" + test -f "$source_dir/install.sh" + test -f "$source_dir/install.ps1" mkdir -p "$artifact_dir" # Do not package symlinks: they could point outside the Skill root and @@ -98,6 +100,8 @@ jobs: done < <(find "$source_dir" -type f -print0 | LC_ALL=C sort -z) test -f "$staging_dir/scripts/xmemo-skill.mjs" + test -f "$staging_dir/install.sh" + test -f "$staging_dir/install.ps1" if ! find "$staging_dir" -type f -print -quit | grep -q .; then echo "No files were eligible for packaging" >&2 exit 1 @@ -115,6 +119,8 @@ jobs: for extracted_dir in "$tar_extract_dir" "$zip_extract_dir"; do test -f "$extracted_dir/scripts/xmemo-skill.mjs" + test -f "$extracted_dir/install.sh" + test -f "$extracted_dir/install.ps1" while IFS= read -r -d '' extracted_file; do if is_sensitive_name "${extracted_file##*/}"; then echo "Sensitive-looking file found in archive: $extracted_file" >&2 diff --git a/README.md b/README.md index 8382022..e4989c8 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,26 @@ xmemo smoke --client codex +
+Bundled XMemo Skill + +```bash +xmemo skill install --dry-run +xmemo skill install +xmemo skill install --target ~/.codex/skills/xmemo-memory +xmemo skill install --target ~/.claude/skills/xmemo-memory +``` + +The command copies the Skill bundled in the current `@xmemo/client` package, so +it also works through `npx @xmemo/client skill install`. It is offline, never +uses XMemo credentials, refuses to overwrite an existing destination by +default, and supports explicit atomic replacement with `--force`. + +The default destination is `./xmemo-skill`. Use `--target` (or +`XMEMO_SKILL_DIR`) for an Agent-specific user or project Skill directory. + +
+
Safe removal diff --git a/lhm.plugin.json b/lhm.plugin.json index ab1044b..4a29c12 100644 --- a/lhm.plugin.json +++ b/lhm.plugin.json @@ -6,7 +6,7 @@ "authorUrl": "https://github.com/yonro", "homepage": "https://xmemo.dev/product/mcp", "icon": "https://raw.githubusercontent.com/yonro/memory-os-cli/main/plugins/xmemo/assets/logo.png", - "version": "0.4.180", + "version": "0.4.181", "category": "productivity", "connectionType": "hybrid", "cloudEndpoint": "https://xmemo.dev/mcp", diff --git a/package-lock.json b/package-lock.json index e6880a3..139a3c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@xmemo/client", - "version": "0.4.180", + "version": "0.4.181", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xmemo/client", - "version": "0.4.180", + "version": "0.4.181", "license": "MIT", "bin": { "memory-os": "bin/memory-os.js", diff --git a/package.json b/package.json index 5eb646c..3c66445 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xmemo/client", - "version": "0.4.180", + "version": "0.4.181", "description": "Privacy-first CLI and MCP setup helper for XMemo.", "mcpName": "io.github.yonro/xmemo", "type": "module", diff --git a/server.json b/server.json index 38892e9..8cd8196 100644 --- a/server.json +++ b/server.json @@ -8,7 +8,7 @@ "url": "https://github.com/yonro/memory-os-cli", "source": "github" }, - "version": "0.4.180", + "version": "0.4.181", "remotes": [ { "type": "streamable-http", @@ -39,7 +39,7 @@ { "registryType": "npm", "identifier": "@xmemo/client", - "version": "0.4.180", + "version": "0.4.181", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/src/cli.js b/src/cli.js index 755fea3..4750700 100644 --- a/src/cli.js +++ b/src/cli.js @@ -16,6 +16,7 @@ import { import { mcpCommand } from './commands/mcp.js'; import { profileCommand } from './commands/profile.js'; import { setupCommand } from './commands/setup.js'; +import { skillCommand } from './commands/skill.js'; import { uninstallCommand } from './commands/uninstall.js'; import { updateCommand } from './commands/update.js'; import { envCommand, writePrivacy } from './config/env.js'; @@ -57,6 +58,10 @@ export async function run(args, io = defaultIo()) { return await setupCommand(args.slice(1), io); } + if (command === 'skill') { + return await skillCommand(args.slice(1), io); + } + if (command === 'uninstall') { return await uninstallCommand(args.slice(1), io); } diff --git a/src/commands/skill.js b/src/commands/skill.js new file mode 100644 index 0000000..9f5a7b6 --- /dev/null +++ b/src/commands/skill.js @@ -0,0 +1,202 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { hasFlag, optionValue } from '../core/args.js'; +import { + CLI_VERSION, + COMMAND_NAME, + PACKAGE_NAME +} from '../core/constants.js'; +import { UsageError } from '../core/errors.js'; +import { writeLine } from '../core/io.js'; + +const BUNDLED_SKILL_DIR = fileURLToPath(new URL('../../skills/xmemo/', import.meta.url)); +const DEFAULT_INSTALL_DIR = 'xmemo-skill'; +const REQUIRED_SKILL_FILES = [ + 'SKILL.md', + path.join('scripts', 'xmemo-skill.mjs') +]; + +export async function skillCommand(args, io) { + const subcommand = args[0] ?? 'help'; + if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { + writeSkillHelp(io); + return 0; + } + + if (subcommand !== 'install') { + throw new UsageError(`Unknown skill command: ${subcommand}`); + } + + const optionArgs = args.slice(1); + if (hasFlag(optionArgs, '--help') || hasFlag(optionArgs, '-h')) { + writeSkillHelp(io); + return 0; + } + validateInstallArgs(optionArgs); + + const dryRun = hasFlag(optionArgs, '--dry-run'); + const force = hasFlag(optionArgs, '--force'); + const outputJson = hasFlag(optionArgs, '--json'); + const cwd = io.cwd ?? process.cwd(); + const configuredTarget = optionValue(optionArgs, '--target') + ?? io.env?.XMEMO_SKILL_DIR + ?? DEFAULT_INSTALL_DIR; + const targetDir = path.resolve(cwd, configuredTarget); + + validateTarget(BUNDLED_SKILL_DIR, targetDir); + const skillVersion = await validateBundledSkill(BUNDLED_SKILL_DIR); + const targetExists = await pathExists(targetDir); + if (targetExists && !force) { + throw new UsageError(`Skill destination already exists: ${targetDir}. Use --force to replace it.`); + } + + const report = { + package: PACKAGE_NAME, + cliVersion: CLI_VERSION, + skillVersion, + source: BUNDLED_SKILL_DIR, + target: targetDir, + dryRun, + force, + replaced: targetExists && !dryRun, + installed: false, + networkUsed: false, + tokenSent: false + }; + + if (!dryRun) { + await installBundledSkill(BUNDLED_SKILL_DIR, targetDir, { replace: targetExists }); + report.installed = true; + } + + if (outputJson) { + writeLine(io.stdout, JSON.stringify(report, null, 2)); + return 0; + } + + const action = dryRun ? 'Would install' : 'Installed'; + writeLine(io.stdout, `${action} bundled XMemo Skill ${skillVersion} to ${targetDir}`); + writeLine(io.stdout, `Source: ${PACKAGE_NAME} ${CLI_VERSION} (offline; no credential used)`); + if (dryRun) { + writeLine(io.stdout, 'Dry run only; no files were changed.'); + } + return 0; +} + +function writeSkillHelp(io) { + writeLine(io.stdout, 'Skill commands:'); + writeLine(io.stdout, ` ${COMMAND_NAME} skill install [--target ] [--dry-run] [--force] [--json]`); + writeLine(io.stdout, ''); + writeLine(io.stdout, `Installs the XMemo Skill bundled with the current ${PACKAGE_NAME} package.`); + writeLine(io.stdout, `The default destination is ./${DEFAULT_INSTALL_DIR}; XMEMO_SKILL_DIR can override it.`); + writeLine(io.stdout, 'Installation is offline and never reads or sends XMemo credentials.'); +} + +function validateInstallArgs(args) { + const flags = new Set(['--dry-run', '--force', '--json', '--help', '-h']); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--target') { + if (!args[index + 1] || args[index + 1].startsWith('--')) { + throw new UsageError('Option --target requires a value.'); + } + index += 1; + continue; + } + if (!flags.has(arg)) { + throw new UsageError(`Unknown skill install option: ${arg}`); + } + } +} + +function validateTarget(sourceDir, targetDir) { + const root = path.parse(targetDir).root; + if (targetDir === root) { + throw new UsageError('Refusing to install a Skill into a filesystem root.'); + } + + const relative = path.relative(sourceDir, targetDir); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + throw new UsageError('Skill destination cannot be the bundled source or a directory inside it.'); + } +} + +async function validateBundledSkill(sourceDir) { + for (const relativePath of REQUIRED_SKILL_FILES) { + const sourcePath = path.join(sourceDir, relativePath); + const stat = await fs.stat(sourcePath).catch(() => null); + if (!stat?.isFile()) { + throw new UsageError(`The npm package is missing bundled Skill file: ${relativePath}`); + } + } + + await rejectSymlinks(sourceDir); + const runtimeSource = await fs.readFile(path.join(sourceDir, 'scripts', 'xmemo-skill.mjs'), 'utf8'); + const skillVersion = runtimeSource.match(/const SKILL_VERSION = '([^']+)'/)?.[1]; + if (!skillVersion) { + throw new UsageError('The bundled XMemo Skill version could not be determined.'); + } + return skillVersion; +} + +async function rejectSymlinks(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new UsageError(`The bundled XMemo Skill contains a symbolic link: ${entry.name}`); + } + if (entry.isDirectory()) { + await rejectSymlinks(entryPath); + } + } +} + +async function installBundledSkill(sourceDir, targetDir, { replace }) { + const parentDir = path.dirname(targetDir); + const baseName = path.basename(targetDir); + const nonce = `${process.pid}-${randomUUID()}`; + const stagingDir = path.join(parentDir, `.${baseName}.xmemo-tmp-${nonce}`); + const backupDir = path.join(parentDir, `.${baseName}.xmemo-backup-${nonce}`); + let backupCreated = false; + + await fs.mkdir(parentDir, { recursive: true }); + try { + await fs.cp(sourceDir, stagingDir, { recursive: true, errorOnExist: true, force: false }); + if (replace) { + await fs.rename(targetDir, backupDir); + backupCreated = true; + } + await fs.rename(stagingDir, targetDir); + if (backupCreated) { + await fs.rm(backupDir, { recursive: true, force: true }); + backupCreated = false; + } + } catch (error) { + if (backupCreated && !await pathExists(targetDir)) { + await fs.rename(backupDir, targetDir).catch(() => {}); + backupCreated = false; + } + throw error; + } finally { + await fs.rm(stagingDir, { recursive: true, force: true }); + if (backupCreated && await pathExists(targetDir)) { + await fs.rm(backupDir, { recursive: true, force: true }); + } + } +} + +async function pathExists(targetPath) { + try { + await fs.access(targetPath); + return true; + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + throw error; + } +} diff --git a/src/ui/help.js b/src/ui/help.js index b9b6ce4..b2c1b99 100644 --- a/src/ui/help.js +++ b/src/ui/help.js @@ -42,6 +42,8 @@ export function writeHelp(io) { writeLine(io.stdout, ' Probe hosted service endpoints and readiness.'); writeLine(io.stdout, ` ${COMMAND_NAME} update [--dry-run]`); writeLine(io.stdout, ' Check or apply the latest npm package update.'); + writeLine(io.stdout, ` ${COMMAND_NAME} skill install [--target ] [--dry-run] [--force] [--json]`); + writeLine(io.stdout, ' Install the XMemo Skill bundled in this npm package without network access.'); writeLine(io.stdout, ''); writeLine(io.stdout, 'MCP And Profiles'); writeLine(io.stdout, ` ${COMMAND_NAME} mcp list`); diff --git a/test/xmemo-skill-command.test.js b/test/xmemo-skill-command.test.js new file mode 100644 index 0000000..a0ccd80 --- /dev/null +++ b/test/xmemo-skill-command.test.js @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { run } from '../src/cli.js'; +import { skillCommand } from '../src/commands/skill.js'; + +test('skill install dry-run reports the bundled source without writing', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-skill-command-dry-')); + try { + const io = captureIo({ cwd: tempDir }); + const code = await run(['skill', 'install', '--json', '--dry-run'], io); + const report = JSON.parse(io.stdout.text); + + assert.equal(code, 0); + assert.equal(report.dryRun, true); + assert.equal(report.installed, false); + assert.equal(report.networkUsed, false); + assert.equal(report.tokenSent, false); + await assert.rejects(fs.access(path.join(tempDir, 'xmemo-skill')), { code: 'ENOENT' }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('skill install copies the npm-bundled Skill and refuses implicit overwrite', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-skill-command-install-')); + try { + const io = captureIo({ cwd: tempDir }); + const code = await skillCommand(['install', '--json'], io); + const targetDir = path.join(tempDir, 'xmemo-skill'); + + assert.equal(code, 0); + assert.equal(JSON.parse(io.stdout.text).installed, true); + assert.match(await fs.readFile(path.join(targetDir, 'SKILL.md'), 'utf8'), /^---/); + await assert.rejects( + skillCommand(['install'], captureIo({ cwd: tempDir })), + /destination already exists/i + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('skill install --force replaces stale destination content', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xmemo-skill-command-force-')); + const targetDir = path.join(tempDir, 'custom-skill'); + try { + await fs.mkdir(targetDir); + await fs.writeFile(path.join(targetDir, 'stale.txt'), 'stale'); + const io = captureIo({ cwd: tempDir }); + const code = await skillCommand(['install', '--target', targetDir, '--force', '--json'], io); + + assert.equal(code, 0); + assert.equal(JSON.parse(io.stdout.text).replaced, true); + await assert.rejects(fs.access(path.join(targetDir, 'stale.txt')), { code: 'ENOENT' }); + assert.match(await fs.readFile(path.join(targetDir, 'SKILL.md'), 'utf8'), /^---/); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +function captureIo({ cwd }) { + const stdout = memoryStream(); + const stderr = memoryStream(); + return { + cwd, + env: {}, + stdout, + stderr + }; +} + +function memoryStream() { + return { + text: '', + write(chunk) { + this.text += String(chunk); + } + }; +} diff --git a/test/xmemo-skill-installers.test.js b/test/xmemo-skill-installers.test.js new file mode 100644 index 0000000..c6c1a41 --- /dev/null +++ b/test/xmemo-skill-installers.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('standalone Skill installers remain HTTPS-only and package the expected entrypoint', async () => { + const [posix, powershell] = await Promise.all([ + fs.readFile(path.join(repoRoot, 'skills/xmemo/install.sh'), 'utf8'), + fs.readFile(path.join(repoRoot, 'skills/xmemo/install.ps1'), 'utf8'), + ]); + + assert.match(posix, /XMEMO_BASE_URL:-https:\/\/xmemo\.dev/); + assert.match(posix, /--proto '=https'/); + assert.match(posix, /--proto-redir '=https'/); + assert.match(posix, /scripts\/xmemo-skill\.mjs/); + assert.doesNotMatch(posix, /XMEMO_KEY|Authorization/); + + assert.match(powershell, /https:\/\/xmemo\.dev/); + assert.match(powershell, /AllowAutoRedirect = \$false/); + assert.match(powershell, /Refusing a non-HTTPS redirect/); + assert.match(powershell, /scripts\\xmemo-skill\.mjs/); + assert.doesNotMatch(powershell, /XMEMO_KEY|Authorization/); +});