Agent skill

Hs2tests

by cmsj in cmsj/Hammerspoon2

Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test

MITAuto-check passed

Install Hs2tests

skills CLI
$ npx skills add cmsj/Hammerspoon2 --skill hs2tests -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install cmsj/Hammerspoon2 hs2tests --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/HSTests .claude/skills/hs2tests && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
hs2tests
GitHub stars
113
Token cost
~5.4k tokens
SKILL.md length
1,478 words
Files
1
Skills in repo
3
Repo updated
First seen
Licence
MIT

At a glance

Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test

  • SKILL.md covers Framework and imports, File locations, Test structure and JSTestHarness — the primary…, plus 8 more sections
  • Instructions only: no scripts, shell commands, URLs or credentials in SKILL.md

What it does

Hs2tests is an agent skill from cmsj/Hammerspoon2. Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test

Its SKILL.md is about 5.4k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

The licence is MIT.

Example prompts

  • “/hs2tests”

What it can do on your machine

Read from SKILL.md and the folder at commit 831ff0e. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    No scripts in the folder and no shell commands in SKILL.md (its code samples are swift).

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    No URLs in SKILL.md.

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Hs2tests loads about 5.4k tokens when it runs. Until then it costs about 36 tokens; SKILL.md has 1,478 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~36
When it runs · the whole SKILL.md, loaded when a task matches
~5.4k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from cmsj/Hammerspoon2 at commit 831ff0e, republished under its MIT licence (© cmsj). 1,478 words, ~5,410 tokens.

