Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/release-xmemo-skill.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,26 @@ xmemo smoke --client codex

</details>

<details>
<summary><strong>Bundled XMemo Skill</strong></summary>

```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.

</details>

<details>
<summary><strong>Safe removal</strong></summary>

Expand Down
2 changes: 1 addition & 1 deletion lhm.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -39,7 +39,7 @@
{
"registryType": "npm",
"identifier": "@xmemo/client",
"version": "0.4.180",
"version": "0.4.181",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
Expand Down
5 changes: 5 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
202 changes: 202 additions & 0 deletions src/commands/skill.js
Original file line number Diff line number Diff line change
@@ -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 <directory>] [--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;
}
}
2 changes: 2 additions & 0 deletions src/ui/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <directory>] [--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`);
Expand Down
Loading
Loading