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/);
+});