Download SKILL.mdSave it as .claude/skills/hs2tests/SKILL.md (or your agent's skills folder).
name
hs2tests
description
Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test

Hammerspoon 2 Testing Guide

Framework and imports

All tests use Swift Testing (not XCTest). Every test file starts with:

swift
import Testing
import JavaScriptCore       // for JS-level module tests
@testable import Hammerspoon_2

Add other framework imports only as needed (e.g. import AppKit, import CoreAudio).


File locations

What you're testingDirectoryFilename pattern
A module's JS APIHammerspoon 2Tests/IntegrationTests/HSFooIntegrationTests.swift
A manager/engine classHammerspoon 2Tests/ManagerTests/FooManagerTests.swift
Console/completion logicHammerspoon 2Tests/ConsoleTests/FooConsoleTests.swift
Mock implementationsHammerspoon 2Tests/Mocks/MockFoo.swift

Test structure

Tests are structs, not classes. Each module's test file must have a top-level wrapper suite that encloses all of that module's inner suites:

swift
@Suite("hs.foo tests")
struct HSFooTests {

    @Suite("hs.foo API structure tests")
    struct HSFooStructureTests {
        private func makeHarness() -> JSTestHarness {
            let harness = JSTestHarness()
            harness.loadModule(HSFooModule.self, as: "foo")
            return harness
        }

        @Test("someMethod is a function")
        func testSomeMethodIsFunction() {
            makeHarness().expectTrue("typeof hs.foo.someMethod === 'function'")
        }
    }

    @Suite("hs.foo calculations")
    struct HSFooCalculationTests {
        // ...
    }
}

The top-level suite name is always "hs.foo tests" (matching the module name) and the struct is named HSFooTests. All inner suites (HSFooStructureTests, HSFooCalculationTests, etc.) are nested inside it.

The private makeHarness() factory avoids repeating setup in every test. Each test should create its own harness (they share no state).


JSTestHarness — the primary testing tool for modules

JSTestHarness spins up an isolated JSContext and loads modules into it, exactly mimicking the real runtime but without a full app launch.

Loading modules
swift
// Load one module
harness.loadModule(HSFooModule.self, as: "foo")
// → available in JS as hs.foo

// Load multiple modules (e.g. hs.ax needs hs.application)
harness.loadModule(HSAXModule.self, as: "ax")
harness.loadModule(HSApplicationModule.self, as: "application")

The companion .js file (hs.foo.js) is loaded automatically if it exists in the bundle, so JS enhancements are included in integration tests without extra setup.

Assertions

Prefer bringing equality-style checks into Swift with a plain #expect(...) rather than expectTrue/expectFalse. expectTrue("typeof x === 'number'") only ever reports "expected true, got false" — Swift Testing's #expect macro captures both sides of a == comparison and reports the actual value on failure, which is far more useful for debugging. Use the evalString/evalBool/evalInt/evalDouble/ evalTypeOf helpers on JSTestHarness for this:

swift
// Type checks — use evalTypeOf, not expectTrue("typeof ... === '...'")
#expect(harness.evalTypeOf("hs.foo.someMethod") == "function")
#expect(harness.evalTypeOf("hs.foo.name") == "string")

// Equality checks against a known value — use the typed eval helpers
#expect(harness.evalString("hs.foo.name") == "expected string")
#expect(harness.evalInt("hs.foo.count") == 42)
#expect(harness.evalDouble("hs.foo.ratio") == 0.5)
#expect(harness.evalBool("hs.foo.isReady") == true)

// Reserve expectTrue/expectFalse for genuine boolean checks with no natural
// "actual vs expected" pair — comparisons, predicates, compound conditions:
harness.expectTrue("Array.isArray(hs.foo.list())")
harness.expectTrue("hs.foo.count >= 0")
harness.expectFalse("hs.foo.isEmpty()")

// expectEqual still works and is fine for quick one-offs, but a direct #expect
// is preferred for the reasons above
harness.expectEqual("hs.foo.name", "expected string")

// Run JS without asserting on the result
harness.eval("hs.foo.doSomething()")
harness.eval("""
    var x = hs.foo.create({ title: 'Test' });
    x.start();
""")

// Get the raw JSValue for complex assertions
let result = harness.evalValue("hs.foo.compute()")
#expect(result?.isObject == true)
#expect(result?.isNull == true || result?.isUndefined == true)

// Get the plain Swift value
let val = harness.eval("hs.foo.compute()") as? Double
#expect(val != nil)
Checking for exceptions

Check that a call does NOT throw (the most common check after any non-trivial eval):

swift
harness.eval("hs.foo.doSomething(complexInput)")
#expect(!harness.hasException)

Check that a call DOES throw (for invalid input validation):

swift
harness.eval("hs.foo.doSomething(badInput)")
harness.expectException()

Standard test suites for a new module

Every new module should have at minimum two suites in its test file, both nested inside a top-level wrapper suite (see Test structure above). If the module produces JS-exported child objects (factory/constructor methods that return HSFoo instances), a memory leak test is also mandatory — see Memory leak tests below.

Suite 1: API structure (mandatory, runs everywhere)

One test per public protocol member, verifying it exists with the right JS type. These tests never touch real OS state and run in any environment.

swift
@Suite("hs.foo tests")
struct HSFooTests {

    @Suite("hs.foo API structure tests")
    struct HSFooStructureTests {

        private func makeHarness() -> JSTestHarness { ... }

        // Functions
        @Test("doThing is a function")
        func testDoThingIsFunction() {
            makeHarness().expectTrue("typeof hs.foo.doThing === 'function'")
        }

        // Properties (numbers, booleans, strings, objects)
        @Test("count is a number")
        func testCountIsNumber() {
            makeHarness().expectTrue("typeof hs.foo.count === 'number'")
        }

        @Test("isEnabled defaults to true")
        func testIsEnabledDefault() {
            makeHarness().expectTrue("hs.foo.isEnabled === true")
        }

        // Sub-objects
        @Test("geocoder is an object")
        func testGeocoderIsObject() {
            makeHarness().expectTrue("typeof hs.foo.geocoder === 'object'")
        }

        // Watcher emitter (if the module uses the EventEmitter watcher pattern)
        @Test("_watcherEmitter is initialized by hs.foo.js")
        func testWatcherEmitterInitialized() {
            makeHarness().expectTrue(
                "hs.foo._watcherEmitter !== null && hs.foo._watcherEmitter !== undefined"
            )
        }

        // Input validation (methods should fail gracefully, not throw)
        @Test("doThing() with null input returns null without throwing")
        func testDoThingNullInput() {
            let harness = makeHarness()
            harness.eval("var r = hs.foo.doThing(null)")
            harness.expectTrue("r === null || r === undefined")
            #expect(!harness.hasException)
        }
    }

    // MARK: - Suite 2

    @Suite("hs.foo calculations")
    struct HSFooCalculationTests {

        private func makeHarness() -> JSTestHarness { ... }

        @Test("distance between London and Paris is ~341km")
        func testDistance() {
            let harness = makeHarness()
            harness.eval("var d = hs.foo.distance(51.5074, -0.1278, 48.8566, 2.3522)")
            harness.expectTrue("Math.abs(d - 341402) < 5000")
            #expect(!harness.hasException)
        }

        @Test("returned object has expected type and properties")
        func testReturnedObject() {
            let harness = makeHarness()
            harness.eval("var obj = hs.foo.create({ title: 'Test' })")
            harness.expectTrue("typeof obj === 'object'")
            harness.expectTrue("typeof obj.identifier === 'string'")
            harness.expectTrue("obj.identifier.length > 0")
        }

        @Test("two created objects have different identifiers")
        func testUniqueIdentifiers() {
            let harness = makeHarness()
            harness.expectTrue("""
                (function() {
                    var a = hs.foo.create({ title: 'A' });
                    var b = hs.foo.create({ title: 'B' });
                    return a.identifier !== b.identifier;
                })()
            """)
        }
    }
}
Suite 2: Behaviour / pure calculations (runs everywhere)

Test that methods return correct values for deterministic inputs — pure calculations, round-trips, invariants. No OS permissions or hardware required. (See example above.)

Suite 3 (optional): Permission/hardware-gated tests

Tests that require real OS state (accessibility, microphone, audio hardware, etc.) must be guarded with .disabled(if:) so they skip gracefully in environments where the permission or hardware is absent. These also nest inside the top-level HSFooTests wrapper.

swift
private nonisolated func isAccessibilityEnabled() -> Bool {
    AXIsProcessTrusted()
}

@Suite("hs.foo tests")
struct HSFooTests {
    // ...

    @Suite("hs.foo real-hardware tests",
           .serialized,
           .disabled(if: !isAccessibilityEnabled(), "Accessibility not granted"))
    struct HSFooHardwareTests {
        // Tests that call real OS APIs
    }
}

The guard function MUST be nonisolated so it can be called from the .disabled trait expression, which is evaluated outside any actor.


Memory leak tests (mandatory for object-producing modules)

Any module whose API lets JS create instances of a Swift class (HSTimer, HSHotkey, HSBonjourSearch, etc.) must have at least one leak test. These tests verify that after hs.reload() (simulated by shutdownForLeakTest()) every child object is properly freed — no strong-reference cycles, no stale NSNotificationCenter observers, no selfRetain left set.

Tools

WeakLeakTracker — holds weak references to tracked objects without preventing their deallocation. Call tracker.track(swiftObj) while the object is alive, then tracker.assertNoLeaks() after everything is torn down.

harness.shutdownForLeakTest() — calls module.shutdown() on every loaded module, removes hs from the JS global object, then runs JSSynchronousGarbageCollectForDebugging so ObjC bridge finalizers execute before the function returns.

Mandatory pattern
swift
// MARK: - Memory Leak Tests

@Test("Active HSFoo is released after shutdown")
func testFooDoesNotLeakAfterReload() {
    let tracker = WeakLeakTracker()
    autoreleasepool {
        let harness = JSTestHarness()
        harness.loadModule(HSFooModule.self, as: "foo")

        // 1. CREATE the object
        harness.eval("var obj = hs.foo.create(…)")

        // 2. ACTIVELY USE IT — call start(), send(), enter(), findServices(), etc.
        //    Do not just create and immediately discard. The goal is to exercise
        //    the path where the object holds OS resources when shutdown is called.
        harness.eval("obj.start()")

        // 3. TRACK the underlying Swift object while it is still alive
        if let swift = harness.evalValue("obj")?.toObjectOf(HSFoo.self) as? HSFoo {
            tracker.track(swift)
        }

        // 4. Drop the JS reference, then shut down
        harness.eval("obj = null")
        harness.shutdownForLeakTest()
    } // autorelease pool drained here; JSValue ObjC refs released, JSContext freed
    tracker.assertNoLeaks()
}
Rules
RuleReason
Wrap harness in an autoreleasepool {} blockDrains ObjC autorelease pool (including JSValues from eval()) so the JSContext is freed before the weak-ref check runs — a plain do {} block does not drain the pool and will silently miss leaks
Track the object before nulling the JS varevalValue() returns nil after the var is cleared
Extract the Swift object with toObjectOf(T.self)All child types are @objc class NSObject subclasses; this cast is safe
Call shutdownForLeakTest() inside the autoreleasepool {} blockSync GC must run while the context is still alive
Call assertNoLeaks() outside the autoreleasepool {} blockThe context (and any bridged objects) must be released first
Start/use the object actively — see table belowAn unstarted object skips the interesting cleanup paths (selfRetain, NSNotificationCenter observers, OS browser delegates, taskTracker strong refs, etc.)
What "actively using" means per object type
ObjectHow to activate
HSTimer (repeating)hs.timer.doEvery(0.05, fn) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) so it fires
HSTimer (one-shot)hs.timer.doAfter(0.05, fn) + same run-loop drain
HSHotkeybind() already enables it; add hk.disable() + hk.enable() to cycle state
HSHotkeyModalmodal.bind(…) to add hotkeys + modal.enter() to make it active
HSEventTaptap.start() — succeeds with Accessibility, fails silently without it; destroy() handles both
HSSpotlightQueryq.setQuery("…").setCallback(fn).start() — use an intentionally unmatchable predicate
HSNotificationn.send() — display may fail without permission; the object is still used
HSBonjourSearchsearch.findServices('_http._tcp.', 'local.', fn) to start browsing
HSTaskt.start() with a long-running command (/bin/sleep 10) so the process is definitely alive when shutdown() is called
Why each object type needs specific handling
  • Timers — Foundation Timer(target:selector:) holds a strong ref to HSTimer as its target until invalidate() is called. shutdown() → destroy() → stop() → invalidate() is the only release path.
  • Hotkeys — enabledHotkeys is a strong array in HSHotkeyModule. Enabled hotkeys are not freed until shutdown() clears the array.
  • EventTaps — start() sets selfRetain = self to keep the tap alive while capturing events. destroy() → stop() clears it.
  • SpotlightQuery / BonjourSearch — NSNotificationCenter and NSNetServiceBrowser hold strong delegate/observer refs. destroy() removes them; without destroy() these objects can never be freed.
  • Tasks — registerActiveTask() stores the task in a strong taskTracker inside the module. It is released only when the module itself is freed.
