@xmemo/client
Advanced tools
| $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 } | ||
| } |
| #!/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" |
| 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; | ||
| } | ||
| } |
+1
-1
| { | ||
| "name": "@xmemo/client", | ||
| "version": "0.4.180", | ||
| "version": "0.4.181", | ||
| "description": "Privacy-first CLI and MCP setup helper for XMemo.", | ||
@@ -5,0 +5,0 @@ "mcpName": "io.github.yonro/xmemo", |
+20
-0
@@ -320,2 +320,22 @@ <div align="center"> | ||
| <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> | ||
@@ -322,0 +342,0 @@ |
| # XMemo Skill Change Log | ||
| ## 1.1.5 | ||
| - Add zero-dependency POSIX and PowerShell installers for the published | ||
| standalone Skill archive. Both enforce HTTPS-only download paths, reject | ||
| non-HTTPS redirects, verify the bundled runtime entrypoint, and never accept | ||
| or send XMemo credentials. | ||
| - Document the installer commands and their destination/origin boundaries; | ||
| installation remains separate from explicit login and credential setup. | ||
| - Regression coverage pins the HTTPS, redirect, entrypoint, and no-token | ||
| guarantees for both installer scripts. | ||
| ## 1.1.4 | ||
@@ -4,0 +15,0 @@ |
@@ -16,3 +16,3 @@ #!/usr/bin/env node | ||
| const SKILL_VERSION = '1.1.4'; | ||
| const SKILL_VERSION = '1.1.5'; | ||
| const credentialsPath = path.join(os.homedir(), '.xmemo', 'skill-credentials.json'); | ||
@@ -19,0 +19,0 @@ const registrationPath = path.join(os.homedir(), '.xmemo', 'skill-registration.json'); |
@@ -19,2 +19,21 @@ --- | ||
| ## Install The Standalone Skill | ||
| For a fresh standalone installation, download the currently published Skill | ||
| archive using the installer appropriate to the host: | ||
| ```text | ||
| curl -fsSL https://xmemo.dev/v1/skill/package/install.sh | sh | ||
| ``` | ||
| ```powershell | ||
| irm https://xmemo.dev/v1/skill/package/install.ps1 | iex | ||
| ``` | ||
| Both installers require HTTPS, follow HTTPS-only redirects, and refuse to | ||
| replace an existing destination. By default they create `xmemo-skill` in the | ||
| current directory. Set `XMEMO_SKILL_DIR` to choose a new destination, or set | ||
| `XMEMO_BASE_URL` only to a trusted HTTPS XMemo origin. They download and unpack | ||
| the archive only; login and credential configuration remain explicit steps. | ||
| ## Hosted Discovery Boundary | ||
@@ -21,0 +40,0 @@ |
+5
-0
@@ -19,2 +19,3 @@ import { | ||
| import { setupCommand } from './commands/setup.js'; | ||
| import { skillCommand } from './commands/skill.js'; | ||
| import { uninstallCommand } from './commands/uninstall.js'; | ||
@@ -61,2 +62,6 @@ import { updateCommand } from './commands/update.js'; | ||
| if (command === 'skill') { | ||
| return await skillCommand(args.slice(1), io); | ||
| } | ||
| if (command === 'uninstall') { | ||
@@ -63,0 +68,0 @@ return await uninstallCommand(args.slice(1), io); |
+2
-0
@@ -45,2 +45,4 @@ import { | ||
| 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, ''); | ||
@@ -47,0 +49,0 @@ writeLine(io.stdout, 'MCP And Profiles'); |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
456519
2.81%72
4.35%7061
2.72%536
3.88%23
4.55%