From d91953248ff4557d97fa634bea0b0da232f49a69 Mon Sep 17 00:00:00 2001 From: Kin Ueng Date: Mon, 11 May 2026 15:03:18 -0500 Subject: [PATCH 1/6] plan: record commit SHA in .openskills.json at install time Co-Authored-By: Claude Opus 4.7 (1M context) --- .plans/install-record-commit-sha.md | 157 ++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .plans/install-record-commit-sha.md diff --git a/.plans/install-record-commit-sha.md b/.plans/install-record-commit-sha.md new file mode 100644 index 0000000..fb421df --- /dev/null +++ b/.plans/install-record-commit-sha.md @@ -0,0 +1,157 @@ +# Record commit SHA in `.openskills.json` at install time + +## What changes for the user + +When `npx openskills install ` clones a git-sourced skill, the resulting `.openskills.json` file records *when* the install happened (`installedAt`) but not *which commit* was installed. After this change, the same file also records the commit SHA that `git clone` brought down, plus a browser-clickable URL pointing at that commit on the upstream host. + +No user-facing command behavior changes. Skills install the same way, in the same place, with the same output. The only observable difference is two extra lines in `.openskills.json`: + +```json +{ + "source": "anthropics/skills/pdf-editor", + "sourceType": "git", + "repoUrl": "https://github.com/anthropics/skills", + "subpath": "pdf-editor", + "installedAt": "2026-05-11T19:42:08.123Z", + "commitSha": "f458cee31a7577a47ba0c9a101976fa599385174", + "commitUrl": "https://github.com/anthropics/skills/commit/f458cee31a7577a47ba0c9a101976fa599385174" +} +``` + +Both fields contain the **full 40-character SHA** that `git rev-parse HEAD` returned — no truncation. `commitUrl` is always an `https://` URL so that terminals (iTerm2, Warp, VS Code's integrated terminal) and editors that auto-linkify URLs make it clickable — `cat .openskills.json` becomes a one-step "what code am I actually running?" lookup. + +### Why this is useful on its own + +**Team and debugging visibility.** When skills are installed in a team-shared repo (the `.claude/skills/` or `.agent/skills/` tree committed alongside the project's code), every developer on the team gets the same installed copy. But when a teammate hits a bug — "this skill is producing weird output", "the docs say feature X but I don't see it" — there's currently no way to answer "what version of the upstream skill are we actually pinned to?" without re-running `install` and hoping the upstream hasn't changed. Recording `commitSha` (plus the clickable `commitUrl`) makes this directly answerable: a teammate (or an LLM helping debug) can `cat .openskills.json`, click straight through to the upstream commit, and see the exact code that's installed. Bug reports to upstream skill authors become actionable too ("we're on commit `f458cee` and seeing X") instead of vague. + +**Self-explaining diffs.** When the skills tree is committed to the team's repo, a `git pull` that brings in a skill update now shows both the changed `SKILL.md` *and* a changed `.openskills.json` with the new `commitSha`/`commitUrl`. A teammate scanning the diff sees the SHA bump and immediately understands the SKILL.md changes came from an upstream sync — no more wondering "did someone hand-edit this?" or "where did this change come from?" + +### What does *not* change + +- Local-source skills (installed from a path on disk via `npx openskills install ./path/to/skill`) are explicitly out of scope: no `commitSha` is recorded for them, even when the local path happens to be inside a git working tree. The team-debugging use case that motivates this PR assumes upstream skills shared via a remote repo; local installs are typically used by skill *authors* iterating on their own work and don't benefit from the same provenance trail. +- Legacy `.openskills.json` files written by previous versions of openskills stay valid — readers tolerate a missing `commitSha`. But going forward, every fresh `install` with this version (or later) will write the field; there is no opt-out and no flag to disable it. In other words: backward-compatible for reads, mandatory for writes. Older installs will be naturally migrated when users next re-install those skills. `update` in this PR does not back-fill the field — it only rewrites `installedAt`, same as today. +- `update`, `sync`, and every other command read and write the metadata exactly as today. Nothing consumes `commitSha` in this PR. +- No new dependencies, no new network calls, no new auth surface. + +## How it works under the hood + +### Where the SHA comes from + +Right after the clone succeeds at [src/commands/install.ts:160](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L160), run: + +```ts +const commitSha = execSync(`git -C "${tempDir}/repo" rev-parse HEAD`, { stdio: ['pipe','pipe','pipe'] }) + .toString().trim(); +sourceInfo.commitSha = commitSha; +sourceInfo.commitUrl = buildCommitUrl(repoUrl, commitSha); +``` + +A note on shallow clones: the existing install code already runs `git clone --depth 1` (see [src/commands/install.ts:157](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L157)), which only downloads the latest snapshot, not the project's full history. That's fine for our SHA lookup — `HEAD` is a tiny pointer file inside the clone that records "the current commit is X", written the moment the clone completes. `git rev-parse HEAD` just reads that pointer, so it works the same whether we fetched 1 commit or 100,000. No history walk, no extra network calls. + +### Monorepo skills can have different SHAs across installs + +The captured SHA is the *whole repo's* HEAD at clone time, not the subpath's. So two skills from the same monorepo (e.g. `anthropics/skills/pdf-editor` and `anthropics/skills/skill-creator`) installed at different times will record different `commitSha` values — even if neither subpath's files changed between the installs. A default `update` re-clones all installed skills in one pass, so monorepo skills converge back to a single SHA after the next update; but in between, divergence is expected and correct. + +### Building the commit URL + +`commitSha` is host-agnostic — we read it via `git rev-parse HEAD` from the local clone, which works regardless of where the repo came from. `commitUrl` is host-specific, since different hosts use different URL patterns for viewing commits (`/commit/`, `/-/commit/`, `/commits/`, …). + +`buildCommitUrl(repoUrl, sha)` is a small pure helper that returns a browser-clickable `https://` URL when it recognizes the host's pattern, or `undefined` otherwise. In this PR it recognizes github-style hosts (github.com plus any host whose commit path follows the `///commit/` convention). For unrecognized hosts, `commitUrl` is omitted from `.openskills.json` and the user still gets `commitSha` to look up manually. + +**Normalization rules for github-style inputs.** The output is always `https://///commit/` — HTTPS scheme is enforced regardless of how the user originally cloned (SSH, `git://`, or HTTPS), so the URL is always browser-clickable: + +- `https://github.com/anthropics/skills` → use as-is. +- `https://github.com/anthropics/skills.git` → strip trailing `.git`. +- `git@github.com:anthropics/skills.git` → rewrite SSH to HTTPS (`git@:` → `https:///`), strip trailing `.git`. +- `git://github.com/anthropics/skills.git` → rewrite scheme to `https`, strip trailing `.git`. + +The helper lives in [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) next to the metadata types, so the same logic can be reused by any future code that needs to display a clickable link (e.g. an `openskills info` command). + +### Why thread it through `sourceInfo` + +The install code writes `.openskills.json` metadata in two different places depending on which branch of the install flow runs: + +- `installSpecificSkill` path ([src/commands/install.ts:312](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L312)), when the user requested one subpath like `anthropics/skills/foo`. +- `installFromRepo` path ([src/commands/install.ts:476](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L476)), the interactive multi-skill selection from a whole repo. + +Both paths receive a shared `InstallSourceInfo` object and ultimately call `buildGitMetadata` ([src/commands/install.ts:498](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L498)) to produce the metadata blob. Stashing `commitSha` onto `sourceInfo` once, before the branching, means `buildGitMetadata` copies it into the metadata regardless of which branch executes. No need to modify two write sites. + +### Data model + +Extend `SkillSourceMetadata` ([src/utils/skill-metadata.ts:8-15](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts#L8-L15)) with one optional field: + +```ts +commitSha?: string; // populated for sourceType === 'git'; set after a successful clone +``` + +Optional, so existing `.openskills.json` files (which won't have it) keep working unchanged. + +Also extend `InstallSourceInfo` in [src/commands/install.ts](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts) with the same optional field, so `buildGitMetadata` can include it. + +### Error handling + +If `git rev-parse HEAD` fails (it shouldn't — we just successfully cloned the same repo a line earlier), the install fails the same way any other unexpected exception fails: the existing try/catch around the clone block reports it and exits non-zero. We do *not* swallow the error to install without a SHA — better to fail loudly so the bug surfaces, given that this command should be reliable. + +## Files to modify + +- [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) — add `commitSha?: string` to `SkillSourceMetadata`. +- [src/commands/install.ts](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts) — capture SHA after clone ([~line 160](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L160)); add field to `InstallSourceInfo`; include in `buildGitMetadata` ([line 498](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L498)). +- `tests/integration/install.test.ts` — new test file (below). + +No changes to `update.ts`, `sync.ts`, or `cli.ts`. No new dependencies. + +## Tests + +Existing vitest unit tests ([tests/commands/install.test.ts](https://github.com/kinueng/openskills/blob/main/tests/commands/install.test.ts), [tests/utils/skill-metadata.test.ts](https://github.com/kinueng/openskills/blob/main/tests/utils/skill-metadata.test.ts)) cover only pure helpers and use `toMatchObject` subset matching — they should keep passing unmodified. The new optional fields (`commitSha`, `commitUrl`) are purely additive. + +New functional coverage goes in a **new file** at `tests/integration/install.test.ts`, dedicated to install-flow tests. As part of this change, the existing `describe('openskills install (local paths)', ...)` block currently inside [tests/integration/e2e.test.ts](https://github.com/kinueng/openskills/blob/main/tests/integration/e2e.test.ts) (3 tests, ~40 lines) is **moved** into the new file. This consolidates all install testing — local-path and git-clone — in one place, regardless of source type, and trims `e2e.test.ts` to its remaining commands (list, read, sync, remove). + +The folder structure makes the unit-vs-functional split visible: + +``` +tests/ +├── commands/ # unit tests of command helpers +├── utils/ # unit tests of utility modules +└── integration/ # functional tests of the built CLI + ├── e2e.test.ts (existing, slightly trimmed) + └── install.test.ts (new — all install-flow tests) +``` + +### `tests/integration/install.test.ts` cases + +Style matches `e2e.test.ts`: invoke the built CLI via `execSync`, real filesystem in a temp dir, assert on `.openskills.json` contents. + +**Moved from `e2e.test.ts` (unchanged):** + +- `openskills install` from absolute local path. +- `openskills install` installs a directory of skills from a local path. +- `openskills install` errors for non-existent local path. + +**New for this PR:** + +1. **Install from real github records `commitSha` and `commitUrl`.** Run `node dist/cli.js install anthropics/skills/pdf-editor --yes` against real github.com. Assert: `.openskills.json` exists, `commitSha` is a 40-char hex string, `commitUrl` equals `https://github.com/anthropics/skills/commit/`. +2. **`commitUrl` returns HTTP 200.** Fetch the recorded `commitUrl`; assert response status is 200. Confirms the URL is actually browser-clickable. +3. **`buildCommitUrl` normalization** (pure-function test cases): HTTPS, HTTPS+`.git`, SSH (`git@github.com:...`), `git://github.com/...` → all produce `https://github.com///commit/`. Unknown host (`https://example.com/foo/bar`) → returns `undefined`. + +The first two new cases require network access at test time. They're appropriate for [tests/integration/](https://github.com/kinueng/openskills/tree/main/tests/integration) (the directory already implies "real CLI, real environment") and run as part of `npm test` — which already executes on every CI run via [.github/workflows/ci.yml](https://github.com/kinueng/openskills/blob/main/.github/workflows/ci.yml). No CI changes needed. + +## Verification + +``` +npm run typecheck +npm test +npm run build +``` + +Manual smoke: + +``` +node dist/cli.js install anthropics/skills/pdf-editor --yes +cat .claude/skills/pdf-editor/.openskills.json # should include "commitSha" and "commitUrl" +``` + +## PR notes + +- Branch from `main`, single feature commit. +- PR title: `feat(install): record git commit SHA in .openskills.json`. +- Body: lead with the team-debugging value (clickable `commitUrl` + self-explaining diffs on `git pull`), describe the field and where it's captured, and confirm backward compatibility (legacy `.openskills.json` files stay valid; the field is mandatory only for new installs going forward). From b0f6d62c8df43b7fc28edd6d07edabb764f634aa Mon Sep 17 00:00:00 2001 From: Kin Ueng Date: Mon, 11 May 2026 19:32:34 -0500 Subject: [PATCH 2/6] test(install): split tests into own file, add e2e git install case Splits install testing into its own integration file separate from the shared e2e suite, so the install-related cases are easy to find and extend as install gains features. tests/integration/install.test.ts (new): - Three local-path install cases moved here verbatim from e2e.test.ts (absolute path, directory of skills, non-existent path error) so all install behavior lives in one place. - One end-to-end git install test: installs anthropics/skills/skills/pdf against real github.com and asserts .openskills.json records a 40-char hex commitSha plus a commitUrl matching https://github.com///commit/. This is the first test in the suite that actually exercises a git clone end to end, and the only one that validates the new commit- recording behavior under real conditions (real network, real github URL format). - Five pure-function tests for buildCommitUrl covering the normalizations the helper has to handle (plain HTTPS, HTTPS with .git suffix, SSH form, git:// scheme, unrecognized host returns undefined). tests/integration/e2e.test.ts: - Removes the "openskills install (local paths)" describe block that was just moved out. The remaining suite keeps covering list, read, sync, and remove, which are not install-specific. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/integration/e2e.test.ts | 38 --------- tests/integration/install.test.ts | 132 ++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 tests/integration/install.test.ts diff --git a/tests/integration/e2e.test.ts b/tests/integration/e2e.test.ts index b4d328d..27d0cd9 100644 --- a/tests/integration/e2e.test.ts +++ b/tests/integration/e2e.test.ts @@ -149,44 +149,6 @@ describe('End-to-end CLI tests', () => { }); }); - describe('openskills install (local paths)', () => { - it('should install from absolute local path', () => { - // Create a source skill - const sourceDir = join(testTempDir, 'source-skills'); - createTestSkill(sourceDir, 'local-skill', 'Local skill'); - - // Install to project - const result = runCli(`install ${join(sourceDir, 'local-skill')} -y`); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Installed'); - - // Verify skill was copied - const installedPath = join(testTempDir, '.claude', 'skills', 'local-skill', 'SKILL.md'); - expect(existsSync(installedPath)).toBe(true); - }); - - it('should install directory of skills from local path', () => { - // Create multiple source skills - const sourceDir = join(testTempDir, 'multi-skills'); - createTestSkill(sourceDir, 'skill-one', 'First skill'); - createTestSkill(sourceDir, 'skill-two', 'Second skill'); - - const result = runCli(`install ${sourceDir} -y`); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('skill-one'); - expect(result.stdout).toContain('skill-two'); - }); - - it('should error for non-existent local path', () => { - const result = runCli(`install /non/existent/path -y`); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('does not exist'); - }); - }); - describe('openskills remove', () => { it('should remove installed skill', () => { const skillsDir = join(testTempDir, '.claude', 'skills'); diff --git a/tests/integration/install.test.ts b/tests/integration/install.test.ts new file mode 100644 index 0000000..7bd37f1 --- /dev/null +++ b/tests/integration/install.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { execSync } from 'child_process'; +import { buildCommitUrl } from '../../src/utils/skill-metadata.js'; + +const testId = Math.random().toString(36).slice(2); +const testTempDir = join(tmpdir(), `openskills-install-${testId}`); +const cliPath = join(process.cwd(), 'dist', 'cli.js'); + +function runCli(args: string, cwd?: string): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execSync(`node ${cliPath} ${args}`, { + cwd: cwd || testTempDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (error: unknown) { + const err = error as { stdout?: string; stderr?: string; status?: number }; + return { + stdout: err.stdout || '', + stderr: err.stderr || '', + exitCode: err.status || 1, + }; + } +} + +function createTestSkill(dir: string, name: string, description: string = 'Test skill'): void { + const skillDir = join(dir, name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + `--- +name: ${name} +description: ${description} +--- + +# ${name} + +Instructions for ${name}. +` + ); +} + +describe('openskills install', () => { + beforeEach(() => { + mkdirSync(testTempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testTempDir, { recursive: true, force: true }); + }); + + describe('local paths', () => { + it('should install from absolute local path', () => { + const sourceDir = join(testTempDir, 'source-skills'); + createTestSkill(sourceDir, 'local-skill', 'Local skill'); + + const result = runCli(`install ${join(sourceDir, 'local-skill')} -y`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Installed'); + + const installedPath = join(testTempDir, '.claude', 'skills', 'local-skill', 'SKILL.md'); + expect(existsSync(installedPath)).toBe(true); + }); + + it('should install directory of skills from local path', () => { + const sourceDir = join(testTempDir, 'multi-skills'); + createTestSkill(sourceDir, 'skill-one', 'First skill'); + createTestSkill(sourceDir, 'skill-two', 'Second skill'); + + const result = runCli(`install ${sourceDir} -y`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('skill-one'); + expect(result.stdout).toContain('skill-two'); + }); + + it('should error for non-existent local path', () => { + const result = runCli(`install /non/existent/path -y`); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('does not exist'); + }); + }); + + describe('git install records commitSha + commitUrl (real github.com)', () => { + it('records 40-char hex commitSha and matching commitUrl for a github skill', () => { + const result = runCli(`install anthropics/skills/skills/pdf -y`); + + expect(result.exitCode).toBe(0); + + const metadataPath = join(testTempDir, '.claude', 'skills', 'pdf', '.openskills.json'); + expect(existsSync(metadataPath)).toBe(true); + + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')); + expect(metadata.sourceType).toBe('git'); + expect(metadata.commitSha).toMatch(/^[0-9a-f]{40}$/); + expect(metadata.commitUrl).toBe( + `https://github.com/anthropics/skills/commit/${metadata.commitSha}` + ); + }); + }); + + describe('buildCommitUrl normalization (pure helper)', () => { + const sha = 'f458cee31a7577a47ba0c9a101976fa599385174'; + const expectedUrl = `https://github.com/anthropics/skills/commit/${sha}`; + + it('passes through plain HTTPS', () => { + expect(buildCommitUrl('https://github.com/anthropics/skills', sha)).toBe(expectedUrl); + }); + + it('strips trailing .git from HTTPS', () => { + expect(buildCommitUrl('https://github.com/anthropics/skills.git', sha)).toBe(expectedUrl); + }); + + it('rewrites SSH form to HTTPS and strips .git', () => { + expect(buildCommitUrl('git@github.com:anthropics/skills.git', sha)).toBe(expectedUrl); + }); + + it('rewrites git:// scheme to https and strips .git', () => { + expect(buildCommitUrl('git://github.com/anthropics/skills.git', sha)).toBe(expectedUrl); + }); + + it('returns undefined for unrecognized hosts', () => { + expect(buildCommitUrl('https://example.com/foo/bar', sha)).toBeUndefined(); + }); + }); +}); From f3accff446d0b20446303c5e3fffd32b101cf8c3 Mon Sep 17 00:00:00 2001 From: Kin Ueng Date: Mon, 11 May 2026 21:34:33 -0500 Subject: [PATCH 3/6] test: use real gitlab skills repo URL in non-github buildCommitUrl test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the placeholder gitlab.com path for the actual `gitlab-org/ai/skills` repo, and rename the test to explicitly document that the assertion exists because gitlab support hasn't shipped yet. Two reasons: - The test stays meaningful when someone adds gitlab support later — they see a real repo URL they can verify against, plus a test name that maps directly to the change they're making. - Made-up placeholders (foo/bar, example.com) read as "fake test fixture" and obscure the design intent that this guard is the host filter doing its job for an unsupported real-world git host. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/integration/install.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/integration/install.test.ts b/tests/integration/install.test.ts index 7bd37f1..2c97b0b 100644 --- a/tests/integration/install.test.ts +++ b/tests/integration/install.test.ts @@ -121,12 +121,21 @@ describe('openskills install', () => { expect(buildCommitUrl('git@github.com:anthropics/skills.git', sha)).toBe(expectedUrl); }); + it('recognizes SSH form regardless of git@ prefix casing', () => { + // Rare in practice but harmless to support — pasted/munged URLs sometimes + // arrive with weird casing. + expect(buildCommitUrl('Git@github.com:anthropics/skills.git', sha)).toBe(expectedUrl); + expect(buildCommitUrl('GIT@github.com:anthropics/skills.git', sha)).toBe(expectedUrl); + }); + it('rewrites git:// scheme to https and strips .git', () => { expect(buildCommitUrl('git://github.com/anthropics/skills.git', sha)).toBe(expectedUrl); }); - it('returns undefined for unrecognized hosts', () => { - expect(buildCommitUrl('https://example.com/foo/bar', sha)).toBeUndefined(); + it('returns undefined for non-github hosts (e.g. gitlab.com is not yet supported)', () => { + // Real gitlab skills repo — picked deliberately so this test stays + // meaningful if/when someone adds gitlab support and needs to update it. + expect(buildCommitUrl('https://gitlab.com/gitlab-org/ai/skills', sha)).toBeUndefined(); }); }); }); From 7d78393638d7038783535c6e73850f099c2af3d4 Mon Sep 17 00:00:00 2001 From: Kin Ueng Date: Mon, 11 May 2026 21:34:53 -0500 Subject: [PATCH 4/6] plan: align install-record-commit-sha plan with implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan was written before the implementation iterated through several design decisions; this updates it to match the shipped code instead of the original intent. Net effect: a future reader of the plan sees the real architecture, not a sketch of what was first imagined. Substantive updates: - JSON example uses real values from a smoke install — `source` and `subpath` reflect the actual nested layout of anthropics/skills (the earlier `pdf-editor` example didn't exist). - Documented the non-github behavior explicitly: commitSha still recorded, commitUrl omitted, yellow warning printed naming the file and field that wasn't populated. - Replaced the SHA-capture code snippet with the exact form used in install.ts (encoding + 'pipe' shorthand, no toString() roundtrip). - Rewrote "Building the commit URL" to reflect what was actually built: github-only registry today; COMMIT_URL_BUILDERS extension point with a code stub; URL class for parsing with the security wins it provides (userinfo, traversal, IDN, query/fragment); case-insensitive SSH prefix. - Split error handling into the two real failure modes — rev-parse aborts the install loudly, missing commitUrl just warns and proceeds. - Test descriptions match the actual file (one e2e github install, the normalization unit tests including case-insensitive, gitlab.com as the documented unsupported-host example). Removed the never-implemented HTTP 200 fetch assertion. - Smoke commands use the real install path; placeholder URLs use clear substitution markers instead of fake domains. Co-Authored-By: Claude Opus 4.7 (1M context) --- .plans/install-record-commit-sha.md | 70 ++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/.plans/install-record-commit-sha.md b/.plans/install-record-commit-sha.md index fb421df..7632794 100644 --- a/.plans/install-record-commit-sha.md +++ b/.plans/install-record-commit-sha.md @@ -4,14 +4,14 @@ When `npx openskills install ` clones a git-sourced skill, the resulting `.openskills.json` file records *when* the install happened (`installedAt`) but not *which commit* was installed. After this change, the same file also records the commit SHA that `git clone` brought down, plus a browser-clickable URL pointing at that commit on the upstream host. -No user-facing command behavior changes. Skills install the same way, in the same place, with the same output. The only observable difference is two extra lines in `.openskills.json`: +No user-facing command behavior changes for github installs. Skills install the same way, in the same place, with the same output. The observable difference is two extra lines in `.openskills.json`: ```json { - "source": "anthropics/skills/pdf-editor", + "source": "anthropics/skills/skills/pdf", "sourceType": "git", "repoUrl": "https://github.com/anthropics/skills", - "subpath": "pdf-editor", + "subpath": "skills/pdf", "installedAt": "2026-05-11T19:42:08.123Z", "commitSha": "f458cee31a7577a47ba0c9a101976fa599385174", "commitUrl": "https://github.com/anthropics/skills/commit/f458cee31a7577a47ba0c9a101976fa599385174" @@ -20,6 +20,8 @@ No user-facing command behavior changes. Skills install the same way, in the sam Both fields contain the **full 40-character SHA** that `git rev-parse HEAD` returned — no truncation. `commitUrl` is always an `https://` URL so that terminals (iTerm2, Warp, VS Code's integrated terminal) and editors that auto-linkify URLs make it clickable — `cat .openskills.json` becomes a one-step "what code am I actually running?" lookup. +For installs from **non-github hosts** (e.g. a self-hosted git server reached by full URL), `commitSha` is still recorded but `commitUrl` is omitted from the JSON. A yellow warning is printed at install time so the user knows: `Warning: clone succeeded, but the \`commitUrl\` field was not written to .openskills.json (host not yet supported for clickable URL generation).` The install itself succeeds. Adding support for another host is a small, well-scoped follow-up (see "Building the commit URL" below). + ### Why this is useful on its own **Team and debugging visibility.** When skills are installed in a team-shared repo (the `.claude/skills/` or `.agent/skills/` tree committed alongside the project's code), every developer on the team gets the same installed copy. But when a teammate hits a bug — "this skill is producing weird output", "the docs say feature X but I don't see it" — there's currently no way to answer "what version of the upstream skill are we actually pinned to?" without re-running `install` and hoping the upstream hasn't changed. Recording `commitSha` (plus the clickable `commitUrl`) makes this directly answerable: a teammate (or an LLM helping debug) can `cat .openskills.json`, click straight through to the upstream commit, and see the exact code that's installed. Bug reports to upstream skill authors become actionable too ("we're on commit `f458cee` and seeing X") instead of vague. @@ -37,13 +39,19 @@ Both fields contain the **full 40-character SHA** that `git rev-parse HEAD` retu ### Where the SHA comes from -Right after the clone succeeds at [src/commands/install.ts:160](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L160), run: +Right after the clone succeeds at [src/commands/install.ts:162](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L162), run: ```ts -const commitSha = execSync(`git -C "${tempDir}/repo" rev-parse HEAD`, { stdio: ['pipe','pipe','pipe'] }) - .toString().trim(); +const commitSha = execSync(`git -C "${tempDir}/repo" rev-parse HEAD`, { + encoding: 'utf-8', + stdio: 'pipe', +}).trim(); sourceInfo.commitSha = commitSha; sourceInfo.commitUrl = buildCommitUrl(repoUrl, commitSha); +if (!sourceInfo.commitUrl) { + // Clone worked — only the URL builder couldn't recognize the host. + console.log(chalk.yellow('Warning: ...')); +} ``` A note on shallow clones: the existing install code already runs `git clone --depth 1` (see [src/commands/install.ts:157](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L157)), which only downloads the latest snapshot, not the project's full history. That's fine for our SHA lookup — `HEAD` is a tiny pointer file inside the clone that records "the current commit is X", written the moment the clone completes. `git rev-parse HEAD` just reads that pointer, so it works the same whether we fetched 1 commit or 100,000. No history walk, no extra network calls. @@ -56,16 +64,26 @@ The captured SHA is the *whole repo's* HEAD at clone time, not the subpath's. So `commitSha` is host-agnostic — we read it via `git rev-parse HEAD` from the local clone, which works regardless of where the repo came from. `commitUrl` is host-specific, since different hosts use different URL patterns for viewing commits (`/commit/`, `/-/commit/`, `/commits/`, …). -`buildCommitUrl(repoUrl, sha)` is a small pure helper that returns a browser-clickable `https://` URL when it recognizes the host's pattern, or `undefined` otherwise. In this PR it recognizes github-style hosts (github.com plus any host whose commit path follows the `///commit/` convention). For unrecognized hosts, `commitUrl` is omitted from `.openskills.json` and the user still gets `commitSha` to look up manually. +`buildCommitUrl(repoUrl, sha)` lives in [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) and returns a browser-clickable `https://` URL when it recognizes the host, or `undefined` otherwise. **github.com is the only registered host today.** For other hosts the field is omitted from the JSON and the install warns (see above). + +**Extensibility.** Host patterns live in a `COMMIT_URL_BUILDERS` map keyed by `URL.hostname`. To support another host, a future contributor adds one entry — no other code touches: -**Normalization rules for github-style inputs.** The output is always `https://///commit/` — HTTPS scheme is enforced regardless of how the user originally cloned (SSH, `git://`, or HTTPS), so the URL is always browser-clickable: +```ts +const COMMIT_URL_BUILDERS: Record = { + 'github.com': (repoPath, commitSha) => { /* validate + format */ }, + // '': (repoPath, commitSha) => `https:///${repoPath}/commit/${commitSha}`, +}; +``` -- `https://github.com/anthropics/skills` → use as-is. -- `https://github.com/anthropics/skills.git` → strip trailing `.git`. -- `git@github.com:anthropics/skills.git` → rewrite SSH to HTTPS (`git@:` → `https:///`), strip trailing `.git`. -- `git://github.com/anthropics/skills.git` → rewrite scheme to `https`, strip trailing `.git`. +Each builder receives the full `repoPath` (everything between the hostname and the optional `.git` suffix) and the SHA, then returns either an `https://` URL or `undefined` if the path shape is wrong for that host. The github builder validates exactly `/` (no nested orgs). -The helper lives in [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) next to the metadata types, so the same logic can be reused by any future code that needs to display a clickable link (e.g. an `openskills info` command). +**URL parsing.** All clone-URL parsing goes through the standard `new URL()` constructor for security and correctness: + +- HTTPS / HTTP / `git://` URLs are passed straight to `URL()`. +- SSH form (`git@host:owner/repo`) isn't a valid URI, so a small `sshToHttps` helper rewrites it to `https://host/owner/repo` first. SSH prefix detection is case-insensitive (`Git@`, `GIT@` are recognized). +- A trailing `.git` is stripped from the path. The leading `/` from `URL.pathname` is stripped. +- The `URL` constructor handles tricky inputs safely: userinfo spoofing (`https://attacker@github.com/...` → `hostname = "github.com"`), path traversal (`/foo/../bar` → `/bar`), IDN homoglyphs, embedded query/fragment. +- Hosts and schemes are normalized to lowercase by `URL()`. The path is preserved verbatim (case-sensitive on github). ### Why thread it through `sourceInfo` @@ -90,13 +108,18 @@ Also extend `InstallSourceInfo` in [src/commands/install.ts](https://github.com/ ### Error handling -If `git rev-parse HEAD` fails (it shouldn't — we just successfully cloned the same repo a line earlier), the install fails the same way any other unexpected exception fails: the existing try/catch around the clone block reports it and exits non-zero. We do *not* swallow the error to install without a SHA — better to fail loudly so the bug surfaces, given that this command should be reliable. +Two distinct failure modes: + +1. **`git rev-parse HEAD` throws** (extremely unlikely after a successful `--depth 1` clone). The existing outer `try/catch` around the clone block catches it, prints the existing failure message, and exits non-zero. The install aborts. We do *not* swallow the error to install without a SHA — better to fail loudly. + +2. **`buildCommitUrl` returns `undefined`** (host not registered, or malformed `repoUrl`). The install **succeeds** — `commitSha` is still written. The `commitUrl` field is simply omitted from `.openskills.json`, and a yellow warning is printed naming the file and field that wasn't populated. No placeholder string is written into the JSON (we deliberately avoid echoing the raw `repoUrl` into the file to side-step any auto-linkifier abuse risk). ## Files to modify -- [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) — add `commitSha?: string` to `SkillSourceMetadata`. -- [src/commands/install.ts](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts) — capture SHA after clone ([~line 160](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L160)); add field to `InstallSourceInfo`; include in `buildGitMetadata` ([line 498](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L498)). +- [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) — add `commitSha?: string` and `commitUrl?: string` to `SkillSourceMetadata`. Add `buildCommitUrl`, the `COMMIT_URL_BUILDERS` registry, and the `parseCloneUrl` / `sshToHttps` helpers. +- [src/commands/install.ts](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts) — add `commitSha` / `commitUrl` to `InstallSourceInfo`; capture SHA after clone; build commit URL; print warning when missing; include both fields in `buildGitMetadata`. - `tests/integration/install.test.ts` — new test file (below). +- [tests/integration/e2e.test.ts](https://github.com/kinueng/openskills/blob/main/tests/integration/e2e.test.ts) — remove the moved `install (local paths)` describe block. No changes to `update.ts`, `sync.ts`, or `cli.ts`. No new dependencies. @@ -129,11 +152,10 @@ Style matches `e2e.test.ts`: invoke the built CLI via `execSync`, real filesyste **New for this PR:** -1. **Install from real github records `commitSha` and `commitUrl`.** Run `node dist/cli.js install anthropics/skills/pdf-editor --yes` against real github.com. Assert: `.openskills.json` exists, `commitSha` is a 40-char hex string, `commitUrl` equals `https://github.com/anthropics/skills/commit/`. -2. **`commitUrl` returns HTTP 200.** Fetch the recorded `commitUrl`; assert response status is 200. Confirms the URL is actually browser-clickable. -3. **`buildCommitUrl` normalization** (pure-function test cases): HTTPS, HTTPS+`.git`, SSH (`git@github.com:...`), `git://github.com/...` → all produce `https://github.com///commit/`. Unknown host (`https://example.com/foo/bar`) → returns `undefined`. +1. **Install from real github records `commitSha` and `commitUrl`.** Run `node dist/cli.js install anthropics/skills/skills/pdf --yes` against real github.com. Assert: `.openskills.json` exists, `commitSha` is a 40-char hex string (`/^[0-9a-f]{40}$/`), `commitUrl` equals `https://github.com/anthropics/skills/commit/`. +2. **`buildCommitUrl` normalization** (pure-function unit tests): HTTPS, HTTPS+`.git`, SSH (`git@github.com:...`), `git://github.com/...` → all produce `https://github.com/anthropics/skills/commit/`. Case-insensitive SSH (`Git@`, `GIT@`) is also covered. A non-github host (`https://gitlab.com/...`) → `buildCommitUrl` returns `undefined`, documenting that gitlab is unsupported today. -The first two new cases require network access at test time. They're appropriate for [tests/integration/](https://github.com/kinueng/openskills/tree/main/tests/integration) (the directory already implies "real CLI, real environment") and run as part of `npm test` — which already executes on every CI run via [.github/workflows/ci.yml](https://github.com/kinueng/openskills/blob/main/.github/workflows/ci.yml). No CI changes needed. +The github install case requires network access. It's appropriate for [tests/integration/](https://github.com/kinueng/openskills/tree/main/tests/integration) (the directory already implies "real CLI, real environment") and runs as part of `npm test` — which already executes on every CI run via [.github/workflows/ci.yml](https://github.com/kinueng/openskills/blob/main/.github/workflows/ci.yml). No CI changes needed. ## Verification @@ -146,8 +168,12 @@ npm run build Manual smoke: ``` -node dist/cli.js install anthropics/skills/pdf-editor --yes -cat .claude/skills/pdf-editor/.openskills.json # should include "commitSha" and "commitUrl" +node dist/cli.js install anthropics/skills/skills/pdf --yes +cat .claude/skills/pdf/.openskills.json # should include "commitSha" and "commitUrl" + +# Optionally verify the warning path against any non-github clone URL: +node dist/cli.js install --yes +# expect: "Warning: clone succeeded, but the `commitUrl` field was not written ..." ``` ## PR notes From 4c71144190ed5052ce5f63e385439cbd7cc7ce44 Mon Sep 17 00:00:00 2001 From: Kin Ueng Date: Mon, 11 May 2026 21:36:00 -0500 Subject: [PATCH 5/6] feat(install): record commit SHA and URL in .openskills.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `openskills install` clones a git-sourced skill, the resulting metadata file now also records the commit SHA that was cloned and a browser-clickable URL pointing at that commit on the upstream host. A teammate cat'ing the file can answer "what version of this skill is installed?" with one click; bug reports to upstream skill authors become "we're on commit and seeing X" instead of vague. src/utils/skill-metadata.ts: - SkillSourceMetadata gains two optional fields, `commitSha` and `commitUrl`. Optional so legacy files written by older versions of openskills stay valid. - New exported `buildCommitUrl(repoUrl, commitSha)` returns a browser-clickable `https://` URL for github clone URLs, or undefined for any other host. - Per-host commit-URL builders live in a `COMMIT_URL_BUILDERS` Record keyed by `URL.hostname`. github.com is the only entry today; adding another host (e.g. gitlab) is a one-line addition to that map with no other code touching. - Internal `parseCloneUrl` uses the standard `URL` class for parsing, which handles userinfo spoofing, path traversal, IDN homoglyphs, and embedded query/fragment safely. A small `sshToHttps` helper rewrites the SSH form (`git@host:owner/repo`) to HTTPS first since SSH isn't a valid URI. SSH prefix detection is case-insensitive. - Trailing `.git` is stripped from the parsed pathname so the derived commit URL is clean. src/commands/install.ts: - InstallSourceInfo gains `commitSha` and `commitUrl` so both metadata-write sites (installSpecificSkill and installFromRepo) pick them up through buildGitMetadata. The SHA is captured once, right after the clone succeeds, via `git rev-parse HEAD` on the shallow clone. - When buildCommitUrl returns undefined (non-github host), the install still succeeds — commitSha is recorded, commitUrl is omitted from the JSON, and a yellow warning is printed naming the file and field that wasn't written. No placeholder string is written into the JSON: we deliberately avoid echoing the raw repoUrl into the file to avoid auto-linkifier abuse risk if a crafted URL is in play. Behavior summary for the user: - github install: .openskills.json gets commitSha + commitUrl, no extra output. - non-github install: .openskills.json gets commitSha only; yellow warning printed; install still succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/install.ts | 26 ++++++++- src/utils/skill-metadata.ts | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/commands/install.ts b/src/commands/install.ts index b4c015e..616bfe0 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -8,7 +8,7 @@ import { checkbox, confirm } from '@inquirer/prompts'; import { ExitPromptError } from '@inquirer/core'; import { hasValidFrontmatter, extractYamlField } from '../utils/yaml.js'; import { ANTHROPIC_MARKETPLACE_SKILLS } from '../utils/marketplace-skills.js'; -import { writeSkillMetadata } from '../utils/skill-metadata.js'; +import { writeSkillMetadata, buildCommitUrl } from '../utils/skill-metadata.js'; import type { InstallOptions } from '../types.js'; import type { SkillSourceMetadata, SkillSourceType } from '../utils/skill-metadata.js'; @@ -17,6 +17,8 @@ interface InstallSourceInfo { sourceType: SkillSourceType; repoUrl?: string; localRoot?: string; + commitSha?: string; + commitUrl?: string; } /** @@ -158,6 +160,26 @@ export async function installSkill(source: string, options: InstallOptions): Pro stdio: 'pipe', }); spinner.succeed('Repository cloned'); + + const commitSha = execSync(`git -C "${tempDir}/repo" rev-parse HEAD`, { + encoding: 'utf-8', + stdio: 'pipe', + }).trim(); + sourceInfo.commitSha = commitSha; + sourceInfo.commitUrl = buildCommitUrl(repoUrl, commitSha); + if (!sourceInfo.commitUrl) { + // Clone worked — only the clickable-URL builder couldn't recognize + // the host. Surface it as a warning, not an error, so the user knows + // the install succeeded and the missing field isn't a sign of failure. + // We don't echo the raw repoUrl here: a crafted input could trick an + // editor's auto-linkifier into rendering attacker-controlled text as + // clickable. The repoUrl is already visible above in the spinner log. + console.log( + chalk.yellow( + 'Warning: clone succeeded, but the `commitUrl` field was not written to .openskills.json (host not yet supported for clickable URL generation).' + ) + ); + } } catch (error) { spinner.fail('Failed to clone repository'); const err = error as { stderr?: Buffer }; @@ -502,6 +524,8 @@ function buildGitMetadata(sourceInfo: InstallSourceInfo, subpath: string): Skill repoUrl: sourceInfo.repoUrl, subpath, installedAt: new Date().toISOString(), + commitSha: sourceInfo.commitSha, + commitUrl: sourceInfo.commitUrl, }; } diff --git a/src/utils/skill-metadata.ts b/src/utils/skill-metadata.ts index bf720b3..0dcbbfa 100644 --- a/src/utils/skill-metadata.ts +++ b/src/utils/skill-metadata.ts @@ -12,6 +12,119 @@ export interface SkillSourceMetadata { subpath?: string; localPath?: string; installedAt: string; + commitSha?: string; + commitUrl?: string; +} + +const GIT_SUFFIX = '.git'; +const SSH_PREFIX = 'git@'; + +/** + * Per-host commit-URL builders. To support a new git host, add an entry + * here keyed by `URL.hostname`. Each builder receives the full `repoPath` + * (everything between the hostname and the optional `.git` suffix) plus + * the SHA, and returns a clickable `https://` URL — or undefined if the + * repoPath isn't valid for that host (e.g. wrong number of segments). + * + * Only github is registered today. To add another host, add one entry + * below with that host's commit-URL pattern. + */ +type CommitUrlBuilder = (repoPath: string, commitSha: string) => string | undefined; +const COMMIT_URL_BUILDERS: Record = { + 'github.com': (repoPath, commitSha) => { + // Github repos are always exactly `/` — no nested orgs or + // subpaths. Anything else is either malformed or not a clone URL. + const parts = repoPath.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return undefined; + } + return `https://github.com/${repoPath}/commit/${commitSha}`; + }, +}; + +/** + * Build a browser-clickable commit URL from a clone-URL + SHA. + * Returns undefined when the host has no registered builder in + * COMMIT_URL_BUILDERS (or its builder rejects the repo path) so callers + * can omit the field. + */ +export function buildCommitUrl(repoUrl: string, commitSha: string): string | undefined { + const parsed = parseCloneUrl(repoUrl); + if (!parsed) { + // Couldn't parse the URL at all (malformed or non-clone string). + return undefined; + } + + const buildUrl = COMMIT_URL_BUILDERS[parsed.host]; + if (!buildUrl) { + // No builder registered for this host — we don't know its commit-URL pattern. + return undefined; + } + + return buildUrl(parsed.repoPath, commitSha); +} + +/** + * Parse a clone URL into host + repoPath. Returns null when parsing fails. + */ +function parseCloneUrl(repoUrl: string): { host: string; repoPath: string } | null { + const parsed = sshToHttps(repoUrl.trim()); + if (!parsed) { + // Either malformed SSH or `new URL()` rejected the input. + return null; + } + + // `pathname` always starts with '/'. Strip the leading slash to get the + // repo path (e.g. `anthropics/skills` or `anthropics/skills.git`). + let repoPath = parsed.pathname.slice(1); + if (repoPath.endsWith(GIT_SUFFIX)) { + // Trailing `.git` is conventional in clone URLs but isn't part of the + // repo's identity — strip it so builders get a clean path. + repoPath = repoPath.slice(0, -GIT_SUFFIX.length); + } + + return { host: parsed.hostname, repoPath }; +} + +/** + * Convert any clone URL into a parsed `URL` object, rewriting SSH form + * (`git@host:owner/repo[.git]`) to HTTPS first since SSH isn't a valid URI. + * Non-SSH inputs are handed straight to `new URL()`. + * + * Returns null when the input can't be parsed: malformed SSH (starts with + * `git@` but missing the ':' separator), or anything `new URL()` rejects + * (bare local paths, free-form strings, unsupported schemes). + * + * Case of host and path is preserved during the SSH rewrite — `URL()` will + * lowercase the host on parse, and path is case-sensitive on github + * (Anthropics ≠ anthropics), so we hand the path through untouched. + */ +function sshToHttps(input: string): URL | null { + let candidate = input; + + // Case-insensitive prefix check so weird-cased inputs like `Git@host:...` + // are still recognized as SSH. + const isSshForm = input.slice(0, SSH_PREFIX.length).toLowerCase() === SSH_PREFIX; + if (isSshForm) { + // Convert "git@host:owner/repo" → "https://host/owner/repo" by swapping + // the first ':' for '/' and prepending the scheme. + const afterPrefix = input.slice(SSH_PREFIX.length); + const colonIdx = afterPrefix.indexOf(':'); + if (colonIdx === -1) { + // SSH-shaped input missing the ':' separator — can't be rewritten. + return null; + } + const host = afterPrefix.slice(0, colonIdx); + const path = afterPrefix.slice(colonIdx + 1); + candidate = `https://${host}/${path}`; + } + + try { + return new URL(candidate); + } catch { + // URL constructor rejected the candidate (bare local path, garbage, etc). + return null; + } } export function readSkillMetadata(skillDir: string): SkillSourceMetadata | null { From 80e33080c490cc39252cfe25d445dbbb109012f3 Mon Sep 17 00:00:00 2001 From: Kin Ueng Date: Mon, 18 May 2026 22:18:01 -0500 Subject: [PATCH 6/6] chore: drop .plans/install-record-commit-sha.md from repo --- .plans/install-record-commit-sha.md | 183 ---------------------------- 1 file changed, 183 deletions(-) delete mode 100644 .plans/install-record-commit-sha.md diff --git a/.plans/install-record-commit-sha.md b/.plans/install-record-commit-sha.md deleted file mode 100644 index 7632794..0000000 --- a/.plans/install-record-commit-sha.md +++ /dev/null @@ -1,183 +0,0 @@ -# Record commit SHA in `.openskills.json` at install time - -## What changes for the user - -When `npx openskills install ` clones a git-sourced skill, the resulting `.openskills.json` file records *when* the install happened (`installedAt`) but not *which commit* was installed. After this change, the same file also records the commit SHA that `git clone` brought down, plus a browser-clickable URL pointing at that commit on the upstream host. - -No user-facing command behavior changes for github installs. Skills install the same way, in the same place, with the same output. The observable difference is two extra lines in `.openskills.json`: - -```json -{ - "source": "anthropics/skills/skills/pdf", - "sourceType": "git", - "repoUrl": "https://github.com/anthropics/skills", - "subpath": "skills/pdf", - "installedAt": "2026-05-11T19:42:08.123Z", - "commitSha": "f458cee31a7577a47ba0c9a101976fa599385174", - "commitUrl": "https://github.com/anthropics/skills/commit/f458cee31a7577a47ba0c9a101976fa599385174" -} -``` - -Both fields contain the **full 40-character SHA** that `git rev-parse HEAD` returned — no truncation. `commitUrl` is always an `https://` URL so that terminals (iTerm2, Warp, VS Code's integrated terminal) and editors that auto-linkify URLs make it clickable — `cat .openskills.json` becomes a one-step "what code am I actually running?" lookup. - -For installs from **non-github hosts** (e.g. a self-hosted git server reached by full URL), `commitSha` is still recorded but `commitUrl` is omitted from the JSON. A yellow warning is printed at install time so the user knows: `Warning: clone succeeded, but the \`commitUrl\` field was not written to .openskills.json (host not yet supported for clickable URL generation).` The install itself succeeds. Adding support for another host is a small, well-scoped follow-up (see "Building the commit URL" below). - -### Why this is useful on its own - -**Team and debugging visibility.** When skills are installed in a team-shared repo (the `.claude/skills/` or `.agent/skills/` tree committed alongside the project's code), every developer on the team gets the same installed copy. But when a teammate hits a bug — "this skill is producing weird output", "the docs say feature X but I don't see it" — there's currently no way to answer "what version of the upstream skill are we actually pinned to?" without re-running `install` and hoping the upstream hasn't changed. Recording `commitSha` (plus the clickable `commitUrl`) makes this directly answerable: a teammate (or an LLM helping debug) can `cat .openskills.json`, click straight through to the upstream commit, and see the exact code that's installed. Bug reports to upstream skill authors become actionable too ("we're on commit `f458cee` and seeing X") instead of vague. - -**Self-explaining diffs.** When the skills tree is committed to the team's repo, a `git pull` that brings in a skill update now shows both the changed `SKILL.md` *and* a changed `.openskills.json` with the new `commitSha`/`commitUrl`. A teammate scanning the diff sees the SHA bump and immediately understands the SKILL.md changes came from an upstream sync — no more wondering "did someone hand-edit this?" or "where did this change come from?" - -### What does *not* change - -- Local-source skills (installed from a path on disk via `npx openskills install ./path/to/skill`) are explicitly out of scope: no `commitSha` is recorded for them, even when the local path happens to be inside a git working tree. The team-debugging use case that motivates this PR assumes upstream skills shared via a remote repo; local installs are typically used by skill *authors* iterating on their own work and don't benefit from the same provenance trail. -- Legacy `.openskills.json` files written by previous versions of openskills stay valid — readers tolerate a missing `commitSha`. But going forward, every fresh `install` with this version (or later) will write the field; there is no opt-out and no flag to disable it. In other words: backward-compatible for reads, mandatory for writes. Older installs will be naturally migrated when users next re-install those skills. `update` in this PR does not back-fill the field — it only rewrites `installedAt`, same as today. -- `update`, `sync`, and every other command read and write the metadata exactly as today. Nothing consumes `commitSha` in this PR. -- No new dependencies, no new network calls, no new auth surface. - -## How it works under the hood - -### Where the SHA comes from - -Right after the clone succeeds at [src/commands/install.ts:162](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L162), run: - -```ts -const commitSha = execSync(`git -C "${tempDir}/repo" rev-parse HEAD`, { - encoding: 'utf-8', - stdio: 'pipe', -}).trim(); -sourceInfo.commitSha = commitSha; -sourceInfo.commitUrl = buildCommitUrl(repoUrl, commitSha); -if (!sourceInfo.commitUrl) { - // Clone worked — only the URL builder couldn't recognize the host. - console.log(chalk.yellow('Warning: ...')); -} -``` - -A note on shallow clones: the existing install code already runs `git clone --depth 1` (see [src/commands/install.ts:157](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L157)), which only downloads the latest snapshot, not the project's full history. That's fine for our SHA lookup — `HEAD` is a tiny pointer file inside the clone that records "the current commit is X", written the moment the clone completes. `git rev-parse HEAD` just reads that pointer, so it works the same whether we fetched 1 commit or 100,000. No history walk, no extra network calls. - -### Monorepo skills can have different SHAs across installs - -The captured SHA is the *whole repo's* HEAD at clone time, not the subpath's. So two skills from the same monorepo (e.g. `anthropics/skills/pdf-editor` and `anthropics/skills/skill-creator`) installed at different times will record different `commitSha` values — even if neither subpath's files changed between the installs. A default `update` re-clones all installed skills in one pass, so monorepo skills converge back to a single SHA after the next update; but in between, divergence is expected and correct. - -### Building the commit URL - -`commitSha` is host-agnostic — we read it via `git rev-parse HEAD` from the local clone, which works regardless of where the repo came from. `commitUrl` is host-specific, since different hosts use different URL patterns for viewing commits (`/commit/`, `/-/commit/`, `/commits/`, …). - -`buildCommitUrl(repoUrl, sha)` lives in [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) and returns a browser-clickable `https://` URL when it recognizes the host, or `undefined` otherwise. **github.com is the only registered host today.** For other hosts the field is omitted from the JSON and the install warns (see above). - -**Extensibility.** Host patterns live in a `COMMIT_URL_BUILDERS` map keyed by `URL.hostname`. To support another host, a future contributor adds one entry — no other code touches: - -```ts -const COMMIT_URL_BUILDERS: Record = { - 'github.com': (repoPath, commitSha) => { /* validate + format */ }, - // '': (repoPath, commitSha) => `https:///${repoPath}/commit/${commitSha}`, -}; -``` - -Each builder receives the full `repoPath` (everything between the hostname and the optional `.git` suffix) and the SHA, then returns either an `https://` URL or `undefined` if the path shape is wrong for that host. The github builder validates exactly `/` (no nested orgs). - -**URL parsing.** All clone-URL parsing goes through the standard `new URL()` constructor for security and correctness: - -- HTTPS / HTTP / `git://` URLs are passed straight to `URL()`. -- SSH form (`git@host:owner/repo`) isn't a valid URI, so a small `sshToHttps` helper rewrites it to `https://host/owner/repo` first. SSH prefix detection is case-insensitive (`Git@`, `GIT@` are recognized). -- A trailing `.git` is stripped from the path. The leading `/` from `URL.pathname` is stripped. -- The `URL` constructor handles tricky inputs safely: userinfo spoofing (`https://attacker@github.com/...` → `hostname = "github.com"`), path traversal (`/foo/../bar` → `/bar`), IDN homoglyphs, embedded query/fragment. -- Hosts and schemes are normalized to lowercase by `URL()`. The path is preserved verbatim (case-sensitive on github). - -### Why thread it through `sourceInfo` - -The install code writes `.openskills.json` metadata in two different places depending on which branch of the install flow runs: - -- `installSpecificSkill` path ([src/commands/install.ts:312](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L312)), when the user requested one subpath like `anthropics/skills/foo`. -- `installFromRepo` path ([src/commands/install.ts:476](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L476)), the interactive multi-skill selection from a whole repo. - -Both paths receive a shared `InstallSourceInfo` object and ultimately call `buildGitMetadata` ([src/commands/install.ts:498](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts#L498)) to produce the metadata blob. Stashing `commitSha` onto `sourceInfo` once, before the branching, means `buildGitMetadata` copies it into the metadata regardless of which branch executes. No need to modify two write sites. - -### Data model - -Extend `SkillSourceMetadata` ([src/utils/skill-metadata.ts:8-15](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts#L8-L15)) with one optional field: - -```ts -commitSha?: string; // populated for sourceType === 'git'; set after a successful clone -``` - -Optional, so existing `.openskills.json` files (which won't have it) keep working unchanged. - -Also extend `InstallSourceInfo` in [src/commands/install.ts](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts) with the same optional field, so `buildGitMetadata` can include it. - -### Error handling - -Two distinct failure modes: - -1. **`git rev-parse HEAD` throws** (extremely unlikely after a successful `--depth 1` clone). The existing outer `try/catch` around the clone block catches it, prints the existing failure message, and exits non-zero. The install aborts. We do *not* swallow the error to install without a SHA — better to fail loudly. - -2. **`buildCommitUrl` returns `undefined`** (host not registered, or malformed `repoUrl`). The install **succeeds** — `commitSha` is still written. The `commitUrl` field is simply omitted from `.openskills.json`, and a yellow warning is printed naming the file and field that wasn't populated. No placeholder string is written into the JSON (we deliberately avoid echoing the raw `repoUrl` into the file to side-step any auto-linkifier abuse risk). - -## Files to modify - -- [src/utils/skill-metadata.ts](https://github.com/kinueng/openskills/blob/main/src/utils/skill-metadata.ts) — add `commitSha?: string` and `commitUrl?: string` to `SkillSourceMetadata`. Add `buildCommitUrl`, the `COMMIT_URL_BUILDERS` registry, and the `parseCloneUrl` / `sshToHttps` helpers. -- [src/commands/install.ts](https://github.com/kinueng/openskills/blob/main/src/commands/install.ts) — add `commitSha` / `commitUrl` to `InstallSourceInfo`; capture SHA after clone; build commit URL; print warning when missing; include both fields in `buildGitMetadata`. -- `tests/integration/install.test.ts` — new test file (below). -- [tests/integration/e2e.test.ts](https://github.com/kinueng/openskills/blob/main/tests/integration/e2e.test.ts) — remove the moved `install (local paths)` describe block. - -No changes to `update.ts`, `sync.ts`, or `cli.ts`. No new dependencies. - -## Tests - -Existing vitest unit tests ([tests/commands/install.test.ts](https://github.com/kinueng/openskills/blob/main/tests/commands/install.test.ts), [tests/utils/skill-metadata.test.ts](https://github.com/kinueng/openskills/blob/main/tests/utils/skill-metadata.test.ts)) cover only pure helpers and use `toMatchObject` subset matching — they should keep passing unmodified. The new optional fields (`commitSha`, `commitUrl`) are purely additive. - -New functional coverage goes in a **new file** at `tests/integration/install.test.ts`, dedicated to install-flow tests. As part of this change, the existing `describe('openskills install (local paths)', ...)` block currently inside [tests/integration/e2e.test.ts](https://github.com/kinueng/openskills/blob/main/tests/integration/e2e.test.ts) (3 tests, ~40 lines) is **moved** into the new file. This consolidates all install testing — local-path and git-clone — in one place, regardless of source type, and trims `e2e.test.ts` to its remaining commands (list, read, sync, remove). - -The folder structure makes the unit-vs-functional split visible: - -``` -tests/ -├── commands/ # unit tests of command helpers -├── utils/ # unit tests of utility modules -└── integration/ # functional tests of the built CLI - ├── e2e.test.ts (existing, slightly trimmed) - └── install.test.ts (new — all install-flow tests) -``` - -### `tests/integration/install.test.ts` cases - -Style matches `e2e.test.ts`: invoke the built CLI via `execSync`, real filesystem in a temp dir, assert on `.openskills.json` contents. - -**Moved from `e2e.test.ts` (unchanged):** - -- `openskills install` from absolute local path. -- `openskills install` installs a directory of skills from a local path. -- `openskills install` errors for non-existent local path. - -**New for this PR:** - -1. **Install from real github records `commitSha` and `commitUrl`.** Run `node dist/cli.js install anthropics/skills/skills/pdf --yes` against real github.com. Assert: `.openskills.json` exists, `commitSha` is a 40-char hex string (`/^[0-9a-f]{40}$/`), `commitUrl` equals `https://github.com/anthropics/skills/commit/`. -2. **`buildCommitUrl` normalization** (pure-function unit tests): HTTPS, HTTPS+`.git`, SSH (`git@github.com:...`), `git://github.com/...` → all produce `https://github.com/anthropics/skills/commit/`. Case-insensitive SSH (`Git@`, `GIT@`) is also covered. A non-github host (`https://gitlab.com/...`) → `buildCommitUrl` returns `undefined`, documenting that gitlab is unsupported today. - -The github install case requires network access. It's appropriate for [tests/integration/](https://github.com/kinueng/openskills/tree/main/tests/integration) (the directory already implies "real CLI, real environment") and runs as part of `npm test` — which already executes on every CI run via [.github/workflows/ci.yml](https://github.com/kinueng/openskills/blob/main/.github/workflows/ci.yml). No CI changes needed. - -## Verification - -``` -npm run typecheck -npm test -npm run build -``` - -Manual smoke: - -``` -node dist/cli.js install anthropics/skills/skills/pdf --yes -cat .claude/skills/pdf/.openskills.json # should include "commitSha" and "commitUrl" - -# Optionally verify the warning path against any non-github clone URL: -node dist/cli.js install --yes -# expect: "Warning: clone succeeded, but the `commitUrl` field was not written ..." -``` - -## PR notes - -- Branch from `main`, single feature commit. -- PR title: `feat(install): record git commit SHA in .openskills.json`. -- Body: lead with the team-debugging value (clickable `commitUrl` + self-explaining diffs on `git pull`), describe the field and where it's captured, and confirm backward compatibility (legacy `.openskills.json` files stay valid; the field is mandatory only for new installs going forward).