Show full SKILL.md (563 more words)Show less
One test per object type is sufficient

You do not need separate tests for "created but not started" and "active". A single test that actively uses the object is more valuable than two tests where one skips the interesting cleanup paths.


Async tests — timing and callbacks

Registering Swift callbacks from JS
swift
var fired = false
harness.registerCallback("onEvent") {
    fired = true
}

// In JS, call: __test_callback('onEvent')
harness.eval("hs.timer.doAfter(0.05, () => __test_callback('onEvent'))")

let success = harness.waitFor(timeout: 0.2) { fired }
#expect(success, "callback should have fired")
#expect(fired)

For callbacks with typed arguments:

swift
var exitCode: Int = -1
harness.registerCallback("onDone") { (code: Int) in
    exitCode = code
}
// In JS: taskComplete(0)  [the callback is registered as the global name]
Synchronous wait (preferred for simple timer tests)
swift
let success = harness.waitFor(timeout: 0.5) { someCondition }
#expect(success, "condition should have been met")

waitFor spins the RunLoop in 10ms steps so timers and notifications fire normally.

Async wait (for tests that touch MainActor tasks)
swift
@Test("task fires completion callback")
func testTaskCompletion() async {
    let harness = JSTestHarness()
    harness.loadModule(HSTaskModule.self, as: "task")
    var done = false
    harness.registerCallback("onDone") { done = true }
    harness.eval("hs.task.new('/bin/echo', ['hi'], () => __test_callback('onDone')).start()")
    let ok = await harness.waitForAsync(timeout: 2.0) { done }
    #expect(ok)
}
Draining the MainActor queue between tests

