From 936f5d9420226b9706758f02d17215496debd62c Mon Sep 17 00:00:00 2001 From: pc-gemini Date: Tue, 11 Aug 2026 16:50:38 +0900 Subject: [PATCH 1/3] feat(skill): add cross-platform installers [INBOX-20260811-SKILL-INSTALL-SCRIPT] --- .github/workflows/release-xmemo-skill.yml | 6 +++ skills/xmemo/install.ps1 | 46 +++++++++++++++++++++++ skills/xmemo/install.sh | 22 +++++++++++ test/xmemo-skill-installers.test.js | 26 +++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 skills/xmemo/install.ps1 create mode 100644 skills/xmemo/install.sh create mode 100644 test/xmemo-skill-installers.test.js 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/skills/xmemo/install.ps1 b/skills/xmemo/install.ps1 new file mode 100644 index 0000000..e9f75b0 --- /dev/null +++ b/skills/xmemo/install.ps1 @@ -0,0 +1,46 @@ +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http + +$baseUrl = if ($env:XMEMO_BASE_URL) { $env:XMEMO_BASE_URL.TrimEnd('/') } else { 'https://xmemo.dev' } +$packageUrl = [Uri]"$baseUrl/v1/skill/package" +$installDir = if ($env:XMEMO_SKILL_DIR) { $env:XMEMO_SKILL_DIR } else { 'xmemo-skill' } +$tempDir = "$installDir.tmp.$PID" + +if ($packageUrl.Scheme -ne 'https') { throw 'XMemo Skill installer requires an HTTPS XMEMO_BASE_URL.' } +if (Test-Path -LiteralPath $installDir) { throw "Destination already exists: $installDir" } +$handler = [System.Net.Http.HttpClientHandler]::new() +$handler.AllowAutoRedirect = $false +$client = [System.Net.Http.HttpClient]::new($handler) +try { + New-Item -ItemType Directory -Path $tempDir, "$tempDir\extract" | Out-Null + $archivePath = "$tempDir\xmemo-skill.tar.gz" + $uri = $packageUrl + $downloaded = $false + for ($redirects = 0; $redirects -lt 6; $redirects++) { + $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + if ([int]$response.StatusCode -ge 300 -and [int]$response.StatusCode -lt 400) { + if (-not $response.Headers.Location) { throw 'HTTPS redirect is missing a location.' } + $nextUri = [Uri]::new($uri, $response.Headers.Location) + $response.Dispose() + if ($nextUri.Scheme -ne 'https') { throw 'Refusing a non-HTTPS redirect.' } + $uri = $nextUri + continue + } + if (-not $response.IsSuccessStatusCode) { throw "Download failed: HTTP $([int]$response.StatusCode)" } + $stream = [System.IO.File]::Create($archivePath) + try { $response.Content.CopyToAsync($stream).GetAwaiter().GetResult() } finally { $stream.Dispose(); $response.Dispose() } + $downloaded = $true + break + } + if (-not $downloaded) { throw 'Too many redirects.' } + & tar.exe -xzf $archivePath -C "$tempDir\extract" + if ($LASTEXITCODE -ne 0) { throw 'Archive extraction failed.' } + if (-not (Test-Path -LiteralPath "$tempDir\extract\scripts\xmemo-skill.mjs" -PathType Leaf)) { + throw 'Archive does not contain xmemo-skill.' + } + Move-Item -LiteralPath "$tempDir\extract" -Destination $installDir + Write-Output "Installed XMemo Skill to $installDir" +} finally { + $client.Dispose() + if (Test-Path -LiteralPath $tempDir) { Remove-Item -LiteralPath $tempDir -Recurse -Force } +} diff --git a/skills/xmemo/install.sh b/skills/xmemo/install.sh new file mode 100644 index 0000000..ab61d73 --- /dev/null +++ b/skills/xmemo/install.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Install the XMemo standalone Skill with only curl and tar available. +set -eu + +base_url="${XMEMO_BASE_URL:-https://xmemo.dev}" +case "$base_url" in https://*) ;; *) printf '%s\n' 'XMemo Skill installer requires an HTTPS XMEMO_BASE_URL.' >&2; exit 1 ;; esac +package_url="${base_url%/}/v1/skill/package" +install_dir="${XMEMO_SKILL_DIR:-xmemo-skill}" +tmp_dir="${install_dir}.tmp.$$" + +fail() { printf '%s\n' "XMemo Skill installer: $1" >&2; exit 1; } +[ ! -e "$install_dir" ] || fail "destination already exists: $install_dir" +cleanup() { rm -rf "$tmp_dir"; } +trap cleanup 0 HUP INT TERM + +mkdir "$tmp_dir" "$tmp_dir/extract" || fail "cannot create temporary directory" +curl --fail --show-error --silent --location --proto '=https' --proto-redir '=https' \ + "$package_url" -o "$tmp_dir/xmemo-skill.tar.gz" || fail "download failed" +tar -xzf "$tmp_dir/xmemo-skill.tar.gz" -C "$tmp_dir/extract" || fail "archive extraction failed" +[ -f "$tmp_dir/extract/scripts/xmemo-skill.mjs" ] || fail "archive does not contain xmemo-skill" +mv "$tmp_dir/extract" "$install_dir" || fail "could not finalize installation" +printf '%s\n' "Installed XMemo Skill to $install_dir" 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/); +}); From 48893e5af6d9c0c1400104d94cef39bd71dcafc3 Mon Sep 17 00:00:00 2001 From: pc-gemini Date: Tue, 11 Aug 2026 20:23:50 +0900 Subject: [PATCH 2/3] feat(cli): install bundled XMemo Skill --- README.md | 20 +++ src/cli.js | 5 + src/commands/skill.js | 202 +++++++++++++++++++++++++++++++ src/ui/help.js | 2 + test/xmemo-skill-command.test.js | 83 +++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 src/commands/skill.js create mode 100644 test/xmemo-skill-command.test.js 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/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); + } + }; +} From f777527e07da424dcfc3708d59378c2f8d2ea46f Mon Sep 17 00:00:00 2001 From: pc-gemini Date: Tue, 11 Aug 2026 22:36:19 +0900 Subject: [PATCH 3/3] release: v0.4.181 --- lhm.plugin.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- server.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) 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"