skillcellar 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -5
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
- package/src/backup.mjs +23 -6
- package/src/cli.mjs +116 -18
- package/src/manifests.mjs +290 -0
- package/src/places.mjs +9 -7
- package/src/plan.mjs +3 -3
- package/src/restore.mjs +160 -44
- package/src/scan.mjs +4 -3
- package/src/tech.mjs +242 -0
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Keeps your [SkillCellar](https://skillcellar.com) library's list of what a computer has for Claude Code, globally and in every project: skills, plugins, MCP servers, memory files and agents, by name and hash. It also hands Claude Code the tasks you queue for that computer on the site, applies playbooks, backs the computer up to a private GitHub repository you own, restores another computer's backup, and tells you which of its skills and plugins are outdated. Node 20 or later, no dependencies; `backup` and `restore` need git. Run it with `npx skillcellar <command>`, or install it with `npm install -g skillcellar`.
|
|
4
4
|
|
|
5
5
|
1. Create an API key at https://skillcellar.com/dashboard/keys, one per computer.
|
|
6
|
-
2. `npx skillcellar login`: paste the key, then choose which of your devices this computer is, or name a new one. `--device <name>` chooses without asking. It then offers to install the skillcellar skill for Claude Code, in `~/.claude/skills/skillcellar
|
|
7
|
-
3. `npx skillcellar sync`: reports the whole computer in one request: what it has globally, then every project Claude Code knows. Each sync replaces what the last one reported from this computer, so a project you deleted, or one Claude Code no longer lists, leaves this computer's list on the site, and the site once no computer has anything in it; marks you once made by hand on the site stay. `--project <name>`, run inside a project's folder, reports only that project (every checkout of it Claude Code knows) and global, under that name, and leaves the others as they are. `--dry-run` prints what would be sent and sends nothing. The site then closes the tasks it finds done, and the sync lists them.
|
|
6
|
+
2. `npx skillcellar login`: paste the key, then choose which of your devices this computer is, or name a new one. `--device <name>` chooses without asking. It then offers to install the skillcellar skill for Claude Code, in `~/.claude/skills/skillcellar`, and asks, once, whether to report each project's technologies (see [Technologies](#technologies)).
|
|
7
|
+
3. `npx skillcellar sync`: reports the whole computer in one request: what it has globally, then every project Claude Code knows, and every clone a restore wrote into. Each sync replaces what the last one reported from this computer, so a project you deleted, or one Claude Code no longer lists, leaves this computer's list on the site, and the site once no computer has anything in it; marks you once made by hand on the site stay. `--project <name>`, run inside a project's folder, reports only that project (every checkout of it Claude Code knows) and global, under that name, and leaves the others as they are. `--dry-run` prints what would be sent and sends nothing. The site then closes the tasks it finds done, and the sync lists them. A sync at a terminal asks about technologies if `login` did not.
|
|
8
8
|
|
|
9
9
|
## Tasks
|
|
10
10
|
|
|
@@ -27,8 +27,22 @@ Both read this computer as `sync` does and send that list as `sync` sends it (na
|
|
|
27
27
|
|
|
28
28
|
`skillcellar check` asks the site which of this computer's skills and plugins their sources have moved past, as the last sync reported them, and prints each with the command that updates it: a skill `npx skills` installed globally, when the git tree of its folder on the repository's default branch is no longer the one its lock recorded (`npx skills update <name> -g`; a skill whose folder is not named as its SKILL.md names it updates with `npx skills update -g`); a plugin, when its marketplace entry declares a version other than the installed one (`claude plugin update <plugin>@<marketplace>`); a skill you wrote or bought, in a folder of its name, when that folder is an older version of it (`skillcellar pull <name> --force`, or `<username>/<name>` for one you bought, with `--scope project` in a project; it replaces the folder with the site's latest version, which is why a folder that is no version of the skill, edited here or another skill of that name, is listed as not checked instead). It then lists what the site cannot check, each with why: a skill `npx skills` installed in a project, whose lock hashes the files, which GitHub does not list; a plugin reported without a version, whose marketplace entry declares none, whose marketplace no longer lists it, or whose marketplace is not on GitHub; a repository that is private, gone or too large to list, or whose folder is no longer at the recorded path; and what the site did not read this time, past its 40 sources or 8 seconds a check or its GitHub budget, which the next check tries again. The site reads each repository and marketplace once an hour, public ones only, with its own GitHub token. `--json` prints the site's answer as it is. Sync first: the check reads what the last sync reported, as its last line says, not the computer as it is now.
|
|
29
29
|
|
|
30
|
+
## Technologies
|
|
31
|
+
|
|
32
|
+
At the first `login` or `sync` at a terminal, the CLI asks once whether to report each project's technologies, such as `nextjs` or `postgres`, so the site can show what fits each project. Until you say yes, it reads and sends nothing about them. `skillcellar tech on` and `skillcellar tech off` change the answer; `skillcellar tech` says which it is.
|
|
33
|
+
|
|
34
|
+
With technologies on, each sync downloads the site's public list at `/tech.json`, without your key, and checks each project where it reports something against it, on this computer:
|
|
35
|
+
|
|
36
|
+
- the files git tracks in the project (the files at its root, when git tracks none), up to three folders below its root, outside folders such as `node_modules`, `vendor`, `dist` or `examples`. A file in a folder starting with a dot counts only where the list names that folder, such as `.github/workflows`, and a manifest there is never read;
|
|
37
|
+
- at the project's root, the few files the list names there, such as `.neon`, even when git does not track them;
|
|
38
|
+
- the package names in its `package.json`, `composer.json`, `requirements*.txt`, `*-requirements.txt`, `requirements/*.txt`, `pyproject.toml`, `Pipfile`, `go.mod`, `Cargo.toml`, `Gemfile` and `pubspec.yaml` files, up to 40 of them, each at most 1 MiB. It never opens a lockfile, a `.env` file or a file through a link.
|
|
39
|
+
|
|
40
|
+
Only the ids of the list that match are sent, at most 32 a project. A project whose folder it cannot read, or whose manifests it cannot get through within two seconds, sends none, and the site keeps what it had there. Turned off, each sync takes them back from the site until one of the whole computer has gone through. When the site's list cannot be read, or the technologies would take a sync past its 5,000 items, the sync goes on without them, says so, and the site keeps what it had.
|
|
41
|
+
|
|
30
42
|
## Backups
|
|
31
43
|
|
|
44
|
+
To move to a new computer, or back onto one you reinstall, follow the step-by-step guide at https://skillcellar.com/guides/new-computer.
|
|
45
|
+
|
|
32
46
|
`skillcellar backup --repo <owner>/<repo>` pushes this computer's Claude Code setup to a private GitHub repository you own, with your own git and its credentials, under `devices/<device>/`: `global/` holds your skills' source files, `CLAUDE.md`, `rules/`, `agents/` and `mcp.json`; `projects/<project>/` holds the same for each project, with its `AGENTS.md`, the session memory Claude Code keeps for it under `memory/`, and its MCP servers in `mcp.json` (the project's `.mcp.json`) and `mcp.local.json` (those Claude Code keeps for its folders). A project checked out more than once on the computer is one project, as on the site: every checkout goes in its one folder, and where two hold a file or a skill of the same name, the one the sync sends first goes and the other is listed as left out. MCP servers of the same name are kept once the same way, but separately in `mcp.json` and in `mcp.local.json`, and the one left out is not listed. A `manifest.json` names the device and its projects. The repository is remembered, so `skillcellar backup` alone works from then on; `--ssh` clones and pushes over SSH, and `--dry-run` lists what would go without cloning, committing or pushing.
|
|
33
47
|
|
|
34
48
|
- In the MCP config every `env` and header value, an address's credentials and its query's values, the parts of its path that are not short words, and an argument that looks like a key are replaced by a placeholder, named after its key where one exists (`${CONTEXT7_KEY}`), else `${ARG_n}` or `${PATH_n}`. A key of a shape the check does not know, in another field, or given as a bare argument shorter than 16 characters, stays: read the backup once before you rely on it.
|
|
@@ -36,13 +50,13 @@ Both read this computer as `sync` does and send that list as `sync` sends it (na
|
|
|
36
50
|
- Before anything is pushed, every file is read for what looks like a key, a token, a password or a private key: known shapes (GitHub, OpenAI, AWS, Stripe, Slack, Google, npm, Neon, Resend, Hugging Face, Groq, JSON Web Tokens, private key blocks), a password in an address, a bearer or basic credential, and a name such as `API_KEY`, `password` or `token` assigned a value with digits in it; neither of the last two counts when the value is elided (`abc123...`) or holds an example word (`dummy`, `fake`, `sample`, `example`, `placeholder`, `redacted` or `changeme`) in any case with no letter next to it; an assignment whose value starts with `example`, `placeholder`, `redacted` or `changeme` is passed whatever follows. An assignment does not count either when its value is unquoted and is a dotted name directly followed by `(`, such as `base64.b64encode(`. Anything found stops the backup with each file and line (the first 20), and nothing is pushed. A file you have read and know holds no secret goes through with `--allow <path>` for that file, and each run lists the files it lets through this way. A backup that pushes remembers each with the hash of its contents, so later runs pass it without `--allow` until it changes, and names it in `manifest.json`, so a restore copies it as it was allowed. Then the repository is checked without a token: one that answers anyone is public, and is refused. The clone lives beside the config, in `skillcellar/backups/<owner>/<repo>`, readable by you only; each backup brings the clone to the remote's state, commits and pushes, and a commit a failed push left behind goes with the next run.
|
|
37
51
|
- The site learns only the repository's name, the commit and the time, which the device's page shows. It holds no token for the repository and never reads the backup.
|
|
38
52
|
|
|
39
|
-
`skillcellar restore` carries out the copy tasks waiting for this computer ("Copy to…" on the compare page, or "Copy everything to…" on a device's page). The site names the other computer's backup repository and commit, and a name is not proof: the restore takes a repository this computer does not itself back up to only with `--repo <owner>/<repo>`, or a yes at the terminal, and refuses a public one. It fetches the backup into a bare mirror beside the config (`skillcellar/backups/<owner>/<repo>.git`, never checked out, so a link in the repository is never followed), reads the commit the site recorded, and writes its skills, memory files and agents into place: a memory file or agent only when its hash is the one the other computer reported, where it reported one, nothing that holds what looks like a secret unless the backup's `manifest.json` names the file as allowed with those very contents, nothing over the skillcellar skill, nothing over a file that is a link, and nothing into a project's `.claude`, `.claude/skills`, `.claude/agents` or `.claude/rules` when that folder is a link
|
|
53
|
+
`skillcellar restore` carries out the copy tasks waiting for this computer ("Copy to…" on the compare page, or "Copy everything to…" on a device's page). The site names the other computer's backup repository and commit, and a name is not proof: the restore takes a repository this computer does not itself back up to only with `--repo <owner>/<repo>`, or a yes at the terminal, and refuses a public one. It fetches the backup into a bare mirror beside the config (`skillcellar/backups/<owner>/<repo>.git`, never checked out, so a link in the repository is never followed), reads the commit the site recorded, and writes its skills, memory files and agents into place: a memory file or agent only when its hash is the one the other computer reported, where it reported one, unless you pass `--take-backup`, which copies the backup's copy and says so; nothing that holds what looks like a secret unless the backup's `manifest.json` names the file as allowed with those very contents, nothing over the skillcellar skill, nothing over a file that is a link, and nothing into a project's `.claude`, `.claude/skills`, `.claude/agents` or `.claude/rules` when that folder is a link. What is there already stays unless you pass `--force`, which replaces every item the tasks name: one the same as the backup's copy is only counted, one that differs is named, and a skill folder without its SKILL.md, which an antivirus can leave behind, is named as no skill rather than kept. A copy of everything in a place also lists what the backup holds there that the other computer's last sync did not report, such as a memory file written after that sync; `--take-backup` copies those too. It prints the commands left: a plugin's install, `npx skills add` for a skill from a repository, under the name the skill's SKILL.md gives in the backup and with `-g -y` for a global task, and `claude mcp add-json` for each MCP server with the backup's config, where each `${NAME}` is yours to fill in, and a `review` note marks a `${VAR:-default}` or `$VAR` reference or a `headersHelper` to read before adding. `--from-backup <skill>` copies a skill's files from the backup instead of printing its command, for a skill that has left its repository; a plugin whose marketplace renamed itself since installs under the name `/plugin marketplace list` shows. It also names the `package.json`, `requirements.txt`, `pyproject.toml` or Playwright config a restored skill carries, so you can rebuild its environment. `--from <device>` takes one computer's tasks; `--ssh` fetches over SSH, and a fetch over HTTPS that git has no credentials for says to add it; `--dry-run` fetches the backup but writes nothing into place. The printed commands are quoted for a POSIX shell; on Windows, paste them into Git Bash or WSL. Each project folder it restores is remembered in the config, so `sync`, `plan` and `backup` read it from then on, as if Claude Code had been opened there. Then `skillcellar sync` closes each task this computer now satisfies.
|
|
40
54
|
|
|
41
55
|
## What it reads
|
|
42
56
|
|
|
43
57
|
Globally, in `~/.claude`:
|
|
44
58
|
|
|
45
|
-
- `skills`: each folder with a SKILL.md, skipping `learned`, `synced
|
|
59
|
+
- `skills`: each folder with a SKILL.md of its own, whatever its name, skipping `learned`, `synced` and hidden folders, and skills nested deeper, such as in skill-creator's `*-workspace` folders; and the lock where `npx skills` records each skill's repository, folder and hash: `$XDG_STATE_HOME/skills/.skill-lock.json` when that variable is set, else `~/.agents/.skill-lock.json`.
|
|
46
60
|
- `plugins/installed_plugins.json` and `known_marketplaces.json`: the plugins Claude Code lists as installed, and their marketplaces.
|
|
47
61
|
- `CLAUDE.md`, `rules/**/*.md` and `agents/**/*.md`.
|
|
48
62
|
- `~/.claude.json`: your MCP servers (`mcpServers`), and the projects Claude Code knows (`projects`), with their own MCP servers.
|
|
@@ -62,7 +76,8 @@ For each place, global or a project:
|
|
|
62
76
|
|
|
63
77
|
- a skill's or plugin's name, the GitHub repository or marketplace it comes from as an https address, its folder in that repository, and a hash or version;
|
|
64
78
|
- an MCP server's name, its transport (stdio, HTTP or SSE), and for stdio the command's own name, such as `npx`, with the package a runner such as `npx`, `uvx`, `bunx` or `pnpm dlx` starts; for HTTP and SSE, the address's origin and its path up to the first part that is not a short word such as `mcp`, `sse` or `v1`, since some addresses carry a key there. A flag the CLI does not know ends its search for the package, since its value could be a key; a flag written as `--flag=value` is skipped, and its value is never sent. Never its `env` or header values, its other arguments, a query, or a user name and password. WebSocket and SDK servers are left out;
|
|
65
|
-
- a memory file's path from its folder, such as `rules/common/testing.md`, and an agent's name, each with a SHA-256 of the file, never its contents
|
|
79
|
+
- a memory file's path from its folder, such as `rules/common/testing.md`, and an agent's name, each with a SHA-256 of the file, never its contents;
|
|
80
|
+
- with technologies on, a project's technologies, as ids of the site's list, never a package's name.
|
|
66
81
|
|
|
67
82
|
The project's name is the one you give with `--project`, else its git remote's `owner/repo`, else its folder's name, and a GitHub remote is also sent as the project's repository; the site keeps the name only as a slug, such as `owner-repo`, and a folder's name as you see it. It sends no path on your computer; a name it cannot send as it is, it lists and leaves out.
|
|
68
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillcellar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Sync what Claude Code has on this computer (skills, plugins, MCP servers, memory and agents) to your SkillCellar library, by name and hash, list the tasks you queue there for Claude Code to carry out, check which skills and plugins are outdated, and apply playbooks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
package/skill/SKILL.md
CHANGED
|
@@ -61,9 +61,9 @@ A removal deletes something from this computer. Show the user exactly what goes,
|
|
|
61
61
|
|
|
62
62
|
## 4. Copy
|
|
63
63
|
|
|
64
|
-
A copy brings what another of the user's computers has, in the same place, from that computer's backup, a private GitHub repository the user owns. The site only names that repository, and a name is not proof: show the user `copy.from.device.name` and `copy.from.backup.repo`, and run the restore only once they confirm the repository is theirs, as `npx skillcellar restore --repo <owner>/<repo>` with the name they confirmed (`--from <device>` takes one computer's tasks; on a computer that backs up to the same repository, `npx skillcellar restore` alone works). For a project task, run it from inside the project's folder on this computer, cloned first if it is not there yet. It refuses a public repository, fetches the backup into the tool's own folder with the user's own git, reads the commit the site recorded, writes skills, memory files and agents into place, keeps a folder or file that is there already, and prints the rest as commands. If it says a folder is there already, ask the user whether to replace it, and run it again with `--force` only on a yes: `--force` replaces every item it names, so tell the user which. If it says there is no backup yet, or that the commit is missing, tell the user to run `npx skillcellar backup` on the other computer, and go on with the next task.
|
|
64
|
+
A copy brings what another of the user's computers has, in the same place, from that computer's backup, a private GitHub repository the user owns. The site only names that repository, and a name is not proof: show the user `copy.from.device.name` and `copy.from.backup.repo`, and run the restore only once they confirm the repository is theirs, as `npx skillcellar restore --repo <owner>/<repo>` with the name they confirmed (`--from <device>` takes one computer's tasks; on a computer that backs up to the same repository, `npx skillcellar restore` alone works). For a project task, run it from inside the project's folder on this computer, cloned first if it is not there yet. It refuses a public repository, fetches the backup into the tool's own folder with the user's own git, reads the commit the site recorded, writes skills, memory files and agents into place, keeps a folder or file that is there already, and prints the rest as commands. If it says a folder is there already, ask the user whether to replace it, and run it again with `--force` only on a yes: `--force` replaces every item it names, so tell the user which. If it says a skill's folder has no SKILL.md, an antivirus may have removed it: tell the user, and copy it again with `--force` only on their yes. If it says the backup's copy of a file is not the one the other computer last reported, or lists what the backup holds that its last sync did not report, show the user and run it again with `--take-backup` only on their yes. If it says there is no backup yet, or that the commit is missing, tell the user to run `npx skillcellar backup` on the other computer, and go on with the next task.
|
|
65
65
|
|
|
66
|
-
The commands it prints run each inside the folder of its task. A plugin's or a repository skill's install command is plain, as section 2 says. A `claude mcp add-json <name> '<json>' --scope <scope>` command is never plain: it adds a server that starts with every Claude Code session, with the backup's config, so show the user its full JSON (the command, its arguments, the address, the headers) and run it only on their yes to that server. A `${NAME}` in that config marks a value the backup left out, such as a key or an environment variable, and a command marked `review` holds a `${VAR:-default}` or `$VAR` reference that reaches into the user's environment, or a `headersHelper` command: both go to the user, as section 2 says, and you never fill a placeholder in. An item marked `tracked` comes with the project's repository: pull it, and copy nothing.
|
|
66
|
+
The commands it prints run each inside the folder of its task. A plugin's or a repository skill's install command is plain, as section 2 says; a global `npx skills add` from `restore` carries `-g -y` already, so add only `--agent claude-code`. If it finds no matching skill, the skill has left its repository: ask the user, and on a yes run the restore again with `--from-backup <name>`, which copies the backup's copy instead. If `/plugin install` says the plugin is not in its marketplace, run `/plugin marketplace list` and install it under the name shown there for that marketplace. A `claude mcp add-json <name> '<json>' --scope <scope>` command is never plain: it adds a server that starts with every Claude Code session, with the backup's config, so show the user its full JSON (the command, its arguments, the address, the headers) and run it only on their yes to that server. A `${NAME}` in that config marks a value the backup left out, such as a key or an environment variable, and a command marked `review` holds a `${VAR:-default}` or `$VAR` reference that reaches into the user's environment, or a `headersHelper` command: both go to the user, as section 2 says, and you never fill a placeholder in. An item marked `tracked` comes with the project's repository: pull it, and copy nothing.
|
|
67
67
|
|
|
68
68
|
A restored skill may need its environment: `restore` names each `package.json` (`npm install`), `requirements.txt` (`pip install -r requirements.txt`), `pyproject.toml` or Playwright config it found. Each of those is a package manager's install, so show it to the user and run it only after their yes to that command, inside the skill's folder.
|
|
69
69
|
|
package/src/backup.mjs
CHANGED
|
@@ -444,6 +444,9 @@ export async function collectBackup({ device, claudeDir, claudeJson, projects, t
|
|
|
444
444
|
// How many findings a stopped backup lists.
|
|
445
445
|
const FINDINGS_MAX = 20;
|
|
446
446
|
|
|
447
|
+
/** A path with `/` between its folders, as on Windows `--allow` may name a file either way. */
|
|
448
|
+
const slashed = (path) => path.replaceAll("\\", "/");
|
|
449
|
+
|
|
447
450
|
/**
|
|
448
451
|
* The files of a backup that hold what looks like a secret, each with the line, up to FINDINGS_MAX,
|
|
449
452
|
* and those that pass despite one, each with the hash of what passed: a file the user allows by its
|
|
@@ -457,8 +460,8 @@ export function findSecretsIn(files, allowed = [], remembered = []) {
|
|
|
457
460
|
if (!found) continue;
|
|
458
461
|
const sha256 = createHash("sha256").update(file.content).digest("hex");
|
|
459
462
|
// A merged file, such as the mcp.json of several checkouts, is allowed by any file it came from.
|
|
460
|
-
const names = file.sources ?? [file.source];
|
|
461
|
-
if (allowed.some((path) => names.some((name) => name === path || name.endsWith(`/${path}`))) || remembered.some((entry) => entry?.file === file.source && entry.sha256 === sha256)) {
|
|
463
|
+
const names = (file.sources ?? [file.source]).map(slashed);
|
|
464
|
+
if (allowed.map(slashed).some((path) => names.some((name) => name === path || name.endsWith(`/${path}`))) || remembered.some((entry) => entry?.file === file.source && entry.sha256 === sha256)) {
|
|
462
465
|
passed.push({ file: file.source, path: file.path, sha256, line: found.line, kind: found.kind });
|
|
463
466
|
continue;
|
|
464
467
|
}
|
|
@@ -501,6 +504,14 @@ export async function currentBranch(clone) {
|
|
|
501
504
|
return head.trim() ? ref.trim() || null : null;
|
|
502
505
|
}
|
|
503
506
|
|
|
507
|
+
// What git says when it has no credentials for a GitHub repository over HTTPS.
|
|
508
|
+
const HTTPS_AUTH_RE = /could not read Username|Authentication failed|terminal prompts disabled|Invalid username or password|repository '[^']*' not found/i;
|
|
509
|
+
|
|
510
|
+
/** Git's failure to reach the backup repository, naming the way out when HTTPS had no credentials: SSH. */
|
|
511
|
+
export function reachError(error, repo) {
|
|
512
|
+
return !repo.ssh && HTTPS_AUTH_RE.test(error.message) ? new Error(`${error.message}. If your GitHub access is an SSH key, run it again with --ssh.`) : error;
|
|
513
|
+
}
|
|
514
|
+
|
|
504
515
|
/**
|
|
505
516
|
* The clone of the backup repository, made if missing, else brought to the remote's state: the
|
|
506
517
|
* clone is the tool's own scratch area, so what a failed run left in its tree is dropped, a commit a
|
|
@@ -509,13 +520,16 @@ export async function currentBranch(clone) {
|
|
|
509
520
|
export async function ensureClone(repo) {
|
|
510
521
|
const clone = clonePath(repo.owner, repo.repo);
|
|
511
522
|
const there = await stat(join(clone, ".git")).catch(() => null);
|
|
523
|
+
const reach = (error) => {
|
|
524
|
+
throw reachError(error, repo);
|
|
525
|
+
};
|
|
512
526
|
if (!there?.isDirectory()) {
|
|
513
527
|
await mkdir(dirname(clone), { recursive: true, mode: 0o700 });
|
|
514
|
-
await git(["clone", "--quiet", repo.url, clone], { timeout: 600_000 });
|
|
528
|
+
await git(["clone", "--quiet", repo.url, clone], { timeout: 600_000 }).catch(reach);
|
|
515
529
|
return clone;
|
|
516
530
|
}
|
|
517
531
|
await git(["remote", "set-url", "origin", repo.url], { cwd: clone });
|
|
518
|
-
await git(["fetch", "--quiet", "origin"], { cwd: clone, timeout: 600_000 });
|
|
532
|
+
await git(["fetch", "--quiet", "origin"], { cwd: clone, timeout: 600_000 }).catch(reach);
|
|
519
533
|
const branch = await currentBranch(clone);
|
|
520
534
|
if (branch) {
|
|
521
535
|
await git(["reset", "--hard", "--quiet"], { cwd: clone });
|
|
@@ -533,13 +547,16 @@ export async function ensureClone(repo) {
|
|
|
533
547
|
export async function ensureMirror(repo) {
|
|
534
548
|
const mirror = `${clonePath(repo.owner, repo.repo)}.git`;
|
|
535
549
|
const there = await stat(join(mirror, "HEAD")).catch(() => null);
|
|
550
|
+
const reach = (error) => {
|
|
551
|
+
throw reachError(error, repo);
|
|
552
|
+
};
|
|
536
553
|
if (!there?.isFile()) {
|
|
537
554
|
await mkdir(dirname(mirror), { recursive: true, mode: 0o700 });
|
|
538
|
-
await git(["clone", "--quiet", "--bare", repo.url, mirror], { timeout: 600_000 });
|
|
555
|
+
await git(["clone", "--quiet", "--bare", repo.url, mirror], { timeout: 600_000 }).catch(reach);
|
|
539
556
|
return mirror;
|
|
540
557
|
}
|
|
541
558
|
await git(["--git-dir", mirror, "remote", "set-url", "origin", repo.url]);
|
|
542
|
-
await git(["--git-dir", mirror, "fetch", "--quiet", "origin", "+refs/heads/*:refs/heads/*"], { timeout: 600_000 });
|
|
559
|
+
await git(["--git-dir", mirror, "fetch", "--quiet", "origin", "+refs/heads/*:refs/heads/*"], { timeout: 600_000 }).catch(reach);
|
|
543
560
|
return mirror;
|
|
544
561
|
}
|
|
545
562
|
|
package/src/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { checkProjectSkills, installPulled, PULL_REF_RE, readPulled } from "./pu
|
|
|
15
15
|
import { runRestore } from "./restore.mjs";
|
|
16
16
|
import { readJson, scanGlobal, scanProject } from "./scan.mjs";
|
|
17
17
|
import { installSkill, skillFolder, skillState } from "./skill.mjs";
|
|
18
|
+
import { detectTech, loadRules, techChoice, techServed } from "./tech.mjs";
|
|
18
19
|
|
|
19
20
|
const DEFAULT_SITE = "https://skillcellar.com";
|
|
20
21
|
const VERSION = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -63,6 +64,11 @@ Usage:
|
|
|
63
64
|
missing with how to install it. It reads this computer as sync does, and the site keeps none of it.
|
|
64
65
|
skillcellar skill
|
|
65
66
|
Install or update the skillcellar skill, which tells Claude Code how to carry out your tasks.
|
|
67
|
+
skillcellar tech [on|off]
|
|
68
|
+
Turn on or off reporting each project's technologies at sync, such as nextjs or postgres:
|
|
69
|
+
this computer checks your projects against the site's public list at <site>/tech.json and
|
|
70
|
+
sends only the ids that match, never a package's name, a file or a path. Off, the next sync
|
|
71
|
+
of the whole computer removes the ones the site has. Alone, it says which it is.
|
|
66
72
|
skillcellar backup [--repo <owner>/<repo>] [--ssh] [--allow <file>] [--dry-run]
|
|
67
73
|
Push this computer's memory files, rules, agents, MCP config (every env and header value,
|
|
68
74
|
address credential and key-like argument replaced by a \${NAME} placeholder) and skills'
|
|
@@ -70,11 +76,17 @@ Usage:
|
|
|
70
76
|
that looks like a secret and refuses a public repository. --repo is remembered; --ssh clones
|
|
71
77
|
and pushes over SSH; --allow <file> pushes a file you have read despite a finding in it,
|
|
72
78
|
and once pushed is remembered while the file stays the same; --dry-run lists what would go.
|
|
73
|
-
skillcellar restore [--from <device>] [--repo <owner>/<repo>] [--ssh] [--force] [--
|
|
79
|
+
skillcellar restore [--from <device>] [--repo <owner>/<repo>] [--ssh] [--force] [--take-backup]
|
|
80
|
+
[--from-backup <skill>] [--dry-run]
|
|
74
81
|
Carry out the copy tasks waiting for this computer: fetch the other computer's backup, write
|
|
75
82
|
its skills, memory files and agents into place, and list the commands left to run. A backup
|
|
76
83
|
in a repository this computer does not back up to is taken only with --repo, or a yes at the
|
|
77
|
-
terminal.
|
|
84
|
+
terminal. Each project folder it restores is remembered, so sync reports it from then on.
|
|
85
|
+
--force replace a folder or file that is there already
|
|
86
|
+
--take-backup copy a file whose backup copy is not the one the other computer last
|
|
87
|
+
reported, and what the backup holds that its last sync did not report
|
|
88
|
+
--from-backup <skill> copy this skill from the backup instead of printing its install
|
|
89
|
+
command, for one that has left its repository
|
|
78
90
|
|
|
79
91
|
login reads the key from SKILLCELLAR_API_KEY when it is set, else from the terminal or the first
|
|
80
92
|
line piped in. The key and the device are kept in ${configPath()}, written with mode 0600.`;
|
|
@@ -152,6 +164,16 @@ async function offerSkill(print) {
|
|
|
152
164
|
else print(`The skillcellar skill is not ${state === "missing" ? "installed" : "up to date"}; run skillcellar skill to ${verb.toLowerCase()} it.`);
|
|
153
165
|
}
|
|
154
166
|
|
|
167
|
+
/** The one question that turns technologies on: nothing about them leaves this computer before a yes. */
|
|
168
|
+
const techQuestion = (site) =>
|
|
169
|
+
`Also report each project's technologies, such as nextjs or postgres, so SkillCellar can show what fits each project? This computer checks your projects against the site's public list at ${new URL(site).host}/tech.json and sends only the ids that match, never a package's name, a file or a path. Change it any time with skillcellar tech on or off. [y/N] `;
|
|
170
|
+
|
|
171
|
+
const askTech = async (site) => /^y(es)?$/i.test(await ask(techQuestion(site)));
|
|
172
|
+
|
|
173
|
+
/** What turning technologies on or off just did, in one line. */
|
|
174
|
+
const techSaved = (tech, site) =>
|
|
175
|
+
tech === "on" ? `Technologies on: each sync reports your projects' technologies as ids from ${new URL(site).host}/tech.json.` : "Technologies off: run skillcellar tech on to report them.";
|
|
176
|
+
|
|
155
177
|
async function login(flags, print) {
|
|
156
178
|
const site = siteOrigin(flags.site ?? process.env.SKILLCELLAR_URL ?? DEFAULT_SITE);
|
|
157
179
|
if (!site) throw new UsageError("--site must be an https address, such as https://skillcellar.com, or http on this computer for development.");
|
|
@@ -159,13 +181,23 @@ async function login(flags, print) {
|
|
|
159
181
|
if (!/^[A-Za-z0-9_]{1,200}$/.test(key)) throw new Error(`That is not an API key. Create one at ${site}/dashboard/keys.`);
|
|
160
182
|
const library = await call(site, key, "GET", "/api/v1/library", undefined, VERSION);
|
|
161
183
|
const device = flags.device !== undefined ? await namedDevice(site, key, flags.device) : await chooseDevice(site, key, library.devices ?? [], print);
|
|
162
|
-
// A backup repository remembered for this site stays remembered across sign-ins.
|
|
184
|
+
// A backup repository remembered for this site stays remembered across sign-ins, and the folders a restore wrote into always.
|
|
163
185
|
const earlier = await readConfig().catch(() => null);
|
|
164
186
|
const backup = earlier?.site === site && earlier.backup ? { backup: earlier.backup } : {};
|
|
165
|
-
|
|
187
|
+
const folders = Array.isArray(earlier?.folders) ? { folders: earlier.folders } : {};
|
|
188
|
+
// So is the answer about technologies, which is this site's to know.
|
|
189
|
+
const tech = earlier?.site === site && typeof earlier.tech === "string" ? { tech: earlier.tech } : {};
|
|
190
|
+
const saved = { site, key, device: { id: device.id, slug: device.slug, name: device.name }, ...backup, ...folders, ...tech };
|
|
191
|
+
await writeConfig(saved);
|
|
166
192
|
const who = library.account?.email ? ` as ${library.account.email}` : "";
|
|
167
193
|
print(`Signed in to ${new URL(site).host}${who}. This computer syncs as ${device.name}.`);
|
|
168
194
|
await offerSkill(print);
|
|
195
|
+
// Asked here as well: a sync Claude Code runs has no terminal to ask at.
|
|
196
|
+
if (!tech.tech && interactive()) {
|
|
197
|
+
const on = (await askTech(site)) ? "on" : "off";
|
|
198
|
+
await writeConfig({ ...saved, tech: on });
|
|
199
|
+
print(techSaved(on, site));
|
|
200
|
+
}
|
|
169
201
|
}
|
|
170
202
|
|
|
171
203
|
const plural = (n, word, words = `${word}s`) => `${n} ${n === 1 ? word : words}`;
|
|
@@ -220,10 +252,25 @@ async function sync(flags, print, warn) {
|
|
|
220
252
|
// Read from the wrong folder, a sync would report nothing and remove what the last one reported.
|
|
221
253
|
await checkClaudeFolder(claudeDir);
|
|
222
254
|
|
|
255
|
+
// Technologies go only after a yes, and only to a site that serves its list: one without it gets
|
|
256
|
+
// the payload it always had, and keeps what it holds.
|
|
257
|
+
const choice = await techChoice(config?.tech, Boolean(config) && !dryRun && interactive(), () => askTech(site));
|
|
258
|
+
if (choice.asked) {
|
|
259
|
+
await writeConfig({ ...config, tech: choice.tech });
|
|
260
|
+
print(techSaved(choice.tech, site));
|
|
261
|
+
}
|
|
262
|
+
// Taking them back needs no rules, only a site that serves the list, whatever its version.
|
|
263
|
+
const rules = choice.tech === "on" && site ? await loadRules(site, VERSION) : null;
|
|
264
|
+
const clearing = choice.tech === "clearing" && site !== null && (await techServed(site, VERSION));
|
|
265
|
+
if (site && ((choice.tech === "on" && !rules) || (choice.tech === "clearing" && !clearing))) {
|
|
266
|
+
warn(`Technologies not ${choice.tech === "on" ? "reported" : "removed"} this time: ${new URL(site).host}/tech.json could not be read. The site keeps what it had.`);
|
|
267
|
+
}
|
|
268
|
+
const reportTech = rules !== null;
|
|
269
|
+
|
|
223
270
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
224
271
|
const cwd = process.cwd();
|
|
225
272
|
const whole = flags.project === undefined;
|
|
226
|
-
let projects = await findProjects({ home, claudeDir, claudeJson, cwd });
|
|
273
|
+
let projects = await findProjects({ home, claudeDir, claudeJson, cwd, restored: config?.folders });
|
|
227
274
|
if (!whole) {
|
|
228
275
|
const here = await findProject(cwd, home);
|
|
229
276
|
if (!here) throw new UsageError("Run sync --project inside the project's folder, not in your home folder.");
|
|
@@ -244,8 +291,9 @@ async function sync(flags, print, warn) {
|
|
|
244
291
|
continue;
|
|
245
292
|
}
|
|
246
293
|
let scanned;
|
|
294
|
+
const tracked = await trackedFiles(project.root);
|
|
247
295
|
try {
|
|
248
|
-
scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson, tracked
|
|
296
|
+
scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson, tracked });
|
|
249
297
|
} catch (error) {
|
|
250
298
|
// One project's broken file stops only that project in a sync of the whole computer.
|
|
251
299
|
if (!whole) throw error;
|
|
@@ -254,7 +302,15 @@ async function sync(flags, print, warn) {
|
|
|
254
302
|
continue;
|
|
255
303
|
}
|
|
256
304
|
skipped.push(...scanned.skipped);
|
|
257
|
-
|
|
305
|
+
// The site keeps a project's technologies only where the device reports something, so a place
|
|
306
|
+
// with nothing lists none; one the CLI could not read lists nothing at all, and keeps what the site had.
|
|
307
|
+
let tech = {};
|
|
308
|
+
if (reportTech) {
|
|
309
|
+
const found = scanned.items.length ? await detectTech(rules, project.root, tracked) : [];
|
|
310
|
+
if (found) tech = { tech: found };
|
|
311
|
+
else warn(`Technologies of ${project.name} not read this time. The site keeps what it had there.`);
|
|
312
|
+
}
|
|
313
|
+
const place = { scope: "project", project: { name: project.name, repo: project.repo, remote: project.remote }, items: scanned.items, ...tech };
|
|
258
314
|
// A complete sync replaces every place on the device, so a project with nothing in it needs no place.
|
|
259
315
|
if (whole && !scanned.items.length) empty.push(place);
|
|
260
316
|
else places.push(place);
|
|
@@ -275,8 +331,15 @@ async function sync(flags, print, warn) {
|
|
|
275
331
|
}
|
|
276
332
|
|
|
277
333
|
const device = config?.device.id ?? null;
|
|
278
|
-
const payload = { device, complete, places: [{ scope: "global", project: null, items: global.items }, ...places] };
|
|
279
|
-
|
|
334
|
+
const payload = { device, complete, ...(reportTech || clearing ? { tech: reportTech } : {}), places: [{ scope: "global", project: null, items: global.items }, ...places] };
|
|
335
|
+
// A project's technologies count as items on the site; past the cap with them, the sync goes without.
|
|
336
|
+
const counted = (withTech) => payload.places.reduce((total, place) => total + place.items.length + (withTech ? (place.tech?.length ?? 0) : 0), 0);
|
|
337
|
+
let items = counted(true);
|
|
338
|
+
if (items > ITEMS_PER_SYNC && counted(false) <= ITEMS_PER_SYNC) {
|
|
339
|
+
payload.places = payload.places.map((place) => Object.fromEntries(Object.entries(place).filter(([key]) => key !== "tech")));
|
|
340
|
+
items = counted(false);
|
|
341
|
+
warn(`Technologies not reported this time: with them, this sync passes the ${ITEMS_PER_SYNC} items one sync takes. The site keeps what it had.`);
|
|
342
|
+
}
|
|
280
343
|
if (items > ITEMS_PER_SYNC) {
|
|
281
344
|
throw new Error(`This computer has ${items} items, and one sync takes ${ITEMS_PER_SYNC}. Sync the projects you need from their folders with --project <name>.`);
|
|
282
345
|
}
|
|
@@ -287,6 +350,12 @@ async function sync(flags, print, warn) {
|
|
|
287
350
|
}
|
|
288
351
|
const result = await call(site, config.key, "POST", "/api/v1/sync", payload, VERSION);
|
|
289
352
|
print(summary(payload, result, config.device.name, whole));
|
|
353
|
+
if (reportTech && typeof result.tech === "number") print(`Reported the technologies of ${plural(result.tech, "project")}.`);
|
|
354
|
+
// Taken back everywhere once a sync of the whole computer has gone through.
|
|
355
|
+
if (clearing && complete) {
|
|
356
|
+
await writeConfig({ ...config, tech: "off" });
|
|
357
|
+
print(`Removed this computer's technologies from ${new URL(site).host}.`);
|
|
358
|
+
}
|
|
290
359
|
for (const note of Array.isArray(result.left) ? result.left : []) warn(`Left out ${note}`);
|
|
291
360
|
const closed = Array.isArray(result.closed) ? result.closed : [];
|
|
292
361
|
if (closed.length) print(closedLine(closed));
|
|
@@ -300,7 +369,7 @@ async function plan(flags, print) {
|
|
|
300
369
|
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
301
370
|
const home = homedir();
|
|
302
371
|
const claudeDir = claudeFolder(home);
|
|
303
|
-
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
|
|
372
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir, config) : [];
|
|
304
373
|
// The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
|
|
305
374
|
const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
306
375
|
if (flags.json) print(JSON.stringify(local, null, 2));
|
|
@@ -352,7 +421,7 @@ async function playbook(flags, action, ref, print, warn) {
|
|
|
352
421
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
353
422
|
const global = await scanGlobal({ home, claudeDir, claudeJson });
|
|
354
423
|
// The project as a sync reads it: every folder in it Claude Code ran in, as well as this one.
|
|
355
|
-
const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd() })).find((entry) => entry.root === here.root) : null;
|
|
424
|
+
const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd(), restored: config.folders })).find((entry) => entry.root === here.root) : null;
|
|
356
425
|
const folders = here ? [...new Set([here.root, ...(known?.folders ?? []), process.cwd()])] : [];
|
|
357
426
|
const project = here ? await scanProject({ root: here.root, folders, claudeDir, claudeJson, tracked: await trackedFiles(here.root) }) : null;
|
|
358
427
|
const body = { ref, global: global.items, project: project?.items ?? null, updates: action === "status" };
|
|
@@ -384,8 +453,8 @@ async function playbook(flags, action, ref, print, warn) {
|
|
|
384
453
|
}
|
|
385
454
|
|
|
386
455
|
/** This computer's projects, as the sync finds them. */
|
|
387
|
-
async function knownProjects(home, claudeDir) {
|
|
388
|
-
return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd() });
|
|
456
|
+
async function knownProjects(home, claudeDir, config) {
|
|
457
|
+
return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd(), restored: config?.folders });
|
|
389
458
|
}
|
|
390
459
|
|
|
391
460
|
/**
|
|
@@ -410,7 +479,7 @@ async function backup(flags, print, warn) {
|
|
|
410
479
|
? ((await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION)).device ?? config.device)
|
|
411
480
|
: { id: null, slug: "this-computer", name: "this computer" };
|
|
412
481
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
413
|
-
const projects = await knownProjects(home, claudeDir);
|
|
482
|
+
const projects = await knownProjects(home, claudeDir, config);
|
|
414
483
|
const allowed = (Array.isArray(flags.allow) ? flags.allow : []).map((path) => path.trim()).filter(Boolean);
|
|
415
484
|
// The repository is remembered as soon as a push has gone through, whatever the site answers after,
|
|
416
485
|
// with the files the user allowed despite a finding, each by the hash of what was allowed.
|
|
@@ -433,9 +502,35 @@ async function restore(flags, print, warn) {
|
|
|
433
502
|
const home = homedir();
|
|
434
503
|
const claudeDir = claudeFolder(home);
|
|
435
504
|
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
436
|
-
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
|
|
505
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir, config) : [];
|
|
437
506
|
const plan = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
438
|
-
await runRestore({ plan, claudeDir, config, flags, version: VERSION, print, warn });
|
|
507
|
+
const { folders } = await runRestore({ plan, claudeDir, config, flags, version: VERSION, print, warn });
|
|
508
|
+
// A clone Claude Code has not been opened in yet is otherwise one no sync reads, and its copy task would never close.
|
|
509
|
+
const remembered = Array.isArray(config.folders) ? config.folders : [];
|
|
510
|
+
const added = [...folders].filter((folder) => !remembered.includes(folder));
|
|
511
|
+
if (added.length) await writeConfig({ ...config, folders: [...remembered, ...added] });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Turns reporting technologies on or off, or says which it is. Off after they were on, the site
|
|
516
|
+
* still has them until a sync of the whole computer takes them back ("clearing").
|
|
517
|
+
*/
|
|
518
|
+
async function tech(word, print) {
|
|
519
|
+
const { config, site } = await signedIn();
|
|
520
|
+
const host = new URL(site).host;
|
|
521
|
+
if (word === undefined) {
|
|
522
|
+
const state = {
|
|
523
|
+
on: `This computer reports each project's technologies at every sync, as ids from ${host}/tech.json. Run skillcellar tech off to stop.`,
|
|
524
|
+
clearing: `Technologies are off. The next sync of the whole computer removes this computer's technologies from ${host}.`,
|
|
525
|
+
off: "This computer does not report its projects' technologies. Run skillcellar tech on to start.",
|
|
526
|
+
}[config.tech];
|
|
527
|
+
print(state ?? "Technologies are not reported: you have not been asked yet. A sync at a terminal asks, or run skillcellar tech on.");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (word !== "on" && word !== "off") throw new UsageError("Run tech on or tech off, or tech alone to see which it is.");
|
|
531
|
+
const next = word === "on" ? "on" : config.tech === "on" || config.tech === "clearing" ? "clearing" : "off";
|
|
532
|
+
await writeConfig({ ...config, tech: next });
|
|
533
|
+
print(next === "clearing" ? `Technologies off: the next sync of the whole computer removes this computer's technologies from ${host}.` : techSaved(next, site));
|
|
439
534
|
}
|
|
440
535
|
|
|
441
536
|
/** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
|
|
@@ -482,6 +577,8 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
482
577
|
repo: { type: "string" },
|
|
483
578
|
ssh: { type: "boolean" },
|
|
484
579
|
from: { type: "string" },
|
|
580
|
+
"take-backup": { type: "boolean" },
|
|
581
|
+
"from-backup": { type: "string", multiple: true },
|
|
485
582
|
allow: { type: "string", multiple: true },
|
|
486
583
|
help: { type: "boolean", short: "h" },
|
|
487
584
|
version: { type: "boolean", short: "v" },
|
|
@@ -502,8 +599,8 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
502
599
|
return flags.help ? 0 : 2;
|
|
503
600
|
}
|
|
504
601
|
try {
|
|
505
|
-
// Only pull
|
|
506
|
-
const [action, ref, ...rest] = command === "playbook" ? extra : command === "pull" ? [undefined, ...extra] : [undefined, undefined, ...extra];
|
|
602
|
+
// Only pull and tech take an argument, the skill it installs or on or off, and playbook two: what to do, and with which playbook.
|
|
603
|
+
const [action, ref, ...rest] = command === "playbook" ? extra : command === "pull" || command === "tech" ? [undefined, ...extra] : [undefined, undefined, ...extra];
|
|
507
604
|
if (rest.length) throw new UsageError(`Unexpected ${rest.join(" ")}.`);
|
|
508
605
|
if (command === "login") await login(flags, print);
|
|
509
606
|
else if (command === "sync") await sync(flags, print, warn);
|
|
@@ -512,6 +609,7 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
512
609
|
else if (command === "pull") await pull(flags, ref, print);
|
|
513
610
|
else if (command === "playbook") await playbook(flags, action, ref, print, warn);
|
|
514
611
|
else if (command === "skill") await skill(print);
|
|
612
|
+
else if (command === "tech") await tech(ref, print);
|
|
515
613
|
else if (command === "backup") await backup(flags, print, warn);
|
|
516
614
|
else if (command === "restore") await restore(flags, print, warn);
|
|
517
615
|
else throw new UsageError(`Unknown command ${command}.`);
|