For test suites that touch async Swift machinery, add an async init that drains the queue so one test's work doesn't bleed into the next:

swift
@Suite("hs.task tests", .serialized)
struct HSTaskTests {
    init() async {
        await JSTestHarness.drainMainActorQueue()
    }
}
Timer interval guidelines

Keep test timers fast. Recommended values:

  • Timer interval: 0.02s – 0.05s (fast enough to fire quickly)
  • waitFor timeout: 3–5× the timer interval (enough headroom, short enough to fail fast)
  • Never use intervals over 0.5s in tests

Tests that modify shared system state

When a test must write to shared OS state (system pasteboard, files), save and restore it in a helper:

swift
private func withSavedPasteboard(_ body: () -> Void) {
    let saved = NSPasteboard.general.pasteboardItems?.map { ... }
    body()
    // restore...
}

@Test("writeString round-trips through readString")
func testStringRoundTrip() {
    withSavedPasteboard {
        let harness = makeHarness()
        harness.eval("hs.pasteboard.writeString('hello')")
        harness.expectEqual("hs.pasteboard.readString()", "hello")
    }
}

Mark suites that touch shared state with .serialized to prevent races:

swift
@Suite("hs.pasteboard read/write tests", .serialized)
struct HSPasteboardReadWriteTests { ... }

