Agent skill

Hs2module

by cmsj in cmsj/Hammerspoon2

Ensure all Hammerspoon v2 modules follow established patterns

MITAuto-check passedMobile

Install Hs2module

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

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

GitHub CLI
$ gh skill install cmsj/Hammerspoon2 hs2module --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/HSModule .claude/skills/hs2module && 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
hs2module
GitHub stars
115
Token cost
~8.6k tokens
SKILL.md length
3,500 words
Files
1
Skills in repo
3
Repo updated
First seen
Licence
MIT

At a glance

Ensure all Hammerspoon v2 modules follow established patterns

  • Works in 3 steps: Deactivates the object (stops timers,… → Detaches all JS callbacks (see JS… → Clears all references
  • Mobile work in your project
  • SKILL.md covers Where do Hammerspoon 2 modules…, Module registration, Basic structure of a module and Child object tracking, plus 7 more sections
  • Calls npm

What it does

Hs2module is an agent skill from cmsj/Hammerspoon2. Ensure all Hammerspoon v2 modules follow established patterns

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

It sits in Mobile. It works with JavaScript and Objective-C. The licence is MIT.

When your agent uses it

  • Mobile work in your project

Example prompts

  • “/hs2module”

Workflow steps

3 steps, taken from the first numbered list in SKILL.md.

  1. Deactivates the object (stops timers, disables hotkeys, stops OS updates, etc.)
  2. Detaches all JS callbacks (see JS callback memory management below)
  3. Clears all references

What it can do on your machine

Read from SKILL.md and the folder at commit f426cf3. 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

    Shell commands in SKILL.md call:

    • npm

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

  • Network

    Links to these hosts (documentation or services it may open):

    • github.com
    • cocoamine.net

    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

Hs2module loads about 8.6k tokens when it runs. Until then it costs about 18 tokens; SKILL.md has 3,500 words of instructions outside code blocks.

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

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 f426cf3, republished under its MIT licence (© cmsj). 3,500 words, ~8,637 tokens.

Download SKILL.mdSave it as .claude/skills/hs2module/SKILL.md (or your agent's skills folder).
name
hs2module
description
Ensure all Hammerspoon v2 modules follow established patterns

Hammerspoon 2 Module Requirements

Hammerspoon 2 consists of two main components:

  • a core engine that bridges native Swift code into JavaScript for the user to write their configuration file
  • Various "modules" that add expose macOS APIs into the JavaScript engine.

Where do Hammerspoon 2 modules live?

In the directory "Hammerspoon 2/Modules", each within a further directory that shares the name they will be exposed to JS with, e.g. "hs.location"

Module registration

A new module needs to be registered with the core JS engine so it can be loaded, these are both in Engine/ModuleRoot.swift:

  • In the ModuleRootAPI protocol: @objc var foo: HSFooModule { get }
  • In the ModuleRoot class body: @objc var foo: HSFooModule { getOrCreate(name: "foo", type: HSFooModule.self) }

This will automatically load hs.foo.js if it exists in the Hammerspoon 2 app bundle, as well as exposing the HSFooModule class to JavaScript

Additionally, the module should be exposed to "Hammerspoon 2Tests/Helpers/JStestHarness.swift" in the loadModules switch: case "foo": loadModule(HSFooModule.self, as: name)

Basic structure of a module

For the case of a module that we intend to be accessible in JS as "hs.foo", the following structural rules must be observed:

  • All of the code for hs.foo should live in "Hammerspoon 2/Modules/hs.foo"
  • The code to be loaded when JavaScript accesses "hs.foo" should live in a file called "HSFooModule.swift"
  • HSFooModule.swift should always import the "Foundation" and "JavaScriptCore" frameworks
  • HSFooModule.swift should always contain at least the following:
    • A protocol definition of the form "@objc protocol HSFooModuleAPI: JSExport" - this is where we define the API that will be exported to JavaScript
    • A class implementation of "HSFooModuleAPI" of the form: "@objc class HSFooModule: NSObject, HSModuleAPI, HSFooModuleAPI"
    • The class must be annotated with @MainActor (all JS-facing code runs on the main thread)
    • Conformance to HSModuleAPI requires four things:
      • var name: String set to "hs.foo"
      • let engineID: UUID — stored property identifying which engine instance owns this module
      • An init in the form:
        swift
        required init(engineID: UUID) {
            self.engineID = engineID
            // ... any pre-super initialisation ...
            super.init()
            AKGarbage("Init of \(name): \(engineID)")
        }
      • A shutdown() method called by the core engine when tearing down the JS environment
  • The HSFooModule class should be annotated with: @_documentation(visibility: private)
  • HSFooModule should always have an isolated deinit that calls AKGarbage("Deinit of \(name): \(engineID)").
  • If the module allocates/retains any data (e.g. watchers, instance children, etc) then it should store weak references to them and be sure to clean them up in its shutdown() method.
  • Any instance child classes should always have an "isolated deinit" method that uses AKGarbage() to announce their deinitialisation.
  • NEVER choose method names that start with "new", "alloc" or "copy" since these can fall foul of ObjC's implicit ARC rules and the objects those methods create will have one un-balanced retains.
  • NEVER name a method "delete" since this is a reserved keyword in JavaScript and cannot be called as a method (e.g. obj.delete() is a syntax error). Use a descriptive alternative such as deletePath(), destroy(), or remove() instead.

Child object tracking

When a module creates child objects that are returned to JavaScript (e.g. HSTimer, HSHotkey, HSBonjourSearch), it must track them so shutdown() can clean them all up.

The canonical pattern is HSWeakObjectSet<T> (Engine/HSWeakObjectSet.swift):

swift
// Declaration (in the module class body)
private var children = HSWeakObjectSet<HSChild>()

// Adding a new child
children.add(child)

// Removing an explicit child (e.g. in removeSearch/removeWatcher)
children.remove(child)

// Shutdown — allObjects returns only live entries (dead ones are compacted on access)
func shutdown() {
    for child in children.allObjects {
        child.destroy()
    }
    children.removeAllObjects()
}

Every child object returned to JS must have a destroy() method that:

  1. Deactivates the object (stops timers, disables hotkeys, stops OS updates, etc.)
  2. Detaches all JS callbacks (see JS callback memory management below)
  3. Clears all references

Both isolated deinit and the hosting module's shutdown() must call destroy(). This ensures cleanup happens whether the object is collected by the JS GC or torn down explicitly by the module.

swift
@objc class HSChild: NSObject, HSChildAPI {
    private var callback: JSCallback?

    func destroy() {
        stop()                         // deactivate
        callback?.detach(from: self)   // detach JS callback
        callback = nil
    }

    isolated deinit {
        destroy()
        AKGarbage("deinit of HSChild")
    }
}

Why HSWeakObjectSet and not NSHashTable.weakObjects()?

  • NSHashTable.weakObjects() has documented autoreleasepool interactions where entries can persist or zero out unpredictably (see https://github.com/GitHawkApp/FlatCache/issues/3 and http://cocoamine.net/blog/2013/12/13/nsmaptable-and-zeroing-weak-references/). HSWeakObjectSet uses plain Swift weak var references which zero correctly per ARC semantics.
  • HSWeakObjectSet stores entries in a [ObjectIdentifier: WeakBox] dictionary, giving O(1) add/remove. Dead entries are compacted lazily on allObjects access.
  • Centralising the implementation means we can change the backing storage in one place without touching every module.
  • Weak refs allow the JS garbage collector to reclaim objects the user has dropped, without requiring an explicit removeXxx() call.
  • The underlying OS keeps active objects alive independently (e.g. the Carbon event handler keeps HSHotkey alive while enabled; the run loop Timer keeps HSTimer alive while running; CLLocationManager holds HSLocationWatcher alive via its delegate). Module tracking is only needed for shutdown().

JS callback memory management

When a JS-exported Swift object stores a JavaScript callback function, always use JSCallback (Engine/JSCallback.swift), never a raw JSValue.

Why: Storing a raw JSValue in a JS-exported Swift object creates a retain cycle that prevents the JS GC from ever collecting the object:

Swift object → JSValue → JSContext → JS wrapper → Swift object

JSCallback breaks the cycle using JSManagedValue. The VM tracks the callback as a conditional reference: alive only while the owner is reachable from JS. When JS GC collects the wrapper, the managed value clears and the Swift object can be freed.

Critical design detail: The JSVirtualMachine is captured at init time (when JSContext.current() is valid — JS is calling into Swift). This allows detach(from:) to work correctly even when called outside JS execution (e.g., from shutdown()). A naive implementation using JSContext.current() at detach time silently fails because that call returns nil when called from Swift outside a JS callback.

Usage — callback set once at init:

swift
private var callback: JSCallback?

init(..., callback: JSValue, ...) {
    // ... stored properties ...
    super.init()
    // Phase 2 — JSContext.current() is non-nil here (called from JS bridge)
    self.callback = JSCallback(value: callback, owner: self)
}

func destroy() {
    callback?.detach(from: self)   // owner passed explicitly — weak var would be zeroed in deinit
    callback = nil
}

func fireCallback() {
    callback?.value?.call(withArguments: [...])
}

Usage — callback exposed as a settable JS property (e.g. HSHotkey.callbackPressed):

When the protocol declares @objc var callbackPressed: JSValue? { get set }, the implementation uses a computed property with JSCallback? backing — the JS-visible type is unchanged:

swift
private var _callbackPressed: JSCallback?

@objc var callbackPressed: JSValue? {
    get { _callbackPressed?.value }
    set {
        _callbackPressed?.detach(from: self)
        _callbackPressed = newValue.flatMap { JSCallback(value: $0, owner: self) }
    }
}

Do not use JSCallback for:

  • Objects that intentionally self-retain while active (e.g. HSCamera, HSAudioDevice use selfRetain = self while a watcher is registered). The retain cycle is intentional there; breaking it would cause premature deallocation.
  • Internal helper objects not exported to JS (e.g. HSAXWatcherObject).

Exception — strong refs for visible UI objects: hs.ui intentionally uses [UUID: HSUIWindow] (strong) for windows, alerts, and dialogs. These must stay alive on screen even if JS drops the reference. This is the only legitimate reason to use strong tracking. UI objects call back to the module to register/unregister themselves when shown/closed.

JSValue retention and JSContext leaks

Every JSValue stored anywhere in Swift holds the entire old JSContext alive until that JSValue is released. If hs.reload() is called and any Swift object is still holding a JSValue from the old context, that context — and its entire JS heap, including all JS proxies for Swift objects — will remain in memory indefinitely.

This section documents the patterns that cause these leaks and how to prevent them.

Closures that capture JSValue are the same as raw JSValue

A closure that closes over a JSValue parameter is functionally identical to storing that JSValue directly. Both hold a strong reference to the old JSContext.

swift
// WRONG — the closure captures `callback` by value; JSValue is retained
interactive.clickCallback = { callback.call(withArguments: []) }

// WRONG — equivalent problem; isHovered is Bool but callback is captured
interactive.hoverCallback = { isHovered in callback.call(withArguments: [isHovered]) }

Any closure stored in a property (even a non-JSValue typed property like (() -> Void)?) that was created by capturing a JSValue must be cleared during shutdown with the same urgency as a raw JSValue.

If JSValue-capturing closures are stored in sub-objects (e.g. an element tree), the parent object must explicitly nil those sub-objects during close()/shutdown() — it is not sufficient to just release the parent via ARC, because ARC only runs when the retain count reaches zero, which may not happen until the JS proxy for the parent is GC'd (which requires the context to be freed first — a chicken-and-egg deadlock).

The correct pattern is to break the chain eagerly in close():

swift
@objc func close() {
    guard isOpen else { return }
    // Eagerly release the element tree so closures that captured JSValue callbacks
    // are freed NOW, before this object's own retain count reaches zero.
    rootElement = nil
    currentElement = nil
    containerStack.removeAll()
    // ... close the window etc. ...
}
_watcherEmitter must be nilled in shutdown()

Every module that uses the Pattern A watcher stores a JS EventEmitter instance in _watcherEmitter: JSValue?. This JSValue holds the old JSContext alive. It MUST be nilled in shutdown() — calling _removeWatcher() alone is not sufficient, because _removeWatcher() only removes the underlying OS listener, not the emitter value itself.

swift
func shutdown() {
    _removeWatcher()
    _watcherEmitter = nil   // REQUIRED — releases the JSValue holding the old context
}

The same applies to any other JSValue? stored directly on a module (e.g. doUntil, doWhile, waitUntil, waitWhile on HSTimerModule): they must all be nilled in shutdown().

close() is the single cleanup point — not deinit

For objects that have a close() or destroy() lifecycle method, all JSValue cleanup must happen in close()/destroy(), not only in deinit. This is because:

  • shutdown() calls close()/destroy() explicitly — this is the primary cleanup path.
  • deinit runs only after the object's ARC count drops to zero.
  • If a JS proxy still references the Swift object (because the old context hasn't been freed yet), deinit never runs — but close() already ran, so that's fine. If close() didn't release the JSValues, the proxy prevents the context from being freed, which prevents deinit from running — a deadlock.

The dependency is:

close() releases JSValues → JSContext freed → JS proxy collected → ARC drops → deinit runs

Reversing this by relying on deinit to release JSValues breaks the chain.

One-shot callbacks: nil after invocation

For objects that show a modal UI and invoke a callback exactly once (e.g. HSUITextPrompt, HSUIFilePicker), nil the callback immediately after calling it. There is no close() to clear it, and the object may remain alive for a long time if not GC'd:

swift
@objc func show() {
    // ... run the modal ...
    if let callback = buttonCallback {
        buttonCallback = nil          // release BEFORE calling, so it's freed even if callback throws
        callback.call(withArguments: [buttonIndex, inputText])
    }
}

Nilling before calling (rather than after) is slightly safer: if the callback throws a JS exception, the JSValue is still released.

Shutdown checklist

When writing or reviewing a module's shutdown(), verify:

  • _removeWatcher() called if the module uses Pattern A watchers
  • _watcherEmitter = nil set after _removeWatcher()
  • Every other JSValue? property stored on the module is nilled
  • Every JSValue?-capturing closure stored on the module or its sub-objects is cleared
  • destroy() called on all tracked child objects (which detach their own JSCallbacks)
  • close() on long-lived UI objects clears element trees and callback properties eagerly
  • If HSFooModule includes an hs.foo.js file, and that file needs to store any properties/methods/objects/etc in the hs.foo namespace, there must be a declaration in HSFooModuleAPI to hold it. JavaScriptCore cannot modify HSFooModule instances at runtime to add additional properties/methods and they will go silently out of scope in unpredictable ways.
  • In general we should avoid creating an hs.foo.js file unless absolutely necessary - it is strongly preferred to keep all code together in Swift. Legitimate uses of a .js file would include the watcher patterns mentioned below

JavaScript API considerations

The HSFooModuleAPI protocol should observe the following rules:

  • Method parameters need to have their labels omitted, ie "@objc func doFoo(_ someParameter: String)" - the underscore before the label means it will not be required to call the method with the label. If this rule is ignored, the name of the method exposed to JavaScript will be a complex mixture of the name of the method and the labels of its parameters.
  • If for some reason we must have parameter labels, the name of the method exposed to JavaScript can be overridden thusly: "@objc(doFoo:) func doFoo(someParameter: String)"
  • If we are allowing users to create "watcher" objects that respond to macOS events and call user-supplied JS callbacks, they should be created/destroyed with methods called "addWatcher" and "removeWatcher"
Never use JSValue for typed parameters or return values

JSValue must NOT appear in @objc protocol ... JSExport declarations as a parameter type or return type for any value that has a concrete Swift type. Use the appropriate Swift type instead:

Instead ofUse
JSValue (string)String or String?
JSValue (number)Int, Double, etc.
JSValue (boolean)Bool
JSValue (array)[String], [Any], etc.
JSValue (object)[String: Any]

JSValue is only acceptable for JavaScript function callbacks (there is no concrete Swift type for a JS function). Examples where JSValue is correct:

swift
@objc func addWatcher(_ listener: JSValue)          // listener is a JS function
@objc func setCallback(_ fn: JSValue) -> HSXxxWatcher // fn is a JS function
@objc var callbackPressed: JSValue? { get set }      // property holding a JS function

If a parameter needs to accept two different concrete types (e.g. a string OR an array), do not reach for JSValue — instead split into two separate methods with descriptive names:

swift
// BAD — hides the type contract, breaks TypeScript, forces runtime JSValue inspection
@objc func findMenuItem(_ item: JSValue) -> [String: Any]?

// GOOD — clear types, two separate entry points
@objc func findMenuItemByName(_ name: String) -> [String: Any]?
@objc func findMenuItemByPath(_ path: [String]) -> [String: Any]?
Bool parameters default to false when omitted

When a @objc method has a Bool parameter, JavaScript callers that omit the argument receive false at the Swift layer (ObjC bridges undefined/missing to NO/false). This has an important consequence for API design:

Design Bool-parameter APIs so that false represents the safe, common, or "do nothing extra" behaviour. The user gets false for free when they call the method with no argument.

swift
// GOOD — false means "don't raise all windows", which is the common case
@objc func activate(_ allWindows: Bool)   // app.activate() works naturally

// GOOD — false means "not hidden", so create() makes a visible item by default
@objc func create(_ hidden: Bool) -> HSMenuBarItem   // hs.menubar.create() → visible

// BAD — false means "not visible", so create() would create a hidden item
//        and callers would need create(true) for the common case
@objc func create(_ visible: Bool) -> HSMenuBarItem

If the natural default behaviour maps to true, invert the parameter name so false is the default (e.g. rename visible → hidden, enabled → disabled, synchronous → async).

Promise-returning methods

Several modules return Promises for async operations. The return type is JSPromise? (a typealias for JSValue), and the docstring - Returns: line should include {Promise<T>} to signal this to the docs generator.

Show full SKILL.md (1,411 more words)Show less
Critical: always use JSContext.current()

Promises MUST be created in the JSContext that made the call, not in JSEngine.shared's context. JSEngine.shared has its own JSContext; if you create a Promise there and return it to JS running in a different context (e.g. the test harness), JavaScriptCore delivers it as an opaque ObjC object with no then method — the JS caller cannot use it as a Promise at all.

The correct pattern is:

swift
// In the protocol:
/// - Returns: {Promise<boolean>} A Promise that resolves to true if successful
@objc func doSomethingAsync() -> JSPromise?

// In the implementation:
@objc func doSomethingAsync() -> JSPromise? {
    guard let context = JSContext.current() else { return nil }
    return wrapAsyncInJSPromise(in: context) { holder in
        Task { @MainActor in
            // ... do async work ...
            holder.resolveWith(result)       // or:
            holder.rejectWithMessage("reason")
        }
    }
}

JSContext.current() returns the JSContext that invoked the current @objc method via JSExport. It is always non-nil when called from a JS→Swift bridge method, so the guard is just a safety net.

Never use JSEngine.shared.createPromise in an @objc JSExport method — that creates the Promise in the wrong context.

For immediately-known results, use the JSContext extensions instead of JSEngine.shared:

swift
guard let context = JSContext.current() else { return nil }
return context.createResolvedPromise(with: value)
return context.createRejectedPromise(with: "error message")

Watchers

Hammerspoon 2 has two established watcher patterns. Choose the right one based on whether the underlying OS API is singleton/global or per-object.

Pattern A — Module-level watcher (hs.screen, hs.usb, hs.application, hs.pasteboard)

Use this when there is a single global OS subscription (NSWorkspace notification, CoreAudio listener, IOKit notification, a polling timer, etc.) that serves all JS callers. hs.screen is the smallest complete example to copy.

The architecture is always two layers: a private Swift subscription (_addWatcher / _removeWatcher) that feeds a shared JS LazyWatcherEmitter (Engine/engine.js), which owns the listeners and starts/stops the Swift subscription lazily. The public on/off/once are defined in the companion JS file, not in Swift.

The contract (issue #254): if on()/once() returns, the listener is registered AND the native watcher is running; otherwise it throws and records nothing, so a retry works. Never just log and return.

Swift protocol (in the @objc protocol HSXxxModuleAPI: JSExport block)
swift
// NOTE: Private API consumed only by hs.xxx.js
/// SKIP_DOCS
@objc(_addWatcher:) func _addWatcher(_ callback: JSFunction) -> Bool
/// SKIP_DOCS
@objc func _removeWatcher()
/// SKIP_DOCS
@objc var _watcherEmitter: JSFunction? { get set }

/// The event names `on()`/`once()` accept - see HSXxxEvent
/// SKIP_DOCS
@objc var _eventNames: [String] { get }

// Set by hs.xxx.js. Must be pre-declared properties, or JSC drops them on GC (issue #185).
/// SKIP_DOCS
@objc var on: JSFunction? { get set }
/// SKIP_DOCS
@objc var off: JSFunction? { get set }
/// SKIP_DOCS
@objc var once: JSFunction? { get set }
Swift implementation
swift
/// Events emitted by hs.xxx's watcher
nonisolated enum HSXxxEvent: String, HSEventName {
    case change
}

@objc var _watcherEmitter: JSFunction? = nil
@objc var _eventNames: [String] { HSXxxEvent.allNames }
@objc var on: JSFunction? = nil
@objc var off: JSFunction? = nil
@objc var once: JSFunction? = nil
private var watcherCallback: JSFunction?

@objc(_addWatcher:) func _addWatcher(_ callback: JSFunction) -> Bool {
    guard watcherCallback == nil else {
        AKWarning("hs.xxx._addWatcher: already watching — refusing second subscription")
        return false
    }
    // ... register with the OS API here. If that can fail, check it and return false
    // BEFORE setting any state, so a failed attempt leaves nothing to clean up ...
    watcherCallback = callback
    AKDebug("hs.xxx._addWatcher: started")
    return true
}

@objc func _removeWatcher() {
    guard watcherCallback != nil else { return }
    // ... unregister from the OS API ...
    watcherCallback = nil
    AKDebug("hs.xxx._removeWatcher: stopped")
}

private func fireWatcherEvent() {
    _ = watcherCallback?.call(withArguments: [HSXxxEvent.change.rawValue])
}

func shutdown() {
    _removeWatcher()
    _watcherEmitter = nil
    on = nil
    off = nil
    once = nil
}

Key rules:

  • _addWatcher MUST return Bool: false for any failure, including refusing a second subscription; true on success. Every false path MUST log its specific cause (AKError, or AKWarning for the refusal) - the JS emitter's error is deliberately generic and not logged, so the native log is what shows the reason, including when on() is called from a promise callback and the throw becomes an unhandled rejection.
  • Emit ONLY via HSXxxEvent.foo.rawValue, never a string literal (see Event names below).
  • Always use [weak self] in any closure passed to the OS listener to avoid retain cycles.
  • When the OS callback arrives off-@MainActor, use MainActor.assumeIsolated { } to enter actor isolation (CoreLocation, CoreAudio, etc. guarantee main-thread delivery).
  • shutdown() MUST call _removeWatcher() and nil _watcherEmitter and the on/off/once slots.
Companion JS file (hs.xxx.js)
js
"use strict";

// Lazily starts the native watcher on the first listener and stops it once the last
// listener is removed. See Engine/engine.js for LazyWatcherEmitter itself.
hs.xxx._watcherEmitter = new LazyWatcherEmitter("hs.xxx", function() {
    return hs.xxx._addWatcher((event) => {
        hs.xxx._watcherEmitter.emit(event);
    });
}, function() {
    hs.xxx._removeWatcher();
}, hs.xxx._eventNames);

/// Register a listener for Xxx events.
/// Parameters:
///  - event: {"change"} The event to listen for
///  - listener: {() => void} Called when the event occurs
/// Throws: true
/// Example:
/// ```js
/// try {
///     hs.xxx.on('change', () => console.log("changed"))
/// } catch (err) {
///     console.error(err.message)
/// }
/// ```
hs.xxx.on = function(event, listener) {
    hs.xxx._watcherEmitter.on(event, listener);
};

// hs.xxx.off and hs.xxx.once follow the same shape; once() also gets `Throws: true` and a
// try/catch example, off() gets neither.

Key rules:

  • The emitter MUST be stored in hs.xxx._watcherEmitter — this is what keeps it alive.
  • The start function MUST return the native _addWatcher(...) result. LazyWatcherEmitter throws a standard "hs.xxx.on(): failed to start watcher for '<event>'" error when it returns false; don't hand-write if (!started) throw ....
  • Use KeyedLazyWatcherEmitter instead when each event name has its own independent native resource (hs.wifi, hs.userdefaults); its start/stop functions receive the event name.
  • Duplicate listener registration is rejected with console.error (not thrown) — the listener is already registered, so the contract still holds.
  • Mark on()/once() docstrings with Throws: true (JS) / - Throws: true (Swift), and wrap their examples in try { ... } catch (err) { console.error(err.message) }. The docs pipeline renders that as a Throws section and @throws {Error}.

Per-instance watchers (HSCamera, HSAudioDevice): on()/once() are native Swift methods, since a per-instance object has no enhancement script to hang them on. They get the emitter from a module-level JS factory (e.g. hs.camera._makeCameraEmitter). They MUST:

  • set ctx.exception and return if the factory is missing or fails to produce an emitter (see HSCamera.ensureWatcherEmitter), rather than silently returning;
  • call the emitter through ctx.callCapturingException { emitter.invokeMethod(...) }. Plain invokeMethod never delivers the emitter's throw to the JS caller's try/catch.

Event names (issue #253):

  • Declare the event names as a Swift enum: nonisolated enum HSXxxEvent: String, HSEventName { case ... } (see Engine/HSEventName.swift). Emit ONLY via HSXxxEvent.foo.rawValue, never a string literal, so the compiler guarantees every emitted name is in the list.
  • Expose it as /// SKIP_DOCS @objc var _eventNames: [String] { get }, implemented as HSXxxEvent.allNames, and pass hs.xxx._eventNames as the 4th argument to LazyWatcherEmitter / KeyedLazyWatcherEmitter. on()/once() then throw for any other name instead of registering a listener that can never fire.
  • The on/off/once docstring unions must list exactly the enum's raw values; npm run docs:test (scripts/check-event-names.js) fails otherwise, and also fails if an emitter is given no list. Only emitters whose names are genuinely arbitrary (hs.userdefaults) are exempted in that script.

Pattern B — Object-level watcher (hs.ax, hs.location)

Use this when each watcher is a discrete, independently-configured object with its own OS subscription and callback — for example, per-app AX observers or per-session location updates.

The watcher class

// Separate file: HSXxxWatcher.swift

@objc protocol HSXxxWatcherAPI: HSTypeAPI, JSExport { @objc var identifier: String { get } // UUID string, set in init @objc @discardableResult func start() -> HSXxxWatcher @objc @discardableResult func stop() -> HSXxxWatcher @objc func setCallback(_ fn: JSValue) -> HSXxxWatcher // ... any additional config properties (e.g. distanceFilter) ... }

@_documentation(visibility: private) @MainActor @objc class HSXxxWatcher: NSObject, HSXxxWatcherAPI /*, OS delegate if needed */ { @objc var typeName = "HSXxxWatcher" @objc let identifier = UUID().uuidString private var callback: JSCallback?

  override init() {
      super.init()
      // set self as OS delegate if needed
  }

  isolated deinit {
      destroy()
      AKGarbage("deinit of HSXxxWatcher(\(identifier))")
  }

  func destroy() {
      _ = stop()
      callback?.detach(from: self)
      callback = nil
  }

  @objc @discardableResult func start() -> HSXxxWatcher {
      // begin OS updates
      return self
  }

  @objc @discardableResult func stop() -> HSXxxWatcher {
      // end OS updates
      return self
  }

  @objc func setCallback(_ fn: JSValue) -> HSXxxWatcher {
      callback?.detach(from: self)
      callback = JSCallback(value: fn, owner: self)
      return self
  }

  // OS delegate callbacks use MainActor.assumeIsolated { } and
  // `_ = callback?.value?.call(withArguments: [...])` to invoke.

}

Module additions

In the module's Swift protocol add:

/// Creates a new Xxx watcher. Call .start() and .setCallback() to activate it. /// The watcher is stopped automatically when the module shuts down. /// - Returns: an HSXxxWatcher /// - Example: /// js /// const w = hs.xxx.addWatcher() /// w.setCallback((event, data) => console.log(event, data)).start() /// @objc func addWatcher() -> HSXxxWatcher

In the module's Swift implementation:

private var watchers = HSWeakObjectSet<HSXxxWatcher>()

func addWatcher() -> HSXxxWatcher { let w = HSXxxWatcher() watchers.add(w) return w }

func removeWatcher(_ watcher: HSXxxWatcher) { watcher.destroy() watchers.remove(watcher) }

func shutdown() { for watcher in watchers.allObjects { watcher.destroy() } watchers.removeAllObjects() // stop any module-level OS state too }

Key rules:

  • The module MUST track all created watcher objects in private var watchers = HSWeakObjectSet<HSXxxWatcher>().
  • Every watcher MUST have a destroy() method (see Child object tracking section).
  • shutdown() MUST call destroy() on all tracked watchers, not just stop().
  • start()/stop() and setCallback() MUST return self for chaining.
  • typeName MUST be set to match the Swift class name for JS introspection.
  • identifier MUST be a UUID().uuidString assigned at init time. }

Key rules:

  • The module MUST track all created watcher objects in private var watchers = HSWeakObjectSet<HSXxxWatcher>().
  • Every watcher MUST have a destroy() method (see Child object tracking section).
  • shutdown() MUST call destroy() on all tracked watchers, not just stop().
  • start()/stop() and setCallback() MUST return self for chaining.
  • typeName MUST be set to match the Swift class name for JS introspection.
  • identifier MUST be a UUID().uuidString assigned at init time.
  • OS delegate callbacks arriving off-@MainActor MUST use MainActor.assumeIsolated { }.
  • Discard the unused JSValue? result from callback?.call() with _ = callback?.call(...).

Pattern B with multiplexing (hs.ax)

When Pattern B watcher objects are keyed by a composite identity (e.g. pid:notification), keep an additional dictionary of the underlying OS observers:

private var observers: [KeyType: OSObserver] = [:] private var watchers: [String: HSXxxWatcher] = [:] // keyed by "component1:component2"

And in _removeWatcher, only tear down the OS observer when the last watcher for that key prefix is removed (see HSAXModule._removeWatcher for the reference implementation).


Logging conventions for watchers

  • AKDebug(...) when successfully starting or stopping a watcher
  • AKWarning(...) when refusing a duplicate registration
  • AKError(...) when an OS call fails during setup or teardown

The section covers both patterns (module-level EventEmitter and object-level watcher), the JS emitter template, the composite-key variant from hs.ax, and all the subtle rules around lazy start, [weak self], assumeIsolated, discarding JSValue?, and the _watcherEmitter GC anchor.

Docstrings conventions

  • Every method/property in the HSFooModuleAPI protocol needs a /// docstring that describes the item, and in the case of methods, also documents each parameter and any return value
  • Every docstring should have a - Example: section with a fenced ```js block
  • Private/internal protocol members (the _addWatcher, _watcherEmitter underbelly) use /// SKIP_DOCS to be omitted from generated HTML
  • Async methods annotate their return: /// - Returns: {Promise<boolean>} A Promise resolving to...
  • Optional parameters (Swift type Foo?) must have ? appended to the parameter name in the docstring to signal optionality to the docs pipeline: /// - Parameter headers?: Optional dictionary of request headers.

Logging

Hammerspoon 2 provides several convenience functions that handle logging per the user's configuration:

  • AKInfo - useful information for the user
  • AKGarbage — object lifecycle events (init/deinit) used to diagnose retain cycles and garbage-collection issues. Compiled into all build configurations, but only actually recorded when the user enables "Garbage collection logging" in Advanced settings (off by default, since it's high-volume) — the check is a cheap no-op otherwise.
  • AKDebug — general debug/trace events (watcher started/stopped, timer fired, etc.)
  • AKWarning — recoverable bad states (invalid input, refused duplicate)
  • AKError — unrecoverable failures (OS call failed, nil context)

These all log into the app's Console window

If we ever emit a console.log() call, even in example code, please note that Hammerspoon 2's implementation of console.log does not support the form console.log("foo: ", bar); - either concatenate strings with + or use interpolated strings.

Avoid using print() if possible.

© 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/HSModule of cmsj/Hammerspoon2.

Open the folder on GitHubat commit f426cf3

Compare with similar skills

Hs2module next to the 5 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.

Hs2module compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Hs2module this skillcmsj/Hammerspoon2115—~8.6kAutomated safety check: PassMIT
Fory Version Bumpapache/fory4.6k—~1.1kAutomated safety check: PassApache-2.0
App Store Reviewsafaiyeh/app-store-review-skill3621 repos~3.4kAutomated safety check: PassMIT
Fory Performance Optimizationapache/fory4.6k—~2.2kAutomated safety check: PassApache-2.0
Legado Book Source GeneratorNarylr350/book-source-creator-skill154—~1.9kAutomated safety check: PassNone
Agent Cdpgronxb/codex-relay679—~956Automated safety check: PassApache-2.0

Similar skills

  • Bump Apache Fory release or post-release development versions across Java, Kotlin, Scala, Python, Rust, Go, C++, C, Dart, JavaScript, Swift, integration tests, examples, and source docs.

    4.6k GitHub stars~1.1k tokensUpdated today
    MobileAuto-check passed
  • App Store Review

    safaiyeh/app-store-review-skill

    Evaluates code against Apple's App Store Review Guidelines. An agent skill from safaiyeh/app-store-review-skill.

    362 GitHub starsUsed in 1 repo~3.4k tokens
    MobileAuto-check passed
  • Run profile-driven bottleneck optimization across Apache Fory implementations (Java, C++, Python/Cython, Go, Rust, Swift, C, JavaScript/TypeScript, Dart, Kotlin, Scala).

    4.6k GitHub stars~2.2k tokensUpdated today
    MobileAuto-check passed
  • Legado Book Source Generator

    Narylr350/book-source-creator-skill

    A skill your agent uses when 用户要求为任意网站生成书源、生成阅读书源、分析小说站点、生成 Legado/阅读规则。强制触发词:书源、生成书源、帮我生成、book source、legado、阅读书源、小说站点分析。如果用户给出了一个 URL 并要求生成或分析,必须加载此 skill。

    154 GitHub stars~1.9k tokensUpdated 1 mo ago
    MobileAuto-check passed
  • Agent Cdp

    gronxb/codex-relay

    Chrome DevTools Protocol CLI workflow for runtime, console, network, trace, memory, and JavaScript CPU profiling analysis.

    679 GitHub stars~956 tokensUpdated today
    MobileAuto-check passed
  • Crossbind

    crossbind/crossbind

    A skill your agent uses when a user wants to call C++ or Rust from JavaScript or TypeScript; add a native library such as GDAL, SQLite, OpenSSL, GEOS or PROJ to a browser, Node.js, Cloudflare Worker…

    148 GitHub stars~1.1k tokensUpdated today
    MobileAuto-check passed

More from cmsj/Hammerspoon2

  • Hs2type

    cmsj/Hammerspoon2

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

    115 GitHub stars~2.6k tokensUpdated today
    Auto-check passed
  • Hs2tests

    cmsj/Hammerspoon2

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

    115 GitHub stars~5.4k tokensUpdated today
    Auto-check passed

Categories

Questions about Hs2module

What does Hs2module do?

Ensure all Hammerspoon v2 modules follow established patterns. Hs2module is an agent skill from cmsj/Hammerspoon2.

When should I use Hs2module?

Hs2module fits situations like: mobile work in your project.

How do I install Hs2module in Claude Code?

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

How do I install Hs2module in Codex?

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

Can I use Hs2module 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 hs2module -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/hs2module, .gemini/skills/hs2module, .github/skills/hs2module and .opencode/skills/hs2module in your project.

What does Hs2module need to run?

Going by SKILL.md and its folder, Hs2module needs the command-line tools its instructions call (npm).

Does Hs2module access the network?

SKILL.md names 2 domains. As links in the text: github.com and cocoamine.net. This is read from the text; nothing was executed.

Is Hs2module 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 Hs2module use?

Hs2module 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 Hs2module use?

About 8.6k tokens (SKILL.md is roughly 35k 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 Hs2module?

Skills that share tags, products or a category with Hs2module: Fory Version Bump (apache/fory, 4.6k stars), App Store Review (safaiyeh/app-store-review-skill, 362 stars), Fory Performance Optimization (apache/fory, 4.6k stars) and Legado Book Source Generator (Narylr350/book-source-creator-skill, 154 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Hs2module?

cmsj (a GitHub user) maintains it in cmsj/Hammerspoon2, which has 115 GitHub stars. The repository holds 3 skills in this directory. The repository was last updated on October 7, 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.