Pure Swift tests (no JSTestHarness)

For testing internal Swift logic that has no JS surface (managers, pure value types, enum metadata, etc.), use Swift Testing directly without JSTestHarness:

swift
import Testing
import Foundation
@testable import Hammerspoon_2

struct PermissionsTypeMetadataTests {

    @Test("All permission types have non-empty displayName")
    func testAllDisplayNamesNonEmpty() {
        for permType in PermissionsType.allCases {
            #expect(!permType.displayName.isEmpty)
        }
    }

    @Test("accessibility displayName is correct")
    func testAccessibilityDisplayName() {
        #expect(PermissionsType.accessibility.displayName == "Accessibility")
    }
}

Mocks

When a component under test has external dependencies (JS engine, file system, settings), inject a mock via dependency injection and place the mock in Hammerspoon 2Tests/Mocks/:

swift
// Mocks/MockFoo.swift
import Foundation
@testable import Hammerspoon_2

class MockFoo: FooProtocol {
    var callCount = 0
    var shouldFail = false
    var lastArgument: String?

    func doThing(_ arg: String) throws {
        callCount += 1
        lastArgument = arg
        if shouldFail { throw SomeError.failure }
    }

    func reset() {
        callCount = 0
        shouldFail = false
        lastArgument = nil
    }
}

Expose every configurable behaviour as a Bool flag (shouldFail, shouldThrow) and record all call arguments so tests can assert on them.


What NOT to test

  • Hardware mutations: don't call setMode(), change audio routing, set display origin, or mirror displays — these disrupt the developer's desktop mid-run.
  • Network-dependent assertions: geocoding, URL loading, and similar async network calls are too flaky for the test suite. Test that the Promise is returned and has a .then method; don't await actual network results.
  • UI display: notifications, alerts, and windows cannot be verified visually in a test runner. Test API shape and non-throwing execution; document the limitation in the suite docstring.
  • Permissions dialogs: never trigger permission request dialogs from a test. Test check* (which returns a bool) but not request* (which shows a system dialog). Gate hardware-dependent tests with .disabled(if:).
  • Real-time sensor data: GPS location, camera frames, and microphone audio are unavailable in the test environment. Test API shape and watcher object lifecycle only.
  • Existence of class methods/properties There is little point testing that the source code still contains itself.

Quick checklist

  • File is in the correct *Tests/ subdirectory
  • Filename matches HSFooIntegrationTests.swift pattern
  • Imports are Testing, JavaScriptCore, @testable import Hammerspoon_2
  • All inner suites are nested inside a top-level @Suite("hs.foo tests") struct HSFooTests {}
  • Tests are structs with @Test("description") on each function
  • A makeHarness() factory avoids repeated boilerplate
  • Suite 1 covers every public protocol member (type + existence)
  • Suite 2 covers deterministic behaviour (no OS permissions needed)
  • Permission/hardware-gated suites use .disabled(if: ...) with a nonisolated guard
  • #expect(!harness.hasException) after every non-trivial eval()
  • Async tests use waitForAsync or waitFor rather than Thread.sleep where possible
  • State-mutating tests use .serialized and save/restore shared state
  • Nothing triggers permission dialogs, hardware mutations, or live network calls
  • If the module creates child JS objects: a testFooDoesNotLeakAfterReload() test exists that (a) creates AND actively starts the object, (b) tracks it with WeakLeakTracker, (c) wraps the harness in an autoreleasepool {} block, and (d) calls tracker.assertNoLeaks() after the block

© cmsj, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in .claude/skills/HSTests of cmsj/Hammerspoon2.

Open the folder on GitHubat commit 831ff0e

Compare with similar skills

Hs2tests next to the 2 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Hs2tests compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Hs2tests this skillcmsj/Hammerspoon2113—~5.4kAutomated safety check: PassMIT
Hs2modulecmsj/Hammerspoon2113—~8.6kAutomated safety check: PassMIT
Hs2typecmsj/Hammerspoon2113—~2.6kAutomated safety check: PassMIT

Similar skills

  • Hs2module

    cmsj/Hammerspoon2

    Ensure all Hammerspoon v2 modules follow established patterns

    113 GitHub stars~8.6k tokensUpdated yesterday
    MobileAuto-check passed
  • Hs2type

    cmsj/Hammerspoon2

    Guide for creating custom types in Hammerspoon 2 — both shared engine types and module-specific types

    113 GitHub stars~2.6k tokensUpdated yesterday
    MobileAuto-check passed

Questions about Hs2tests

What does Hs2tests do?

Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test. Hs2tests is an agent skill from cmsj/Hammerspoon2.

How do I install Hs2tests in Claude Code?

Run `npx skills add cmsj/Hammerspoon2 --skill hs2tests -a claude-code`. Or copy the skill folder (.claude/skills/HSTests in cmsj/Hammerspoon2) into .claude/skills/hs2tests in your project. Claude Code loads it when a task matches its description.

How do I install Hs2tests in Codex?

Run `npx skills add cmsj/Hammerspoon2 --skill hs2tests -a codex`. Or copy the skill folder (.claude/skills/HSTests in cmsj/Hammerspoon2) into .agents/skills/hs2tests in your project. Codex loads it when a task matches its description.

Can I use Hs2tests in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add cmsj/Hammerspoon2 --skill hs2tests -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/hs2tests, .gemini/skills/hs2tests, .github/skills/hs2tests and .opencode/skills/hs2tests in your project.

What does Hs2tests need to run?

SKILL.md names no scripts, command-line tools or credentials: Hs2tests is instructions for the agent only.

Does Hs2tests access the network?

SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.

Is Hs2tests safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Hs2tests use?

Hs2tests is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Hs2tests use?

About 5.4k tokens (SKILL.md is roughly 22k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Hs2tests?

Skills that share tags, products or a category with Hs2tests: Hs2module (cmsj/Hammerspoon2, 113 stars) and Hs2type (cmsj/Hammerspoon2, 113 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Hs2tests?

cmsj (a GitHub user) maintains it in cmsj/Hammerspoon2, which has 113 GitHub stars. The repository holds 3 skills in this directory. The repository was last updated on October 6, 2026.

Source: cmsj/Hammerspoon2 on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.